', undefined).expect('a')
+ })
+ })
+
+ it('flags usage', function () {
+ test(DynamicRegex.transform, () => {
+ given('abc', 'A', undefined, 'i').expect('a')
+ given('abc\ndef', '^d', undefined, 'm').expect('d')
+ given('abc\ndef', 'c(.)', '$1', 's').expect('\n')
+ })
+ })
+})
diff --git a/services/dynamic/dynamic-regex.tester.js b/services/dynamic/dynamic-regex.tester.js
new file mode 100644
index 0000000000000..07bb737a3f934
--- /dev/null
+++ b/services/dynamic/dynamic-regex.tester.js
@@ -0,0 +1,123 @@
+import { createServiceTester } from '../tester.js'
+
+export const t = await createServiceTester()
+
+////////// OK tests //////////
+
+t.create('Search only')
+ .get(
+ '.json?url=https://raw.githubusercontent.com/badges/shields/refs/heads/master/frontend/blog/2024-07-10-sunsetting-shields-custom-logos.md&search=unblocking \\[.*\\]',
+ )
+ .expectBadge({
+ label: 'match',
+ message: 'unblocking [#4947]',
+ color: 'blue',
+ })
+
+t.create('Search & replace with flags')
+ .get(
+ '.json?url=https://raw.githubusercontent.com/badges/shields/refs/heads/master/frontend/blog/2024-07-10-sunsetting-shields-custom-logos.md&search=Unblocking \\[(.*)\\]&replace=$1&flags=i',
+ )
+ .expectBadge({
+ label: 'match',
+ message: '#4947',
+ color: 'blue',
+ })
+
+////////// KO tests (specific) //////////
+
+t.create('No result')
+ .get(
+ '.json?url=https://raw.githubusercontent.com/badges/shields/refs/heads/master/frontend/blog/2024-07-10-sunsetting-shields-custom-logos.md&search=notfound',
+ )
+ .expectBadge({
+ label: 'match',
+ message: 'no result',
+ color: 'lightgrey',
+ })
+
+t.create('Invalid Regex')
+ .get(
+ '.json?url=https://raw.githubusercontent.com/badges/shields/refs/heads/master/frontend/blog/2024-07-10-sunsetting-shields-custom-logos.md&search=x(%3F%3Dy)',
+ )
+ .expectBadge({
+ label: 'match',
+ message: 'Invalid re2 regex: invalid perl operator: (?=',
+ color: 'red',
+ })
+
+t.create('Invalid flags')
+ .get(
+ '.json?url=https://raw.githubusercontent.com/badges/shields/refs/heads/master/frontend/blog/2024-07-10-sunsetting-shields-custom-logos.md&search=questions.*providing&flags=s0',
+ )
+ .expectBadge({
+ label: 'match',
+ message: 'Invalid flags, must be one of: ims',
+ color: 'red',
+ })
+
+////////// KO tests (common) //////////
+
+t.create('No URL specified').get('.json?search=.*&label=Found').expectBadge({
+ label: 'Found',
+ message: 'invalid query parameter: url',
+ color: 'red',
+})
+
+t.create('No search specified')
+ .get(
+ '.json?url=https://raw.githubusercontent.com/badges/shields/refs/heads/master/README.md&label=Found',
+ )
+ .expectBadge({
+ label: 'Found',
+ message: 'invalid query parameter: search',
+ color: 'red',
+ })
+
+t.create('Invalid url')
+ .get(
+ '.json?url=https://github.com/badges/shields/raw/master/notafile.json&search=.*',
+ )
+ .expectBadge({
+ label: 'match',
+ message: 'resource not found',
+ color: 'red',
+ })
+
+t.create('Malformed url')
+ .get(
+ '.json?url=https://raw.githubusercontent.com/badges/shields/refs/heads/master/%0README.md&search=.*&label=Found',
+ )
+ .expectBadge({
+ label: 'Found',
+ message: 'invalid',
+ color: 'lightgrey',
+ })
+
+t.create('User color overrides default')
+ .get(
+ '.json?url=https://raw.githubusercontent.com/badges/shields/refs/heads/master/frontend/blog/2024-07-10-sunsetting-shields-custom-logos.md&search=unblocking \\[.*\\]&color=10ADED',
+ )
+ .expectBadge({
+ label: 'match',
+ message: 'unblocking [#4947]',
+ color: '#10aded',
+ })
+
+t.create('Error color overrides default')
+ .get(
+ '.json?url=https://github.com/badges/shields/raw/master/notafile.json&search=$1',
+ )
+ .expectBadge({
+ label: 'match',
+ message: 'resource not found',
+ color: 'red',
+ })
+
+t.create('Error color overrides user specified')
+ .get('.json?search=$1&color=10ADED')
+ .expectBadge({
+ label: 'match',
+ message: 'invalid query parameter: url',
+ color: 'red',
+ })
diff --git a/services/dynamic/dynamic-toml.service.js b/services/dynamic/dynamic-toml.service.js
new file mode 100644
index 0000000000000..d4527d1b50380
--- /dev/null
+++ b/services/dynamic/dynamic-toml.service.js
@@ -0,0 +1,58 @@
+import { MetricNames } from '../../core/base-service/metric-helper.js'
+import { BaseTomlService, queryParams } from '../index.js'
+import { createRoute } from './dynamic-helpers.js'
+import jsonPath from './json-path.js'
+
+const description = `
+The Dynamic TOML Badge allows you to extract an arbitrary value from any
+TOML Document using a JSONPath selector and show it on a badge.
+`
+
+export default class DynamicToml extends jsonPath(BaseTomlService) {
+ static enabledMetrics = [MetricNames.SERVICE_RESPONSE_SIZE]
+ static route = createRoute('toml')
+ static openApi = {
+ '/badge/dynamic/toml': {
+ get: {
+ summary: 'Dynamic TOML Badge',
+ description,
+ parameters: queryParams(
+ {
+ name: 'url',
+ description: 'The URL to a TOML document',
+ required: true,
+ example:
+ 'https://raw.githubusercontent.com/squirrelchat/smol-toml/mistress/bench/testfiles/toml-spec-example.toml',
+ },
+ {
+ name: 'query',
+ description:
+ 'A JSONPath expression that will be used to query the document',
+ required: true,
+ example: '$.title',
+ },
+ {
+ name: 'prefix',
+ description: 'Optional prefix to append to the value',
+ example: '[',
+ },
+ {
+ name: 'suffix',
+ description: 'Optional suffix to append to the value',
+ example: ']',
+ },
+ ),
+ },
+ },
+ }
+
+ async fetch({ schema, url, httpErrors }) {
+ return this._requestToml({
+ schema,
+ url,
+ httpErrors,
+ logErrors: [],
+ options: { timeout: { request: 3500 } },
+ })
+ }
+}
diff --git a/services/dynamic/dynamic-toml.tester.js b/services/dynamic/dynamic-toml.tester.js
new file mode 100644
index 0000000000000..edbf056cda7b9
--- /dev/null
+++ b/services/dynamic/dynamic-toml.tester.js
@@ -0,0 +1,107 @@
+import { createServiceTester } from '../tester.js'
+export const t = await createServiceTester()
+
+t.create('No URL specified')
+ .get('.json?query=$.name&label=Package Name')
+ .expectBadge({
+ label: 'Package Name',
+ message: 'invalid query parameter: url',
+ color: 'red',
+ })
+
+t.create('No query specified')
+ .get(
+ '.json?url=https://raw.githubusercontent.com/squirrelchat/smol-toml/mistress/bench/testfiles/toml-spec-example.toml&label=Package Name',
+ )
+ .expectBadge({
+ label: 'Package Name',
+ message: 'invalid query parameter: query',
+ color: 'red',
+ })
+
+t.create('TOML from url')
+ .get(
+ '.json?url=https://raw.githubusercontent.com/squirrelchat/smol-toml/mistress/bench/testfiles/toml-spec-example.toml&query=$.title',
+ )
+ .expectBadge({
+ label: 'custom badge',
+ message: 'TOML Example',
+ color: 'blue',
+ })
+
+t.create('TOML from url | multiple results')
+ .get(
+ '.json?url=https://raw.githubusercontent.com/squirrelchat/smol-toml/mistress/bench/testfiles/toml-spec-example.toml&query=$.database.data[0][*]',
+ )
+ .expectBadge({ label: 'custom badge', message: 'delta, phi' })
+
+t.create('TOML from url | caching with new query params')
+ .get(
+ '.json?url=https://raw.githubusercontent.com/squirrelchat/smol-toml/mistress/bench/testfiles/toml-spec-example.toml&query=$.owner.name',
+ )
+ .expectBadge({ label: 'custom badge', message: 'Tom Preston-Werner' })
+
+t.create('TOML from url | with prefix & suffix & label')
+ .get(
+ '.json?url=https://raw.githubusercontent.com/squirrelchat/smol-toml/mistress/bench/testfiles/toml-spec-example.toml&query=$.database.temp_targets.cpu&prefix=%2B&suffix=%C2%B0C&label=CPU Temp Target',
+ )
+ .expectBadge({ label: 'CPU Temp Target', message: '+79.5°C' })
+
+t.create('TOML from url | object doesnt exist')
+ .get(
+ '.json?url=https://raw.githubusercontent.com/squirrelchat/smol-toml/mistress/bench/testfiles/toml-spec-example.toml&query=$.does_not_exist',
+ )
+ .expectBadge({
+ label: 'custom badge',
+ message: 'no result',
+ color: 'lightgrey',
+ })
+
+t.create('TOML from url | invalid url')
+ .get(
+ '.json?url=https://raw.githubusercontent.com/squirrelchat/smol-toml/mistress/bench/testfiles/not-a-file.toml&query=$.version',
+ )
+ .expectBadge({
+ label: 'custom badge',
+ message: 'resource not found',
+ color: 'red',
+ })
+
+t.create('TOML from url | user color overrides default')
+ .get(
+ '.json?url=https://raw.githubusercontent.com/squirrelchat/smol-toml/mistress/bench/testfiles/toml-spec-example.toml&query=$.title&color=10ADED',
+ )
+ .expectBadge({
+ label: 'custom badge',
+ message: 'TOML Example',
+ color: '#10aded',
+ })
+
+t.create('TOML from url | error color overrides default')
+ .get(
+ '.json?url=https://raw.githubusercontent.com/squirrelchat/smol-toml/mistress/bench/testfiles/not-a-file.toml&query=$.version',
+ )
+ .expectBadge({
+ label: 'custom badge',
+ message: 'resource not found',
+ color: 'red',
+ })
+
+t.create('TOML from url | error color overrides user specified')
+ .get('.json?query=$.version&color=10ADED')
+ .expectBadge({
+ label: 'custom badge',
+ message: 'invalid query parameter: url',
+ color: 'red',
+ })
+
+t.create('TOML contains a string')
+ .get('.json?url=https://example.test/toml&query=$.foo,')
+ .intercept(nock =>
+ nock('https://example.test').get('/toml').reply(200, '"foo"'),
+ )
+ .expectBadge({
+ label: 'custom badge',
+ message: 'unparseable toml response',
+ color: 'lightgrey',
+ })
diff --git a/services/dynamic/dynamic-xml.service.js b/services/dynamic/dynamic-xml.service.js
index 2278f7260a305..effa2ac0f6ffe 100644
--- a/services/dynamic/dynamic-xml.service.js
+++ b/services/dynamic/dynamic-xml.service.js
@@ -1,10 +1,31 @@
-import { DOMParser } from 'xmldom'
+import { DOMParser, MIME_TYPE } from '@xmldom/xmldom'
import xpath from 'xpath'
import { MetricNames } from '../../core/base-service/metric-helper.js'
-import { renderDynamicBadge, errorMessages } from '../dynamic-common.js'
-import { BaseService, InvalidResponse, InvalidParameter } from '../index.js'
+import { renderDynamicBadge, httpErrors } from '../dynamic-common.js'
+import {
+ BaseService,
+ InvalidResponse,
+ InvalidParameter,
+ queryParams,
+} from '../index.js'
import { createRoute } from './dynamic-helpers.js'
+const MIME_TYPES = Object.values(MIME_TYPE)
+
+const description = `
+The Dynamic XML Badge allows you to extract an arbitrary value from any
+XML Document using an XPath selector and show it on a badge.
+
+Useful resources for constructing XPath selectors:
+- [XPather](http://xpather.com/)
+- [XPath Cheat Sheet](https://devhints.io/xpath/)
+
+Note: For XML documents that use a default namespace prefix, you will need to use the
+[local-name](https://developer.mozilla.org/en-US/docs/Web/XPath/Functions/local-name)
+function to construct your query.
+For example \`/*[local-name()='myelement']\` rather than \`/myelement\`.
+`
+
// This service extends BaseService because it uses a different XML parser
// than BaseXmlService which can be used with xpath.
//
@@ -15,18 +36,68 @@ export default class DynamicXml extends BaseService {
static category = 'dynamic'
static enabledMetrics = [MetricNames.SERVICE_RESPONSE_SIZE]
static route = createRoute('xml')
+ static openApi = {
+ '/badge/dynamic/xml': {
+ get: {
+ summary: 'Dynamic XML Badge',
+ description,
+ parameters: queryParams(
+ {
+ name: 'url',
+ description: 'The URL to a XML document',
+ required: true,
+ example: 'https://httpbin.org/xml',
+ },
+ {
+ name: 'query',
+ description:
+ 'An XPath expression that will be used to query the document',
+ required: true,
+ example: '//slideshow/slide[1]/title',
+ },
+ {
+ name: 'prefix',
+ description: 'Optional prefix to append to the value',
+ example: '[',
+ },
+ {
+ name: 'suffix',
+ description: 'Optional suffix to append to the value',
+ example: ']',
+ },
+ ),
+ },
+ },
+ }
+
static defaultBadgeData = { label: 'custom badge' }
- transform({ pathExpression, buffer }) {
+ getmimeType(contentType) {
+ return MIME_TYPES.find(mime => contentType.includes(mime)) ?? 'text/xml'
+ }
+
+ transform({ pathExpression, buffer, contentType = 'text/xml' }) {
// e.g. //book[2]/@id
const pathIsAttr = (
pathExpression.split('/').slice(-1)[0] || ''
).startsWith('@')
- const parsed = new DOMParser().parseFromString(buffer)
+
+ let parsed
+ try {
+ parsed = new DOMParser().parseFromString(buffer, contentType)
+ } catch (e) {
+ throw new InvalidResponse({ prettyMessage: e.message })
+ }
let values
try {
- values = xpath.select(pathExpression, parsed)
+ if (contentType === 'text/html') {
+ values = xpath
+ .parse(pathExpression)
+ .select({ node: parsed, isHtml: true })
+ } else {
+ values = xpath.select(pathExpression, parsed)
+ }
} catch (e) {
throw new InvalidParameter({ prettyMessage: e.message })
}
@@ -63,15 +134,25 @@ export default class DynamicXml extends BaseService {
}
async handle(_namedParams, { url, query: pathExpression, prefix, suffix }) {
- const { buffer } = await this._request({
+ const { buffer, res } = await this._request({
url,
- options: { headers: { Accept: 'application/xml, text/xml' } },
- errorMessages,
+ options: {
+ headers: { Accept: 'application/xml, text/xml' },
+ timeout: { request: 3500 },
+ },
+ httpErrors,
+ logErrors: [],
})
+ let contentType = 'text/xml'
+ if (res.headers['content-type']) {
+ contentType = this.getmimeType(res.headers['content-type'])
+ }
+
const { values: value } = this.transform({
pathExpression,
buffer,
+ contentType,
})
return renderDynamicBadge({ value, prefix, suffix })
diff --git a/services/dynamic/dynamic-xml.spec.js b/services/dynamic/dynamic-xml.spec.js
index ca233d2c0abeb..c3f1b64c91ab3 100644
--- a/services/dynamic/dynamic-xml.spec.js
+++ b/services/dynamic/dynamic-xml.spec.js
@@ -20,6 +20,29 @@ const exampleXml = `
`
+const exampleHtml = `
+
+
+
+
+ Herman Melville - Moby-Dick
+
+
+ Availing himself of the mild, summer-cool weather that now reigned in these
+ latitudes, and in preparation for the peculiarly active pursuits shortly to
+ be anticipated, Perth, the begrimed, blistered old blacksmith, had not
+ removed his portable forge to the hold again, after concluding his
+ contributory work for Ahab's leg, but still retained it on deck, fast lashed
+ to ringbolts by the foremast; being now almost incessantly invoked by the
+ headsmen, and harpooneers, and bowsmen to do some little job for them;
+ altering, or repairing, or new shaping their various weapons and boat
+ furniture.
+
+
+
+
+`
+
describe('DynamicXml', function () {
describe('transform()', function () {
beforeEach(function () {
@@ -35,7 +58,7 @@ describe('DynamicXml', function () {
DynamicXml.prototype.transform({
pathExpression: '//book/title',
buffer: exampleXml,
- })
+ }),
)
.to.throw(InvalidResponse)
.with.property('prettyMessage', 'unsupported query')
@@ -52,7 +75,7 @@ describe('DynamicXml', function () {
given({ pathExpression: '//book/title/text()', buffer: exampleXml }).expect(
{
values: ["XML Developer's Guide", 'Midnight Rain'],
- }
+ },
)
given({
pathExpression: 'string(//book[1]/title)',
@@ -126,5 +149,37 @@ describe('DynamicXml', function () {
}).expect({
values: ["XML Developer's Guide", '44.95'],
})
+ given({
+ pathExpression: '//h1[1]',
+ buffer: exampleHtml,
+ contentType: 'text/html',
+ }).expect({
+ values: ['Herman Melville - Moby-Dick'],
+ })
+
+ // lowercase doctype
+ // https://github.com/badges/shields/issues/10827
+ given({
+ pathExpression: '//h1[1]',
+ buffer: exampleHtml.replace('', ''),
+ contentType: 'text/html',
+ }).expect({
+ values: ['Herman Melville - Moby-Dick'],
+ })
+ })
+
+ test(DynamicXml.prototype.getmimeType, () => {
+ // known types
+ given('text/html').expect('text/html')
+ given('application/xml').expect('application/xml')
+ given('application/xhtml+xml').expect('application/xhtml+xml')
+ given('image/svg+xml').expect('image/svg+xml')
+
+ // with character set
+ given('text/html; charset=utf-8').expect('text/html')
+
+ // should fall back to text/xml if mime type is not one of the known types
+ given('text/csv').expect('text/xml')
+ given('foobar').expect('text/xml')
})
})
diff --git a/services/dynamic/dynamic-xml.tester.js b/services/dynamic/dynamic-xml.tester.js
index a35d303c3ec41..e84b834ac83e4 100644
--- a/services/dynamic/dynamic-xml.tester.js
+++ b/services/dynamic/dynamic-xml.tester.js
@@ -1,4 +1,4 @@
-import queryString from 'query-string'
+import qs from 'qs'
import { createServiceTester } from '../tester.js'
import { exampleXml } from './dynamic-response-fixtures.js'
export const t = await createServiceTester()
@@ -25,10 +25,10 @@ t.create('No query specified')
t.create('XML from url')
.get(
- `.json?${queryString.stringify({
+ `.json?${qs.stringify({
url: exampleUrl,
query: "//book[@id='bk102']/title",
- })}`
+ })}`,
)
.intercept(withExampleXml)
.expectBadge({
@@ -39,10 +39,10 @@ t.create('XML from url')
t.create('uri query parameter alias')
.get(
- `.json?${queryString.stringify({
+ `.json?${qs.stringify({
uri: exampleUrl,
query: "//book[@id='bk102']/title",
- })}`
+ })}`,
)
.intercept(withExampleXml)
.expectBadge({
@@ -53,10 +53,10 @@ t.create('uri query parameter alias')
t.create('attribute')
.get(
- `.json?${queryString.stringify({
+ `.json?${qs.stringify({
url: exampleUrl,
query: '//book[2]/@id',
- })}`
+ })}`,
)
.intercept(withExampleXml)
.expectBadge({
@@ -66,26 +66,27 @@ t.create('attribute')
t.create('multiple results')
.get(
- `.json?${queryString.stringify({
+ `.json?${qs.stringify({
url: exampleUrl,
query: '//book/title',
- })}`
+ })}`,
)
.intercept(withExampleXml)
.expectBadge({
label: 'custom badge',
+ // truncated to 255 chars
message:
- "XML Developer's Guide, Midnight Rain, Maeve Ascendant, Oberon's Legacy, The Sundered Grail, Lover Birds, Splish Splash, Creepy Crawlies, Paradox Lost, Microsoft .NET: The Programming Bible, MSXML3: A Comprehensive Guide, Visual Studio 7: A Comprehensive Guide",
+ "XML Developer's Guide, Midnight Rain, Maeve Ascendant, Oberon's Legacy, The Sundered Grail, Lover Birds, Splish Splash, Creepy Crawlies, Paradox Lost, Microsoft .NET: The Programming Bible, MSXML3: A Comprehensive Guide, Visual Studio 7: A Comprehensive G",
})
t.create('prefix and suffix')
.get(
- `.json?${queryString.stringify({
+ `.json?${qs.stringify({
url: exampleUrl,
query: "//book[@id='bk102']/title",
prefix: 'title is ',
suffix: ', innit',
- })}`
+ })}`,
)
.intercept(withExampleXml)
.expectBadge({
@@ -94,10 +95,10 @@ t.create('prefix and suffix')
t.create('query doesnt exist')
.get(
- `.json?${queryString.stringify({
+ `.json?${qs.stringify({
url: exampleUrl,
query: '//does/not/exist',
- })}`
+ })}`,
)
.intercept(withExampleXml)
.expectBadge({
@@ -108,10 +109,10 @@ t.create('query doesnt exist')
t.create('query doesnt exist (attribute)')
.get(
- `.json?${queryString.stringify({
+ `.json?${qs.stringify({
url: exampleUrl,
query: '//does/not/@exist',
- })}`
+ })}`,
)
.intercept(withExampleXml)
.expectBadge({
@@ -122,10 +123,10 @@ t.create('query doesnt exist (attribute)')
t.create('Cannot resolve QName')
.get(
- `.json?${queryString.stringify({
+ `.json?${qs.stringify({
url: exampleUrl,
query: '//a:si',
- })}`
+ })}`,
)
.intercept(withExampleXml)
.expectBadge({
@@ -136,10 +137,10 @@ t.create('Cannot resolve QName')
t.create('XPath parse error')
.get(
- `.json?${queryString.stringify({
+ `.json?${qs.stringify({
url: exampleUrl,
query: '//a[contains(@href, "foo"]',
- })}`
+ })}`,
)
.intercept(withExampleXml)
.expectBadge({
@@ -150,7 +151,7 @@ t.create('XPath parse error')
t.create('XML from url | invalid url')
.get(
- '.json?url=https://github.com/badges/shields/raw/master/notafile.xml&query=//version'
+ '.json?url=https://github.com/badges/shields/raw/master/notafile.xml&query=//version',
)
.expectBadge({
label: 'custom badge',
@@ -160,26 +161,26 @@ t.create('XML from url | invalid url')
t.create('request should set Accept header')
.get(
- `.json?${queryString.stringify({
+ `.json?${qs.stringify({
url: exampleUrl,
query: "//book[@id='bk102']/title",
- })}`
+ })}`,
)
.intercept(nock =>
nock('https://example.test', {
reqheaders: { accept: 'application/xml, text/xml' },
})
.get('/example.xml')
- .reply(200, exampleXml)
+ .reply(200, exampleXml),
)
.expectBadge({ label: 'custom badge', message: 'Midnight Rain' })
t.create('query with node function')
.get(
- `.json?${queryString.stringify({
+ `.json?${qs.stringify({
url: exampleUrl,
query: '//book[1]/title/text()',
- })}`
+ })}`,
)
.intercept(withExampleXml)
.expectBadge({
@@ -190,10 +191,10 @@ t.create('query with node function')
t.create('query with type conversion to string')
.get(
- `.json?${queryString.stringify({
+ `.json?${qs.stringify({
url: exampleUrl,
query: 'string(//book[1]/title)',
- })}`
+ })}`,
)
.intercept(withExampleXml)
.expectBadge({
@@ -204,10 +205,10 @@ t.create('query with type conversion to string')
t.create('query with type conversion to number')
.get(
- `.json?${queryString.stringify({
+ `.json?${qs.stringify({
url: exampleUrl,
query: 'number(//book[1]/price)',
- })}`
+ })}`,
)
.intercept(withExampleXml)
.expectBadge({
@@ -215,3 +216,16 @@ t.create('query with type conversion to number')
message: '44.95',
color: 'blue',
})
+
+t.create('query HTML document')
+ .get(
+ `.json?${qs.stringify({
+ url: 'https://httpbin.org/html',
+ query: '//h1[1]',
+ })}`,
+ )
+ .expectBadge({
+ label: 'custom badge',
+ message: 'Herman Melville - Moby-Dick',
+ color: 'blue',
+ })
diff --git a/services/dynamic/dynamic-yaml.service.js b/services/dynamic/dynamic-yaml.service.js
index 1c606f90334ea..5fd7e80da340d 100644
--- a/services/dynamic/dynamic-yaml.service.js
+++ b/services/dynamic/dynamic-yaml.service.js
@@ -1,17 +1,58 @@
import { MetricNames } from '../../core/base-service/metric-helper.js'
-import { BaseYamlService } from '../index.js'
+import { BaseYamlService, queryParams } from '../index.js'
import { createRoute } from './dynamic-helpers.js'
import jsonPath from './json-path.js'
+const description = `
+The Dynamic YAML Badge allows you to extract an arbitrary value from any
+YAML Document using a JSONPath selector and show it on a badge.
+`
+
export default class DynamicYaml extends jsonPath(BaseYamlService) {
static enabledMetrics = [MetricNames.SERVICE_RESPONSE_SIZE]
static route = createRoute('yaml')
+ static openApi = {
+ '/badge/dynamic/yaml': {
+ get: {
+ summary: 'Dynamic YAML Badge',
+ description,
+ parameters: queryParams(
+ {
+ name: 'url',
+ description: 'The URL to a YAML document',
+ required: true,
+ example:
+ 'https://raw.githubusercontent.com/badges/shields/master/.github/dependabot.yml',
+ },
+ {
+ name: 'query',
+ description:
+ 'A JSONPath expression that will be used to query the document',
+ required: true,
+ example: '$.version',
+ },
+ {
+ name: 'prefix',
+ description: 'Optional prefix to append to the value',
+ example: '[',
+ },
+ {
+ name: 'suffix',
+ description: 'Optional suffix to append to the value',
+ example: ']',
+ },
+ ),
+ },
+ },
+ }
- async fetch({ schema, url, errorMessages }) {
+ async fetch({ schema, url, httpErrors }) {
return this._requestYaml({
schema,
url,
- errorMessages,
+ httpErrors,
+ logErrors: [],
+ options: { timeout: { request: 3500 } },
})
}
}
diff --git a/services/dynamic/dynamic-yaml.tester.js b/services/dynamic/dynamic-yaml.tester.js
index e3d5cce1fcae3..5a93004a42ee0 100644
--- a/services/dynamic/dynamic-yaml.tester.js
+++ b/services/dynamic/dynamic-yaml.tester.js
@@ -11,7 +11,7 @@ t.create('No URL specified')
t.create('No query specified')
.get(
- '.json?url=https://raw.githubusercontent.com/kubernetes/charts/568291d6e476c39ca8322c30c3f601d0383d4760/stable/coredns/Chart.yaml&label=Package Name'
+ '.json?url=https://raw.githubusercontent.com/kubernetes/charts/568291d6e476c39ca8322c30c3f601d0383d4760/stable/coredns/Chart.yaml&label=Package Name',
)
.expectBadge({
label: 'Package Name',
@@ -21,7 +21,7 @@ t.create('No query specified')
t.create('YAML from url')
.get(
- '.json?url=https://raw.githubusercontent.com/kubernetes/charts/568291d6e476c39ca8322c30c3f601d0383d4760/stable/coredns/Chart.yaml&query=$.name'
+ '.json?url=https://raw.githubusercontent.com/kubernetes/charts/568291d6e476c39ca8322c30c3f601d0383d4760/stable/coredns/Chart.yaml&query=$.name',
)
.expectBadge({
label: 'custom badge',
@@ -31,7 +31,7 @@ t.create('YAML from url')
t.create('YAML from uri (support uri query parameter)')
.get(
- '.json?uri=https://raw.githubusercontent.com/kubernetes/charts/568291d6e476c39ca8322c30c3f601d0383d4760/stable/coredns/Chart.yaml&query=$.name'
+ '.json?uri=https://raw.githubusercontent.com/kubernetes/charts/568291d6e476c39ca8322c30c3f601d0383d4760/stable/coredns/Chart.yaml&query=$.name',
)
.expectBadge({
label: 'custom badge',
@@ -41,25 +41,25 @@ t.create('YAML from uri (support uri query parameter)')
t.create('YAML from url | multiple results')
.get(
- '.json?url=https://raw.githubusercontent.com/kubernetes/charts/568291d6e476c39ca8322c30c3f601d0383d4760/stable/coredns/Chart.yaml&query=$..keywords[0:2:1]'
+ '.json?url=https://raw.githubusercontent.com/kubernetes/charts/568291d6e476c39ca8322c30c3f601d0383d4760/stable/coredns/Chart.yaml&query=$..keywords[0:2:1]',
)
.expectBadge({ label: 'custom badge', message: 'coredns, dns' })
t.create('YAML from url | caching with new query params')
.get(
- '.json?url=https://raw.githubusercontent.com/kubernetes/charts/568291d6e476c39ca8322c30c3f601d0383d4760/stable/coredns/Chart.yaml&query=$.version'
+ '.json?url=https://raw.githubusercontent.com/kubernetes/charts/568291d6e476c39ca8322c30c3f601d0383d4760/stable/coredns/Chart.yaml&query=$.version',
)
.expectBadge({ label: 'custom badge', message: '0.8.0' })
t.create('YAML from url | with prefix & suffix & label')
.get(
- '.json?url=https://raw.githubusercontent.com/kubernetes/charts/568291d6e476c39ca8322c30c3f601d0383d4760/stable/coredns/Chart.yaml&query=$.version&prefix=v&suffix= dev&label=Shields'
+ '.json?url=https://raw.githubusercontent.com/kubernetes/charts/568291d6e476c39ca8322c30c3f601d0383d4760/stable/coredns/Chart.yaml&query=$.version&prefix=v&suffix= dev&label=Shields',
)
.expectBadge({ label: 'Shields', message: 'v0.8.0 dev' })
t.create('YAML from url | object doesnt exist')
.get(
- '.json?url=https://raw.githubusercontent.com/kubernetes/charts/568291d6e476c39ca8322c30c3f601d0383d4760/stable/coredns/Chart.yaml&query=$.does_not_exist'
+ '.json?url=https://raw.githubusercontent.com/kubernetes/charts/568291d6e476c39ca8322c30c3f601d0383d4760/stable/coredns/Chart.yaml&query=$.does_not_exist',
)
.expectBadge({
label: 'custom badge',
@@ -69,7 +69,7 @@ t.create('YAML from url | object doesnt exist')
t.create('YAML from url | invalid url')
.get(
- '.json?url=https://raw.githubusercontent.com/kubernetes/charts/568291d6e476c39ca8322c30c3f601d0383d4760/stable/coredns/notafile.yaml&query=$.version'
+ '.json?url=https://raw.githubusercontent.com/kubernetes/charts/568291d6e476c39ca8322c30c3f601d0383d4760/stable/coredns/notafile.yaml&query=$.version',
)
.expectBadge({
label: 'custom badge',
@@ -79,13 +79,13 @@ t.create('YAML from url | invalid url')
t.create('YAML from url | user color overrides default')
.get(
- '.json?url=https://raw.githubusercontent.com/kubernetes/charts/568291d6e476c39ca8322c30c3f601d0383d4760/stable/coredns/Chart.yaml&query=$.name&color=10ADED'
+ '.json?url=https://raw.githubusercontent.com/kubernetes/charts/568291d6e476c39ca8322c30c3f601d0383d4760/stable/coredns/Chart.yaml&query=$.name&color=10ADED',
)
.expectBadge({ label: 'custom badge', message: 'coredns', color: '#10aded' })
t.create('YAML from url | error color overrides default')
.get(
- '.json?url=https://raw.githubusercontent.com/kubernetes/charts/568291d6e476c39ca8322c30c3f601d0383d4760/stable/coredns/notafile.yaml&query=$.version'
+ '.json?url=https://raw.githubusercontent.com/kubernetes/charts/568291d6e476c39ca8322c30c3f601d0383d4760/stable/coredns/notafile.yaml&query=$.version',
)
.expectBadge({
label: 'custom badge',
@@ -102,12 +102,11 @@ t.create('YAML from url | error color overrides user specified')
})
t.create('YAML contains a string')
- .get('.json?url=https://example.test/yaml&query=$.foo,')
+ .get('.json?url=https://example.test/yaml&query=$,')
.intercept(nock =>
- nock('https://example.test').get('/yaml').reply(200, '"foo"')
+ nock('https://example.test').get('/yaml').reply(200, '"foo"'),
)
.expectBadge({
label: 'custom badge',
- message: 'resource must contain an object or array',
- color: 'lightgrey',
+ message: 'foo',
})
diff --git a/services/dynamic/json-path.js b/services/dynamic/json-path.js
index ed242c944aaf4..48ebf05d9374c 100644
--- a/services/dynamic/json-path.js
+++ b/services/dynamic/json-path.js
@@ -3,8 +3,8 @@
*/
import Joi from 'joi'
-import jp from 'jsonpath'
-import { renderDynamicBadge, errorMessages } from '../dynamic-common.js'
+import { JSONPath as jp } from 'jsonpath-plus'
+import { renderDynamicBadge, httpErrors } from '../dynamic-common.js'
import { InvalidParameter, InvalidResponse } from '../index.js'
/**
@@ -24,15 +24,15 @@ export default superclass =>
* @param {object} attrs Refer to individual attrs
* @param {Joi} attrs.schema Joi schema to validate the response transformed to JSON
* @param {string} attrs.url URL to request
- * @param {object} [attrs.errorMessages={}] Key-value map of status codes
+ * @param {object} [attrs.httpErrors={}] Key-value map of status codes
* and custom error messages e.g: `{ 404: 'package not found' }`.
* This can be used to extend or override the
* [default](https://github.com/badges/shields/blob/master/services/dynamic-common.js#L8)
* @returns {object} Parsed response
*/
- async fetch({ schema, url, errorMessages }) {
+ async fetch({ schema, url, httpErrors }) {
throw new Error(
- `fetch() function not implemented for ${this.constructor.name}`
+ `fetch() function not implemented for ${this.constructor.name}`,
)
}
@@ -40,36 +40,27 @@ export default superclass =>
const data = await this.fetch({
schema: Joi.any(),
url,
- errorMessages,
+ httpErrors,
})
- // JSONPath only works on objects and arrays.
- // https://github.com/badges/shields/issues/4018
- if (typeof data !== 'object') {
- throw new InvalidResponse({
- prettyMessage: 'resource must contain an object or array',
- })
- }
-
let values
try {
- values = jp.query(data, pathExpression)
+ values = jp({ json: data, path: pathExpression, eval: false })
} catch (e) {
const { message } = e
if (
- message.startsWith('Lexical error') ||
- message.startsWith('Parse error') ||
- message.includes('Unexpected token')
+ message.includes('prevented in JSONPath expression') ||
+ e instanceof TypeError
) {
throw new InvalidParameter({
- prettyMessage: 'unparseable jsonpath query',
+ prettyMessage: 'query not supported',
})
} else {
throw e
}
}
- if (!values.length) {
+ if (!values || !values.length) {
throw new InvalidResponse({ prettyMessage: 'no result' })
}
diff --git a/services/dynamic/json-path.spec.js b/services/dynamic/json-path.spec.js
index 7e309dba653f6..b67e4c5b061a4 100644
--- a/services/dynamic/json-path.spec.js
+++ b/services/dynamic/json-path.spec.js
@@ -1,8 +1,7 @@
-import chai from 'chai'
+import { expect, use } from 'chai'
import chaiAsPromised from 'chai-as-promised'
import jsonPath from './json-path.js'
-const { expect } = chai
-chai.use(chaiAsPromised)
+use(chaiAsPromised)
describe('JSON Path service factory', function () {
describe('fetch()', function () {
@@ -13,7 +12,7 @@ describe('JSON Path service factory', function () {
return expect(jsonPathServiceInstance.fetch({})).to.be.rejectedWith(
Error,
- 'fetch() function not implemented for JsonPathService'
+ 'fetch() function not implemented for JsonPathService',
)
})
})
diff --git a/services/eclipse-marketplace/eclipse-marketplace-base.js b/services/eclipse-marketplace/eclipse-marketplace-base.js
index a4276731b9582..558807368e659 100644
--- a/services/eclipse-marketplace/eclipse-marketplace-base.js
+++ b/services/eclipse-marketplace/eclipse-marketplace-base.js
@@ -1,18 +1,11 @@
import { BaseXmlService } from '../index.js'
export default class EclipseMarketplaceBase extends BaseXmlService {
- static buildRoute(base) {
- return {
- base,
- pattern: ':name',
- }
- }
-
async fetch({ name, schema }) {
return this._requestXml({
schema,
url: `https://marketplace.eclipse.org/content/${name}/api/p`,
- errorMessages: { 404: 'solution not found' },
+ httpErrors: { 404: 'solution not found' },
})
}
}
diff --git a/services/eclipse-marketplace/eclipse-marketplace-downloads.service.js b/services/eclipse-marketplace/eclipse-marketplace-downloads.service.js
index c7cbaf118379c..77b208b339893 100644
--- a/services/eclipse-marketplace/eclipse-marketplace-downloads.service.js
+++ b/services/eclipse-marketplace/eclipse-marketplace-downloads.service.js
@@ -1,73 +1,59 @@
import Joi from 'joi'
-import { metric } from '../text-formatters.js'
-import { downloadCount as downloadCountColor } from '../color-formatters.js'
+import { renderDownloadsBadge } from '../downloads.js'
+import { pathParams } from '../index.js'
import { nonNegativeInteger } from '../validators.js'
import EclipseMarketplaceBase from './eclipse-marketplace-base.js'
-const monthlyResponseSchema = Joi.object({
+const downloadsResponseSchema = Joi.object({
marketplace: Joi.object({
node: Joi.object({
installsrecent: nonNegativeInteger,
- }),
- }),
-}).required()
-
-const totalResponseSchema = Joi.object({
- marketplace: Joi.object({
- node: Joi.object({
installstotal: nonNegativeInteger,
}),
}),
}).required()
-function DownloadsForInterval(interval) {
- const {
- base,
- schema,
- messageSuffix = '',
- name,
- } = {
- month: {
- base: 'eclipse-marketplace/dm',
- messageSuffix: '/month',
- schema: monthlyResponseSchema,
- name: 'EclipseMarketplaceDownloadsMonth',
- },
- total: {
- base: 'eclipse-marketplace/dt',
- schema: totalResponseSchema,
- name: 'EclipseMarketplaceDownloadsTotal',
- },
- }[interval]
+export default class EclipseMarketplaceDownloads extends EclipseMarketplaceBase {
+ static category = 'downloads'
+ static route = {
+ base: 'eclipse-marketplace',
+ pattern: ':interval(dm|dt)/:name',
+ }
- return class EclipseMarketplaceDownloads extends EclipseMarketplaceBase {
- static name = name
- static category = 'downloads'
- static route = this.buildRoute(base)
- static examples = [
- {
- title: 'Eclipse Marketplace',
- namedParams: { name: 'notepad4e' },
- staticPreview: this.render({ downloads: 30000 }),
+ static openApi = {
+ '/eclipse-marketplace/{interval}/{name}': {
+ get: {
+ summary: 'Eclipse Marketplace Downloads',
+ parameters: pathParams(
+ {
+ name: 'interval',
+ example: 'dt',
+ schema: { type: 'string', enum: this.getEnum('interval') },
+ description: 'Monthly or Total downloads',
+ },
+ {
+ name: 'name',
+ example: 'planet-themes',
+ },
+ ),
},
- ]
+ },
+ }
- static render({ downloads }) {
- return {
- message: `${metric(downloads)}${messageSuffix}`,
- color: downloadCountColor(downloads),
- }
- }
+ static render({ interval, downloads }) {
+ const intervalString = interval === 'dm' ? 'month' : null
+ return renderDownloadsBadge({ downloads, interval: intervalString })
+ }
- async handle({ name }) {
- const { marketplace } = await this.fetch({ name, schema })
- const downloads =
- interval === 'total'
- ? marketplace.node.installstotal
- : marketplace.node.installsrecent
- return this.constructor.render({ downloads })
- }
+ async handle({ interval, name }) {
+ const { marketplace } = await this.fetch({
+ schema: downloadsResponseSchema,
+ name,
+ })
+ const downloads =
+ interval === 'dt'
+ ? marketplace.node.installstotal
+ : marketplace.node.installsrecent
+ return this.constructor.render({ downloads, interval })
}
}
-
-export default ['month', 'total'].map(DownloadsForInterval)
diff --git a/services/eclipse-marketplace/eclipse-marketplace-favorites.service.js b/services/eclipse-marketplace/eclipse-marketplace-favorites.service.js
index 1f004bad2262f..b13368d71e7ec 100644
--- a/services/eclipse-marketplace/eclipse-marketplace-favorites.service.js
+++ b/services/eclipse-marketplace/eclipse-marketplace-favorites.service.js
@@ -1,4 +1,5 @@
import Joi from 'joi'
+import { pathParams } from '../index.js'
import { nonNegativeInteger } from '../validators.js'
import EclipseMarketplaceBase from './eclipse-marketplace-base.js'
@@ -12,14 +13,22 @@ const favoritesResponseSchema = Joi.object({
export default class EclipseMarketplaceFavorites extends EclipseMarketplaceBase {
static category = 'other'
- static route = this.buildRoute('eclipse-marketplace/favorites')
- static examples = [
- {
- title: 'Eclipse Marketplace',
- namedParams: { name: 'notepad4e' },
- staticPreview: this.render({ favorited: 55 }),
+ static route = {
+ base: 'eclipse-marketplace/favorites',
+ pattern: ':name',
+ }
+
+ static openApi = {
+ '/eclipse-marketplace/favorites/{name}': {
+ get: {
+ summary: 'Eclipse Marketplace Favorites',
+ parameters: pathParams({
+ name: 'name',
+ example: 'notepad4e',
+ }),
+ },
},
- ]
+ }
static defaultBadgeData = { label: 'favorites' }
diff --git a/services/eclipse-marketplace/eclipse-marketplace-license.service.js b/services/eclipse-marketplace/eclipse-marketplace-license.service.js
index b25b0ac223c18..bda1410e06dff 100644
--- a/services/eclipse-marketplace/eclipse-marketplace-license.service.js
+++ b/services/eclipse-marketplace/eclipse-marketplace-license.service.js
@@ -1,4 +1,5 @@
import Joi from 'joi'
+import { pathParams } from '../index.js'
import EclipseMarketplaceBase from './eclipse-marketplace-base.js'
const licenseResponseSchema = Joi.object({
@@ -11,14 +12,22 @@ const licenseResponseSchema = Joi.object({
export default class EclipseMarketplaceLicense extends EclipseMarketplaceBase {
static category = 'license'
- static route = this.buildRoute('eclipse-marketplace/l')
- static examples = [
- {
- title: 'Eclipse Marketplace',
- namedParams: { name: 'notepad4e' },
- staticPreview: this.render({ license: 'GPL' }),
+ static route = {
+ base: 'eclipse-marketplace/l',
+ pattern: ':name',
+ }
+
+ static openApi = {
+ '/eclipse-marketplace/l/{name}': {
+ get: {
+ summary: 'Eclipse Marketplace License',
+ parameters: pathParams({
+ name: 'name',
+ example: 'notepad4e',
+ }),
+ },
},
- ]
+ }
static defaultBadgeData = { label: 'license' }
diff --git a/services/eclipse-marketplace/eclipse-marketplace-license.tester.js b/services/eclipse-marketplace/eclipse-marketplace-license.tester.js
index 471651696b295..d12cbe61817f1 100644
--- a/services/eclipse-marketplace/eclipse-marketplace-license.tester.js
+++ b/services/eclipse-marketplace/eclipse-marketplace-license.tester.js
@@ -17,8 +17,8 @@ t.create('unspecified license')
- `
- )
+ `,
+ ),
)
.expectBadge({
label: 'license',
diff --git a/services/eclipse-marketplace/eclipse-marketplace-update.service.js b/services/eclipse-marketplace/eclipse-marketplace-update.service.js
index a13b7d0d03ddc..ab16e77086d3d 100644
--- a/services/eclipse-marketplace/eclipse-marketplace-update.service.js
+++ b/services/eclipse-marketplace/eclipse-marketplace-update.service.js
@@ -1,6 +1,6 @@
import Joi from 'joi'
-import { formatDate } from '../text-formatters.js'
-import { age as ageColor } from '../color-formatters.js'
+import { pathParams } from '../index.js'
+import { renderDateBadge } from '../date.js'
import { nonNegativeInteger } from '../validators.js'
import EclipseMarketplaceBase from './eclipse-marketplace-base.js'
@@ -14,30 +14,31 @@ const updateResponseSchema = Joi.object({
export default class EclipseMarketplaceUpdate extends EclipseMarketplaceBase {
static category = 'activity'
- static route = this.buildRoute('eclipse-marketplace/last-update')
- static examples = [
- {
- title: 'Eclipse Marketplace',
- namedParams: { name: 'notepad4e' },
- staticPreview: this.render({ date: new Date().getTime() }),
+ static route = {
+ base: 'eclipse-marketplace/last-update',
+ pattern: ':name',
+ }
+
+ static openApi = {
+ '/eclipse-marketplace/last-update/{name}': {
+ get: {
+ summary: 'Eclipse Marketplace Last Update',
+ parameters: pathParams({
+ name: 'name',
+ example: 'notepad4e',
+ }),
+ },
},
- ]
+ }
static defaultBadgeData = { label: 'updated' }
- static render({ date }) {
- return {
- message: formatDate(date),
- color: ageColor(date),
- }
- }
-
async handle({ name }) {
const { marketplace } = await this.fetch({
name,
schema: updateResponseSchema,
})
const date = 1000 * parseInt(marketplace.node.changed)
- return this.constructor.render({ date })
+ return renderDateBadge(date)
}
}
diff --git a/services/eclipse-marketplace/eclipse-marketplace-version.service.js b/services/eclipse-marketplace/eclipse-marketplace-version.service.js
index 0e367fa12595d..9da479366ff28 100644
--- a/services/eclipse-marketplace/eclipse-marketplace-version.service.js
+++ b/services/eclipse-marketplace/eclipse-marketplace-version.service.js
@@ -1,4 +1,5 @@
import Joi from 'joi'
+import { pathParams } from '../index.js'
import { renderVersionBadge } from '../version.js'
import EclipseMarketplaceBase from './eclipse-marketplace-base.js'
@@ -12,14 +13,22 @@ const versionResponseSchema = Joi.object({
export default class EclipseMarketplaceVersion extends EclipseMarketplaceBase {
static category = 'version'
- static route = this.buildRoute('eclipse-marketplace/v')
- static examples = [
- {
- title: 'Eclipse Marketplace',
- namedParams: { name: 'notepad4e' },
- staticPreview: this.render({ version: '1.0.1' }),
+ static route = {
+ base: 'eclipse-marketplace/v',
+ pattern: ':name',
+ }
+
+ static openApi = {
+ '/eclipse-marketplace/v/{name}': {
+ get: {
+ summary: 'Eclipse Marketplace Version',
+ parameters: pathParams({
+ name: 'name',
+ example: 'notepad4e',
+ }),
+ },
},
- ]
+ }
static defaultBadgeData = { label: 'eclipse marketplace' }
diff --git a/services/ecologi/ecologi-carbon.service.js b/services/ecologi/ecologi-carbon.service.js
index 4aac425d1e608..3f90ad1c32879 100644
--- a/services/ecologi/ecologi-carbon.service.js
+++ b/services/ecologi/ecologi-carbon.service.js
@@ -1,7 +1,7 @@
import Joi from 'joi'
import { metric } from '../text-formatters.js'
import { floorCount } from '../color-formatters.js'
-import { BaseJsonService } from '../index.js'
+import { BaseJsonService, pathParams } from '../index.js'
const apiSchema = Joi.object({
total: Joi.number().positive().required(),
@@ -10,13 +10,17 @@ const apiSchema = Joi.object({
export default class EcologiCarbonOffset extends BaseJsonService {
static category = 'other'
static route = { base: 'ecologi/carbon', pattern: ':username' }
- static examples = [
- {
- title: 'Ecologi (Carbon Offset)',
- namedParams: { username: 'ecologi' },
- staticPreview: this.render({ count: 15.05 }),
+ static openApi = {
+ '/ecologi/carbon/{username}': {
+ get: {
+ summary: 'Ecologi (Carbon Offset)',
+ parameters: pathParams({
+ name: 'username',
+ example: 'ecologi',
+ }),
+ },
},
- ]
+ }
static defaultBadgeData = { label: 'carbon offset' }
@@ -30,7 +34,7 @@ export default class EcologiCarbonOffset extends BaseJsonService {
return this._requestJson({
url,
schema: apiSchema,
- errorMessages: {
+ httpErrors: {
404: 'username not found',
},
})
diff --git a/services/ecologi/ecologi-carbon.tester.js b/services/ecologi/ecologi-carbon.tester.js
index 17394e5e293b7..16f0b1fb7ae46 100644
--- a/services/ecologi/ecologi-carbon.tester.js
+++ b/services/ecologi/ecologi-carbon.tester.js
@@ -1,12 +1,12 @@
import { createServiceTester } from '../tester.js'
-import { withRegex } from '../test-validators.js'
+import { isMetricWithPattern } from '../test-validators.js'
export const t = await createServiceTester()
t.create('request for existing username')
.get('/ecologi.json')
.expectBadge({
label: 'carbon offset',
- message: withRegex(/[\d.]+ tonnes/),
+ message: isMetricWithPattern(/ tonnes/),
})
t.create('invalid username').get('/non-existent-username.json').expectBadge({
diff --git a/services/ecologi/ecologi-trees.service.js b/services/ecologi/ecologi-trees.service.js
index 46d41b2f00f19..724a4105528a3 100644
--- a/services/ecologi/ecologi-trees.service.js
+++ b/services/ecologi/ecologi-trees.service.js
@@ -2,7 +2,7 @@ import Joi from 'joi'
import { metric } from '../text-formatters.js'
import { floorCount } from '../color-formatters.js'
import { nonNegativeInteger } from '../validators.js'
-import { BaseJsonService } from '../index.js'
+import { BaseJsonService, pathParams } from '../index.js'
const apiSchema = Joi.object({
total: nonNegativeInteger,
@@ -11,13 +11,17 @@ const apiSchema = Joi.object({
export default class EcologiTrees extends BaseJsonService {
static category = 'other'
static route = { base: 'ecologi/trees', pattern: ':username' }
- static examples = [
- {
- title: 'Ecologi (Trees)',
- namedParams: { username: 'ecologi' },
- staticPreview: this.render({ count: 250 }),
+ static openApi = {
+ '/ecologi/trees/{username}': {
+ get: {
+ summary: 'Ecologi (Trees)',
+ parameters: pathParams({
+ name: 'username',
+ example: 'ecologi',
+ }),
+ },
},
- ]
+ }
static defaultBadgeData = { label: 'trees' }
@@ -30,7 +34,7 @@ export default class EcologiTrees extends BaseJsonService {
return this._requestJson({
url,
schema: apiSchema,
- errorMessages: {
+ httpErrors: {
404: 'username not found',
},
})
diff --git a/services/ecologi/ecologi-trees.tester.js b/services/ecologi/ecologi-trees.tester.js
index 01d776263a396..4aafb00ffb58e 100644
--- a/services/ecologi/ecologi-trees.tester.js
+++ b/services/ecologi/ecologi-trees.tester.js
@@ -12,7 +12,7 @@ t.create('request for existing username')
.intercept(nock =>
nock('https://public.ecologi.com')
.get('/users/ecologi/trees')
- .reply(200, { total: 50 })
+ .reply(200, { total: 50 }),
)
.expectBadge({
label: 'trees',
diff --git a/services/elm-package/elm-package.service.js b/services/elm-package/elm-package.service.js
index dd606d090c0ff..efa97a03abfec 100644
--- a/services/elm-package/elm-package.service.js
+++ b/services/elm-package/elm-package.service.js
@@ -1,20 +1,30 @@
import Joi from 'joi'
import { renderVersionBadge } from '../version.js'
import { semver } from '../validators.js'
-import { BaseJsonService } from '../index.js'
+import { BaseJsonService, pathParams } from '../index.js'
const schema = Joi.object({ version: semver }).required()
export default class ElmPackage extends BaseJsonService {
static category = 'version'
static route = { base: 'elm-package/v', pattern: ':user/:packageName' }
- static examples = [
- {
- title: 'Elm package',
- namedParams: { user: 'elm', packageName: 'core' },
- staticPreview: this.render({ version: '1.0.2' }),
+ static openApi = {
+ '/elm-package/v/{user}/{packageName}': {
+ get: {
+ summary: 'Elm package',
+ parameters: pathParams(
+ {
+ name: 'user',
+ example: 'elm',
+ },
+ {
+ name: 'packageName',
+ example: 'core',
+ },
+ ),
+ },
},
- ]
+ }
static defaultBadgeData = { label: 'elm package' }
@@ -27,7 +37,7 @@ export default class ElmPackage extends BaseJsonService {
const { version } = await this._requestJson({
schema,
url,
- errorMessages: {
+ httpErrors: {
404: 'package not found',
},
})
diff --git a/services/endpoint-common.js b/services/endpoint-common.js
index 1f3cf8d00cff6..0e1207cc9549f 100644
--- a/services/endpoint-common.js
+++ b/services/endpoint-common.js
@@ -1,3 +1,9 @@
+/**
+ * Common functions and utilities for tasks related to endpoint badges.
+ *
+ * @module
+ */
+
import Joi from 'joi'
import validate from '../core/base-service/validate.js'
import { InvalidResponse } from './index.js'
@@ -7,13 +13,18 @@ const optionalStringWhenNamedLogoPresent = Joi.alternatives().conditional(
{
is: Joi.string().required(),
then: Joi.string(),
- }
+ },
)
const optionalNumberWhenAnyLogoPresent = Joi.alternatives()
.conditional('namedLogo', { is: Joi.string().required(), then: Joi.number() })
.conditional('logoSvg', { is: Joi.string().required(), then: Joi.number() })
+/**
+ * Joi schema for validating endpoint.
+ *
+ * @type {Joi}
+ */
const endpointSchema = Joi.object({
schemaVersion: 1,
label: Joi.string().allow('').required(),
@@ -24,21 +35,36 @@ const endpointSchema = Joi.object({
namedLogo: Joi.string(),
logoSvg: Joi.string(),
logoColor: optionalStringWhenNamedLogoPresent,
- logoWidth: optionalNumberWhenAnyLogoPresent,
- logoPosition: optionalNumberWhenAnyLogoPresent,
+ logoSize: optionalStringWhenNamedLogoPresent,
style: Joi.string(),
cacheSeconds: Joi.number().integer().min(0),
+ /*
+ Retained for legacy compatibility
+ Although this does nothing,
+ passing it should not throw an error
+ */
+ logoPosition: optionalNumberWhenAnyLogoPresent,
+ logoWidth: optionalNumberWhenAnyLogoPresent,
})
// `namedLogo` or `logoSvg`; not both.
.oxor('namedLogo', 'logoSvg')
.required()
-// Strictly validate according to the endpoint schema. This rejects unknown /
-// invalid keys. Optionally it prints those keys in the message in order to
-// provide detailed feedback.
+/**
+ * Strictly validate the data according to the endpoint schema.
+ * This rejects unknown/invalid keys.
+ * Optionally it prints those keys in the message to provide detailed feedback.
+ *
+ * @param {object} data Object containing the data for validation
+ * @param {object} attrs Refer to individual attributes
+ * @param {string} [attrs.prettyErrorMessage] If provided then error message is set to this value
+ * @param {boolean} [attrs.includeKeys] If true then includes error details in error message, defaults to false
+ * @throws {InvalidResponse|Error} Error if Joi validation fails due to invalid or no schema
+ * @returns {object} Value if Joi validation is success
+ */
function validateEndpointData(
data,
- { prettyErrorMessage = 'invalid response data', includeKeys = false } = {}
+ { prettyErrorMessage = 'invalid response data', includeKeys = false } = {},
) {
return validate(
{
@@ -50,21 +76,33 @@ function validateEndpointData(
allowAndStripUnknownKeys: false,
},
data,
- endpointSchema
+ endpointSchema,
)
}
const anySchema = Joi.any()
+/**
+ * Fetches data from the endpoint and validates the data.
+ *
+ * @param {object} serviceInstance Instance of Endpoint class
+ * @param {object} attrs Refer to individual attributes
+ * @param {string} attrs.url Endpoint URL
+ * @param {object} attrs.httpErrors Object containing error messages for different error codes
+ * @param {string} attrs.validationPrettyErrorMessage If provided then the error message is set to this value
+ * @param {boolean} attrs.includeKeys If true then includes error details in error message
+ * @returns {object} Data fetched from endpoint
+ */
async function fetchEndpointData(
serviceInstance,
- { url, errorMessages, validationPrettyErrorMessage, includeKeys }
+ { url, httpErrors, validationPrettyErrorMessage, includeKeys },
) {
const json = await serviceInstance._requestJson({
schema: anySchema,
url,
- errorMessages,
- options: { gzip: true },
+ httpErrors,
+ logErrors: [],
+ options: { decompress: true, timeout: { request: 3500 } },
})
return validateEndpointData(json, {
prettyErrorMessage: validationPrettyErrorMessage,
diff --git a/services/endpoint/endpoint-redirect.service.js b/services/endpoint/endpoint-redirect.service.js
index c8db8c7de1f32..9ab34660cc7df 100644
--- a/services/endpoint/endpoint-redirect.service.js
+++ b/services/endpoint/endpoint-redirect.service.js
@@ -1,11 +1,12 @@
-import { redirector } from '../index.js'
+import { deprecatedService } from '../index.js'
-export default redirector({
+export default deprecatedService({
category: 'other',
+ label: 'endpoint',
route: {
base: 'badge/endpoint',
pattern: '',
},
- transformPath: () => '/endpoint',
- dateAdded: new Date('2019-02-19'),
+ dateAdded: new Date('2025-12-20'),
+ issueUrl: 'https://github.com/badges/shields/pull/11583',
})
diff --git a/services/endpoint/endpoint-redirect.tester.js b/services/endpoint/endpoint-redirect.tester.js
index e0c6de4a5865e..153cd6074cf75 100644
--- a/services/endpoint/endpoint-redirect.tester.js
+++ b/services/endpoint/endpoint-redirect.tester.js
@@ -7,5 +7,8 @@ export const t = new ServiceTester({
})
t.create('Build: default branch')
- .get('.svg?url=https://example.com/badge.json')
- .expectRedirect('/endpoint.svg?url=https://example.com/badge.json')
+ .get('.json?url=https://example.com/badge.json')
+ .expectBadge({
+ label: 'endpoint',
+ message: 'https://github.com/badges/shields/pull/11583',
+ })
diff --git a/services/endpoint/endpoint.service.js b/services/endpoint/endpoint.service.js
index 8a64ccc74ca88..1052248fa0fe9 100644
--- a/services/endpoint/endpoint.service.js
+++ b/services/endpoint/endpoint.service.js
@@ -1,24 +1,142 @@
import { URL } from 'url'
import Joi from 'joi'
-import { errorMessages } from '../dynamic-common.js'
-import { optionalUrl } from '../validators.js'
+import configModule from 'config'
+import { httpErrors } from '../dynamic-common.js'
+import { url } from '../validators.js'
import { fetchEndpointData } from '../endpoint-common.js'
-import { BaseJsonService, InvalidParameter } from '../index.js'
+import { BaseJsonService, InvalidParameter, queryParams } from '../index.js'
const blockedDomains = ['github.com', 'shields.io']
const queryParamSchema = Joi.object({
- url: optionalUrl.required(),
+ url,
}).required()
+const description = `
+Using the endpoint badge, you can provide content for a badge through
+a JSON endpoint. The content can be prerendered, or generated on the
+fly. To strike a balance between responsiveness and bandwidth
+utilization on one hand, and freshness on the other, cache behavior is
+configurable, subject to the Shields minimum. The endpoint URL is
+provided to Shields through the query string. Shields fetches it and
+formats the badge.
+
+The endpoint badge takes a single required query param: url, which is the URL to your JSON endpoint
+
+
+
Example JSON Endpoint Response
+
{ "schemaVersion": 1, "label": "hello", "message": "sweet world", "color": "orange" }
+
Example Shields Response
+
+
+
+
Schema
+
+
+
+ Property
+ Description
+
+
+ schemaVersion
+ Required. Always the number 1.
+
+
+ label
+
+ Required. The left text, or the empty string to omit the left side of
+ the badge. This can be overridden by the query string.
+
+
+
+ message
+ Required. Can't be empty. The right text.
+
+
+ color
+
+ Default: lightgrey. The right color. Supports the eight
+ named colors above, as well as hex, rgb, rgba, hsl, hsla and css named
+ colors. This can be overridden by the query string.
+
+
+
+ labelColor
+
+ Default: grey. The left color. This can be overridden by
+ the query string.
+
+
+
+ isError
+
+ Default: false. true to treat this as an
+ error badge. This prevents the user from overriding the color. In the
+ future, it may affect cache behavior.
+
+
+
+ namedLogo
+
+ Default: none. One of the
+ simple-icons slugs. Can be
+ overridden by the query string.
+
+
+
+ logoSvg
+ Default: none. An SVG string containing a custom logo.
+
+
+ logoColor
+
+ Default: none. Same meaning as the query string. Can be overridden by
+ the query string. Only works for simple-icons logos.
+
+
+
+ logoSize
+
+ Default: none. Make icons adaptively resize by setting auto.
+ Useful for some wider logos like amd and amg.
+ Supported for simple-icons logos only.
+
+
+
+ style
+
+ Default: flat. The default template to use. Can be
+ overridden by the query string.
+
+
+
+
+
`
+
export default class Endpoint extends BaseJsonService {
static category = 'dynamic'
+
static route = {
base: 'endpoint',
pattern: '',
queryParamSchema,
}
+ static openApi = {
+ '/endpoint': {
+ get: {
+ summary: 'Endpoint Badge',
+ description,
+ parameters: queryParams({
+ name: 'url',
+ description: 'The URL to your JSON endpoint',
+ required: true,
+ example: 'https://shields.redsparr0w.com/2473/monday',
+ }),
+ },
+ },
+ }
+
static _cacheLength = 300
static defaultBadgeData = { label: 'custom badge' }
@@ -31,8 +149,7 @@ export default class Endpoint extends BaseJsonService {
namedLogo,
logoSvg,
logoColor,
- logoWidth,
- logoPosition,
+ logoSize,
style,
cacheSeconds,
}) {
@@ -45,13 +162,22 @@ export default class Endpoint extends BaseJsonService {
namedLogo,
logoSvg,
logoColor,
- logoWidth,
- logoPosition,
+ logoSize,
style,
- cacheSeconds,
+ // don't allow the user to set cacheSeconds any shorter than this._cacheLength
+ cacheSeconds: Math.max(
+ ...[this._cacheLength, cacheSeconds].filter(x => x !== undefined),
+ ),
}
}
+ constructor(...args) {
+ super(...args)
+ const config = configModule.util.toObject()
+ this._allowUnsecuredEndpointRequests =
+ config?.public?.allowUnsecuredEndpointRequests || false
+ }
+
async handle(namedParams, { url }) {
let protocol, hostname
try {
@@ -61,7 +187,7 @@ export default class Endpoint extends BaseJsonService {
} catch (e) {
throw new InvalidParameter({ prettyMessage: 'invalid url' })
}
- if (protocol !== 'https:') {
+ if (protocol !== 'https:' && !this._allowUnsecuredEndpointRequests) {
throw new InvalidParameter({ prettyMessage: 'please use https' })
}
if (blockedDomains.some(domain => hostname.endsWith(domain))) {
@@ -70,7 +196,7 @@ export default class Endpoint extends BaseJsonService {
const validated = await fetchEndpointData(this, {
url,
- errorMessages,
+ httpErrors,
validationPrettyErrorMessage: 'invalid properties',
includeKeys: true,
})
diff --git a/services/endpoint/endpoint.spec.js b/services/endpoint/endpoint.spec.js
new file mode 100644
index 0000000000000..cfccd918aa347
--- /dev/null
+++ b/services/endpoint/endpoint.spec.js
@@ -0,0 +1,58 @@
+import { expect } from 'chai'
+import sinon from 'sinon'
+import nock from 'nock'
+import configModule from 'config'
+import { defaultContext } from '../test-helpers.js'
+import { InvalidParameter } from '../index.js'
+import Endpoint from './endpoint.service.js'
+
+describe('Endpoint', function () {
+ afterEach(function () {
+ sinon.restore()
+ })
+ it('allows unsecured endpoint when config enabled', async function () {
+ nock('http://example.com').get('/badge').reply(200, {
+ schemaVersion: 1,
+ label: 'unsecured',
+ message: 'allowed',
+ })
+
+ sinon.stub(configModule.util, 'toObject').returns({
+ public: { allowUnsecuredEndpointRequests: true },
+ })
+
+ const endpoint = new Endpoint(defaultContext, {
+ handleInternalErrors: false,
+ })
+ const result = await endpoint.handle(
+ {},
+ { url: 'http://example.com/badge' },
+ )
+ expect(result).to.include({ label: 'unsecured', message: 'allowed' })
+ })
+
+ it('blocks unsecured endpoint when config disabled', async function () {
+ nock('http://example.com').get('/badge').reply(200, {
+ schemaVersion: 1,
+ label: 'unsecured',
+ message: 'allowed',
+ })
+
+ sinon.stub(configModule.util, 'toObject').returns({
+ public: { allowUnsecuredEndpointRequests: false },
+ })
+
+ const endpoint = new Endpoint(defaultContext, {
+ handleInternalErrors: false,
+ })
+ let error
+ try {
+ await endpoint.handle({}, { url: 'http://example.com/badge' })
+ } catch (e) {
+ error = e
+ }
+ expect(error)
+ .to.be.instanceOf(InvalidParameter)
+ .to.have.property('prettyMessage', 'please use https')
+ })
+})
diff --git a/services/endpoint/endpoint.tester.js b/services/endpoint/endpoint.tester.js
index 3be249e02cea0..464833fafb9b4 100644
--- a/services/endpoint/endpoint.tester.js
+++ b/services/endpoint/endpoint.tester.js
@@ -1,6 +1,6 @@
import zlib from 'zlib'
import { expect } from 'chai'
-import { getShieldsIcon } from '../../lib/logos.js'
+import { getSimpleIcon } from '../../lib/logos.js'
import { createServiceTester } from '../tester.js'
export const t = await createServiceTester()
@@ -11,7 +11,7 @@ t.create('Valid schema')
schemaVersion: 1,
label: '',
message: 'yo',
- })
+ }),
)
.expectBadge({ label: '', message: 'yo' })
@@ -24,7 +24,7 @@ t.create('color and labelColor')
message: 'yo',
color: '#f0dcc3',
labelColor: '#e6e6fa',
- })
+ }),
)
.expectBadge({
label: 'hey',
@@ -41,7 +41,7 @@ t.create('style')
label: 'hey',
message: 'yo',
color: '#99c',
- })
+ }),
)
.expectBadge({
label: 'hey',
@@ -59,11 +59,11 @@ t.create('named logo')
label: 'hey',
message: 'yo',
namedLogo: 'npm',
- })
+ }),
)
.after((err, res, body) => {
expect(err).not.to.be.ok
- expect(body).to.include(getShieldsIcon({ name: 'npm' }))
+ expect(body).to.include(getSimpleIcon({ name: 'npm' }))
})
t.create('named logo with color')
@@ -73,18 +73,34 @@ t.create('named logo with color')
schemaVersion: 1,
label: 'hey',
message: 'yo',
- namedLogo: 'npm',
+ namedLogo: 'github',
logoColor: 'blue',
- })
+ }),
)
.after((err, res, body) => {
expect(err).not.to.be.ok
- expect(body).to.include(getShieldsIcon({ name: 'npm', color: 'blue' }))
+ expect(body).to.include(getSimpleIcon({ name: 'github', color: 'blue' }))
+ })
+
+t.create('named logo with size')
+ .get('.svg?url=https://example.com/badge')
+ .intercept(nock =>
+ nock('https://example.com/').get('/badge').reply(200, {
+ schemaVersion: 1,
+ label: 'hey',
+ message: 'yo',
+ namedLogo: 'github',
+ logoSize: 'auto',
+ }),
+ )
+ .after((err, res, body) => {
+ expect(err).not.to.be.ok
+ expect(body).to.include(getSimpleIcon({ name: 'github', size: 'auto' }))
})
const logoSvg = Buffer.from(
- getShieldsIcon({ name: 'npm' }).replace('data:image/svg+xml;base64,', ''),
- 'base64'
+ getSimpleIcon({ name: 'npm' }).replace('data:image/svg+xml;base64,', ''),
+ 'base64',
).toString('ascii')
t.create('custom svg logo')
@@ -95,13 +111,15 @@ t.create('custom svg logo')
label: 'hey',
message: 'yo',
logoSvg,
- })
+ }),
)
.after((err, res, body) => {
expect(err).not.to.be.ok
- expect(body).to.include(getShieldsIcon({ name: 'npm' }))
+ expect(body).to.include(getSimpleIcon({ name: 'npm' }))
})
+// The logoWidth param was removed, but passing it should not
+// throw a validation error. It should just do nothing.
t.create('logoWidth')
.get('.json?url=https://example.com/badge')
.intercept(nock =>
@@ -111,12 +129,29 @@ t.create('logoWidth')
message: 'yo',
logoSvg,
logoWidth: 30,
- })
+ }),
+ )
+ .expectBadge({
+ label: 'hey',
+ message: 'yo',
+ })
+
+// The logoPosition param was removed, but passing it should not
+// throw a validation error. It should just do nothing.
+t.create('logoPosition')
+ .get('.json?url=https://example.com/badge')
+ .intercept(nock =>
+ nock('https://example.com/').get('/badge').reply(200, {
+ schemaVersion: 1,
+ label: 'hey',
+ message: 'yo',
+ logoSvg,
+ logoPosition: 30,
+ }),
)
.expectBadge({
label: 'hey',
message: 'yo',
- logoWidth: 30,
})
t.create('Invalid schema')
@@ -124,7 +159,7 @@ t.create('Invalid schema')
.intercept(nock =>
nock('https://example.com/').get('/badge').reply(200, {
schemaVersion: -1,
- })
+ }),
)
.expectBadge({
label: 'custom badge',
@@ -140,7 +175,7 @@ t.create('Invalid schema')
message: 'yo',
extra: 'keys',
bogus: true,
- })
+ }),
)
.expectBadge({
label: 'custom badge',
@@ -155,7 +190,7 @@ t.create('User color overrides success color')
label: '',
message: 'yo',
color: 'blue',
- })
+ }),
)
.expectBadge({ label: '', message: 'yo', color: '#101010' })
@@ -167,7 +202,7 @@ t.create('User legacy color overrides success color')
label: '',
message: 'yo',
color: 'blue',
- })
+ }),
)
.expectBadge({ label: '', message: 'yo', color: '#101010' })
@@ -180,7 +215,7 @@ t.create('User color does not override error color')
label: 'something is',
message: 'not right',
color: 'red',
- })
+ }),
)
.expectBadge({ label: 'something is', message: 'not right', color: 'red' })
@@ -193,7 +228,7 @@ t.create('User legacy color does not override error color')
label: 'something is',
message: 'not right',
color: 'red',
- })
+ }),
)
.expectBadge({ label: 'something is', message: 'not right', color: 'red' })
@@ -205,7 +240,7 @@ t.create('cacheSeconds')
label: '',
message: 'yo',
cacheSeconds: 500,
- })
+ }),
)
.expectHeader('cache-control', 'max-age=500, s-maxage=500')
@@ -217,7 +252,7 @@ t.create('user can override service cacheSeconds')
label: '',
message: 'yo',
cacheSeconds: 500,
- })
+ }),
)
.expectHeader('cache-control', 'max-age=1000, s-maxage=1000')
@@ -229,7 +264,7 @@ t.create('user does not override longer service cacheSeconds')
label: '',
message: 'yo',
cacheSeconds: 500,
- })
+ }),
)
.expectHeader('cache-control', 'max-age=500, s-maxage=500')
@@ -241,7 +276,7 @@ t.create('cacheSeconds does not override longer Shields default')
label: '',
message: 'yo',
cacheSeconds: 10,
- })
+ }),
)
.expectHeader('cache-control', 'max-age=300, s-maxage=300')
@@ -275,9 +310,9 @@ t.create('gzipped endpoint')
.reply(
200,
zlib.gzipSync(
- JSON.stringify({ schemaVersion: 1, label: '', message: 'yo' })
+ JSON.stringify({ schemaVersion: 1, label: '', message: 'yo' }),
),
- { 'Content-Encoding': 'gzip' }
- )
+ { 'Content-Encoding': 'gzip' },
+ ),
)
.expectBadge({ label: '', message: 'yo' })
diff --git a/services/f-droid/f-droid.service.js b/services/f-droid/f-droid.service.js
index 3ce765eb202c2..d662d545b10aa 100644
--- a/services/f-droid/f-droid.service.js
+++ b/services/f-droid/f-droid.service.js
@@ -2,10 +2,10 @@ import Joi from 'joi'
import {
optionalNonNegativeInteger,
nonNegativeInteger,
+ optionalUrl,
} from '../validators.js'
-import { addv } from '../text-formatters.js'
-import { version as versionColor } from '../color-formatters.js'
-import { BaseJsonService, NotFound } from '../index.js'
+import { renderVersionBadge } from '../version.js'
+import { BaseJsonService, NotFound, pathParam, queryParam } from '../index.js'
const schema = Joi.object({
packageName: Joi.string().required(),
@@ -17,43 +17,52 @@ const schema = Joi.object({
}).required()
const queryParamSchema = Joi.object({
+ baseUrl: optionalUrl,
include_prereleases: Joi.equal(''),
}).required()
export default class FDroid extends BaseJsonService {
static category = 'version'
static route = { base: 'f-droid/v', pattern: ':appId', queryParamSchema }
- static examples = [
- {
- title: 'F-Droid',
- namedParams: { appId: 'org.thosp.yourlocalweather' },
- staticPreview: this.render({ version: '1.0' }),
- keywords: ['fdroid', 'android', 'app'],
- },
- {
- title: 'F-Droid (including pre-releases)',
- namedParams: { appId: 'org.dystopia.email' },
- queryParams: { include_prereleases: null },
- staticPreview: this.render({ version: '1.2.1' }),
- keywords: ['fdroid', 'android', 'app'],
+ static openApi = {
+ '/f-droid/v/{appId}': {
+ get: {
+ summary: 'F-Droid Version',
+ description: `
+ [F-Droid](https://f-droid.org/) is a catalogue of Open Source Android apps.
+
+ This badge by default uses f-droid.org, but also supports custom repos.
+ `,
+ parameters: [
+ pathParam({
+ name: 'appId',
+ example: 'org.dystopia.email',
+ }),
+ queryParam({
+ name: 'baseUrl',
+ example: 'https://apt.izzysoft.de/fdroid',
+ description:
+ 'URL of a third party F-Droid server. If the API is not located at root path, specify the additional path to the API.',
+ }),
+ queryParam({
+ name: 'include_prereleases',
+ schema: { type: 'boolean' },
+ example: null,
+ }),
+ ],
+ },
},
- ]
+ }
static defaultBadgeData = { label: 'f-droid' }
- static render({ version }) {
- return {
- message: addv(version),
- color: versionColor(version),
- }
- }
-
- async fetch({ appId }) {
- const url = `https://f-droid.org/api/v1/packages/${appId}`
+ async fetch({ baseUrl, appId }) {
+ baseUrl = baseUrl.replace(/\/$/, '')
+ const url = `${baseUrl}/api/v1/packages/${appId}`
return this._requestJson({
schema,
url,
- errorMessages: {
+ httpErrors: {
403: 'app not found',
404: 'app not found',
},
@@ -63,21 +72,24 @@ export default class FDroid extends BaseJsonService {
transform({ json, suggested }) {
const svc = suggested && json.suggestedVersionCode
const packages = (json.packages || []).filter(
- ({ versionCode }) => !svc || versionCode <= svc
+ ({ versionCode }) => !svc || versionCode <= svc,
)
if (packages.length === 0) {
throw new NotFound({ prettyMessage: 'no packages found' })
}
const version = packages.reduce((a, b) =>
- a.versionCode > b.versionCode ? a : b
+ a.versionCode > b.versionCode ? a : b,
).versionName
return { version }
}
- async handle({ appId }, { include_prereleases: includePre }) {
- const json = await this.fetch({ appId })
+ async handle(
+ { appId },
+ { baseUrl = 'https://f-droid.org', include_prereleases: includePre },
+ ) {
+ const json = await this.fetch({ baseUrl, appId })
const suggested = includePre === undefined
const { version } = this.transform({ json, suggested })
- return this.constructor.render({ version })
+ return renderVersionBadge({ version })
}
}
diff --git a/services/f-droid/f-droid.tester.js b/services/f-droid/f-droid.tester.js
index ea63f06ee635d..3c8074d9cedd2 100644
--- a/services/f-droid/f-droid.tester.js
+++ b/services/f-droid/f-droid.tester.js
@@ -31,45 +31,115 @@ const testJson = `
const base = 'https://f-droid.org/api/v1'
const path = `/packages/${testPkg}`
-t.create('Package is found')
+t.create('f-droid.org: Package is found')
.get(`/v/${testPkg}.json`)
.intercept(nock => nock(base).get(path).reply(200, testJson))
.expectBadge({ label: 'f-droid', message: 'v0.2.7' })
-t.create('Package is found (pre-release)')
+t.create('f-droid.org: Package is found (pre-release)')
.get(`/v/${testPkg}.json?include_prereleases`)
.intercept(nock => nock(base).get(path).reply(200, testJson))
.expectBadge({ label: 'f-droid', message: 'v0.2.11' })
-t.create('Package is not found with 403')
+t.create('f-droid.org: Package is not found with 403')
.get(`/v/${testPkg}.json`)
.intercept(nock => nock(base).get(path).reply(403, 'some 403 text'))
.expectBadge({ label: 'f-droid', message: 'app not found' })
-t.create('Package is not found with 404')
+t.create('f-droid.org: Package is not found with 404')
.get('/v/io.shiels.does.not.exist.json')
+ .intercept(nock =>
+ nock(base)
+ .get('/packages/io.shiels.does.not.exist')
+ .reply(404, 'some 404 text'),
+ )
.expectBadge({ label: 'f-droid', message: 'app not found' })
-t.create('Package is not found with no packages available (empty array)"')
+t.create(
+ 'f-droid.org: Package is not found with no packages available (empty array)"',
+)
.get(`/v/${testPkg}.json`)
.intercept(nock =>
nock(base)
.get(path)
- .reply(200, `{"packageName":"${testPkg}","packages":[]}`)
+ .reply(200, `{"packageName":"${testPkg}","packages":[]}`),
)
.expectBadge({ label: 'f-droid', message: 'no packages found' })
-t.create('Package is not found with no packages available (missing array)"')
+t.create(
+ 'f-droid.org: Package is not found with no packages available (missing array)"',
+)
.get(`/v/${testPkg}.json`)
.intercept(nock =>
- nock(base).get(path).reply(200, `{"packageName":"${testPkg}"}`)
+ nock(base).get(path).reply(200, `{"packageName":"${testPkg}"}`),
)
.expectBadge({ label: 'f-droid', message: 'no packages found' })
/* If this test fails, either the API has changed or the app was deleted. */
-t.create('The real api did not change')
+t.create('f-droid.org: The real api did not change')
.get('/v/org.thosp.yourlocalweather.json')
.expectBadge({
label: 'f-droid',
message: isVPlusDottedVersionAtLeastOne,
})
+
+const base2 = 'https://apt.izzysoft.de/fdroid/api/v1'
+const path2 = `/packages/${testPkg}`
+
+t.create('custom repo: Package is found')
+ .get(`/v/${testPkg}.json?baseUrl=https%3A%2F%2Fapt.izzysoft.de%2Ffdroid`)
+ .intercept(nock => nock(base2).get(path2).reply(200, testJson))
+ .expectBadge({ label: 'f-droid', message: 'v0.2.7' })
+
+t.create('custom repo: Package is found (pre-release)')
+ .get(
+ `/v/${testPkg}.json?baseUrl=https%3A%2F%2Fapt.izzysoft.de%2Ffdroid&include_prereleases`,
+ )
+ .intercept(nock => nock(base2).get(path2).reply(200, testJson))
+ .expectBadge({ label: 'f-droid', message: 'v0.2.11' })
+
+t.create('custom repo: Package is not found with 403')
+ .get(`/v/${testPkg}.json?baseUrl=https%3A%2F%2Fapt.izzysoft.de%2Ffdroid`)
+ .intercept(nock => nock(base2).get(path2).reply(403, 'some 403 text'))
+ .expectBadge({ label: 'f-droid', message: 'app not found' })
+
+t.create('custom repo: Package is not found with 404')
+ .get(
+ '/v/io.shiels.does.not.exist.json?baseUrl=https%3A%2F%2Fapt.izzysoft.de%2Ffdroid',
+ )
+ .intercept(nock =>
+ nock(base2)
+ .get('/packages/io.shiels.does.not.exist')
+ .reply(404, 'some 404 text'),
+ )
+ .expectBadge({ label: 'f-droid', message: 'app not found' })
+
+t.create(
+ 'custom repo: Package is not found with no packages available (empty array)"',
+)
+ .get(`/v/${testPkg}.json?baseUrl=https%3A%2F%2Fapt.izzysoft.de%2Ffdroid`)
+ .intercept(nock =>
+ nock(base2)
+ .get(path2)
+ .reply(200, `{"packageName":"${testPkg}","packages":[]}`),
+ )
+ .expectBadge({ label: 'f-droid', message: 'no packages found' })
+
+t.create(
+ 'custom repo: Package is not found with no packages available (missing array)"',
+)
+ .get(`/v/${testPkg}.json?baseUrl=https%3A%2F%2Fapt.izzysoft.de%2Ffdroid`)
+ .intercept(nock =>
+ nock(base2).get(path2).reply(200, `{"packageName":"${testPkg}"}`),
+ )
+ .expectBadge({ label: 'f-droid', message: 'no packages found' })
+
+/* If this test fails, either the API has changed or the app was deleted. */
+t.create('custom repo: The real api did not change')
+ .get(
+ '/v/com.looker.droidify.json?baseUrl=https%3A%2F%2Fapt.izzysoft.de%2Ffdroid',
+ )
+ .expectBadge({
+ label: 'f-droid',
+ message: isVPlusDottedVersionAtLeastOne,
+ })
diff --git a/services/factorio-mod-portal/factorio-mod-portal.service.js b/services/factorio-mod-portal/factorio-mod-portal.service.js
new file mode 100644
index 0000000000000..f8ab8eadea741
--- /dev/null
+++ b/services/factorio-mod-portal/factorio-mod-portal.service.js
@@ -0,0 +1,177 @@
+import Joi from 'joi'
+import { BaseJsonService, pathParams } from '../index.js'
+import { renderDateBadge } from '../date.js'
+import { nonNegativeInteger } from '../validators.js'
+import { renderDownloadsBadge } from '../downloads.js'
+import { renderVersionBadge } from '../version.js'
+
+const schema = Joi.object({
+ downloads_count: nonNegativeInteger,
+ releases: Joi.array()
+ .items(
+ Joi.object({
+ version: Joi.string().required(),
+ released_at: Joi.string().required(),
+ info_json: Joi.object({
+ factorio_version: Joi.string().required(),
+ }).required(),
+ }),
+ )
+ .min(1)
+ .required(),
+}).required()
+
+// Factorio Mod portal API
+// @see https://wiki.factorio.com/Mod_portal_API
+class BaseFactorioModPortalService extends BaseJsonService {
+ async fetch({ modName }) {
+ const { releases, downloads_count } = await this._requestJson({
+ schema,
+ url: `https://mods.factorio.com/api/mods/${modName}`,
+ httpErrors: {
+ 404: 'mod not found',
+ },
+ })
+
+ return {
+ downloads_count,
+ latest_release: releases[releases.length - 1],
+ }
+ }
+}
+
+// Badge for mod's latest updated version
+class FactorioModPortalLatestVersion extends BaseFactorioModPortalService {
+ static category = 'version'
+
+ static route = {
+ base: 'factorio-mod-portal/v',
+ pattern: ':modName',
+ }
+
+ static openApi = {
+ '/factorio-mod-portal/v/{modName}': {
+ get: {
+ summary: 'Factorio Mod Portal mod version',
+ parameters: pathParams({
+ name: 'modName',
+ example: 'rso-mod',
+ }),
+ },
+ },
+ }
+
+ static defaultBadgeData = { label: 'latest version' }
+
+ static render({ version }) {
+ return renderVersionBadge({ version })
+ }
+
+ async handle({ modName }) {
+ const resp = await this.fetch({ modName })
+ return this.constructor.render({ version: resp.latest_release.version })
+ }
+}
+
+// Badge for mod's latest compatible Factorio version
+class FactorioModPortalFactorioVersion extends BaseFactorioModPortalService {
+ static category = 'platform-support'
+
+ static route = {
+ base: 'factorio-mod-portal/factorio-version',
+ pattern: ':modName',
+ }
+
+ static openApi = {
+ '/factorio-mod-portal/factorio-version/{modName}': {
+ get: {
+ summary: 'Factorio Mod Portal factorio versions',
+ parameters: pathParams({
+ name: 'modName',
+ example: 'rso-mod',
+ }),
+ },
+ },
+ }
+
+ static defaultBadgeData = { label: 'factorio version' }
+
+ static render({ version }) {
+ return renderVersionBadge({ version })
+ }
+
+ async handle({ modName }) {
+ const resp = await this.fetch({ modName })
+ const version = resp.latest_release.info_json.factorio_version
+ return this.constructor.render({ version })
+ }
+}
+
+// Badge for mod's last updated date
+class FactorioModPortalLastUpdated extends BaseFactorioModPortalService {
+ static category = 'activity'
+
+ static route = {
+ base: 'factorio-mod-portal/last-updated',
+ pattern: ':modName',
+ }
+
+ static openApi = {
+ '/factorio-mod-portal/last-updated/{modName}': {
+ get: {
+ summary: 'Factorio Mod Portal last updated',
+ parameters: pathParams({
+ name: 'modName',
+ example: 'rso-mod',
+ }),
+ },
+ },
+ }
+
+ static defaultBadgeData = { label: 'last updated' }
+
+ async handle({ modName }) {
+ const resp = await this.fetch({ modName })
+ return renderDateBadge(resp.latest_release.released_at)
+ }
+}
+
+// Badge for mod's total download count
+class FactorioModPortalDownloads extends BaseFactorioModPortalService {
+ static category = 'downloads'
+
+ static route = {
+ base: 'factorio-mod-portal/dt',
+ pattern: ':modName',
+ }
+
+ static openApi = {
+ '/factorio-mod-portal/dt/{modName}': {
+ get: {
+ summary: 'Factorio Mod Portal downloads',
+ parameters: pathParams({
+ name: 'modName',
+ example: 'rso-mod',
+ }),
+ },
+ },
+ }
+
+ static defaultBadgeData = { label: 'downloads' }
+
+ static render({ downloads }) {
+ return renderDownloadsBadge({ downloads })
+ }
+
+ async handle({ modName }) {
+ const resp = await this.fetch({ modName })
+ return this.constructor.render({ downloads: resp.downloads_count })
+ }
+}
+
+export {
+ FactorioModPortalLatestVersion,
+ FactorioModPortalLastUpdated,
+ FactorioModPortalFactorioVersion,
+ FactorioModPortalDownloads,
+}
diff --git a/services/factorio-mod-portal/factorio-mod-portal.tester.js b/services/factorio-mod-portal/factorio-mod-portal.tester.js
new file mode 100644
index 0000000000000..1c06399116d82
--- /dev/null
+++ b/services/factorio-mod-portal/factorio-mod-portal.tester.js
@@ -0,0 +1,47 @@
+import {
+ isVPlusDottedVersionNClauses,
+ isFormattedDate,
+ isMetric,
+} from '../test-validators.js'
+import { ServiceTester } from '../tester.js'
+
+export const t = new ServiceTester({
+ id: 'factorio-mod-portal',
+ title: 'Factorio Mod Portal',
+})
+
+t.create('Latest Version (rso-mod, valid)').get('/v/rso-mod.json').expectBadge({
+ label: 'latest version',
+ message: isVPlusDottedVersionNClauses,
+})
+
+t.create('Latest Version (mod not found)')
+ .get('/v/mod-that-doesnt-exist.json')
+ .expectBadge({ label: 'latest version', message: 'mod not found' })
+
+t.create('Factorio Version (rso-mod, valid)')
+ .get('/factorio-version/rso-mod.json')
+ .expectBadge({
+ label: 'factorio version',
+ message: isVPlusDottedVersionNClauses,
+ })
+
+t.create('Factorio Version (mod not found)')
+ .get('/factorio-version/mod-that-doesnt-exist.json')
+ .expectBadge({ label: 'factorio version', message: 'mod not found' })
+
+t.create('Last Updated (rso-mod, valid)')
+ .get('/last-updated/rso-mod.json')
+ .expectBadge({ label: 'last updated', message: isFormattedDate })
+
+t.create('Last Updated (mod not found)')
+ .get('/last-updated/mod-that-doesnt-exist.json')
+ .expectBadge({ label: 'last updated', message: 'mod not found' })
+
+t.create('Downloads (rso-mod, valid)')
+ .get('/dt/rso-mod.json')
+ .expectBadge({ label: 'downloads', message: isMetric })
+
+t.create('Downloads (mod not found)')
+ .get('/dt/mod-that-doesnt-exist.json')
+ .expectBadge({ label: 'downloads', message: 'mod not found' })
diff --git a/services/fedora/fedora.service.js b/services/fedora/fedora.service.js
index d869bcd6efef1..aa11ea521d291 100644
--- a/services/fedora/fedora.service.js
+++ b/services/fedora/fedora.service.js
@@ -1,6 +1,6 @@
import Joi from 'joi'
import { renderVersionBadge } from '../version.js'
-import { BaseJsonService } from '../index.js'
+import { BaseJsonService, pathParams } from '../index.js'
const schema = Joi.object({
version: Joi.string().required(),
@@ -9,18 +9,40 @@ const schema = Joi.object({
// No way to permalink to current "stable", https://pagure.io/mdapi/issue/69
const defaultBranch = 'rawhide'
+const description =
+ 'See mdapi docs for information on valid branches.'
+
export default class Fedora extends BaseJsonService {
static category = 'version'
static route = { base: 'fedora/v', pattern: ':packageName/:branch?' }
- static examples = [
- {
- title: 'Fedora package',
- namedParams: { packageName: 'rpm', branch: 'rawhide' },
- staticPreview: renderVersionBadge({ version: '4.14.2.1' }),
- documentation:
- 'See mdapi docs for information on valid branches.',
+ static openApi = {
+ '/fedora/v/{packageName}/{branch}': {
+ get: {
+ summary: 'Fedora package (with branch)',
+ description,
+ parameters: pathParams(
+ {
+ name: 'packageName',
+ example: 'rpm',
+ },
+ {
+ name: 'branch',
+ example: 'rawhide',
+ },
+ ),
+ },
},
- ]
+ '/fedora/v/{packageName}': {
+ get: {
+ summary: 'Fedora package',
+ description,
+ parameters: pathParams({
+ name: 'packageName',
+ example: 'rpm',
+ }),
+ },
+ },
+ }
static defaultBadgeData = { label: 'fedora' }
@@ -28,10 +50,10 @@ export default class Fedora extends BaseJsonService {
const data = await this._requestJson({
schema,
url: `https://apps.fedoraproject.org/mdapi/${encodeURIComponent(
- branch
+ branch,
)}/pkg/${encodeURIComponent(packageName)}`,
- errorMessages: {
- 400: 'branch not found',
+ httpErrors: {
+ 400: 'branch or package not found',
},
})
return renderVersionBadge({ version: data.version })
diff --git a/services/fedora/fedora.tester.js b/services/fedora/fedora.tester.js
index 51f0d38c5165b..4fbd9ae45ec29 100644
--- a/services/fedora/fedora.tester.js
+++ b/services/fedora/fedora.tester.js
@@ -9,10 +9,10 @@ t.create('Fedora package (default branch, valid)')
message: isVPlusDottedVersionNClausesWithOptionalSuffixAndEpoch,
})
-t.create('Fedora package (not found)')
+t.create('Fedora package (package not found)')
.get('/not-a-package/rawhide.json')
- .expectBadge({ label: 'fedora', message: 'not found' })
+ .expectBadge({ label: 'fedora', message: 'branch or package not found' })
t.create('Fedora package (branch not found)')
.get('/not-a-package/not-a-branch.json')
- .expectBadge({ label: 'fedora', message: 'branch not found' })
+ .expectBadge({ label: 'fedora', message: 'branch or package not found' })
diff --git a/services/feedz/feedz.service.js b/services/feedz/feedz.service.js
index 54d237de38116..2973b4bd3b6de 100644
--- a/services/feedz/feedz.service.js
+++ b/services/feedz/feedz.service.js
@@ -1,67 +1,69 @@
import Joi from 'joi'
-import { BaseJsonService, NotFound } from '../index.js'
+import { BaseJsonService, NotFound, pathParams } from '../index.js'
import {
- renderVersionBadge,
searchServiceUrl,
stripBuildMetadata,
selectVersion,
} from '../nuget/nuget-helpers.js'
+import { renderVersionBadge } from '../version.js'
-const schema = Joi.object({
+const singlePageSchema = Joi.object({
+ '@id': Joi.string().required(),
items: Joi.array()
.items(
Joi.object({
- items: Joi.array().items(
- Joi.object({
- catalogEntry: Joi.object({
- version: Joi.string().required(),
- }).required(),
- })
- ),
- }).required()
+ catalogEntry: Joi.object({
+ version: Joi.string().required(),
+ }).required(),
+ }),
)
.default([]),
}).required()
+const packageSchema = Joi.object({
+ items: Joi.array().items(singlePageSchema).default([]),
+}).required()
+
class FeedzVersionService extends BaseJsonService {
static category = 'version'
static route = {
base: 'feedz',
- pattern: ':which(v|vpre)/:organization/:repository/:packageName',
+ pattern: ':variant(v|vpre)/:organization/:repository/:packageName',
}
- static examples = [
- {
- title: 'Feedz',
- pattern: 'v/:organization/:repository/:packageName',
- namedParams: {
- organization: 'shieldstests',
- repository: 'mongodb',
- packageName: 'MongoDB.Driver.Core',
- },
- staticPreview: this.render({ version: '2.10.4' }),
- },
- {
- title: 'Feedz (with prereleases)',
- pattern: 'vpre/:organization/:repository/:packageName',
- namedParams: {
- organization: 'shieldstests',
- repository: 'mongodb',
- packageName: 'MongoDB.Driver.Core',
+ static openApi = {
+ '/feedz/{variant}/{organization}/{repository}/{packageName}': {
+ get: {
+ summary: 'Feedz Version',
+ parameters: pathParams(
+ {
+ name: 'variant',
+ example: 'v',
+ description: 'version or version including pre-releases',
+ schema: { type: 'string', enum: this.getEnum('variant') },
+ },
+ {
+ name: 'organization',
+ example: 'shieldstests',
+ },
+ {
+ name: 'repository',
+ example: 'mongodb',
+ },
+ {
+ name: 'packageName',
+ example: 'MongoDB.Driver.Core',
+ },
+ ),
},
- staticPreview: this.render({ version: '2.11.0-beta2' }),
},
- ]
+ }
static defaultBadgeData = {
label: 'feedz',
}
- static render(props) {
- return renderVersionBadge(props)
- }
-
apiUrl({ organization, repository }) {
return `https://f.feedz.io/${organization}/${repository}/nuget`
}
@@ -69,20 +71,39 @@ class FeedzVersionService extends BaseJsonService {
async fetch({ baseUrl, packageName }) {
const registrationsBaseUrl = await searchServiceUrl(
baseUrl,
- 'RegistrationsBaseUrl'
+ 'RegistrationsBaseUrl',
)
return await this._requestJson({
- schema,
+ schema: packageSchema,
url: `${registrationsBaseUrl}${packageName}/index.json`,
- errorMessages: {
+ httpErrors: {
404: 'repository or package not found',
},
})
}
+ async fetchItems({ json }) {
+ if (json.items.length === 0 || json.items.some(i => i.catalogEntry)) {
+ return json
+ } else {
+ const items = await Promise.all(
+ json.items.map(i =>
+ this._requestJson({
+ schema: singlePageSchema,
+ url: i['@id'],
+ httpErrors: {
+ 404: 'repository or package not found',
+ },
+ }),
+ ),
+ )
+ return { items }
+ }
+ }
+
transform({ json, includePrereleases }) {
const versions = json.items.flatMap(tl =>
- tl.items.map(i => stripBuildMetadata(i.catalogEntry.version))
+ tl.items.map(i => stripBuildMetadata(i.catalogEntry.version)),
)
if (versions.length >= 1) {
return selectVersion(versions, includePrereleases)
@@ -91,14 +112,15 @@ class FeedzVersionService extends BaseJsonService {
}
}
- async handle({ which, organization, repository, packageName }) {
- const includePrereleases = which === 'vpre'
+ async handle({ variant, organization, repository, packageName }) {
+ const includePrereleases = variant === 'vpre'
const baseUrl = this.apiUrl({ organization, repository })
const json = await this.fetch({ baseUrl, packageName })
- const version = this.transform({ json, includePrereleases })
- return this.constructor.render({
+ const fetchedJson = await this.fetchItems({ json })
+ const version = this.transform({ json: fetchedJson, includePrereleases })
+ return renderVersionBadge({
version,
- feed: FeedzVersionService.defaultBadgeData.label,
+ defaultLabel: FeedzVersionService.defaultBadgeData.label,
})
}
}
diff --git a/services/feedz/feedz.service.spec.js b/services/feedz/feedz.service.spec.js
index c6f1444c39f8c..68f7f04df866d 100644
--- a/services/feedz/feedz.service.spec.js
+++ b/services/feedz/feedz.service.spec.js
@@ -22,13 +22,13 @@ function noItemsJson() {
describe('Feedz service', function () {
test(FeedzVersionService.prototype.apiUrl, () => {
given({ organization: 'shieldstests', repository: 'public' }).expect(
- 'https://f.feedz.io/shieldstests/public/nuget'
+ 'https://f.feedz.io/shieldstests/public/nuget',
)
})
test(FeedzVersionService.prototype.transform, () => {
given({ json: json([['1.0.0']]), includePrereleases: false }).expect(
- '1.0.0'
+ '1.0.0',
)
given({
json: json([['1.0.0', '1.0.1']]),
@@ -48,10 +48,10 @@ describe('Feedz service', function () {
includePrereleases: false,
}).expect('1.0.1')
given({ json: json([['1.0.1'], []]), includePrereleases: false }).expect(
- '1.0.1'
+ '1.0.1',
)
given({ json: json([[], ['1.0.1']]), includePrereleases: false }).expect(
- '1.0.1'
+ '1.0.1',
)
given({
json: json([['1.0.0'], ['1.0.1-beta1']]),
@@ -72,25 +72,25 @@ describe('Feedz service', function () {
}).expect('1.0.1-beta1')
given({ json: json([]), includePrereleases: false }).expectError(
- 'Not Found: package not found'
+ 'Not Found: package not found',
)
given({ json: json([[]]), includePrereleases: false }).expectError(
- 'Not Found: package not found'
+ 'Not Found: package not found',
)
given({ json: json([[], []]), includePrereleases: false }).expectError(
- 'Not Found: package not found'
+ 'Not Found: package not found',
)
given({ json: json([]), includePrereleases: true }).expectError(
- 'Not Found: package not found'
+ 'Not Found: package not found',
)
given({ json: json([[]]), includePrereleases: true }).expectError(
- 'Not Found: package not found'
+ 'Not Found: package not found',
)
given({ json: noItemsJson(), includePrereleases: false }).expectError(
- 'Not Found: package not found'
+ 'Not Found: package not found',
)
given({ json: noItemsJson(), includePrereleases: true }).expectError(
- 'Not Found: package not found'
+ 'Not Found: package not found',
)
})
})
diff --git a/services/feedz/feedz.tester.js b/services/feedz/feedz.tester.js
index 99133e1025352..d240b8b237c8e 100644
--- a/services/feedz/feedz.tester.js
+++ b/services/feedz/feedz.tester.js
@@ -11,6 +11,8 @@ export const t = new ServiceTester({
// - Shields.TestPackage: 0.0.1, 0.1.0-pre, 1.0.0
// - Shields.TestPreOnly: 0.1.0-pre
// - Shields.MultiPage: 0.1.0-0.1.100 plus 1.0.0 but the response has multiple top-level `items`
+// - Shields.MultiPageNoItems: 0.0.0-0.0.256 plus 1.0.0 but the response has multiple top-level
+// `items` without `catalogEntries`
// The source code of these packages is here: https://github.com/jakubfijalkowski/shields-test-packages
// version
@@ -22,14 +24,6 @@ t.create('version (valid)')
color: 'blue',
})
-t.create('version (yellow badge)')
- .get('/feedz/v/shieldstests/public/Shields.TestPreOnly.json')
- .expectBadge({
- label: 'feedz',
- message: 'v0.1.0-pre',
- color: 'yellow',
- })
-
t.create('version (orange badge)')
.get('/feedz/v/shieldstests/public/Shields.NoV1.json')
.expectBadge({
@@ -46,6 +40,14 @@ t.create('multi-page')
color: 'blue',
})
+t.create('multi-page-no-items')
+ .get('/feedz/v/shieldstests/public/Shields.MultiPageNoItems.json')
+ .expectBadge({
+ label: 'feedz',
+ message: 'v1.0.0',
+ color: 'blue',
+ })
+
t.create('repository (not found)')
.get('/feedz/v/foo/bar/not-a-real-package.json')
.expectBadge({ label: 'feedz', message: 'repository or package not found' })
@@ -67,14 +69,6 @@ t.create('version (pre) (valid)')
color: 'blue',
})
-t.create('version (pre) (yellow badge)')
- .get('/feedz/vpre/shieldstests/public/Shields.TestPreOnly.json')
- .expectBadge({
- label: 'feedz',
- message: 'v0.1.0-pre',
- color: 'yellow',
- })
-
t.create('version (pre) (orange badge)')
.get('/feedz/vpre/shieldstests/public/Shields.NoV1.json')
.expectBadge({
diff --git a/services/flathub/flathub-downloads.service.js b/services/flathub/flathub-downloads.service.js
new file mode 100644
index 0000000000000..2e384e61ca776
--- /dev/null
+++ b/services/flathub/flathub-downloads.service.js
@@ -0,0 +1,35 @@
+import Joi from 'joi'
+import { BaseJsonService, pathParams } from '../index.js'
+import { renderDownloadsBadge } from '../downloads.js'
+
+const schema = Joi.object({
+ installs_total: Joi.number().integer().required(),
+}).required()
+
+export default class FlathubDownloads extends BaseJsonService {
+ static category = 'downloads'
+ static route = { base: 'flathub/downloads', pattern: ':packageName' }
+ static openApi = {
+ '/flathub/downloads/{packageName}': {
+ get: {
+ summary: 'Flathub Downloads',
+ parameters: pathParams({
+ name: 'packageName',
+ example: 'org.mozilla.firefox',
+ }),
+ },
+ },
+ }
+
+ static defaultBadgeData = { label: 'installs' }
+
+ async handle({ packageName }) {
+ const data = await this._requestJson({
+ schema,
+ url: `https://flathub.org/api/v2/stats/${encodeURIComponent(
+ packageName,
+ )}`,
+ })
+ return renderDownloadsBadge({ downloads: data.installs_total })
+ }
+}
diff --git a/services/flathub/flathub-downloads.tester.js b/services/flathub/flathub-downloads.tester.js
new file mode 100644
index 0000000000000..deb763430dada
--- /dev/null
+++ b/services/flathub/flathub-downloads.tester.js
@@ -0,0 +1,14 @@
+import { isMetric } from '../test-validators.js'
+import { createServiceTester } from '../tester.js'
+export const t = await createServiceTester()
+
+t.create('Flathub Downloads (valid)')
+ .get('/org.mozilla.firefox.json')
+ .expectBadge({
+ label: 'installs',
+ message: isMetric,
+ })
+
+t.create('Flathub Downloads (not found)')
+ .get('/not.a.package.json')
+ .expectBadge({ label: 'installs', message: 'not found' })
diff --git a/services/flathub/flathub-version.service.js b/services/flathub/flathub-version.service.js
new file mode 100644
index 0000000000000..1d4c3441e3413
--- /dev/null
+++ b/services/flathub/flathub-version.service.js
@@ -0,0 +1,43 @@
+import Joi from 'joi'
+import { renderVersionBadge } from '../version.js'
+import { BaseJsonService, pathParams } from '../index.js'
+
+const schema = Joi.object({
+ releases: Joi.array().items(
+ Joi.object({
+ timestamp: Joi.string().required(),
+ version: Joi.string().required(),
+ }).required(),
+ ),
+}).required()
+
+export default class FlathubVersion extends BaseJsonService {
+ static category = 'version'
+ static route = { base: 'flathub/v', pattern: ':packageName' }
+ static openApi = {
+ '/flathub/v/{packageName}': {
+ get: {
+ summary: 'Flathub Version',
+ parameters: pathParams({
+ name: 'packageName',
+ example: 'org.mozilla.firefox',
+ }),
+ },
+ },
+ }
+
+ static defaultBadgeData = { label: 'flathub' }
+
+ async handle({ packageName }) {
+ const { releases } = await this._requestJson({
+ schema,
+ url: `https://flathub.org/api/v2/appstream/${encodeURIComponent(packageName)}`,
+ })
+
+ const latestRelease = releases.sort(
+ (a, b) => parseInt(a['timestamp']) - parseInt(b['timestamp']),
+ )[releases.length - 1]
+
+ return renderVersionBadge({ version: latestRelease['version'] })
+ }
+}
diff --git a/services/flathub/flathub-version.tester.js b/services/flathub/flathub-version.tester.js
new file mode 100644
index 0000000000000..9d151af20aae6
--- /dev/null
+++ b/services/flathub/flathub-version.tester.js
@@ -0,0 +1,38 @@
+import { isVPlusDottedVersionNClauses } from '../test-validators.js'
+import { createServiceTester } from '../tester.js'
+export const t = await createServiceTester()
+
+t.create('Flathub Version (valid)')
+ .get('/org.srb2.SRB2Kart-Saturn.json')
+ .expectBadge({
+ label: 'flathub',
+ message: isVPlusDottedVersionNClauses,
+ })
+
+t.create('Flathub Version (valid)')
+ .get('/org.mozilla.firefox.json')
+ .intercept(nock =>
+ nock('https://flathub.org')
+ .get('/api/v2/appstream/org.mozilla.firefox')
+ .reply(200, {
+ releases: [
+ {
+ timestamp: '1715769600',
+ version: '78.0.2',
+ },
+ {
+ timestamp: '1715769601',
+ version: '78.0.0',
+ },
+ {
+ timestamp: '1715769602',
+ version: '78.0.1',
+ },
+ ],
+ }),
+ )
+ .expectBadge({ label: 'flathub', message: 'v78.0.1' })
+
+t.create('Flathub Version (not found)')
+ .get('/not.a.package.json')
+ .expectBadge({ label: 'flathub', message: 'not found' })
diff --git a/services/flathub/flathub.service.js b/services/flathub/flathub.service.js
deleted file mode 100644
index 1b46191ebb9f8..0000000000000
--- a/services/flathub/flathub.service.js
+++ /dev/null
@@ -1,31 +0,0 @@
-import Joi from 'joi'
-import { renderVersionBadge } from '../version.js'
-import { BaseJsonService } from '../index.js'
-
-const schema = Joi.object({
- currentReleaseVersion: Joi.string().required(),
-}).required()
-
-export default class Flathub extends BaseJsonService {
- static category = 'version'
- static route = { base: 'flathub/v', pattern: ':packageName' }
- static examples = [
- {
- title: 'Flathub',
- namedParams: {
- packageName: 'org.mozilla.firefox',
- },
- staticPreview: renderVersionBadge({ version: '78.0.2' }),
- },
- ]
-
- static defaultBadgeData = { label: 'flathub' }
-
- async handle({ packageName }) {
- const data = await this._requestJson({
- schema,
- url: `https://flathub.org/api/v1/apps/${encodeURIComponent(packageName)}`,
- })
- return renderVersionBadge({ version: data.currentReleaseVersion })
- }
-}
diff --git a/services/flathub/flathub.tester.js b/services/flathub/flathub.tester.js
deleted file mode 100644
index 727497013dea6..0000000000000
--- a/services/flathub/flathub.tester.js
+++ /dev/null
@@ -1,24 +0,0 @@
-import { isVPlusDottedVersionNClauses } from '../test-validators.js'
-import { createServiceTester } from '../tester.js'
-export const t = await createServiceTester()
-
-t.create('Flathub (valid)').get('/org.mozilla.firefox.json').expectBadge({
- label: 'flathub',
- message: isVPlusDottedVersionNClauses,
-})
-
-t.create('Flathub (valid)')
- .get('/org.mozilla.firefox.json')
- .intercept(nock =>
- nock('https://flathub.org')
- .get('/api/v1/apps/org.mozilla.firefox')
- .reply(200, {
- flatpakAppId: 'org.mozilla.firefox',
- currentReleaseVersion: '78.0.1',
- })
- )
- .expectBadge({ label: 'flathub', message: 'v78.0.1' })
-
-t.create('Flathub (not found)')
- .get('/not.a.package.json')
- .expectBadge({ label: 'flathub', message: 'not found' })
diff --git a/services/freecodecamp/freecodecamp-points.service.js b/services/freecodecamp/freecodecamp-points.service.js
index acf8fb0daa590..01fb117c6170a 100644
--- a/services/freecodecamp/freecodecamp-points.service.js
+++ b/services/freecodecamp/freecodecamp-points.service.js
@@ -1,12 +1,13 @@
import Joi from 'joi'
import { metric } from '../text-formatters.js'
-import { BaseJsonService, InvalidResponse, NotFound } from '../index.js'
+import { BaseJsonService, InvalidResponse, pathParams } from '../index.js'
/**
* Validates that the schema response is what we're expecting.
- * The username pattern should match the freeCodeCamp repository.
+ * The username pattern should match the requirements in the freeCodeCamp
+ * repository.
*
- * @see https://github.com/freeCodeCamp/freeCodeCamp/blob/main/utils/validate.js#L14
+ * @see https://github.com/freeCodeCamp/freeCodeCamp/blob/main/utils/validate.js
*/
const schema = Joi.object({
entities: Joi.object({
@@ -15,7 +16,7 @@ const schema = Joi.object({
.pattern(/^[a-zA-Z0-9\-_+]*$/, {
points: Joi.number().allow(null).required(),
}),
- }).optional(),
+ }).required(),
}).required()
/**
@@ -29,13 +30,17 @@ export default class FreeCodeCampPoints extends BaseJsonService {
pattern: ':username',
}
- static examples = [
- {
- title: 'freeCodeCamp points',
- namedParams: { username: 'sethi' },
- staticPreview: this.render({ points: 934 }),
+ static openApi = {
+ '/freecodecamp/points/{username}': {
+ get: {
+ summary: 'freeCodeCamp points',
+ parameters: pathParams({
+ name: 'username',
+ example: 'qapaloma',
+ }),
+ },
},
- ]
+ }
static defaultBadgeData = { label: 'points', color: 'info' }
@@ -46,24 +51,24 @@ export default class FreeCodeCampPoints extends BaseJsonService {
async fetch({ username }) {
return this._requestJson({
schema,
- url: `https://api.freecodecamp.org/api/users/get-public-profile`,
+ url: 'https://api.freecodecamp.org/users/get-public-profile',
options: {
- qs: {
+ searchParams: {
username,
},
},
+ httpErrors: { 404: 'profile not found' },
})
}
static transform(response, username) {
const { entities } = response
- if (entities === undefined)
- throw new NotFound({ prettyMessage: 'profile not found' })
-
const { points } = entities.user[username]
- if (points === null) throw new InvalidResponse({ prettyMessage: 'private' })
+ if (points === null) {
+ throw new InvalidResponse({ prettyMessage: 'private' })
+ }
return points
}
diff --git a/services/freecodecamp/freecodecamp-points.tester.js b/services/freecodecamp/freecodecamp-points.tester.js
index e228f13752ca3..93df9aff266c5 100644
--- a/services/freecodecamp/freecodecamp-points.tester.js
+++ b/services/freecodecamp/freecodecamp-points.tester.js
@@ -3,7 +3,7 @@ import { isMetric } from '../test-validators.js'
export const t = await createServiceTester()
t.create('Total Points Valid')
- .get('/sethi.json')
+ .get('/qapaloma.json')
.expectBadge({ label: 'points', message: isMetric })
t.create('Total Points Private')
diff --git a/services/galaxytoolshed/galaxytoolshed-activity.service.js b/services/galaxytoolshed/galaxytoolshed-activity.service.js
new file mode 100644
index 0000000000000..c0f7cc087bbc0
--- /dev/null
+++ b/services/galaxytoolshed/galaxytoolshed-activity.service.js
@@ -0,0 +1,42 @@
+import { pathParams } from '../index.js'
+import { renderDateBadge } from '../date.js'
+import BaseGalaxyToolshedService from './galaxytoolshed-base.js'
+
+export default class GalaxyToolshedCreatedDate extends BaseGalaxyToolshedService {
+ static category = 'activity'
+ static route = {
+ base: 'galaxytoolshed/created-date',
+ pattern: ':repository/:owner',
+ }
+
+ static openApi = {
+ '/galaxytoolshed/created-date/{repository}/{owner}': {
+ get: {
+ summary: 'Galaxy Toolshed - Created Date',
+ parameters: pathParams(
+ {
+ name: 'repository',
+ example: 'sra_tools',
+ },
+ {
+ name: 'owner',
+ example: 'iuc',
+ },
+ ),
+ },
+ },
+ }
+
+ static defaultBadgeData = {
+ label: 'created date',
+ }
+
+ async handle({ repository, owner }) {
+ const response = await this.fetchLastOrderedInstallableRevisionsSchema({
+ repository,
+ owner,
+ })
+ const { create_time: date } = response[0]
+ return renderDateBadge(date, true)
+ }
+}
diff --git a/services/galaxytoolshed/galaxytoolshed-activity.tester.js b/services/galaxytoolshed/galaxytoolshed-activity.tester.js
new file mode 100644
index 0000000000000..94c80a684d830
--- /dev/null
+++ b/services/galaxytoolshed/galaxytoolshed-activity.tester.js
@@ -0,0 +1,23 @@
+import { isFormattedDate } from '../test-validators.js'
+import { createServiceTester } from '../tester.js'
+
+export const t = await createServiceTester()
+
+t.create('Created Date')
+ .get('/sra_tools/iuc.json')
+ .expectBadge({ label: 'created date', message: isFormattedDate })
+
+t.create('Created Date - repository not found')
+ .get('/sra_tool/iuc.json')
+ .expectBadge({ label: 'created date', message: 'not found' })
+
+t.create('Created Date - owner not found')
+ .get('/sra_tools/iu.json')
+ .expectBadge({ label: 'created date', message: 'not found' })
+
+t.create('Created Date - changesetRevision not found')
+ .get('/bioqc/badilla.json')
+ .expectBadge({
+ label: 'created date',
+ message: 'changesetRevision not found',
+ })
diff --git a/services/galaxytoolshed/galaxytoolshed-base.js b/services/galaxytoolshed/galaxytoolshed-base.js
new file mode 100644
index 0000000000000..145f98b5d87d6
--- /dev/null
+++ b/services/galaxytoolshed/galaxytoolshed-base.js
@@ -0,0 +1,55 @@
+import Joi from 'joi'
+import { nonNegativeInteger } from '../validators.js'
+import { NotFound, BaseJsonService } from '../index.js'
+
+const orderedInstallableRevisionsSchema = Joi.array()
+ .items(Joi.string())
+ .required()
+
+const repositoryRevisionInstallInfoSchema = Joi.array()
+ .ordered(
+ Joi.object({
+ create_time: Joi.date().required(),
+ times_downloaded: nonNegativeInteger,
+ }).required(),
+ )
+ .items(Joi.any())
+
+export default class BaseGalaxyToolshedService extends BaseJsonService {
+ static defaultBadgeData = { label: 'galaxytoolshed' }
+ static baseUrl = 'https://toolshed.g2.bx.psu.edu'
+
+ async fetchOrderedInstallableRevisionsSchema({ repository, owner }) {
+ return this._requestJson({
+ schema: orderedInstallableRevisionsSchema,
+ url: `${this.constructor.baseUrl}/api/repositories/get_ordered_installable_revisions?name=${repository}&owner=${owner}`,
+ })
+ }
+
+ async fetchRepositoryRevisionInstallInfoSchema({
+ repository,
+ owner,
+ changesetRevision,
+ }) {
+ return this._requestJson({
+ schema: repositoryRevisionInstallInfoSchema,
+ url: `${this.constructor.baseUrl}/api/repositories/get_repository_revision_install_info?name=${repository}&owner=${owner}&changeset_revision=${changesetRevision}`,
+ })
+ }
+
+ async fetchLastOrderedInstallableRevisionsSchema({ repository, owner }) {
+ const changesetRevisions =
+ await this.fetchOrderedInstallableRevisionsSchema({
+ repository,
+ owner,
+ })
+ if (!Array.isArray(changesetRevisions) || !changesetRevisions.length) {
+ throw new NotFound({ prettyMessage: 'changesetRevision not found' })
+ }
+ return this.fetchRepositoryRevisionInstallInfoSchema({
+ repository,
+ owner,
+ changesetRevision: changesetRevisions[0],
+ })
+ }
+}
diff --git a/services/galaxytoolshed/galaxytoolshed-downloads.service.js b/services/galaxytoolshed/galaxytoolshed-downloads.service.js
new file mode 100644
index 0000000000000..0267661147ed1
--- /dev/null
+++ b/services/galaxytoolshed/galaxytoolshed-downloads.service.js
@@ -0,0 +1,42 @@
+import { pathParams } from '../index.js'
+import { renderDownloadsBadge } from '../downloads.js'
+import BaseGalaxyToolshedService from './galaxytoolshed-base.js'
+
+export default class GalaxyToolshedDownloads extends BaseGalaxyToolshedService {
+ static category = 'downloads'
+ static route = {
+ base: 'galaxytoolshed/downloads',
+ pattern: ':repository/:owner',
+ }
+
+ static openApi = {
+ '/galaxytoolshed/downloads/{repository}/{owner}': {
+ get: {
+ summary: 'Galaxy Toolshed - Downloads',
+ parameters: pathParams(
+ {
+ name: 'repository',
+ example: 'sra_tools',
+ },
+ {
+ name: 'owner',
+ example: 'iuc',
+ },
+ ),
+ },
+ },
+ }
+
+ static defaultBadgeData = {
+ label: 'downloads',
+ }
+
+ async handle({ repository, owner }) {
+ const response = await this.fetchLastOrderedInstallableRevisionsSchema({
+ repository,
+ owner,
+ })
+ const { times_downloaded: downloads } = response[0]
+ return renderDownloadsBadge({ downloads })
+ }
+}
diff --git a/services/galaxytoolshed/galaxytoolshed-downloads.tester.js b/services/galaxytoolshed/galaxytoolshed-downloads.tester.js
new file mode 100644
index 0000000000000..2695e297b781c
--- /dev/null
+++ b/services/galaxytoolshed/galaxytoolshed-downloads.tester.js
@@ -0,0 +1,28 @@
+import { isMetric } from '../test-validators.js'
+import { createServiceTester } from '../tester.js'
+
+export const t = await createServiceTester()
+
+t.create('downloads - raw').get('/sra_tools/iuc.json').expectBadge({
+ label: 'downloads',
+ message: isMetric,
+})
+
+t.create('downloads - repository not found')
+ .get('/sra_tool/iuc.json')
+ .expectBadge({
+ label: 'downloads',
+ message: 'not found',
+ })
+
+t.create('downloads - owner not found').get('/sra_tools/iu.json').expectBadge({
+ label: 'downloads',
+ message: 'not found',
+})
+
+t.create('downloads - changesetRevision not found')
+ .get('/bioqc/badilla.json')
+ .expectBadge({
+ label: 'downloads',
+ message: 'changesetRevision not found',
+ })
diff --git a/services/galaxytoolshed/galaxytoolshed-version.service.js b/services/galaxytoolshed/galaxytoolshed-version.service.js
new file mode 100644
index 0000000000000..90f9b88cde416
--- /dev/null
+++ b/services/galaxytoolshed/galaxytoolshed-version.service.js
@@ -0,0 +1,107 @@
+import { NotFound, pathParams } from '../index.js'
+import { renderVersionBadge } from '../version.js'
+import GalaxyToolshedService from './galaxytoolshed-base.js'
+
+export class GalaxyToolshedVersion extends GalaxyToolshedService {
+ static category = 'version'
+ static route = {
+ base: 'galaxytoolshed/v',
+ pattern: ':repository/:owner/:tool?/:requirement?',
+ }
+
+ static openApi = {
+ '/galaxytoolshed/v/{repository}/{owner}': {
+ get: {
+ summary: 'Galaxy Toolshed - Repository Version',
+ parameters: pathParams(
+ {
+ name: 'repository',
+ example: 'sra_tools',
+ },
+ {
+ name: 'owner',
+ example: 'iuc',
+ },
+ ),
+ },
+ },
+ '/galaxytoolshed/v/{repository}/{owner}/{tool}': {
+ get: {
+ summary: 'Galaxy Toolshed - Tool Version',
+ parameters: pathParams(
+ {
+ name: 'repository',
+ example: 'sra_tools',
+ },
+ {
+ name: 'owner',
+ example: 'iuc',
+ },
+ {
+ name: 'tool',
+ example: 'fastq_dump',
+ },
+ ),
+ },
+ },
+ '/galaxytoolshed/v/{repository}/{owner}/{tool}/{requirement}': {
+ get: {
+ summary: 'Galaxy Toolshed - Tool Requirement Version',
+ parameters: pathParams(
+ {
+ name: 'repository',
+ example: 'sra_tools',
+ },
+ {
+ name: 'owner',
+ example: 'iuc',
+ },
+ {
+ name: 'tool',
+ example: 'fastq_dump',
+ },
+ {
+ name: 'requirement',
+ example: 'perl',
+ },
+ ),
+ },
+ },
+ }
+
+ static transform({ response, tool, requirement }) {
+ if (tool !== undefined) {
+ const dataTool = response[1].valid_tools.find(x => x.id === tool)
+ if (dataTool === undefined) {
+ throw new NotFound({ prettyMessage: 'tool not found' })
+ }
+ // Requirement version
+ if (requirement !== undefined) {
+ const dataRequirement = dataTool.requirements.find(
+ x => x.name === requirement,
+ )
+ if (dataRequirement === undefined) {
+ throw new NotFound({ prettyMessage: 'requirement not found' })
+ }
+ return dataRequirement.version
+ }
+ // Tool version
+ return dataTool.version
+ }
+ // Repository version
+ return response[1].changeset_revision
+ }
+
+ async handle({ repository, owner, tool, requirement }) {
+ const response = await this.fetchLastOrderedInstallableRevisionsSchema({
+ repository,
+ owner,
+ })
+ const version = this.constructor.transform({
+ response,
+ tool,
+ requirement,
+ })
+ return renderVersionBadge({ version })
+ }
+}
diff --git a/services/galaxytoolshed/galaxytoolshed-version.tester.js b/services/galaxytoolshed/galaxytoolshed-version.tester.js
new file mode 100644
index 0000000000000..bc14a6749d0e2
--- /dev/null
+++ b/services/galaxytoolshed/galaxytoolshed-version.tester.js
@@ -0,0 +1,51 @@
+import { withRegex, isVPlusTripleDottedVersion } from '../test-validators.js'
+import { createServiceTester } from '../tester.js'
+
+export const t = await createServiceTester()
+
+t.create('version - repository')
+ .get('/sra_tools/iuc.json')
+ .expectBadge({
+ label: 'galaxytoolshed',
+ message: withRegex(/^([\w\d]+)$/),
+ })
+t.create('version - tool').get('/sra_tools/iuc/fastq_dump.json').expectBadge({
+ label: 'galaxytoolshed',
+ message: isVPlusTripleDottedVersion,
+})
+t.create('version - requirement')
+ .get('/sra_tools/iuc/fastq_dump/perl.json')
+ .expectBadge({
+ label: 'galaxytoolshed',
+ message: isVPlusTripleDottedVersion,
+ })
+
+// Not found
+t.create('version - changesetRevision not found')
+ .get('/bioqc/badilla.json')
+ .expectBadge({
+ label: 'galaxytoolshed',
+ message: 'changesetRevision not found',
+ })
+t.create('version - repository not found')
+ .get('/sra_too/iuc.json')
+ .expectBadge({
+ label: 'galaxytoolshed',
+ message: 'not found',
+ })
+t.create('version - owner not found').get('/sra_tool/iu.json').expectBadge({
+ label: 'galaxytoolshed',
+ message: 'not found',
+})
+t.create('version - tool not found')
+ .get('/sra_tools/iuc/fastq_dum.json')
+ .expectBadge({
+ label: 'galaxytoolshed',
+ message: 'tool not found',
+ })
+t.create('version - requirement not found')
+ .get('/sra_tools/iuc/fastq_dump/per.json')
+ .expectBadge({
+ label: 'galaxytoolshed',
+ message: 'requirement not found',
+ })
diff --git a/services/gem/gem-downloads.service.js b/services/gem/gem-downloads.service.js
index fc72bb3519d6b..15d911218bd90 100644
--- a/services/gem/gem-downloads.service.js
+++ b/services/gem/gem-downloads.service.js
@@ -1,12 +1,15 @@
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'
-
-const keywords = ['ruby']
+import {
+ BaseJsonService,
+ InvalidParameter,
+ InvalidResponse,
+ pathParams,
+} from '../index.js'
+import { description } from './gem-helpers.js'
const gemSchema = Joi.object({
downloads: nonNegativeInteger,
@@ -19,7 +22,7 @@ const versionSchema = Joi.array()
prerelease: Joi.boolean().required(),
number: Joi.string().required(),
downloads_count: nonNegativeInteger,
- })
+ }),
)
.min(1)
.required()
@@ -27,79 +30,57 @@ const versionSchema = Joi.array()
export default class GemDownloads extends BaseJsonService {
static category = 'downloads'
static route = { base: 'gem', pattern: ':variant(dt|dtv|dv)/:gem/:version?' }
- static examples = [
- {
- title: 'Gem',
- pattern: 'dv/:gem/:version',
- namedParams: {
- gem: 'rails',
- version: 'stable',
+ static openApi = {
+ '/gem/dt/{gem}': {
+ get: {
+ summary: 'Gem Total Downloads',
+ description,
+ parameters: pathParams({
+ name: 'gem',
+ example: 'rails',
+ }),
},
- staticPreview: this.render({
- variant: 'dv',
- version: 'stable',
- downloads: 70000,
- }),
- keywords,
},
- {
- title: 'Gem',
- pattern: 'dv/:gem/:version',
- namedParams: {
- gem: 'rails',
- version: '4.1.0',
+ '/gem/dtv/{gem}': {
+ get: {
+ summary: 'Gem Downloads (for latest version)',
+ description,
+ parameters: pathParams({
+ name: 'gem',
+ example: 'rails',
+ }),
},
- staticPreview: this.render({
- variant: 'dv',
- version: '4.1.0',
- downloads: 50000,
- }),
- keywords,
},
- {
- title: 'Gem',
- pattern: 'dtv/:gem',
- namedParams: { gem: 'rails' },
- staticPreview: this.render({
- variant: 'dtv',
- downloads: 70000,
- }),
- keywords,
- },
- {
- title: 'Gem',
- pattern: 'dt/:gem',
- namedParams: { gem: 'rails' },
- staticPreview: this.render({
- variant: 'dt',
- downloads: 900000,
- }),
- keywords,
+ '/gem/dv/{gem}/{version}': {
+ get: {
+ summary: 'Gem Downloads (for specified version)',
+ description,
+ parameters: pathParams(
+ {
+ name: 'gem',
+ example: 'rails',
+ },
+ {
+ name: 'version',
+ example: '4.1.0',
+ },
+ ),
+ },
},
- ]
+ }
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 }) {
const json = await this._requestJson({
url: `https://rubygems.org/api/v1/versions/${gem}.json`,
schema: versionSchema,
- errorMessages: {
+ httpErrors: {
404: 'gem not found',
},
})
@@ -107,7 +88,9 @@ export default class GemDownloads extends BaseJsonService {
let wantedVersion
if (version === 'stable') {
wantedVersion = latestVersion(
- json.filter(({ prerelease }) => !prerelease).map(({ number }) => number)
+ json
+ .filter(({ prerelease }) => !prerelease)
+ .map(({ number }) => number),
)
} else {
wantedVersion = version
@@ -128,7 +111,7 @@ export default class GemDownloads extends BaseJsonService {
await this._requestJson({
url: `https://rubygems.org/api/v1/gems/${gem}.json`,
schema: gemSchema,
- errorMessages: {
+ httpErrors: {
404: 'gem not found',
},
})
diff --git a/services/gem/gem-helpers.js b/services/gem/gem-helpers.js
new file mode 100644
index 0000000000000..13c475ea0bcc3
--- /dev/null
+++ b/services/gem/gem-helpers.js
@@ -0,0 +1,28 @@
+import { valid, maxSatisfying, prerelease } from '@renovatebot/ruby-semver'
+
+const description =
+ '[Ruby Gems](https://rubygems.org/) is a registry for ruby libraries'
+
+function latest(versions) {
+ // latest Ruby Gems version, including pre-releases
+ return maxSatisfying(versions, '>0')
+}
+
+function versionColor(version) {
+ if (!valid(version)) {
+ return 'lightgrey'
+ }
+
+ version = `${version}`
+ let first = version[0]
+ if (first === 'v') {
+ first = version[1]
+ }
+
+ if (first === '0' || prerelease(version)) {
+ return 'orange'
+ }
+ return 'blue'
+}
+
+export { description, latest, versionColor }
diff --git a/services/gem/gem-helpers.spec.js b/services/gem/gem-helpers.spec.js
new file mode 100644
index 0000000000000..90d2a1761376f
--- /dev/null
+++ b/services/gem/gem-helpers.spec.js
@@ -0,0 +1,17 @@
+import { test, given } from 'sazerac'
+import { latest, versionColor } from './gem-helpers.js'
+
+describe('Gem helpers', function () {
+ test(latest, () => {
+ given(['2.0.0', '2.0.0.beta1']).expect('2.0.0')
+ given(['2.0.0.beta1', '1.9.0']).expect('2.0.0.beta1')
+ given(['0.0.1', '0.0.2']).expect('0.0.2')
+ })
+
+ test(versionColor, () => {
+ given('1.9.0').expect('blue')
+ given('2.0.0.beta1').expect('orange')
+ given('0.0.1').expect('orange')
+ given('v1').expect('lightgrey')
+ })
+})
diff --git a/services/gem/gem-owner.service.js b/services/gem/gem-owner.service.js
index 5ed9d0df7dd40..b4b86577079ca 100644
--- a/services/gem/gem-owner.service.js
+++ b/services/gem/gem-owner.service.js
@@ -1,26 +1,32 @@
import Joi from 'joi'
import { floorCount as floorCountColor } from '../color-formatters.js'
-import { BaseJsonService } from '../index.js'
+import { metric } from '../text-formatters.js'
+import { BaseJsonService, pathParams } from '../index.js'
+import { description } from './gem-helpers.js'
const ownerSchema = Joi.array().required()
export default class GemOwner extends BaseJsonService {
static category = 'other'
static route = { base: 'gem/u', pattern: ':user' }
- static examples = [
- {
- title: 'Gems',
- namedParams: { user: 'raphink' },
- staticPreview: this.render({ count: 34 }),
- keywords: ['ruby'],
+ static openApi = {
+ '/gem/u/{user}': {
+ get: {
+ summary: 'Gem Owner',
+ description,
+ parameters: pathParams({
+ name: 'user',
+ example: 'raphink',
+ }),
+ },
},
- ]
+ }
static defaultBadgeData = { label: 'gems' }
static render({ count }) {
return {
- message: count,
+ message: metric(count),
color: floorCountColor(count, 10, 50, 100),
}
}
diff --git a/services/gem/gem-rank.service.js b/services/gem/gem-rank.service.js
index de46d4933649d..2377811e65924 100644
--- a/services/gem/gem-rank.service.js
+++ b/services/gem/gem-rank.service.js
@@ -1,15 +1,14 @@
import Joi from 'joi'
import { floorCount } from '../color-formatters.js'
import { ordinalNumber } from '../text-formatters.js'
-import { BaseJsonService, InvalidResponse } from '../index.js'
-
-const keywords = ['ruby']
+import { BaseJsonService, InvalidResponse, pathParams } from '../index.js'
+import { description } from './gem-helpers.js'
const totalSchema = Joi.array()
.items(
Joi.object({
total_ranking: Joi.number().integer().min(0).allow(null),
- })
+ }),
)
.min(1)
.required()
@@ -17,7 +16,7 @@ const dailySchema = Joi.array()
.items(
Joi.object({
daily_ranking: Joi.number().integer().min(0).allow(null),
- })
+ }),
)
.min(1)
.required()
@@ -25,26 +24,26 @@ const dailySchema = Joi.array()
export default class GemRank extends BaseJsonService {
static category = 'downloads'
static route = { base: 'gem', pattern: ':period(rt|rd)/:gem' }
- static examples = [
- {
- title: 'Gem download rank',
- pattern: 'rt/:gem',
- namedParams: {
- gem: 'puppet',
+ static openApi = {
+ '/gem/{period}/{gem}': {
+ get: {
+ summary: 'Gem download rank',
+ description,
+ parameters: pathParams(
+ {
+ name: 'period',
+ example: 'rt',
+ description: 'total or daily ranking',
+ schema: { type: 'string', enum: this.getEnum('period') },
+ },
+ {
+ name: 'gem',
+ example: 'puppet',
+ },
+ ),
},
- staticPreview: this.render({ period: 'rt', rank: 332 }),
- keywords,
},
- {
- title: 'Gem download rank (daily)',
- pattern: 'rd/:gem',
- namedParams: {
- gem: 'facter',
- },
- staticPreview: this.render({ period: 'rd', rank: 656 }),
- keywords,
- },
- ]
+ }
static defaultBadgeData = { label: 'rank' }
diff --git a/services/gem/gem-rank.tester.js b/services/gem/gem-rank.tester.js
index 73626c7b9c926..ca70f15e6a79c 100644
--- a/services/gem/gem-rank.tester.js
+++ b/services/gem/gem-rank.tester.js
@@ -1,12 +1,7 @@
-import Joi from 'joi'
import { createServiceTester } from '../tester.js'
+import { isOrdinalNumber, isOrdinalNumberDaily } from '../test-validators.js'
export const t = await createServiceTester()
-const isOrdinalNumber = Joi.string().regex(/^[1-9][0-9]+(ᵗʰ|ˢᵗ|ⁿᵈ|ʳᵈ)$/)
-const isOrdinalNumberDaily = Joi.string().regex(
- /^[1-9][0-9]*(ᵗʰ|ˢᵗ|ⁿᵈ|ʳᵈ) daily$/
-)
-
t.create('total rank (valid)').get('/rt/rspec-puppet-facts.json').expectBadge({
label: 'rank',
message: isOrdinalNumber,
@@ -31,6 +26,6 @@ t.create('rank is null')
date: '2019-01-06',
daily_ranking: null,
},
- ])
+ ]),
)
.expectBadge({ label: 'rank', message: 'invalid rank' })
diff --git a/services/gem/gem-version.service.js b/services/gem/gem-version.service.js
index d122c625c6a46..836edce404b07 100644
--- a/services/gem/gem-version.service.js
+++ b/services/gem/gem-version.service.js
@@ -1,6 +1,7 @@
import Joi from 'joi'
-import { renderVersionBadge, latest } from '../version.js'
-import { BaseJsonService } from '../index.js'
+import { renderVersionBadge } from '../version.js'
+import { BaseJsonService, pathParam, queryParam } from '../index.js'
+import { description, latest, versionColor } from './gem-helpers.js'
const schema = Joi.object({
// In most cases `version` will be a SemVer but the registry doesn't
@@ -12,7 +13,7 @@ const versionSchema = Joi.array()
.items(
Joi.object({
number: Joi.string().required(),
- })
+ }),
)
.min(1)
.required()
@@ -24,28 +25,30 @@ const queryParamSchema = Joi.object({
export default class GemVersion extends BaseJsonService {
static category = 'version'
static route = { base: 'gem/v', pattern: ':gem', queryParamSchema }
- static examples = [
- {
- title: 'Gem',
- namedParams: { gem: 'formatador' },
- staticPreview: this.render({ version: '2.1.0' }),
- keywords: ['ruby'],
- },
- {
- title: 'Gem (including prereleases)',
- namedParams: { gem: 'flame' },
- queryParams: {
- include_prereleases: null,
+ static openApi = {
+ '/gem/v/{gem}': {
+ get: {
+ summary: 'Gem Version',
+ description,
+ parameters: [
+ pathParam({
+ name: 'gem',
+ example: 'formatador',
+ }),
+ queryParam({
+ name: 'include_prereleases',
+ schema: { type: 'boolean' },
+ example: null,
+ }),
+ ],
},
- staticPreview: this.render({ version: '5.0.0.rc6' }),
- keywords: ['ruby'],
},
- ]
+ }
static defaultBadgeData = { label: 'gem' }
static render({ version }) {
- return renderVersionBadge({ version })
+ return renderVersionBadge({ version, versionFormatter: versionColor })
}
async fetch({ gem }) {
diff --git a/services/gem/gem-version.tester.js b/services/gem/gem-version.tester.js
index 0043e2ff9be92..fbc75abcb3129 100644
--- a/services/gem/gem-version.tester.js
+++ b/services/gem/gem-version.tester.js
@@ -17,7 +17,7 @@ t.create('version (not found)')
// this is the same as isVPlusDottedVersionNClausesWithOptionalSuffix from test-validators.js
// except that it also accepts regexes like 5.0.0.rc5 - the . before the rc5 is not accepted in the original
const isVPlusDottedVersionNClausesWithOptionalSuffix = withRegex(
- /^v\d+(\.\d+)*([-+~.].*)?$/
+ /^v\d+(\.\d+)*([-+~.].*)?$/,
)
t.create('version including prereleases (valid)')
.get('/flame.json?include_prereleases')
diff --git a/services/gerrit/gerrit.service.js b/services/gerrit/gerrit.service.js
index 04ebca37578d2..03904768a1b4b 100644
--- a/services/gerrit/gerrit.service.js
+++ b/services/gerrit/gerrit.service.js
@@ -1,9 +1,9 @@
import Joi from 'joi'
-import { optionalUrl } from '../validators.js'
-import { BaseJsonService } from '../index.js'
+import { url } from '../validators.js'
+import { BaseJsonService, pathParam, queryParam } from '../index.js'
const queryParamSchema = Joi.object({
- baseUrl: optionalUrl.required(),
+ baseUrl: url,
}).required()
const schema = Joi.object({
@@ -13,19 +13,24 @@ const schema = Joi.object({
export default class Gerrit extends BaseJsonService {
static category = 'issue-tracking'
static route = { base: 'gerrit', pattern: ':changeId', queryParamSchema }
- static examples = [
- {
- title: 'Gerrit change status',
- namedParams: {
- changeId: '1011478',
+ static openApi = {
+ '/gerrit/{changeId}': {
+ get: {
+ summary: 'Gerrit change status',
+ parameters: [
+ pathParam({
+ name: 'changeId',
+ example: '1011478',
+ }),
+ queryParam({
+ name: 'baseUrl',
+ example: 'https://android-review.googlesource.com',
+ required: true,
+ }),
+ ],
},
- queryParams: { baseUrl: 'https://android-review.googlesource.com' },
- staticPreview: this.render({
- changeId: 1011478,
- status: 'MERGED',
- }),
},
- ]
+ }
static defaultBadgeData = { label: 'gerrit' }
@@ -64,7 +69,7 @@ export default class Gerrit extends BaseJsonService {
return this._requestJson({
schema,
url: `${baseUrl}/changes/${changeId}`,
- errorMessages: {
+ httpErrors: {
404: 'change not found',
},
})
diff --git a/services/gerrit/gerrit.tester.js b/services/gerrit/gerrit.tester.js
index 2b80baf7cc5de..3985782289ddc 100644
--- a/services/gerrit/gerrit.tester.js
+++ b/services/gerrit/gerrit.tester.js
@@ -1,11 +1,12 @@
import { createServiceTester } from '../tester.js'
export const t = await createServiceTester()
-// Change open since December 2010, hopefully won't get merged or abandoned anytime soon.
+// Change open since September 2017, hopefully won't get merged or abandoned anytime soon.
+// https://android-review.googlesource.com/c/platform/bootable/recovery/+/494609
t.create('Gerrit new change')
- .get('/2013.json?baseUrl=https://git.eclipse.org/r')
+ .get('/494609.json?baseUrl=https://android-review.googlesource.com')
.expectBadge({
- label: 'change 2013',
+ label: 'change 494609',
message: 'new',
color: '#2cbe4e',
})
diff --git a/services/gitea/gitea-base.js b/services/gitea/gitea-base.js
new file mode 100644
index 0000000000000..2a14ba846ef01
--- /dev/null
+++ b/services/gitea/gitea-base.js
@@ -0,0 +1,19 @@
+import { BaseJsonService } from '../index.js'
+
+export default class GiteaBase extends BaseJsonService {
+ static auth = {
+ passKey: 'gitea_token',
+ serviceKey: 'gitea',
+ }
+
+ async fetch({ url, options, schema, httpErrors }) {
+ return this._requestJson(
+ this.authHelper.withBearerAuthHeader({
+ schema,
+ url,
+ options,
+ httpErrors,
+ }),
+ )
+ }
+}
diff --git a/services/gitea/gitea-base.spec.js b/services/gitea/gitea-base.spec.js
new file mode 100644
index 0000000000000..0323801ca5888
--- /dev/null
+++ b/services/gitea/gitea-base.spec.js
@@ -0,0 +1,48 @@
+import Joi from 'joi'
+import { expect } from 'chai'
+import nock from 'nock'
+import { cleanUpNockAfterEach, defaultContext } from '../test-helpers.js'
+import GiteaBase from './gitea-base.js'
+
+class DummyGiteaService extends GiteaBase {
+ static route = { base: 'fake-base' }
+
+ async handle() {
+ const data = await this.fetch({
+ schema: Joi.any(),
+ url: 'https://gitea.com/api/v1/repos/CanisHelix/shields-badge-test/releases',
+ })
+ return { message: data.message }
+ }
+}
+
+describe('GiteaBase', function () {
+ describe('auth', function () {
+ cleanUpNockAfterEach()
+
+ const config = {
+ public: {
+ services: {
+ gitea: {
+ authorizedOrigins: ['https://gitea.com'],
+ },
+ },
+ },
+ private: {
+ gitea_token: 'fake-key',
+ },
+ }
+
+ it('sends the auth information as configured', async function () {
+ const scope = nock('https://gitea.com')
+ .get('/api/v1/repos/CanisHelix/shields-badge-test/releases')
+ .matchHeader('Authorization', 'Bearer fake-key')
+ .reply(200, { message: 'fake message' })
+ expect(
+ await DummyGiteaService.invoke(defaultContext, config, {}),
+ ).to.not.have.property('isError')
+
+ scope.done()
+ })
+ })
+})
diff --git a/services/gitea/gitea-common-fetch.js b/services/gitea/gitea-common-fetch.js
new file mode 100644
index 0000000000000..84bc11a94bde4
--- /dev/null
+++ b/services/gitea/gitea-common-fetch.js
@@ -0,0 +1,14 @@
+async function fetchIssue(
+ serviceInstance,
+ { user, repo, baseUrl, options, httpErrors },
+) {
+ return serviceInstance._request(
+ serviceInstance.authHelper.withBearerAuthHeader({
+ url: `${baseUrl}/api/v1/repos/${user}/${repo}/issues`,
+ options,
+ httpErrors,
+ }),
+ )
+}
+
+export { fetchIssue }
diff --git a/services/gitea/gitea-forks.service.js b/services/gitea/gitea-forks.service.js
new file mode 100644
index 0000000000000..ac830c85383dc
--- /dev/null
+++ b/services/gitea/gitea-forks.service.js
@@ -0,0 +1,76 @@
+import Joi from 'joi'
+import { pathParam, queryParam } from '../index.js'
+import { optionalUrl, nonNegativeInteger } from '../validators.js'
+import { metric } from '../text-formatters.js'
+import GiteaBase from './gitea-base.js'
+import { description, httpErrorsFor } from './gitea-helper.js'
+
+const schema = Joi.object({
+ forks_count: nonNegativeInteger,
+}).required()
+
+const queryParamSchema = Joi.object({
+ gitea_url: optionalUrl,
+}).required()
+
+export default class GiteaForks extends GiteaBase {
+ static category = 'social'
+
+ static route = {
+ base: 'gitea/forks',
+ pattern: ':user/:repo',
+ queryParamSchema,
+ }
+
+ static openApi = {
+ '/gitea/forks/{user}/{repo}': {
+ get: {
+ summary: 'Gitea Forks',
+ description,
+ parameters: [
+ pathParam({
+ name: 'user',
+ example: 'gitea',
+ }),
+ pathParam({
+ name: 'repo',
+ example: 'tea',
+ }),
+ queryParam({
+ name: 'gitea_url',
+ example: 'https://gitea.com',
+ }),
+ ],
+ },
+ },
+ }
+
+ static defaultBadgeData = { label: 'forks', namedLogo: 'gitea' }
+
+ static render({ baseUrl, user, repo, forkCount }) {
+ return {
+ message: metric(forkCount),
+ style: 'social',
+ color: 'blue',
+ link: [`${baseUrl}/${user}/${repo}`, `${baseUrl}/${user}/${repo}/forks`],
+ }
+ }
+
+ async fetch({ user, repo, baseUrl }) {
+ // https://gitea.com/api/swagger#/repository
+ return super.fetch({
+ schema,
+ url: `${baseUrl}/api/v1/repos/${user}/${repo}`,
+ httpErrors: httpErrorsFor(),
+ })
+ }
+
+ async handle({ user, repo }, { gitea_url: baseUrl = 'https://gitea.com' }) {
+ const { forks_count: forkCount } = await this.fetch({
+ user,
+ repo,
+ baseUrl,
+ })
+ return this.constructor.render({ baseUrl, user, repo, forkCount })
+ }
+}
diff --git a/services/gitea/gitea-forks.tester.js b/services/gitea/gitea-forks.tester.js
new file mode 100644
index 0000000000000..1795572231fe2
--- /dev/null
+++ b/services/gitea/gitea-forks.tester.js
@@ -0,0 +1,32 @@
+import { isMetric } from '../test-validators.js'
+import { createServiceTester } from '../tester.js'
+
+export const t = await createServiceTester()
+
+t.create('Forks')
+ .get('/gitea/tea.json')
+ .expectBadge({
+ label: 'forks',
+ message: isMetric,
+ color: 'blue',
+ link: ['https://gitea.com/gitea/tea', 'https://gitea.com/gitea/tea/forks'],
+ })
+
+t.create('Forks (self-managed)')
+ .get('/Codeberg/forgejo.json?gitea_url=https://codeberg.org')
+ .expectBadge({
+ label: 'forks',
+ message: isMetric,
+ color: 'blue',
+ link: [
+ 'https://codeberg.org/Codeberg/forgejo',
+ 'https://codeberg.org/Codeberg/forgejo/forks',
+ ],
+ })
+
+t.create('Forks (project not found)')
+ .get('/CanisHelix/does-not-exist.json?gitea_url=https://codeberg.org')
+ .expectBadge({
+ label: 'forks',
+ message: 'user or repo not found',
+ })
diff --git a/services/gitea/gitea-helper.js b/services/gitea/gitea-helper.js
new file mode 100644
index 0000000000000..3d2e575d5ccae
--- /dev/null
+++ b/services/gitea/gitea-helper.js
@@ -0,0 +1,35 @@
+import { metric } from '../text-formatters.js'
+
+const description = `
+By default this badge looks for repositories on [gitea.com](https://gitea.com).
+To specify another instance like [codeberg](https://codeberg.org/), [forgejo](https://forgejo.org/) or a self-hosted instance, use the \`gitea_url\` query param.
+`
+
+function httpErrorsFor(notFoundMessage = 'user or repo not found') {
+ return {
+ 403: 'private repo',
+ 404: notFoundMessage,
+ }
+}
+
+function renderIssue({ variant, labels, defaultBadgeData, count }) {
+ const state = variant.split('-')[0]
+ const raw = variant.endsWith('-raw')
+ const isMultiLabel = labels && labels.includes(',')
+ const labelText = labels ? `${isMultiLabel ? `${labels}` : labels} ` : ''
+
+ let labelPrefix = ''
+ let messageSuffix = ''
+ if (raw) {
+ labelPrefix = `${state} `
+ } else {
+ messageSuffix = state
+ }
+ return {
+ label: `${labelPrefix}${labelText}${defaultBadgeData.label}`,
+ message: `${metric(count)}${messageSuffix ? ' ' : ''}${messageSuffix}`,
+ color: count > 0 ? 'yellow' : 'brightgreen',
+ }
+}
+
+export { description, httpErrorsFor, renderIssue }
diff --git a/services/gitea/gitea-issues.service.js b/services/gitea/gitea-issues.service.js
new file mode 100644
index 0000000000000..cac75aaea869b
--- /dev/null
+++ b/services/gitea/gitea-issues.service.js
@@ -0,0 +1,96 @@
+import Joi from 'joi'
+import { pathParam, queryParam } from '../index.js'
+import { optionalUrl, nonNegativeInteger } from '../validators.js'
+import { fetchIssue } from './gitea-common-fetch.js'
+import { description, httpErrorsFor, renderIssue } from './gitea-helper.js'
+import GiteaBase from './gitea-base.js'
+
+const schema = Joi.object({ 'x-total-count': nonNegativeInteger }).required()
+
+const queryParamSchema = Joi.object({
+ labels: Joi.string(),
+ gitea_url: optionalUrl,
+}).required()
+
+export default class GiteaIssues extends GiteaBase {
+ static category = 'issue-tracking'
+
+ static route = {
+ base: 'gitea/issues',
+ pattern:
+ ':variant(all|all-raw|open|open-raw|closed|closed-raw)/:user/:repo+',
+ queryParamSchema,
+ }
+
+ static openApi = {
+ '/gitea/issues/{variant}/{user}/{repo}': {
+ get: {
+ summary: 'Gitea Issues',
+ description,
+ parameters: [
+ pathParam({
+ name: 'variant',
+ example: 'all',
+ schema: { type: 'string', enum: this.getEnum('variant') },
+ }),
+ pathParam({
+ name: 'user',
+ example: 'gitea',
+ }),
+ pathParam({
+ name: 'repo',
+ example: 'tea',
+ }),
+ queryParam({
+ name: 'gitea_url',
+ example: 'https://gitea.com',
+ }),
+ queryParam({
+ name: 'labels',
+ example: 'test,failure::new',
+ description:
+ 'If you want to use multiple labels, you can use a comma (,) to separate them, e.g. foo,bar',
+ }),
+ ],
+ },
+ },
+ }
+
+ static defaultBadgeData = { label: 'issues', color: 'informational' }
+ async handle(
+ { variant, user, repo },
+ { gitea_url: baseUrl = 'https://gitea.com', labels },
+ ) {
+ const options = {
+ searchParams: {
+ page: '1',
+ limit: '1',
+ type: 'issues',
+ state: variant.replace('-raw', ''),
+ },
+ }
+ if (labels) {
+ options.searchParams.labels = labels
+ }
+
+ const { res } = await fetchIssue(this, {
+ user,
+ repo,
+ baseUrl,
+ options,
+ httpErrors: httpErrorsFor(),
+ })
+
+ const data = this.constructor._validate(res.headers, schema)
+ // The total number of issues is in the `x-total-count` field in the headers.
+ // Pull requests are an issue of type pulls
+ // https://gitea.com/api/swagger#/issue
+ const count = data['x-total-count']
+ return renderIssue({
+ variant,
+ labels,
+ defaultBadgeData: this.constructor.defaultBadgeData,
+ count,
+ })
+ }
+}
diff --git a/services/gitea/gitea-issues.tester.js b/services/gitea/gitea-issues.tester.js
new file mode 100644
index 0000000000000..cfbf71133548e
--- /dev/null
+++ b/services/gitea/gitea-issues.tester.js
@@ -0,0 +1,167 @@
+import { createServiceTester } from '../tester.js'
+import {
+ isMetric,
+ isMetricOpenIssues,
+ isMetricClosedIssues,
+ isMetricWithPattern,
+} from '../test-validators.js'
+
+export const t = await createServiceTester()
+
+t.create('Issues (project not found)')
+ .get('/open/CanisHelix/do-not-exist.json')
+ .expectBadge({
+ label: 'issues',
+ message: 'user or repo not found',
+ })
+
+/**
+ * Opened issue number case
+ */
+t.create('Opened issues')
+ .get(
+ '/open/CanisHelix/shields-badge-test.json?gitea_url=https://codeberg.org',
+ )
+ .expectBadge({
+ label: 'issues',
+ message: isMetricOpenIssues,
+ })
+
+t.create('Open issues raw')
+ .get(
+ '/open-raw/CanisHelix/shields-badge-test.json?gitea_url=https://codeberg.org',
+ )
+ .expectBadge({
+ label: 'open issues',
+ message: isMetric,
+ })
+
+t.create('Open issues by label is > zero')
+ .get(
+ '/open/CanisHelix/shields-badge-test.json?gitea_url=https://codeberg.org&labels=question',
+ )
+ .expectBadge({
+ label: 'question issues',
+ message: isMetricOpenIssues,
+ })
+
+t.create('Open issues by multi-word label is > zero')
+ .get(
+ '/open/CanisHelix/shields-badge-test.json?gitea_url=https://codeberg.org&labels=question,enhancement',
+ )
+ .expectBadge({
+ label: 'question,enhancement issues',
+ message: isMetricOpenIssues,
+ })
+
+t.create('Open issues by label (raw)')
+ .get(
+ '/open-raw/CanisHelix/shields-badge-test.json?gitea_url=https://codeberg.org&labels=question',
+ )
+ .expectBadge({
+ label: 'open question issues',
+ message: isMetric,
+ })
+
+t.create('Opened issues by Scoped labels')
+ .get(
+ '/open/CanisHelix/shields-badge-test.json?gitea_url=https://codeberg.org&labels=question,enhancement/new',
+ )
+ .expectBadge({
+ label: 'question,enhancement/new issues',
+ message: isMetricOpenIssues,
+ })
+
+/**
+ * Closed issue number case
+ */
+t.create('Closed issues')
+ .get(
+ '/closed/CanisHelix/shields-badge-test.json?gitea_url=https://codeberg.org',
+ )
+ .expectBadge({
+ label: 'issues',
+ message: isMetricClosedIssues,
+ })
+
+t.create('Closed issues raw')
+ .get(
+ '/closed-raw/CanisHelix/shields-badge-test.json?gitea_url=https://codeberg.org',
+ )
+ .expectBadge({
+ label: 'closed issues',
+ message: isMetric,
+ })
+
+t.create('Closed issues by label is > zero')
+ .get(
+ '/closed/CanisHelix/shields-badge-test.json?gitea_url=https://codeberg.org&labels=bug',
+ )
+ .expectBadge({
+ label: 'bug issues',
+ message: isMetricClosedIssues,
+ })
+
+t.create('Closed issues by multi-word label is > zero')
+ .get(
+ '/closed/CanisHelix/shields-badge-test.json?gitea_url=https://codeberg.org&labels=bug,good%20first%20issue',
+ )
+ .expectBadge({
+ label: 'bug,good first issue issues',
+ message: isMetricClosedIssues,
+ })
+
+t.create('Closed issues by label (raw)')
+ .get(
+ '/closed-raw/CanisHelix/shields-badge-test.json?gitea_url=https://codeberg.org&labels=bug',
+ )
+ .expectBadge({
+ label: 'closed bug issues',
+ message: isMetric,
+ })
+
+/**
+ * All issue number case
+ */
+t.create('All issues')
+ .get('/all/CanisHelix/shields-badge-test.json?gitea_url=https://codeberg.org')
+ .expectBadge({
+ label: 'issues',
+ message: isMetricWithPattern(/ all/),
+ })
+
+t.create('All issues raw')
+ .get(
+ '/all-raw/CanisHelix/shields-badge-test.json?gitea_url=https://codeberg.org',
+ )
+ .expectBadge({
+ label: 'all issues',
+ message: isMetric,
+ })
+
+t.create('All issues by label is > zero')
+ .get(
+ '/all/CanisHelix/shields-badge-test.json?gitea_url=https://codeberg.org&labels=question',
+ )
+ .expectBadge({
+ label: 'question issues',
+ message: isMetricWithPattern(/ all/),
+ })
+
+t.create('All issues by multi-word label is > zero')
+ .get(
+ '/all/CanisHelix/shields-badge-test.json?gitea_url=https://codeberg.org&labels=question,enhancement',
+ )
+ .expectBadge({
+ label: 'question,enhancement issues',
+ message: isMetricWithPattern(/ all/),
+ })
+
+t.create('All issues by label (raw)')
+ .get(
+ '/all-raw/CanisHelix/shields-badge-test.json?gitea_url=https://codeberg.org&labels=question',
+ )
+ .expectBadge({
+ label: 'all question issues',
+ message: isMetric,
+ })
diff --git a/services/gitea/gitea-languages-count.service.js b/services/gitea/gitea-languages-count.service.js
new file mode 100644
index 0000000000000..31b68435b5454
--- /dev/null
+++ b/services/gitea/gitea-languages-count.service.js
@@ -0,0 +1,76 @@
+import Joi from 'joi'
+import { nonNegativeInteger, optionalUrl } from '../validators.js'
+import { metric } from '../text-formatters.js'
+import { pathParam, queryParam } from '../index.js'
+import { description, httpErrorsFor } from './gitea-helper.js'
+import GiteaBase from './gitea-base.js'
+
+/*
+We're expecting a response like { "Python": 39624, "Shell": 104 }
+The keys could be anything and {} is a valid response (e.g: for an empty repo)
+*/
+const schema = Joi.object().pattern(/./, nonNegativeInteger)
+
+const queryParamSchema = Joi.object({
+ gitea_url: optionalUrl,
+}).required()
+
+export default class GiteaLanguageCount extends GiteaBase {
+ static category = 'analysis'
+
+ static route = {
+ base: 'gitea/languages/count',
+ pattern: ':user/:repo',
+ queryParamSchema,
+ }
+
+ static openApi = {
+ '/gitea/languages/count/{user}/{repo}': {
+ get: {
+ summary: 'Gitea language count',
+ description,
+ parameters: [
+ pathParam({
+ name: 'user',
+ example: 'gitea',
+ }),
+ pathParam({
+ name: 'repo',
+ example: 'tea',
+ }),
+ queryParam({
+ name: 'gitea_url',
+ example: 'https://gitea.com',
+ }),
+ ],
+ },
+ },
+ }
+
+ static defaultBadgeData = { label: 'languages' }
+
+ static render({ languagesCount }) {
+ return {
+ message: metric(languagesCount),
+ color: 'blue',
+ }
+ }
+
+ async fetch({ user, repo, baseUrl }) {
+ // https://gitea.com/api/swagger#/repository/repoGetLanguages
+ return super.fetch({
+ schema,
+ url: `${baseUrl}/api/v1/repos/${user}/${repo}/languages`,
+ httpErrors: httpErrorsFor(),
+ })
+ }
+
+ async handle({ user, repo }, { gitea_url: baseUrl = 'https://gitea.com' }) {
+ const data = await this.fetch({
+ user,
+ repo,
+ baseUrl,
+ })
+ return this.constructor.render({ languagesCount: Object.keys(data).length })
+ }
+}
diff --git a/services/gitea/gitea-languages-count.tester.js b/services/gitea/gitea-languages-count.tester.js
new file mode 100644
index 0000000000000..fe94a2fe905f7
--- /dev/null
+++ b/services/gitea/gitea-languages-count.tester.js
@@ -0,0 +1,32 @@
+import Joi from 'joi'
+import { createServiceTester } from '../tester.js'
+
+export const t = await createServiceTester()
+
+t.create('language count').get('/gitea/tea.json').expectBadge({
+ label: 'languages',
+ message: Joi.number().integer().positive(),
+})
+
+t.create('language count (empty repo) (self-managed)')
+ .get(
+ '/CanisHelix/shields-badge-test-empty.json?gitea_url=https://codeberg.org',
+ )
+ .expectBadge({
+ label: 'languages',
+ message: '0',
+ })
+
+t.create('language count (self-managed)')
+ .get('/CanisHelix/shields-badge-test.json?gitea_url=https://codeberg.org')
+ .expectBadge({
+ label: 'languages',
+ message: Joi.number().integer().positive(),
+ })
+
+t.create('language count (user or repo not found) (self-managed)')
+ .get('/CanisHelix/does-not-exist.json?gitea_url=https://codeberg.org')
+ .expectBadge({
+ label: 'languages',
+ message: 'user or repo not found',
+ })
diff --git a/services/gitea/gitea-last-commit.service.js b/services/gitea/gitea-last-commit.service.js
new file mode 100644
index 0000000000000..edf33a06645fe
--- /dev/null
+++ b/services/gitea/gitea-last-commit.service.js
@@ -0,0 +1,143 @@
+import Joi from 'joi'
+import { renderDateBadge } from '../date.js'
+import { pathParam, queryParam } from '../index.js'
+import { optionalUrl, relativeUri } from '../validators.js'
+import GiteaBase from './gitea-base.js'
+import { description, httpErrorsFor } from './gitea-helper.js'
+
+const schema = Joi.array()
+ .items(
+ Joi.object({
+ commit: Joi.object({
+ author: Joi.object({
+ date: Joi.string().required(),
+ }).required(),
+ committer: Joi.object({
+ date: Joi.string().required(),
+ }).required(),
+ }).required(),
+ }).required(),
+ )
+ .required()
+ .min(1)
+
+const displayEnum = ['author', 'committer']
+
+const queryParamSchema = Joi.object({
+ path: relativeUri,
+ display_timestamp: Joi.string()
+ .valid(...displayEnum)
+ .default('author'),
+ gitea_url: optionalUrl,
+}).required()
+
+export default class GiteaLastCommit extends GiteaBase {
+ static category = 'activity'
+
+ static route = {
+ base: 'gitea/last-commit',
+ pattern: ':user/:repo/:branch*',
+ queryParamSchema,
+ }
+
+ static openApi = {
+ '/gitea/last-commit/{user}/{repo}': {
+ get: {
+ summary: 'Gitea Last Commit',
+ description,
+ parameters: [
+ pathParam({
+ name: 'user',
+ example: 'gitea',
+ }),
+ pathParam({
+ name: 'repo',
+ example: 'tea',
+ }),
+ queryParam({
+ name: 'path',
+ example: 'README.md',
+ schema: { type: 'string' },
+ description: 'File path to resolve the last commit for.',
+ }),
+ queryParam({
+ name: 'display_timestamp',
+ example: 'committer',
+ schema: { type: 'string', enum: displayEnum },
+ description: 'Defaults to `author` if not specified',
+ }),
+ queryParam({
+ name: 'gitea_url',
+ example: 'https://gitea.com',
+ }),
+ ],
+ },
+ },
+ '/gitea/last-commit/{user}/{repo}/{branch}': {
+ get: {
+ summary: 'Gitea Last Commit (branch)',
+ description,
+ parameters: [
+ pathParam({
+ name: 'user',
+ example: 'gitea',
+ }),
+ pathParam({
+ name: 'repo',
+ example: 'tea',
+ }),
+ pathParam({
+ name: 'branch',
+ example: 'main',
+ }),
+ queryParam({
+ name: 'path',
+ example: 'README.md',
+ schema: { type: 'string' },
+ description: 'File path to resolve the last commit for.',
+ }),
+ queryParam({
+ name: 'display_timestamp',
+ example: 'committer',
+ schema: { type: 'string', enum: displayEnum },
+ description: 'Defaults to `author` if not specified',
+ }),
+ queryParam({
+ name: 'gitea_url',
+ example: 'https://gitea.com',
+ }),
+ ],
+ },
+ },
+ }
+
+ static defaultBadgeData = { label: 'last commit' }
+
+ async fetch({ user, repo, branch, baseUrl, path }) {
+ // https://gitea.com/api/swagger#/repository
+ return super.fetch({
+ schema,
+ url: `${baseUrl}/api/v1/repos/${user}/${repo}/commits`,
+ options: { searchParams: { sha: branch, path, limit: 1 } },
+ httpErrors: httpErrorsFor('user, repo or path not found'),
+ })
+ }
+
+ async handle(
+ { user, repo, branch },
+ {
+ gitea_url: baseUrl = 'https://gitea.com',
+ display_timestamp: displayTimestamp,
+ path,
+ },
+ ) {
+ const body = await this.fetch({
+ user,
+ repo,
+ branch,
+ baseUrl,
+ path,
+ })
+ return renderDateBadge(body[0].commit[displayTimestamp].date)
+ }
+}
diff --git a/services/gitea/gitea-last-commit.tester.js b/services/gitea/gitea-last-commit.tester.js
new file mode 100644
index 0000000000000..f77ac6af27b60
--- /dev/null
+++ b/services/gitea/gitea-last-commit.tester.js
@@ -0,0 +1,81 @@
+import { isFormattedDate } from '../test-validators.js'
+import { createServiceTester } from '../tester.js'
+
+export const t = await createServiceTester()
+
+t.create('Last Commit (recent)').get('/gitea/tea.json').expectBadge({
+ label: 'last commit',
+ message: isFormattedDate,
+})
+
+t.create('Last Commit (recent) (top-level file path)')
+ .get('/gitea/tea.json?path=README.md')
+ .expectBadge({
+ label: 'last commit',
+ message: isFormattedDate,
+ })
+
+t.create('Last Commit (recent) (top-level dir path)')
+ .get('/gitea/tea.json?path=docs')
+ .expectBadge({
+ label: 'last commit',
+ message: isFormattedDate,
+ })
+
+t.create('Last Commit (recent) (top-level dir path with trailing slash)')
+ .get('/gitea/tea.json?path=docs/')
+ .expectBadge({
+ label: 'last commit',
+ message: isFormattedDate,
+ })
+
+t.create('Last Commit (recent) (nested dir path)')
+ .get('/gitea/tea.json?path=docs/CLI.md')
+ .expectBadge({
+ label: 'last commit',
+ message: isFormattedDate,
+ })
+
+t.create('Last Commit (recent) (path)')
+ .get('/gitea/tea.json?path=README.md')
+ .expectBadge({
+ label: 'last commit',
+ message: isFormattedDate,
+ })
+
+t.create('Last Commit (recent) (self-managed)')
+ .get('/CanisHelix/shields-badge-test.json?gitea_url=https://codeberg.org')
+ .expectBadge({
+ label: 'last commit',
+ message: isFormattedDate,
+ })
+
+t.create('Last Commit (on-branch) (self-managed)')
+ .get(
+ '/CanisHelix/shields-badge-test/scoped.json?gitea_url=https://codeberg.org',
+ )
+ .expectBadge({
+ label: 'last commit',
+ message: isFormattedDate,
+ })
+
+t.create('Last Commit (user not found)')
+ .get('/CanisHelix/does-not-exist.json?gitea_url=https://codeberg.org')
+ .expectBadge({
+ label: 'last commit',
+ message: 'user, repo or path not found',
+ })
+
+t.create('Last Commit (repo not found)')
+ .get('/gitea/not-a-repo.json')
+ .expectBadge({
+ label: 'last commit',
+ message: 'user, repo or path not found',
+ })
+
+t.create('Last Commit (path not found)')
+ .get('/gitea/tea.json?path=not/a/dir')
+ .expectBadge({
+ label: 'last commit',
+ message: 'user, repo or path not found',
+ })
diff --git a/services/gitea/gitea-pull-requests.service.js b/services/gitea/gitea-pull-requests.service.js
new file mode 100644
index 0000000000000..5b00f2485e218
--- /dev/null
+++ b/services/gitea/gitea-pull-requests.service.js
@@ -0,0 +1,97 @@
+import Joi from 'joi'
+import { pathParam, queryParam } from '../index.js'
+import { optionalUrl, nonNegativeInteger } from '../validators.js'
+import { fetchIssue } from './gitea-common-fetch.js'
+import { description, httpErrorsFor, renderIssue } from './gitea-helper.js'
+import GiteaBase from './gitea-base.js'
+
+const schema = Joi.object({ 'x-total-count': nonNegativeInteger }).required()
+
+const queryParamSchema = Joi.object({
+ labels: Joi.string(),
+ gitea_url: optionalUrl,
+}).required()
+
+export default class GiteaPullRequests extends GiteaBase {
+ static category = 'issue-tracking'
+
+ static route = {
+ base: 'gitea/pull-requests',
+ pattern:
+ ':variant(all|all-raw|open|open-raw|closed|closed-raw)/:user/:repo+',
+ queryParamSchema,
+ }
+
+ static openApi = {
+ '/gitea/pull-requests/{variant}/{user}/{repo}': {
+ get: {
+ summary: 'Gitea Pull Requests',
+ description,
+ parameters: [
+ pathParam({
+ name: 'variant',
+ example: 'all',
+ schema: { type: 'string', enum: this.getEnum('variant') },
+ }),
+ pathParam({
+ name: 'user',
+ example: 'gitea',
+ }),
+ pathParam({
+ name: 'repo',
+ example: 'tea',
+ }),
+ queryParam({
+ name: 'gitea_url',
+ example: 'https://gitea.com',
+ }),
+ queryParam({
+ name: 'labels',
+ example: 'test,failure::new',
+ description:
+ 'If you want to use multiple labels, you can use a comma (,) to separate them, e.g. foo,bar',
+ }),
+ ],
+ },
+ },
+ }
+
+ static defaultBadgeData = { label: 'pull requests', color: 'informational' }
+
+ async handle(
+ { variant, user, repo },
+ { gitea_url: baseUrl = 'https://gitea.com', labels },
+ ) {
+ const options = {
+ searchParams: {
+ page: '1',
+ limit: '1',
+ type: 'pulls',
+ state: variant.replace('-raw', ''),
+ },
+ }
+ if (labels) {
+ options.searchParams.labels = labels
+ }
+
+ const { res } = await fetchIssue(this, {
+ user,
+ repo,
+ baseUrl,
+ options,
+ httpErrors: httpErrorsFor(),
+ })
+
+ const data = this.constructor._validate(res.headers, schema)
+ // The total number of issues is in the `x-total-count` field in the headers.
+ // Pull requests are an issue of type pulls
+ // https://gitea.com/api/swagger#/issue
+ const count = data['x-total-count']
+ return renderIssue({
+ variant,
+ labels,
+ defaultBadgeData: this.constructor.defaultBadgeData,
+ count,
+ })
+ }
+}
diff --git a/services/gitea/gitea-pull-requests.tester.js b/services/gitea/gitea-pull-requests.tester.js
new file mode 100644
index 0000000000000..a2849fbc7e9aa
--- /dev/null
+++ b/services/gitea/gitea-pull-requests.tester.js
@@ -0,0 +1,167 @@
+import { createServiceTester } from '../tester.js'
+import {
+ isMetric,
+ isMetricOpenIssues,
+ isMetricClosedIssues,
+ isMetricWithPattern,
+} from '../test-validators.js'
+
+export const t = await createServiceTester()
+
+t.create('Pulls (project not found)')
+ .get('/open/CanisHelix/do-not-exist.json')
+ .expectBadge({
+ label: 'pull requests',
+ message: 'user or repo not found',
+ })
+
+/**
+ * Opened pulls number case
+ */
+t.create('Opened pulls')
+ .get(
+ '/open/CanisHelix/shields-badge-test.json?gitea_url=https://codeberg.org',
+ )
+ .expectBadge({
+ label: 'pull requests',
+ message: isMetricOpenIssues,
+ })
+
+t.create('Open pulls raw')
+ .get(
+ '/open-raw/CanisHelix/shields-badge-test.json?gitea_url=https://codeberg.org',
+ )
+ .expectBadge({
+ label: 'open pull requests',
+ message: isMetric,
+ })
+
+t.create('Open pulls by label is > zero')
+ .get(
+ '/open/CanisHelix/shields-badge-test.json?gitea_url=https://codeberg.org&labels=upstream',
+ )
+ .expectBadge({
+ label: 'upstream pull requests',
+ message: isMetricOpenIssues,
+ })
+
+t.create('Open pulls by multi-word label is > zero')
+ .get(
+ '/open/CanisHelix/shields-badge-test.json?gitea_url=https://codeberg.org&labels=upstream,enhancement',
+ )
+ .expectBadge({
+ label: 'upstream,enhancement pull requests',
+ message: isMetricOpenIssues,
+ })
+
+t.create('Open pulls by label (raw)')
+ .get(
+ '/open-raw/CanisHelix/shields-badge-test.json?gitea_url=https://codeberg.org&labels=upstream',
+ )
+ .expectBadge({
+ label: 'open upstream pull requests',
+ message: isMetric,
+ })
+
+t.create('Opened pulls by Scoped label')
+ .get(
+ '/open/CanisHelix/shields-badge-test.json?gitea_url=https://codeberg.org&labels=failure/new',
+ )
+ .expectBadge({
+ label: 'failure/new pull requests',
+ message: isMetricOpenIssues,
+ })
+
+/**
+ * Closed pulls number case
+ */
+t.create('Closed pulls')
+ .get(
+ '/closed/CanisHelix/shields-badge-test.json?gitea_url=https://codeberg.org',
+ )
+ .expectBadge({
+ label: 'pull requests',
+ message: isMetricClosedIssues,
+ })
+
+t.create('Closed pulls raw')
+ .get(
+ '/closed-raw/CanisHelix/shields-badge-test.json?gitea_url=https://codeberg.org',
+ )
+ .expectBadge({
+ label: 'closed pull requests',
+ message: isMetric,
+ })
+
+t.create('Closed pulls by label is > zero')
+ .get(
+ '/closed/CanisHelix/shields-badge-test.json?gitea_url=https://codeberg.org&labels=bug',
+ )
+ .expectBadge({
+ label: 'bug pull requests',
+ message: isMetricClosedIssues,
+ })
+
+t.create('Closed pulls by multi-word label is > zero')
+ .get(
+ '/closed/CanisHelix/shields-badge-test.json?gitea_url=https://codeberg.org&labels=bug,good%20first%20issue',
+ )
+ .expectBadge({
+ label: 'bug,good first issue pull requests',
+ message: isMetricClosedIssues,
+ })
+
+t.create('Closed pulls by label (raw)')
+ .get(
+ '/closed-raw/CanisHelix/shields-badge-test.json?gitea_url=https://codeberg.org&labels=bug',
+ )
+ .expectBadge({
+ label: 'closed bug pull requests',
+ message: isMetric,
+ })
+
+/**
+ * All pulls number case
+ */
+t.create('All pulls')
+ .get('/all/CanisHelix/shields-badge-test.json?gitea_url=https://codeberg.org')
+ .expectBadge({
+ label: 'pull requests',
+ message: isMetricWithPattern(/ all/),
+ })
+
+t.create('All pulls raw')
+ .get(
+ '/all-raw/CanisHelix/shields-badge-test.json?gitea_url=https://codeberg.org',
+ )
+ .expectBadge({
+ label: 'all pull requests',
+ message: isMetric,
+ })
+
+t.create('All pulls by label is > zero')
+ .get(
+ '/all/CanisHelix/shields-badge-test.json?gitea_url=https://codeberg.org&labels=upstream',
+ )
+ .expectBadge({
+ label: 'upstream pull requests',
+ message: isMetricWithPattern(/ all/),
+ })
+
+t.create('All pulls by multi-word label is > zero')
+ .get(
+ '/all/CanisHelix/shields-badge-test.json?gitea_url=https://codeberg.org&labels=upstream,enhancement',
+ )
+ .expectBadge({
+ label: 'upstream,enhancement pull requests',
+ message: isMetricWithPattern(/ all/),
+ })
+
+t.create('All pulls by label (raw)')
+ .get(
+ '/all-raw/CanisHelix/shields-badge-test.json?gitea_url=https://codeberg.org&labels=upstream',
+ )
+ .expectBadge({
+ label: 'all upstream pull requests',
+ message: isMetric,
+ })
diff --git a/services/gitea/gitea-release.service.js b/services/gitea/gitea-release.service.js
new file mode 100644
index 0000000000000..8a6b2817ec8c4
--- /dev/null
+++ b/services/gitea/gitea-release.service.js
@@ -0,0 +1,146 @@
+import Joi from 'joi'
+import { optionalUrl } from '../validators.js'
+import { latest, renderVersionBadge } from '../version.js'
+import { NotFound, pathParam, queryParam } from '../index.js'
+import { description, httpErrorsFor } from './gitea-helper.js'
+import GiteaBase from './gitea-base.js'
+
+const schema = Joi.array().items(
+ Joi.object({
+ name: Joi.string().required(),
+ tag_name: Joi.string().required(),
+ prerelease: Joi.boolean().required(),
+ }),
+)
+
+const sortEnum = ['date', 'semver']
+const displayNameEnum = ['tag', 'release']
+const dateOrderByEnum = ['created_at', 'published_at']
+
+const queryParamSchema = Joi.object({
+ gitea_url: optionalUrl,
+ include_prereleases: Joi.equal(''),
+ sort: Joi.string()
+ .valid(...sortEnum)
+ .default('date'),
+ display_name: Joi.string()
+ .valid(...displayNameEnum)
+ .default('tag'),
+ date_order_by: Joi.string()
+ .valid(...dateOrderByEnum)
+ .default('created_at'),
+}).required()
+
+export default class GiteaRelease extends GiteaBase {
+ static category = 'version'
+
+ static route = {
+ base: 'gitea/v/release',
+ pattern: ':user/:repo',
+ queryParamSchema,
+ }
+
+ static openApi = {
+ '/gitea/v/release/{user}/{repo}': {
+ get: {
+ summary: 'Gitea Release',
+ description,
+ parameters: [
+ pathParam({
+ name: 'user',
+ example: 'gitea',
+ }),
+ pathParam({
+ name: 'repo',
+ example: 'tea',
+ }),
+ queryParam({
+ name: 'gitea_url',
+ example: 'https://gitea.com',
+ }),
+ queryParam({
+ name: 'include_prereleases',
+ schema: { type: 'boolean' },
+ example: null,
+ }),
+ queryParam({
+ name: 'sort',
+ schema: { type: 'string', enum: sortEnum },
+ example: 'semver',
+ }),
+ queryParam({
+ name: 'display_name',
+ schema: { type: 'string', enum: displayNameEnum },
+ example: 'release',
+ }),
+ queryParam({
+ name: 'date_order_by',
+ schema: { type: 'string', enum: dateOrderByEnum },
+ example: 'created_at',
+ }),
+ ],
+ },
+ },
+ }
+
+ static defaultBadgeData = { label: 'release' }
+
+ async fetch({ user, repo, baseUrl }) {
+ // https://gitea.com/api/swagger#/repository/repoGetRelease
+ return super.fetch({
+ schema,
+ url: `${baseUrl}/api/v1/repos/${user}/${repo}/releases`,
+ httpErrors: httpErrorsFor(),
+ })
+ }
+
+ 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 latest(
+ releases.map(t => t[displayKey]),
+ { pre: includePrereleases },
+ )
+ }
+
+ if (!includePrereleases) {
+ const stableReleases = releases.filter(release => !release.prerelease)
+ if (stableReleases.length > 0) {
+ return stableReleases[0][displayKey]
+ }
+ }
+
+ return releases[0][displayKey]
+ }
+
+ async handle(
+ { user, repo },
+ {
+ gitea_url: baseUrl = 'https://gitea.com',
+ include_prereleases: pre,
+ sort,
+ display_name: displayName,
+ date_order_by: orderBy,
+ },
+ ) {
+ const isSemver = sort === 'semver'
+ const releases = await this.fetch({
+ user,
+ repo,
+ baseUrl,
+ isSemver,
+ })
+ const version = this.constructor.transform({
+ releases,
+ isSemver,
+ includePrereleases: pre !== undefined,
+ displayName,
+ })
+ return renderVersionBadge({ version })
+ }
+}
diff --git a/services/gitea/gitea-release.tester.js b/services/gitea/gitea-release.tester.js
new file mode 100644
index 0000000000000..9c7602997e8ce
--- /dev/null
+++ b/services/gitea/gitea-release.tester.js
@@ -0,0 +1,49 @@
+import Joi from 'joi'
+import { createServiceTester } from '../tester.js'
+export const t = await createServiceTester()
+
+t.create('Release (latest by date)')
+ .get('/gitea/tea.json')
+ .expectBadge({
+ label: 'release',
+ message: Joi.string(),
+ color: Joi.any().valid(...['orange', 'blue']),
+ })
+
+t.create('Release (latest by date) (self-managed)')
+ .get('/CanisHelix/shields-badge-test.json?gitea_url=https://codeberg.org')
+ .expectBadge({ label: 'release', message: 'v3.0.0', color: 'blue' })
+
+t.create('Release (latest by date, order by created_at) (self-managed)')
+ .get(
+ '/CanisHelix/shields-badge-test.json?gitea_url=https://codeberg.org&date_order_by=created_at',
+ )
+ .expectBadge({ label: 'release', message: 'v3.0.0', color: 'blue' })
+
+t.create('Release (latest by date, order by published_at) (self-managed)')
+ .get(
+ '/CanisHelix/shields-badge-test.json?gitea_url=https://codeberg.org&date_order_by=published_at',
+ )
+ .expectBadge({ label: 'release', message: 'v3.0.0', color: 'blue' })
+
+t.create('Release (latest by semver) (self-managed)')
+ .get(
+ '/CanisHelix/shields-badge-test.json?gitea_url=https://codeberg.org&sort=semver',
+ )
+ .expectBadge({ label: 'release', message: 'v4.0.0', color: 'blue' })
+
+t.create('Release (latest by semver pre-release) (self-managed)')
+ .get(
+ '/CanisHelix/shields-badge-test.json?gitea_url=https://codeberg.org&sort=semver&include_prereleases',
+ )
+ .expectBadge({ label: 'release', message: 'v5.0.0-rc1', color: 'orange' })
+
+t.create('Release (project not found) (self-managed)')
+ .get('/CanisHelix/does-not-exist.json?gitea_url=https://codeberg.org')
+ .expectBadge({ label: 'release', message: 'user or repo not found' })
+
+t.create('Release (no tags) (self-managed)')
+ .get(
+ '/CanisHelix/shields-badge-test-empty.json?gitea_url=https://codeberg.org',
+ )
+ .expectBadge({ label: 'release', message: 'no releases found' })
diff --git a/services/gitea/gitea-stars.service.js b/services/gitea/gitea-stars.service.js
new file mode 100644
index 0000000000000..43732b4068e59
--- /dev/null
+++ b/services/gitea/gitea-stars.service.js
@@ -0,0 +1,76 @@
+import Joi from 'joi'
+import { pathParam, queryParam } from '../index.js'
+import { optionalUrl, nonNegativeInteger } from '../validators.js'
+import { metric } from '../text-formatters.js'
+import GiteaBase from './gitea-base.js'
+import { description, httpErrorsFor } from './gitea-helper.js'
+
+const schema = Joi.object({
+ stars_count: nonNegativeInteger,
+}).required()
+
+const queryParamSchema = Joi.object({
+ gitea_url: optionalUrl,
+}).required()
+
+export default class GiteaStars extends GiteaBase {
+ static category = 'social'
+
+ static route = {
+ base: 'gitea/stars',
+ pattern: ':user/:repo',
+ queryParamSchema,
+ }
+
+ static openApi = {
+ '/gitea/stars/{user}/{repo}': {
+ get: {
+ summary: 'Gitea Stars',
+ description,
+ parameters: [
+ pathParam({
+ name: 'user',
+ example: 'gitea',
+ }),
+ pathParam({
+ name: 'repo',
+ example: 'tea',
+ }),
+ queryParam({
+ name: 'gitea_url',
+ example: 'https://gitea.com',
+ }),
+ ],
+ },
+ },
+ }
+
+ static defaultBadgeData = { label: 'stars', namedLogo: 'gitea' }
+
+ static render({ baseUrl, user, repo, starCount }) {
+ return {
+ message: metric(starCount),
+ style: 'social',
+ color: 'blue',
+ link: [`${baseUrl}/${user}/${repo}`, `${baseUrl}/${user}/${repo}/stars`],
+ }
+ }
+
+ async fetch({ user, repo, baseUrl }) {
+ // https://gitea.com/api/swagger#/repository
+ return super.fetch({
+ schema,
+ url: `${baseUrl}/api/v1/repos/${user}/${repo}`,
+ httpErrors: httpErrorsFor(),
+ })
+ }
+
+ async handle({ user, repo }, { gitea_url: baseUrl = 'https://gitea.com' }) {
+ const { stars_count: starCount } = await this.fetch({
+ user,
+ repo,
+ baseUrl,
+ })
+ return this.constructor.render({ baseUrl, user, repo, starCount })
+ }
+}
diff --git a/services/gitea/gitea-stars.tester.js b/services/gitea/gitea-stars.tester.js
new file mode 100644
index 0000000000000..bec444bfa74e3
--- /dev/null
+++ b/services/gitea/gitea-stars.tester.js
@@ -0,0 +1,32 @@
+import { isMetric } from '../test-validators.js'
+import { createServiceTester } from '../tester.js'
+
+export const t = await createServiceTester()
+
+t.create('Stars')
+ .get('/gitea/tea.json')
+ .expectBadge({
+ label: 'stars',
+ message: isMetric,
+ color: 'blue',
+ link: ['https://gitea.com/gitea/tea', 'https://gitea.com/gitea/tea/stars'],
+ })
+
+t.create('Stars (self-managed)')
+ .get('/CanisHelix/shields-badge-test.json?gitea_url=https://codeberg.org')
+ .expectBadge({
+ label: 'stars',
+ message: isMetric,
+ color: 'blue',
+ link: [
+ 'https://codeberg.org/CanisHelix/shields-badge-test',
+ 'https://codeberg.org/CanisHelix/shields-badge-test/stars',
+ ],
+ })
+
+t.create('Stars (project not found)')
+ .get('/CanisHelix/does-not-exist.json?gitea_url=https://codeberg.org')
+ .expectBadge({
+ label: 'stars',
+ message: 'user or repo not found',
+ })
diff --git a/services/github/auth/acceptor.js b/services/github/auth/acceptor.js
index 80347be581ca2..ac7ec88e949e7 100644
--- a/services/github/auth/acceptor.js
+++ b/services/github/auth/acceptor.js
@@ -1,14 +1,13 @@
-import queryString from 'query-string'
-import request from 'request'
-import { userAgent } from '../../../core/base-service/legacy-request-handler.js'
+import qs from 'qs'
+import { fetch } from '../../../core/base-service/got.js'
import log from '../../../core/server/log.js'
function setRoutes({ server, authHelper, onTokenAccepted }) {
- const baseUrl = process.env.GATSBY_BASE_URL || 'https://img.shields.io'
+ const baseUrl = 'https://img.shields.io'
server.route(/^\/github-auth$/, (data, match, end, ask) => {
ask.res.statusCode = 302 // Found.
- const query = queryString.stringify({
+ const query = qs.stringify({
// TODO The `_user` property bypasses security checks in AuthHelper.
// (e.g: enforceStrictSsl and shouldAuthenticateRequest).
// Do not use it elsewhere. It would be better to clean this up so
@@ -18,25 +17,23 @@ function setRoutes({ server, authHelper, onTokenAccepted }) {
})
ask.res.setHeader(
'Location',
- `https://github.com/login/oauth/authorize?${query}`
+ `https://github.com/login/oauth/authorize?${query}`,
)
end('')
})
- server.route(/^\/github-auth\/done$/, (data, match, end, ask) => {
+ server.route(/^\/github-auth\/done$/, async (data, match, end, ask) => {
if (!data.code) {
log.log(`GitHub OAuth data: ${JSON.stringify(data)}`)
return end('GitHub OAuth authentication failed to provide a code.')
}
const options = {
- url: 'https://github.com/login/oauth/access_token',
method: 'POST',
headers: {
'Content-type': 'application/x-www-form-urlencoded;charset=UTF-8',
- 'User-Agent': userAgent,
},
- form: queryString.stringify({
+ form: {
// TODO The `_user` and `_pass` properties bypass security checks in
// AuthHelper (e.g: enforceStrictSsl and shouldAuthenticateRequest).
// Do not use them elsewhere. It would be better to clean
@@ -44,40 +41,42 @@ function setRoutes({ server, authHelper, onTokenAccepted }) {
client_id: authHelper._user,
client_secret: authHelper._pass,
code: data.code,
- }),
+ },
}
- request(options, (err, res, body) => {
- if (err != null) {
- return end('The connection to GitHub failed.')
- }
- let content
- try {
- content = queryString.parse(body)
- } catch (e) {
- return end('The GitHub OAuth token could not be parsed.')
- }
+ let resp
+ try {
+ resp = await fetch('https://github.com/login/oauth/access_token', options)
+ } catch (e) {
+ return end('The connection to GitHub failed.')
+ }
- const { access_token: token } = content
- if (!token) {
- return end('The GitHub OAuth process did not return a user token.')
- }
+ let content
+ try {
+ content = qs.parse(resp.buffer)
+ } catch (e) {
+ return end('The GitHub OAuth token could not be parsed.')
+ }
- ask.res.setHeader('Content-Type', 'text/html')
- end(
- 'Shields.io has received your app-specific GitHub user token. ' +
- 'You can revoke it by going to ' +
- 'GitHub .
' +
- 'Until you do, you have now increased the rate limit for GitHub ' +
- 'requests going through Shields.io. GitHub-related badges are ' +
- 'therefore more robust.
' +
- 'Thanks for contributing to a smoother experience for ' +
- 'everyone!
' +
- 'Back to the website
'
- )
+ const { access_token: token } = content
+ if (!token) {
+ return end('The GitHub OAuth process did not return a user token.')
+ }
- onTokenAccepted(token)
- })
+ ask.res.setHeader('Content-Type', 'text/html')
+ end(
+ 'Shields.io has received your app-specific GitHub user token. ' +
+ 'You can revoke it by going to ' +
+ 'GitHub .
' +
+ 'Until you do, you have now increased the rate limit for GitHub ' +
+ 'requests going through Shields.io. GitHub-related badges are ' +
+ 'therefore more robust.
' +
+ 'Thanks for contributing to a smoother experience for ' +
+ 'everyone!
' +
+ 'Back to the website
',
+ )
+
+ onTokenAccepted(token)
})
}
diff --git a/services/github/auth/acceptor.spec.js b/services/github/auth/acceptor.spec.js
index 2d6cab6e24d83..dd22769adbaa5 100644
--- a/services/github/auth/acceptor.spec.js
+++ b/services/github/auth/acceptor.spec.js
@@ -1,19 +1,20 @@
import { expect } from 'chai'
import Camp from '@shields_io/camp'
-import FormData from 'form-data'
+import { FormData } from 'undici'
import sinon from 'sinon'
import portfinder from 'portfinder'
-import queryString from 'query-string'
+import qs from 'qs'
import nock from 'nock'
import got from '../../../core/got-test-client.js'
import GithubConstellation from '../github-constellation.js'
import { setRoutes } from './acceptor.js'
const fakeClientId = 'githubdabomb'
+const fakeClientSecret = 'foobar'
describe('Github token acceptor', function () {
const oauthHelper = GithubConstellation._createOauthHelper({
- private: { gh_client_id: fakeClientId },
+ private: { gh_client_id: fakeClientId, gh_client_secret: fakeClientSecret },
})
let port, baseUrl
@@ -49,11 +50,11 @@ describe('Github token acceptor', function () {
expect(res.statusCode).to.equal(302)
- const qs = queryString.stringify({
+ const queryString = qs.stringify({
client_id: fakeClientId,
redirect_uri: 'https://img.shields.io/github-auth/done',
})
- const expectedLocationHeader = `https://github.com/login/oauth/authorize?${qs}`
+ const expectedLocationHeader = `https://github.com/login/oauth/authorize?${queryString}`
expect(res.headers.location).to.equal(expectedLocationHeader)
})
@@ -62,7 +63,7 @@ describe('Github token acceptor', function () {
it('should return an error', async function () {
const res = await got(`${baseUrl}/github-auth/done`)
expect(res.body).to.equal(
- 'GitHub OAuth authentication failed to provide a code.'
+ 'GitHub OAuth authentication failed to provide a code.',
)
})
})
@@ -78,11 +79,11 @@ describe('Github token acceptor', function () {
scope = nock('https://github.com')
.post('/login/oauth/access_token')
.reply((url, requestBody) => {
- expect(queryString.parse(requestBody).code).to.equal(fakeCode)
- return [
- 200,
- queryString.stringify({ access_token: fakeAccessToken }),
- ]
+ const parsedBody = qs.parse(requestBody)
+ expect(parsedBody.client_id).to.equal(fakeClientId)
+ expect(parsedBody.client_secret).to.equal(fakeClientSecret)
+ expect(parsedBody.code).to.equal(fakeCode)
+ return [200, qs.stringify({ access_token: fakeAccessToken })]
})
})
@@ -110,10 +111,11 @@ describe('Github token acceptor', function () {
const res = await got.post(`${baseUrl}/github-auth/done`, {
body: form,
})
- expect(res.body).to.startWith(
- 'Shields.io has received your app-specific GitHub user token.'
- )
-
+ expect(
+ res.body.startsWith(
+ '
Shields.io has received your app-specific GitHub user token.',
+ ),
+ ).to.be.true
expect(onTokenAccepted).to.have.been.calledWith(fakeAccessToken)
})
})
diff --git a/services/github/auth/admin.js b/services/github/auth/admin.js
deleted file mode 100644
index 9ffea4d056415..0000000000000
--- a/services/github/auth/admin.js
+++ /dev/null
@@ -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 }
diff --git a/services/github/auth/admin.spec.js b/services/github/auth/admin.spec.js
deleted file mode 100644
index fb751498e0270..0000000000000
--- a/services/github/auth/admin.spec.js
+++ /dev/null
@@ -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')
- })
- })
- }
-})
diff --git a/services/github/gist/github-gist-last-commit-redirect.service.js b/services/github/gist/github-gist-last-commit-redirect.service.js
new file mode 100644
index 0000000000000..bff653f0a7d39
--- /dev/null
+++ b/services/github/gist/github-gist-last-commit-redirect.service.js
@@ -0,0 +1,8 @@
+import { redirector } from '../../index.js'
+
+export default redirector({
+ category: 'activity',
+ route: { base: 'github-gist/last-commit', pattern: ':gistId' },
+ transformPath: ({ gistId }) => `/github/gist/last-commit/${gistId}`,
+ dateAdded: new Date('2022-10-09'),
+})
diff --git a/services/github/gist/github-gist-last-commit-redirect.tester.js b/services/github/gist/github-gist-last-commit-redirect.tester.js
new file mode 100644
index 0000000000000..a9b6d254f529d
--- /dev/null
+++ b/services/github/gist/github-gist-last-commit-redirect.tester.js
@@ -0,0 +1,13 @@
+import { ServiceTester } from '../../tester.js'
+
+export const t = new ServiceTester({
+ id: 'GistLastCommitRedirect',
+ title: 'GitHub Gist Last Commit Redirect',
+ pathPrefix: '/github-gist',
+})
+
+t.create('Last Commit redirect')
+ .get('/last-commit/a8b8c979d200ffde13cc08505f7a6436')
+ .expectRedirect(
+ '/github/gist/last-commit/a8b8c979d200ffde13cc08505f7a6436.svg',
+ )
diff --git a/services/github/gist/github-gist-last-commit.service.js b/services/github/gist/github-gist-last-commit.service.js
new file mode 100644
index 0000000000000..d090c9d3f2c36
--- /dev/null
+++ b/services/github/gist/github-gist-last-commit.service.js
@@ -0,0 +1,41 @@
+import Joi from 'joi'
+import { pathParams } from '../../index.js'
+import { renderDateBadge } from '../../date.js'
+import { GithubAuthV3Service } from '../github-auth-service.js'
+import { documentation, httpErrorsFor } from '../github-helpers.js'
+
+const schema = Joi.object({
+ updated_at: Joi.string().required(),
+}).required()
+
+export default class GistLastCommit extends GithubAuthV3Service {
+ static category = 'activity'
+ static route = { base: 'github/gist/last-commit', pattern: ':gistId' }
+ static openApi = {
+ '/github/gist/last-commit/{gistId}': {
+ get: {
+ summary: 'GitHub Gist last commit',
+ description: `Shows the latest commit to a GitHub Gist.\n${documentation}`,
+ parameters: pathParams({
+ name: 'gistId',
+ example: '8710649',
+ }),
+ },
+ },
+ }
+
+ static defaultBadgeData = { label: 'last commit' }
+
+ async fetch({ gistId }) {
+ return this._requestJson({
+ url: `/gists/${gistId}`,
+ schema,
+ httpErrors: httpErrorsFor('gist not found'),
+ })
+ }
+
+ async handle({ gistId }) {
+ const { updated_at: commitDate } = await this.fetch({ gistId })
+ return renderDateBadge(commitDate)
+ }
+}
diff --git a/services/github/gist/github-gist-last-commit.tester.js b/services/github/gist/github-gist-last-commit.tester.js
new file mode 100644
index 0000000000000..5d82b48c333fd
--- /dev/null
+++ b/services/github/gist/github-gist-last-commit.tester.js
@@ -0,0 +1,24 @@
+import { createServiceTester } from '../../tester.js'
+export const t = await createServiceTester()
+
+t.create('last commit in gist (ancient)').get('/871064.json').expectBadge({
+ label: 'last commit',
+ message: 'september 2015',
+ color: 'red',
+})
+
+// not checking the color badge, since in August 2022 it is orange but later it will become red
+t.create('last commit in gist (still ancient but slightly less so)')
+ .get('/870071abadfd66a28bf539677332f12b.json')
+ .expectBadge({
+ label: 'last commit',
+ message: 'october 2020',
+ })
+
+t.create('last commit in gist (gist not found)')
+ .get('/55555555555555.json')
+ .expectBadge({
+ label: 'last commit',
+ message: 'gist not found',
+ color: 'red',
+ })
diff --git a/services/github/gist/github-gist-stars-redirect.service.js b/services/github/gist/github-gist-stars-redirect.service.js
new file mode 100644
index 0000000000000..49a2d51d5fdb6
--- /dev/null
+++ b/services/github/gist/github-gist-stars-redirect.service.js
@@ -0,0 +1,9 @@
+import { deprecatedService } from '../../index.js'
+
+export default deprecatedService({
+ category: 'social',
+ label: 'github',
+ route: { base: 'github/stars/gists', pattern: ':gistId' },
+ dateAdded: new Date('2025-12-20'),
+ issueUrl: 'https://github.com/badges/shields/pull/11583',
+})
diff --git a/services/github/gist/github-gist-stars-redirect.tester.js b/services/github/gist/github-gist-stars-redirect.tester.js
new file mode 100644
index 0000000000000..10142fe41e06c
--- /dev/null
+++ b/services/github/gist/github-gist-stars-redirect.tester.js
@@ -0,0 +1,13 @@
+import { ServiceTester } from '../../tester.js'
+export const t = new ServiceTester({
+ id: 'GistStarsRedirect',
+ title: 'Github Gist Stars Redirect',
+ pathPrefix: '/github',
+})
+
+t.create('Stars deprecated')
+ .get('/stars/gists/a8b8c979d200ffde13cc08505f7a6436.json')
+ .expectBadge({
+ label: 'github',
+ message: 'https://github.com/badges/shields/pull/11583',
+ })
diff --git a/services/github/gist/github-gist-stars.service.js b/services/github/gist/github-gist-stars.service.js
new file mode 100644
index 0000000000000..16769e93c2ce7
--- /dev/null
+++ b/services/github/gist/github-gist-stars.service.js
@@ -0,0 +1,117 @@
+import gql from 'graphql-tag'
+import Joi from 'joi'
+import { metric } from '../../text-formatters.js'
+import { NotFound, pathParams } from '../../index.js'
+import { GithubAuthV4Service } from '../github-auth-service.js'
+import { documentation as commonDocumentation } from '../github-helpers.js'
+
+const schema = Joi.object({
+ data: Joi.object({
+ viewer: Joi.object({
+ gist: Joi.object({
+ stargazerCount: Joi.number().required(),
+ url: Joi.string().required(),
+ owner: Joi.object({
+ login: Joi.string().required(),
+ }).required(),
+ name: Joi.string().required(),
+ }).allow(null),
+ }).required(),
+ }).required(),
+}).required()
+
+const description = `${commonDocumentation}
+
+This badge shows the number of stargazers for a gist. Gist id is accepted as input and 'gist not found' is returned if the gist is not found for the given gist id.`
+
+export default class GistStars extends GithubAuthV4Service {
+ static category = 'social'
+
+ static route = {
+ base: 'github/gist/stars',
+ pattern: ':gistId',
+ }
+
+ static openApi = {
+ '/github/gist/stars/{gistId}': {
+ get: {
+ summary: 'GitHub Gist stars',
+ description,
+ parameters: pathParams({
+ name: 'gistId',
+ example: '47a4d00457a92aa426dbd48a18776322',
+ }),
+ },
+ },
+ }
+
+ static defaultBadgeData = {
+ label: 'Stars',
+ color: 'blue',
+ namedLogo: 'github',
+ }
+
+ static render({ stargazerCount, url, stargazers }) {
+ return {
+ message: metric(stargazerCount),
+ style: 'social',
+ link: [url, stargazers],
+ }
+ }
+
+ async fetch({ gistId }) {
+ const data = await this._requestGraphql({
+ query: gql`
+ query ($gistId: String!) {
+ viewer {
+ gist(name: $gistId) {
+ stargazerCount
+ url
+ name
+ owner {
+ login
+ }
+ }
+ }
+ }
+ `,
+ variables: {
+ gistId,
+ },
+ schema,
+ })
+ return data
+ }
+
+ static transform({ data }) {
+ const {
+ data: {
+ viewer: { gist },
+ },
+ } = data
+
+ if (!gist) {
+ throw new NotFound({ prettyMessage: 'gist not found' })
+ }
+
+ const {
+ stargazerCount,
+ url,
+ name,
+ owner: { login },
+ } = gist
+
+ const stargazers = `https://gist.github.com/${login}/${name}/stargazers`
+
+ return { stargazerCount, url, stargazers }
+ }
+
+ async handle({ gistId }) {
+ const data = await this.fetch({ gistId })
+ const { stargazerCount, url, stargazers } =
+ await this.constructor.transform({
+ data,
+ })
+ return this.constructor.render({ stargazerCount, url, stargazers })
+ }
+}
diff --git a/services/github/gist/github-gist-stars.tester.js b/services/github/gist/github-gist-stars.tester.js
new file mode 100644
index 0000000000000..2d88d407452a0
--- /dev/null
+++ b/services/github/gist/github-gist-stars.tester.js
@@ -0,0 +1,25 @@
+import { createServiceTester } from '../../tester.js'
+import { isMetric } from '../../test-validators.js'
+
+export const t = await createServiceTester()
+
+t.create('Gist Total Stars')
+ .get('/47a4d00457a92aa426dbd48a18776322.json')
+ .expectBadge({
+ label: 'Stars',
+ message: isMetric,
+ color: 'blue',
+ link: [
+ 'https://gist.github.com/47a4d00457a92aa426dbd48a18776322',
+ 'https://gist.github.com/maratori/47a4d00457a92aa426dbd48a18776322/stargazers',
+ ],
+ })
+
+t.create('Gist Total Stars (Not Found)')
+ .get('/invalid-gist-id.json')
+ .expectBadge({
+ label: 'Stars',
+ message: 'gist not found',
+ color: 'red',
+ link: [],
+ })
diff --git a/services/github/github-actions-workflow-status.service.js b/services/github/github-actions-workflow-status.service.js
new file mode 100644
index 0000000000000..d05c2476170de
--- /dev/null
+++ b/services/github/github-actions-workflow-status.service.js
@@ -0,0 +1,75 @@
+import Joi from 'joi'
+import { isBuildStatus, renderBuildStatusBadge } from '../build-status.js'
+import { BaseSvgScrapingService, pathParam, queryParam } from '../index.js'
+import { documentation } from './github-helpers.js'
+
+const schema = Joi.object({
+ message: Joi.alternatives()
+ .try(isBuildStatus, Joi.equal('no status'))
+ .required(),
+}).required()
+
+const queryParamSchema = Joi.object({
+ event: Joi.string(),
+ branch: Joi.alternatives().try(Joi.string(), Joi.number().cast('string')),
+}).required()
+
+export default class GithubActionsWorkflowStatus extends BaseSvgScrapingService {
+ static category = 'build'
+
+ static route = {
+ base: 'github/actions/workflow/status',
+ pattern: ':user/:repo/:workflow+',
+ queryParamSchema,
+ }
+
+ static openApi = {
+ '/github/actions/workflow/status/{user}/{repo}/{workflow}': {
+ get: {
+ summary: 'GitHub Actions Workflow Status',
+ description: documentation,
+ parameters: [
+ pathParam({ name: 'user', example: 'actions' }),
+ pathParam({ name: 'repo', example: 'toolkit' }),
+ pathParam({ name: 'workflow', example: 'unit-tests.yml' }),
+ queryParam({ name: 'branch', example: 'main' }),
+ queryParam({
+ name: 'event',
+ example: 'push',
+ description:
+ 'See GitHub Actions [Events that trigger workflows](https://docs.github.com/en/actions/using-workflows/events-that-trigger-workflows) for allowed values.',
+ }),
+ ],
+ },
+ },
+ }
+
+ static _cacheLength = 60
+
+ static defaultBadgeData = {
+ label: 'build',
+ }
+
+ async fetch({ user, repo, workflow, branch, event }) {
+ const workflowPath = workflow
+ .split('/')
+ .map(el => encodeURIComponent(el))
+ .join('/')
+ const { message: status } = await this._requestSvg({
+ schema,
+ url: `https://github.com/${user}/${repo}/actions/workflows/${workflowPath}/badge.svg`,
+ options: { searchParams: { branch, event } },
+ valueMatcher: />([^<>]+)<\/tspan><\/text><\/g> {},
globalToken,
reserveFraction = 0.25,
+ restApiVersion,
}) {
Object.assign(this, {
baseUrl,
- withPooling,
+ authType,
onTokenInvalidated,
globalToken,
reserveFraction,
})
- if (this.withPooling) {
+ if (this.authType === this.constructor.AUTH_TYPES.TOKEN_POOL) {
this.standardTokens = new TokenPool({ batchSize: 25 })
this.searchTokens = new TokenPool({ batchSize: 5 })
this.graphqlTokens = new TokenPool({ batchSize: 25 })
}
+ this.restApiVersion = restApiVersion
+ }
+
+ addToken(tokenString) {
+ if (this.authType === this.constructor.AUTH_TYPES.TOKEN_POOL) {
+ this.standardTokens.add(tokenString)
+ this.searchTokens.add(tokenString)
+ this.graphqlTokens.add(tokenString)
+ } else {
+ throw Error('When not using a token pool, do not provide tokens')
+ }
}
- serializeDebugInfo({ sanitize = true } = {}) {
- if (this.withPooling) {
+ getTokenDebugInfo({ sanitize = true } = {}) {
+ if (this.authType === this.constructor.AUTH_TYPES.TOKEN_POOL) {
return {
standardTokens: this.standardTokens.serializeDebugInfo({ sanitize }),
searchTokens: this.searchTokens.serializeDebugInfo({ sanitize }),
@@ -66,16 +87,6 @@ class GithubApiProvider {
}
}
- addToken(tokenString) {
- if (this.withPooling) {
- this.standardTokens.add(tokenString)
- this.searchTokens.add(tokenString)
- this.graphqlTokens.add(tokenString)
- } else {
- throw Error('When not using a token pool, do not provide tokens')
- }
- }
-
getV3RateLimitFromHeaders(headers) {
const h = Joi.attempt(headers, headerSchema)
return {
@@ -86,8 +97,7 @@ class GithubApiProvider {
}
getV4RateLimitFromBody(body) {
- const parsedBody = JSON.parse(body)
- const b = Joi.attempt(parsedBody, bodySchema)
+ const b = Joi.attempt(body, bodySchema)
return {
rateLimit: b.data.rateLimit.limit,
totalUsesRemaining: b.data.rateLimit.remaining,
@@ -99,11 +109,20 @@ class GithubApiProvider {
let rateLimit, totalUsesRemaining, nextReset
if (url.startsWith('/graphql')) {
try {
+ const parsedBody = JSON.parse(res.body)
+
+ if ('message' in parsedBody && !('data' in parsedBody)) {
+ if (parsedBody.message === 'Sorry. Your account was suspended.') {
+ this.invalidateToken(token)
+ return
+ }
+ }
+
;({ rateLimit, totalUsesRemaining, nextReset } =
- this.getV4RateLimitFromBody(res.body))
+ this.getV4RateLimitFromBody(parsedBody))
} catch (e) {
console.error(
- `Could not extract rate limit info from response body ${res.body}`
+ `Could not extract rate limit info from response body ${res.body}`,
)
log.error(e)
return
@@ -122,8 +141,8 @@ class GithubApiProvider {
`Invalid GitHub rate limit headers ${JSON.stringify(
logHeaders,
undefined,
- 2
- )}`
+ 2,
+ )}`,
)
log.error(e)
return
@@ -151,63 +170,51 @@ 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
let tokenString
- if (this.withPooling) {
+ if (this.authType === this.constructor.AUTH_TYPES.TOKEN_POOL) {
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 {
+ } else if (this.authType === this.constructor.AUTH_TYPES.GLOBAL_TOKEN) {
tokenString = this.globalToken
}
const mergedOptions = {
...options,
...{
- url,
- baseUrl,
headers: {
'User-Agent': userAgent,
- Authorization: `token ${tokenString}`,
+ 'X-GitHub-Api-Version': this.restApiVersion,
...options.headers,
},
},
}
+ if (
+ this.authType === this.constructor.AUTH_TYPES.TOKEN_POOL ||
+ this.authType === this.constructor.AUTH_TYPES.GLOBAL_TOKEN
+ ) {
+ mergedOptions.headers.Authorization = `token ${tokenString}`
+ }
- 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.authType === this.constructor.AUTH_TYPES.TOKEN_POOL) {
+ 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
}
}
diff --git a/services/github/github-api-provider.spec.js b/services/github/github-api-provider.spec.js
index dbb59d68e0552..31f4dcb5187fa 100644
--- a/services/github/github-api-provider.spec.js
+++ b/services/github/github-api-provider.spec.js
@@ -8,7 +8,11 @@ describe('Github API provider', function () {
let mockStandardToken, mockSearchToken, mockGraphqlToken, provider
beforeEach(function () {
- provider = new GithubApiProvider({ baseUrl, reserveFraction })
+ provider = new GithubApiProvider({
+ baseUrl,
+ authType: GithubApiProvider.AUTH_TYPES.TOKEN_POOL,
+ reserveFraction,
+ })
mockStandardToken = { update: sinon.spy(), invalidate: sinon.spy() }
sinon.stub(provider.standardTokens, 'next').returns(mockStandardToken)
@@ -21,47 +25,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 +62,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 +96,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 +109,67 @@ 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)
- }
+ context('unauthorized API responses', function () {
+ it('should invoke the callback and update the token with the expected values (unauthorized, v3)', 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
+ })
+
+ it('should invoke the callback and update the token with the expected values (unauthorized, v4)', async function () {
+ const mockResponse = { res: { statusCode: 401, body: {} } }
+ const mockRequest = sinon.stub().resolves(mockResponse)
+ await provider.fetch(mockRequest, '/graphql', {})
+ expect(mockGraphqlToken.invalidate).to.have.been.calledOnce
+ expect(mockGraphqlToken.update).not.to.have.been.called
+ })
- 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 (suspended, v4)', async function () {
+ const mockResponse = {
+ res: {
+ statusCode: 200,
+ body: '{ "message": "Sorry. Your account was suspended." }',
+ },
+ }
+ const mockRequest = sinon.stub().resolves(mockResponse)
+ await provider.fetch(mockRequest, '/graphql', {})
+ expect(mockGraphqlToken.invalidate).to.have.been.calledOnce
+ expect(mockGraphqlToken.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',
+ )
})
})
})
diff --git a/services/github/github-auth-service.js b/services/github/github-auth-service.js
index 826ab3990d5ab..67f249f02c9e5 100644
--- a/services/github/github-auth-service.js
+++ b/services/github/github-auth-service.js
@@ -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 { requestFetcher, githubApiProvider } = context
+ return githubApiProvider.fetch.bind(githubApiProvider, requestFetcher)
}
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,12 +35,12 @@ 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
}
async _requestGraphql(attrs) {
- const url = `/graphql`
+ const url = '/graphql'
/*
The Github v4 API requires us to query the rateLimit object to return
@@ -66,10 +60,17 @@ class GithubAuthV4Service extends BaseGraphqlService {
resetAt
}
}
- `
+ `,
)
- return super._requestGraphql({ ...attrs, ...{ url, query } })
+ return super._requestGraphql({
+ ...attrs,
+ ...{
+ url,
+ query,
+ httpErrorMessages: { 401: 'auth required for graphql api' },
+ },
+ })
}
}
diff --git a/services/github/github-auth-service.spec.js b/services/github/github-auth-service.spec.js
index 928589400626a..d75df3f9db6d4 100644
--- a/services/github/github-auth-service.spec.js
+++ b/services/github/github-auth-service.spec.js
@@ -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,31 +26,42 @@ describe('GithubAuthV3Service', function () {
}
it('forwards custom Accept header', async function () {
- const sendAndCacheRequestWithCallbacks = sinon.stub().returns(
+ const requestFetcher = 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({
baseUrl: 'https://github-api.example.com',
+ authType: GithubApiProvider.AUTH_TYPES.TOKEN_POOL,
+ restApiVersion: '2022-11-28',
})
const mockToken = { update: sinon.mock(), invalidate: sinon.mock() }
sinon.stub(githubApiProvider.standardTokens, 'next').returns(mockToken)
DummyGithubAuthV3Service.invoke({
- sendAndCacheRequestWithCallbacks,
+ requestFetcher,
githubApiProvider,
})
- expect(sendAndCacheRequestWithCallbacks).to.have.been.calledOnceWith({
- headers: {
- 'User-Agent': 'Shields.io/2003a',
- Accept: 'application/vnd.github.antiope-preview+json',
- Authorization: 'token undefined',
+ expect(requestFetcher).to.have.been.calledOnceWith(
+ 'https://github-api.example.com/repos/badges/shields/check-runs',
+ {
+ headers: {
+ 'User-Agent': 'shields (self-hosted)/dev',
+ Accept: 'application/vnd.github.antiope-preview+json',
+ Authorization: 'token undefined',
+ 'X-GitHub-Api-Version': '2022-11-28',
+ },
},
- url: 'https://github-api.example.com/repos/badges/shields/check-runs',
- baseUrl: 'https://github-api.example.com',
- })
+ )
})
})
diff --git a/services/github/github-check-runs.service.js b/services/github/github-check-runs.service.js
new file mode 100644
index 0000000000000..c1971f5d5ecbe
--- /dev/null
+++ b/services/github/github-check-runs.service.js
@@ -0,0 +1,169 @@
+import Joi from 'joi'
+import countBy from 'lodash.countby'
+import { pathParam, queryParam } from '../index.js'
+import { nonNegativeInteger } from '../validators.js'
+import { renderBuildStatusBadge } from '../build-status.js'
+import { GithubAuthV3Service } from './github-auth-service.js'
+import {
+ documentation as commonDocumentation,
+ httpErrorsFor,
+} from './github-helpers.js'
+
+const description = `
+The Check Runs service shows the status of GitHub action runs.
+
+${commonDocumentation}
+`
+
+const schema = Joi.object({
+ total_count: nonNegativeInteger,
+ check_runs: Joi.array()
+ .items(
+ Joi.object({
+ name: Joi.string().required(),
+ status: Joi.equal('completed', 'in_progress', 'queued').required(),
+ conclusion: Joi.equal(
+ 'action_required',
+ 'cancelled',
+ 'failure',
+ 'neutral',
+ 'skipped',
+ 'success',
+ 'timed_out',
+ null,
+ ).required(),
+ }),
+ )
+ .default([]),
+}).required()
+
+const queryParamSchema = Joi.object({
+ nameFilter: Joi.string(),
+})
+
+export default class GithubCheckRuns extends GithubAuthV3Service {
+ static category = 'build'
+ static route = {
+ base: 'github/check-runs',
+ pattern: ':user/:repo/:ref+',
+ queryParamSchema,
+ }
+
+ static openApi = {
+ '/github/check-runs/{user}/{repo}/{branch}': {
+ get: {
+ summary: 'GitHub branch check runs',
+ description,
+ parameters: [
+ pathParam({ name: 'user', example: 'badges' }),
+ pathParam({ name: 'repo', example: 'shields' }),
+ pathParam({ name: 'branch', example: 'master' }),
+ queryParam({
+ name: 'nameFilter',
+ description: 'Name of a check run',
+ example: 'test-lint',
+ }),
+ ],
+ },
+ },
+ '/github/check-runs/{user}/{repo}/{commit}': {
+ get: {
+ summary: 'GitHub commit check runs',
+ description,
+ parameters: [
+ pathParam({ name: 'user', example: 'badges' }),
+ pathParam({ name: 'repo', example: 'shields' }),
+ pathParam({
+ name: 'commit',
+ example: '91b108d4b7359b2f8794a4614c11cb1157dc9fff',
+ }),
+ queryParam({
+ name: 'nameFilter',
+ description: 'Name of a check run',
+ example: 'test-lint',
+ }),
+ ],
+ },
+ },
+ '/github/check-runs/{user}/{repo}/{tag}': {
+ get: {
+ summary: 'GitHub tag check runs',
+ description,
+ parameters: [
+ pathParam({ name: 'user', example: 'badges' }),
+ pathParam({ name: 'repo', example: 'shields' }),
+ pathParam({ name: 'tag', example: '3.3.0' }),
+ queryParam({
+ name: 'nameFilter',
+ description: 'Name of a check run',
+ example: 'test-lint',
+ }),
+ ],
+ },
+ },
+ }
+
+ static defaultBadgeData = { label: 'checks' }
+
+ static transform(
+ { total_count: totalCount, check_runs: checkRuns },
+ nameFilter,
+ ) {
+ const filteredCheckRuns =
+ nameFilter && nameFilter.length > 0
+ ? checkRuns.filter(checkRun => checkRun.name === nameFilter)
+ : checkRuns
+
+ return {
+ total: totalCount,
+ statusCounts: countBy(filteredCheckRuns, 'status'),
+ conclusionCounts: countBy(filteredCheckRuns, 'conclusion'),
+ }
+ }
+
+ static mapState({ total, statusCounts, conclusionCounts }) {
+ let state
+ if (total === 0) {
+ state = 'no check runs'
+ } else if (statusCounts.queued) {
+ state = 'queued'
+ } else if (statusCounts.in_progress) {
+ state = 'pending'
+ } else if (statusCounts.completed) {
+ // all check runs are completed, now evaluate conclusions
+ const orangeStates = ['action_required', 'stale']
+ const redStates = ['cancelled', 'failure', 'timed_out']
+
+ // assume "passing (green)"
+ state = 'passing'
+ for (const stateValue of Object.keys(conclusionCounts)) {
+ if (orangeStates.includes(stateValue)) {
+ // orange state renders "passing (orange)"
+ state = 'partially succeeded'
+ } else if (redStates.includes(stateValue)) {
+ // red state renders "failing (red)"
+ state = 'failing'
+ break
+ }
+ }
+ } else {
+ state = 'unknown status'
+ }
+ return state
+ }
+
+ async handle({ user, repo, ref }, { nameFilter }) {
+ // https://docs.github.com/en/rest/checks/runs#list-check-runs-for-a-git-reference
+ const json = await this._requestJson({
+ url: `/repos/${user}/${repo}/commits/${ref}/check-runs`,
+ httpErrors: httpErrorsFor('ref or repo not found'),
+ schema,
+ })
+
+ const state = this.constructor.mapState(
+ this.constructor.transform(json, nameFilter),
+ )
+
+ return renderBuildStatusBadge({ status: state })
+ }
+}
diff --git a/services/github/github-check-runs.spec.js b/services/github/github-check-runs.spec.js
new file mode 100644
index 0000000000000..5c233a73bd04b
--- /dev/null
+++ b/services/github/github-check-runs.spec.js
@@ -0,0 +1,94 @@
+import { test, given } from 'sazerac'
+import GithubCheckRuns from './github-check-runs.service.js'
+
+describe('GithubCheckRuns.transform', function () {
+ test(GithubCheckRuns.transform, () => {
+ given(
+ {
+ total_count: 3,
+ check_runs: [
+ { status: 'completed', conclusion: 'success' },
+ { status: 'completed', conclusion: 'failure' },
+ { status: 'in_progress', conclusion: null },
+ ],
+ },
+ '',
+ ).expect({
+ total: 3,
+ statusCounts: { completed: 2, in_progress: 1 },
+ conclusionCounts: { success: 1, failure: 1, null: 1 },
+ })
+
+ given(
+ {
+ total_count: 3,
+ check_runs: [
+ { name: 'test1', status: 'completed', conclusion: 'success' },
+ { name: 'test2', status: 'completed', conclusion: 'failure' },
+ { name: 'test3', status: 'in_progress', conclusion: null },
+ ],
+ },
+ '',
+ ).expect({
+ total: 3,
+ statusCounts: { completed: 2, in_progress: 1 },
+ conclusionCounts: { success: 1, failure: 1, null: 1 },
+ })
+
+ given(
+ {
+ total_count: 3,
+ check_runs: [
+ { name: 'test1', status: 'completed', conclusion: 'success' },
+ { name: 'test2', status: 'completed', conclusion: 'failure' },
+ { name: 'test3', status: 'in_progress', conclusion: null },
+ ],
+ },
+ 'test1',
+ ).expect({
+ total: 3,
+ statusCounts: { completed: 1 },
+ conclusionCounts: { success: 1 },
+ })
+ })
+})
+
+describe('GithubCheckRuns', function () {
+ test(GithubCheckRuns.mapState, () => {
+ given({
+ total: 0,
+ statusCounts: null,
+ conclusionCounts: null,
+ }).expect('no check runs')
+ given({
+ total: 1,
+ statusCounts: { queued: 1 },
+ conclusionCounts: null,
+ }).expect('queued')
+ given({
+ total: 1,
+ statusCounts: { in_progress: 1 },
+ conclusionCounts: null,
+ }).expect('pending')
+ given({
+ total: 1,
+ statusCounts: { completed: 1 },
+ conclusionCounts: { success: 1 },
+ }).expect('passing')
+ given({
+ total: 2,
+ statusCounts: { completed: 2 },
+ conclusionCounts: { success: 1, stale: 1 },
+ }).expect('partially succeeded')
+ given({
+ total: 3,
+ statusCounts: { completed: 3 },
+ conclusionCounts: { success: 1, stale: 1, failure: 1 },
+ }).expect('failing')
+ given({
+ total: 1,
+ statusCounts: { somethingelse: 1 },
+ conclusionCounts: null,
+ }).expect('unknown status')
+ })
+})
diff --git a/services/github/github-check-runs.tester.js b/services/github/github-check-runs.tester.js
new file mode 100644
index 0000000000000..de1ddec9689f4
--- /dev/null
+++ b/services/github/github-check-runs.tester.js
@@ -0,0 +1,31 @@
+import { createServiceTester } from '../tester.js'
+import { isBuildStatus } from '../build-status.js'
+export const t = await createServiceTester()
+
+t.create('check runs - for branch')
+ .get('/badges/shields/master.json')
+ .expectBadge({
+ label: 'checks',
+ message: isBuildStatus,
+ })
+
+t.create('check runs - for branch with filter')
+ .get('/badges/shields/master.json?nameFilter=test-lint')
+ .expectBadge({
+ label: 'checks',
+ message: isBuildStatus,
+ })
+
+t.create('check runs - no tests')
+ .get('/badges/shields/5d4ab86b1b5ddfb3c4a70a70bd19932c52603b8c.json')
+ .expectBadge({
+ label: 'checks',
+ message: 'no check runs',
+ })
+
+t.create('check runs - nonexistent ref')
+ .get('/badges/shields/this-ref-does-not-exist.json')
+ .expectBadge({
+ label: 'checks',
+ message: 'ref or repo not found',
+ })
diff --git a/services/github/github-checks-status.service.js b/services/github/github-checks-status.service.js
index 35097f7b5a042..4deac27cd6ed4 100644
--- a/services/github/github-checks-status.service.js
+++ b/services/github/github-checks-status.service.js
@@ -1,7 +1,22 @@
import Joi from 'joi'
+import { pathParam } from '../index.js'
import { isBuildStatus, renderBuildStatusBadge } from '../build-status.js'
import { GithubAuthV3Service } from './github-auth-service.js'
-import { documentation, errorMessagesFor } from './github-helpers.js'
+import {
+ documentation as commonDocumentation,
+ httpErrorsFor,
+} from './github-helpers.js'
+
+const description = `
+Displays the status of a tag, commit, or branch, as reported by the Commit Status API.
+
+Note: Nowadays, GitHub Actions and many third party integrations report state via
+the Checks API. If this badge does not show expected values, please try out our
+corresponding [Check Runs badge][check-runs-link] instead. You can read more about status checks in
+the [GitHub documentation](https://docs.github.com/en/pull-requests/collaborating-with-pull-requests/collaborating-on-repositories-with-code-quality-features/about-status-checks).
+
+${commonDocumentation}
+`
const schema = Joi.object({
state: isBuildStatus,
@@ -14,54 +29,54 @@ export default class GithubChecksStatus extends GithubAuthV3Service {
pattern: ':user/:repo/:ref',
}
- static examples = [
- {
- title: 'GitHub branch checks state',
- namedParams: {
- user: 'badges',
- repo: 'shields',
- ref: 'master',
+ static openApi = {
+ '/github/checks-status/{user}/{repo}/{branch}': {
+ get: {
+ summary: 'GitHub branch status',
+ description: `${description}
+ [check-runs-link]: https://shields.io/badges/git-hub-branch-check-runs`,
+ parameters: [
+ pathParam({ name: 'user', example: 'badges' }),
+ pathParam({ name: 'repo', example: 'shields' }),
+ pathParam({ name: 'branch', example: 'master' }),
+ ],
},
- staticPreview: renderBuildStatusBadge({
- status: 'success',
- }),
- keywords: ['status'],
- documentation,
},
- {
- title: 'GitHub commit checks state',
- namedParams: {
- user: 'badges',
- repo: 'shields',
- ref: '91b108d4b7359b2f8794a4614c11cb1157dc9fff',
+ '/github/checks-status/{user}/{repo}/{commit}': {
+ get: {
+ summary: 'GitHub commit status',
+ description: `${description}
+ [check-runs-link]: https://shields.io/badges/git-hub-commit-check-runs`,
+ parameters: [
+ pathParam({ name: 'user', example: 'badges' }),
+ pathParam({ name: 'repo', example: 'shields' }),
+ pathParam({
+ name: 'commit',
+ example: '91b108d4b7359b2f8794a4614c11cb1157dc9fff',
+ }),
+ ],
},
- staticPreview: renderBuildStatusBadge({
- status: 'success',
- }),
- keywords: ['status'],
- documentation,
},
- {
- title: 'GitHub tag checks state',
- namedParams: {
- user: 'badges',
- repo: 'shields',
- ref: '3.3.0',
+ '/github/checks-status/{user}/{repo}/{tag}': {
+ get: {
+ summary: 'GitHub tag status',
+ description: `${description}
+ [check-runs-link]: https://shields.io/badges/git-hub-tag-check-runs`,
+ parameters: [
+ pathParam({ name: 'user', example: 'badges' }),
+ pathParam({ name: 'repo', example: 'shields' }),
+ pathParam({ name: 'tag', example: '3.3.0' }),
+ ],
},
- staticPreview: renderBuildStatusBadge({
- status: 'success',
- }),
- keywords: ['status'],
- documentation,
},
- ]
+ }
static defaultBadgeData = { label: 'checks' }
async handle({ user, repo, ref }) {
const { state } = await this._requestJson({
url: `/repos/${user}/${repo}/commits/${ref}/status`,
- errorMessages: errorMessagesFor('ref or repo not found'),
+ httpErrors: httpErrorsFor('ref or repo not found'),
schema,
})
diff --git a/services/github/github-code-size.service.js b/services/github/github-code-size.service.js
index c5b394fbcea1d..7ade29503099f 100644
--- a/services/github/github-code-size.service.js
+++ b/services/github/github-code-size.service.js
@@ -1,4 +1,5 @@
-import prettyBytes from 'pretty-bytes'
+import { pathParams } from '../index.js'
+import { renderSizeBadge } from '../size.js'
import { BaseGithubLanguage } from './github-languages-base.js'
import { documentation } from './github-helpers.js'
@@ -9,29 +10,29 @@ export default class GithubCodeSize extends BaseGithubLanguage {
pattern: ':user/:repo',
}
- static examples = [
- {
- title: 'GitHub code size in bytes',
- namedParams: {
- user: 'badges',
- repo: 'shields',
+ static openApi = {
+ '/github/languages/code-size/{user}/{repo}': {
+ get: {
+ summary: 'GitHub code size in bytes',
+ description: documentation,
+ parameters: pathParams(
+ {
+ name: 'user',
+ example: 'badges',
+ },
+ {
+ name: 'repo',
+ example: 'shields',
+ },
+ ),
},
- staticPreview: this.render({ size: 1625000 }),
- documentation,
},
- ]
+ }
static defaultBadgeData = { label: 'code size' }
- static render({ size }) {
- return {
- message: prettyBytes(size),
- color: 'blue',
- }
- }
-
async handle({ user, repo }) {
const data = await this.fetch({ user, repo })
- return this.constructor.render({ size: this.getTotalSize(data) })
+ return renderSizeBadge(this.getTotalSize(data), 'iec', 'code size')
}
}
diff --git a/services/github/github-code-size.tester.js b/services/github/github-code-size.tester.js
index 39ec144e802f5..16c41129d798f 100644
--- a/services/github/github-code-size.tester.js
+++ b/services/github/github-code-size.tester.js
@@ -1,4 +1,4 @@
-import { isFileSize } from '../test-validators.js'
+import { isIecFileSize } from '../test-validators.js'
import { createServiceTester } from '../tester.js'
export const t = await createServiceTester()
@@ -6,7 +6,7 @@ t.create('code size in bytes for all languages')
.get('/badges/shields.json')
.expectBadge({
label: 'code size',
- message: isFileSize,
+ message: isIecFileSize,
})
t.create('code size in bytes for all languages (empty repo)')
diff --git a/services/github/github-commit-activity.service.js b/services/github/github-commit-activity.service.js
index 821827a77d051..f1504f36387b7 100644
--- a/services/github/github-commit-activity.service.js
+++ b/services/github/github-commit-activity.service.js
@@ -1,10 +1,15 @@
import gql from 'graphql-tag'
import Joi from 'joi'
-import { InvalidResponse } from '../index.js'
+import parseLinkHeader from 'parse-link-header'
+import { InvalidResponse, pathParam, queryParam } from '../index.js'
import { metric } from '../text-formatters.js'
import { nonNegativeInteger } from '../validators.js'
import { GithubAuthV4Service } from './github-auth-service.js'
-import { transformErrors, documentation } from './github-helpers.js'
+import {
+ transformErrors,
+ documentation,
+ httpErrorsFor,
+} from './github-helpers.js'
const schema = Joi.object({
data: Joi.object({
@@ -13,48 +18,78 @@ const schema = Joi.object({
history: Joi.object({
totalCount: nonNegativeInteger,
}).required(),
- }),
+ }).allow(null),
}).required(),
}).required(),
}).required()
+const queryParamSchema = Joi.object({
+ authorFilter: Joi.string(),
+})
+
export default class GitHubCommitActivity extends GithubAuthV4Service {
static category = 'activity'
static route = {
base: 'github/commit-activity',
- pattern: ':interval(y|m|4w|w)/:user/:repo/:branch*',
+ pattern: ':interval(t|y|m|4w|w)/:user/:repo/:branch*',
+ queryParamSchema,
}
- static examples = [
- {
- title: 'GitHub commit activity',
- // Override the pattern to omit the deprecated interval "4w".
- pattern: ':interval(y|m|w)/:user/:repo',
- namedParams: { interval: 'm', user: 'eslint', repo: 'eslint' },
- staticPreview: this.render({ interval: 'm', commitCount: 457 }),
- keywords: ['commits'],
- documentation,
+ static openApi = {
+ '/github/commit-activity/{interval}/{user}/{repo}': {
+ get: {
+ summary: 'GitHub commit activity',
+ description: documentation,
+ parameters: [
+ pathParam({
+ name: 'interval',
+ example: 'm',
+ description: 'Commits in the last Week, Month, Year, or Total',
+ schema: {
+ type: 'string',
+ // Override the enum to omit the deprecated interval "4w".
+ enum: ['w', 'm', 'y', 't'],
+ },
+ }),
+ pathParam({ name: 'user', example: 'badges' }),
+ pathParam({ name: 'repo', example: 'squint' }),
+ queryParam({ name: 'authorFilter', example: 'calebcartwright' }),
+ ],
+ },
},
- {
- title: 'GitHub commit activity (branch)',
- // Override the pattern to omit the deprecated interval "4w".
- pattern: ':interval(y|m|w)/:user/:repo/:branch*',
- namedParams: {
- interval: 'm',
- user: 'badges',
- repo: 'squint',
- branch: 'main',
+ '/github/commit-activity/{interval}/{user}/{repo}/{branch}': {
+ get: {
+ summary: 'GitHub commit activity (branch)',
+ description: documentation,
+ parameters: [
+ pathParam({
+ name: 'interval',
+ example: 'm',
+ description: 'Commits in the last Week, Month, Year, or Total',
+ schema: {
+ type: 'string',
+ // Override the enum to omit the deprecated interval "4w".
+ enum: ['w', 'm', 'y', 't'],
+ },
+ }),
+ pathParam({ name: 'user', example: 'badges' }),
+ pathParam({ name: 'repo', example: 'squint' }),
+ pathParam({ name: 'branch', example: 'main' }),
+ queryParam({ name: 'authorFilter', example: 'calebcartwright' }),
+ ],
},
- staticPreview: this.render({ interval: 'm', commitCount: 5 }),
- keywords: ['commits'],
- documentation,
},
- ]
+ }
static defaultBadgeData = { label: 'commit activity', color: 'blue' }
- static render({ interval, commitCount }) {
+ static render({ interval, commitCount, authorFilter }) {
+ // If total commits selected change label from commit activity to commits
+ const label = interval === 't' ? 'commits' : 'commit activity'
+ const authorFilterLabel = authorFilter ? ` by ${authorFilter}` : ''
+
const intervalLabel = {
+ t: '',
y: '/year',
m: '/month',
'4w': '/four weeks',
@@ -62,6 +97,7 @@ export default class GitHubCommitActivity extends GithubAuthV4Service {
}[interval]
return {
+ label: `${label}${authorFilterLabel}`,
message: `${metric(commitCount)}${intervalLabel}`,
}
}
@@ -74,7 +110,7 @@ export default class GitHubCommitActivity extends GithubAuthV4Service {
$user: String!
$repo: String!
$branch: String!
- $since: GitTimestamp!
+ $since: GitTimestamp
) {
repository(owner: $user, name: $repo) {
object(expression: $branch) {
@@ -98,6 +134,30 @@ export default class GitHubCommitActivity extends GithubAuthV4Service {
})
}
+ async fetchAuthorFilter({
+ interval,
+ user,
+ repo,
+ branch = 'HEAD',
+ authorFilter,
+ }) {
+ const since =
+ this.constructor.getIntervalQueryStartDate({ interval }) || undefined
+
+ return this._request({
+ url: `/repos/${user}/${repo}/commits`,
+ options: {
+ searchParams: {
+ sha: branch,
+ author: authorFilter,
+ per_page: '1',
+ since,
+ },
+ },
+ httpErrors: httpErrorsFor('repo or branch not found'),
+ })
+ }
+
static transform({ data }) {
const {
repository: { object: repo },
@@ -110,10 +170,22 @@ export default class GitHubCommitActivity extends GithubAuthV4Service {
return repo.history.totalCount
}
+ static transformAuthorFilter({ res }) {
+ const parsed = parseLinkHeader(res.headers.link)
+
+ if (!parsed) {
+ return 0
+ }
+
+ return parsed.last.page
+ }
+
static getIntervalQueryStartDate({ interval }) {
const now = new Date()
- if (interval === 'y') {
+ if (interval === 't') {
+ return null
+ } else if (interval === 'y') {
now.setUTCFullYear(now.getUTCFullYear() - 1)
} else if (interval === 'm' || interval === '4w') {
now.setUTCDate(now.getUTCDate() - 30)
@@ -124,9 +196,21 @@ export default class GitHubCommitActivity extends GithubAuthV4Service {
return now.toISOString()
}
- async handle({ interval, user, repo, branch }) {
- const json = await this.fetch({ interval, user, repo, branch })
- const commitCount = this.constructor.transform(json)
- return this.constructor.render({ interval, commitCount })
+ async handle({ interval, user, repo, branch }, { authorFilter }) {
+ let commitCount
+ if (authorFilter) {
+ const authorFilterRes = await this.fetchAuthorFilter({
+ interval,
+ user,
+ repo,
+ branch,
+ authorFilter,
+ })
+ commitCount = this.constructor.transformAuthorFilter(authorFilterRes)
+ } else {
+ const json = await this.fetch({ interval, user, repo, branch })
+ commitCount = this.constructor.transform(json)
+ }
+ return this.constructor.render({ interval, commitCount, authorFilter })
}
}
diff --git a/services/github/github-commit-activity.spec.js b/services/github/github-commit-activity.spec.js
index 1815bf2dd90c1..8096b84196437 100644
--- a/services/github/github-commit-activity.spec.js
+++ b/services/github/github-commit-activity.spec.js
@@ -9,7 +9,7 @@ describe('GitHubCommitActivity', function () {
expect(() =>
GitHubCommitActivity.transform({
data: { repository: { object: null } },
- })
+ }),
)
.to.throw(InvalidResponse)
.with.property('prettyMessage', 'invalid branch')
@@ -30,7 +30,7 @@ describe('GitHubCommitActivity', function () {
expect(
GitHubCommitActivity.getIntervalQueryStartDate({
interval: 'y',
- })
+ }),
).to.equal('2020-08-28T02:21:34.000Z')
})
@@ -39,7 +39,7 @@ describe('GitHubCommitActivity', function () {
expect(
GitHubCommitActivity.getIntervalQueryStartDate({
interval: 'm',
- })
+ }),
).to.equal('2021-03-01T02:21:34.000Z')
})
@@ -48,7 +48,7 @@ describe('GitHubCommitActivity', function () {
expect(
GitHubCommitActivity.getIntervalQueryStartDate({
interval: '4w',
- })
+ }),
).to.equal('2021-02-05T02:21:34.000Z')
})
@@ -57,7 +57,7 @@ describe('GitHubCommitActivity', function () {
expect(
GitHubCommitActivity.getIntervalQueryStartDate({
interval: 'w',
- })
+ }),
).to.equal('2021-12-24T23:59:34.000Z')
})
})
diff --git a/services/github/github-commit-activity.tester.js b/services/github/github-commit-activity.tester.js
index b109d5b8102ba..1efa3bf119c7e 100644
--- a/services/github/github-commit-activity.tester.js
+++ b/services/github/github-commit-activity.tester.js
@@ -2,25 +2,54 @@ import Joi from 'joi'
import {
isMetricOverTimePeriod,
isZeroOverTimePeriod,
+ isMetric,
} from '../test-validators.js'
import { createServiceTester } from '../tester.js'
export const t = await createServiceTester()
const isCommitActivity = Joi.alternatives().try(
isMetricOverTimePeriod,
- isZeroOverTimePeriod
+ isZeroOverTimePeriod,
)
+const authorFilterUser = 'jnullj'
+
+t.create('commit activity (total)').get('/t/badges/shields.json').expectBadge({
+ label: 'commits',
+ message: isMetric,
+})
+
+t.create('commit activity (total) by author')
+ .get(`/t/badges/shields.json?authorFilter=${authorFilterUser}`)
+ .expectBadge({
+ label: `commits by ${authorFilterUser}`,
+ message: isMetric,
+ })
+
t.create('commit activity (1 year)').get('/y/eslint/eslint.json').expectBadge({
label: 'commit activity',
message: isMetricOverTimePeriod,
})
+t.create('commit activity (1 year) by author')
+ .get(`/y/badges/shields.json?authorFilter=${authorFilterUser}`)
+ .expectBadge({
+ label: `commit activity by ${authorFilterUser}`,
+ message: isCommitActivity,
+ })
+
t.create('commit activity (1 month)').get('/m/eslint/eslint.json').expectBadge({
label: 'commit activity',
message: isMetricOverTimePeriod,
})
+t.create('commit activity (1 month) by author')
+ .get(`/m/badges/shields.json?authorFilter=${authorFilterUser}`)
+ .expectBadge({
+ label: `commit activity by ${authorFilterUser}`,
+ message: isCommitActivity,
+ })
+
t.create('commit activity (4 weeks)')
.get('/4w/eslint/eslint.json')
.expectBadge({
@@ -28,11 +57,25 @@ t.create('commit activity (4 weeks)')
message: isMetricOverTimePeriod,
})
+t.create('commit activity (4 weeks) by author')
+ .get(`/4w/badges/shields.json?authorFilter=${authorFilterUser}`)
+ .expectBadge({
+ label: `commit activity by ${authorFilterUser}`,
+ message: isCommitActivity,
+ })
+
t.create('commit activity (1 week)').get('/w/eslint/eslint.json').expectBadge({
label: 'commit activity',
message: isCommitActivity,
})
+t.create('commit activity (1 week) by author')
+ .get(`/w/badges/shields.json?authorFilter=${authorFilterUser}`)
+ .expectBadge({
+ label: `commit activity by ${authorFilterUser}`,
+ message: isCommitActivity,
+ })
+
t.create('commit activity (custom branch)')
.get('/y/badges/squint/main.json')
.expectBadge({
@@ -40,9 +83,38 @@ t.create('commit activity (custom branch)')
message: isCommitActivity,
})
+t.create('commit activity (custom branch) by author')
+ .get(`/y/badges/squint/main.json?authorFilter=${authorFilterUser}`)
+ .expectBadge({
+ label: `commit activity by ${authorFilterUser}`,
+ message: isCommitActivity,
+ })
+
t.create('commit activity (repo not found)')
.get('/w/badges/helmets.json')
.expectBadge({
label: 'commit activity',
message: 'repo not found',
})
+
+t.create('commit activity (invalid branch)')
+ .get('/w/badges/shields/invalidBranchName.json')
+ .expectBadge({
+ label: 'commit activity',
+ message: 'invalid branch',
+ })
+
+// test for error handling of author filter as it uses REST and not GraphQL
+t.create('commit activity (repo not found)')
+ .get('/w/badges/helmets.json?authorFilter=zaphod')
+ .expectBadge({
+ label: 'commit activity',
+ message: 'repo or branch not found',
+ })
+
+t.create('commit activity (invalid branch)')
+ .get('/w/badges/shields/invalidBranchName.json?authorFilter=zaphod')
+ .expectBadge({
+ label: 'commit activity',
+ message: 'repo or branch not found',
+ })
diff --git a/services/github/github-commit-status.service.js b/services/github/github-commit-status.service.js
index 46aae3410a8d4..9e7aa0e3c7f51 100644
--- a/services/github/github-commit-status.service.js
+++ b/services/github/github-commit-status.service.js
@@ -1,7 +1,7 @@
import Joi from 'joi'
-import { NotFound, InvalidParameter } from '../index.js'
+import { NotFound, InvalidParameter, pathParams } from '../index.js'
import { GithubAuthV3Service } from './github-auth-service.js'
-import { documentation, errorMessagesFor } from './github-helpers.js'
+import { documentation, httpErrorsFor } from './github-helpers.js'
const schema = Joi.object({
// https://stackoverflow.com/a/23969867/893113
@@ -15,23 +15,32 @@ export default class GithubCommitStatus extends GithubAuthV3Service {
pattern: ':user/:repo/:branch/:commit',
}
- static examples = [
- {
- title: 'GitHub commit merge status',
- namedParams: {
- user: 'badges',
- repo: 'shields',
- branch: 'master',
- commit: '5d4ab86b1b5ddfb3c4a70a70bd19932c52603b8c',
+ static openApi = {
+ '/github/commit-status/{user}/{repo}/{branch}/{commit}': {
+ get: {
+ summary: 'GitHub commit merge status',
+ description: documentation,
+ parameters: pathParams(
+ {
+ name: 'user',
+ example: 'badges',
+ },
+ {
+ name: 'repo',
+ example: 'shields',
+ },
+ {
+ name: 'branch',
+ example: 'master',
+ },
+ {
+ name: 'commit',
+ example: '5d4ab86b1b5ddfb3c4a70a70bd19932c52603b8c',
+ },
+ ),
},
- staticPreview: this.render({
- isInBranch: true,
- branch: 'master',
- }),
- keywords: ['branch'],
- documentation,
},
- ]
+ }
static defaultBadgeData = { label: 'commit status' }
@@ -55,7 +64,7 @@ export default class GithubCommitStatus extends GithubAuthV3Service {
try {
;({ status } = await this._requestJson({
url: `/repos/${user}/${repo}/compare/${branch}...${commit}`,
- errorMessages: errorMessagesFor('commit or branch not found'),
+ httpErrors: httpErrorsFor('commit or branch not found'),
schema,
}))
} catch (e) {
diff --git a/services/github/github-commit-status.tester.js b/services/github/github-commit-status.tester.js
index 5e6f9f9fdc19d..c7718c75382c8 100644
--- a/services/github/github-commit-status.tester.js
+++ b/services/github/github-commit-status.tester.js
@@ -11,15 +11,15 @@ t.create('commit status - commit in branch')
})
t.create(
- 'commit status - checked commit is identical with the newest commit in branch'
+ 'commit status - checked commit is identical with the newest commit in branch',
)
.get('/badges/shields/master/5d4ab86b1b5ddfb3c4a70a70bd19932c52603b8c.json')
.intercept(nock =>
nock('https://api.github.com')
.get(
- '/repos/badges/shields/compare/master...5d4ab86b1b5ddfb3c4a70a70bd19932c52603b8c'
+ '/repos/badges/shields/compare/master...5d4ab86b1b5ddfb3c4a70a70bd19932c52603b8c',
)
- .reply(200, { status: 'identical' })
+ .reply(200, { status: 'identical' }),
)
.expectBadge({
label: 'commit status',
@@ -45,7 +45,7 @@ t.create('commit status - unknown commit id')
t.create('commit status - unknown branch')
.get(
- '/badges/shields/this-branch-does-not-exist/b551a3a8daf1c48dba32a3eab1edf99b10c28863.json'
+ '/badges/shields/this-branch-does-not-exist/b551a3a8daf1c48dba32a3eab1edf99b10c28863.json',
)
.expectBadge({
label: 'commit status',
@@ -68,9 +68,9 @@ t.create('commit status - 404 with invalid JSON form github')
.intercept(nock =>
nock('https://api.github.com')
.get(
- '/repos/badges/shields/compare/master...5d4ab86b1b5ddfb3c4a70a70bd19932c52603b8c'
+ '/repos/badges/shields/compare/master...5d4ab86b1b5ddfb3c4a70a70bd19932c52603b8c',
)
- .reply(404, invalidJSONString)
+ .reply(404, invalidJSONString),
)
.expectBadge({
label: 'commit status',
diff --git a/services/github/github-commits-difference.service.js b/services/github/github-commits-difference.service.js
new file mode 100644
index 0000000000000..14042f035c979
--- /dev/null
+++ b/services/github/github-commits-difference.service.js
@@ -0,0 +1,57 @@
+import Joi from 'joi'
+import { pathParam, queryParam } from '../index.js'
+import { metric } from '../text-formatters.js'
+import { nonNegativeInteger } from '../validators.js'
+import { GithubAuthV3Service } from './github-auth-service.js'
+import { documentation, httpErrorsFor } from './github-helpers.js'
+
+const schema = Joi.object({ total_commits: nonNegativeInteger }).required()
+
+const queryParamSchema = Joi.object({
+ base: Joi.string().required(),
+ head: Joi.string().required(),
+}).required()
+
+export default class GithubCommitsDifference extends GithubAuthV3Service {
+ static category = 'activity'
+ static route = {
+ base: 'github/commits-difference',
+ pattern: ':user/:repo',
+ queryParamSchema,
+ }
+
+ static openApi = {
+ '/github/commits-difference/{user}/{repo}': {
+ get: {
+ summary: 'GitHub commits difference between two branches/tags/commits',
+ description: documentation,
+ parameters: [
+ pathParam({ name: 'user', example: 'microsoft' }),
+ pathParam({ name: 'repo', example: 'vscode' }),
+ queryParam({ name: 'base', example: '1.60.0', required: true }),
+ queryParam({ name: 'head', example: '82f2db7', required: true }),
+ ],
+ },
+ },
+ }
+
+ static defaultBadgeData = { label: 'commits difference' }
+
+ static render({ commitCount }) {
+ return {
+ message: metric(commitCount),
+ color: 'blue',
+ }
+ }
+
+ async handle({ user, repo }, { base, head }) {
+ const notFoundMessage = 'could not establish commit difference between refs'
+ const { total_commits: commitCount } = await this._requestJson({
+ schema,
+ url: `/repos/${user}/${repo}/compare/${base}...${head}`,
+ httpErrors: httpErrorsFor(notFoundMessage),
+ })
+
+ return this.constructor.render({ commitCount })
+ }
+}
diff --git a/services/github/github-commits-difference.tester.js b/services/github/github-commits-difference.tester.js
new file mode 100644
index 0000000000000..0d47bf6195029
--- /dev/null
+++ b/services/github/github-commits-difference.tester.js
@@ -0,0 +1,43 @@
+import { isMetric } from '../test-validators.js'
+import { createServiceTester } from '../tester.js'
+export const t = await createServiceTester()
+
+t.create('Commits difference - correct, between branches')
+ .get('/microsoft/vscode.json?base=standalone/0.1.x&head=release/1.21')
+ .expectBadge({
+ label: 'commits difference',
+ message: isMetric,
+ color: 'blue',
+ })
+
+t.create('Commits difference - correct, between tags')
+ .get('/microsoft/vscode.json?base=1.58.0&head=1.59.0')
+ .expectBadge({
+ label: 'commits difference',
+ message: isMetric,
+ color: 'blue',
+ })
+
+t.create('Commits difference - correct, between commits')
+ .get('/microsoft/vscode.json?base=3d82ef7&head=82f2db7')
+ .expectBadge({
+ label: 'commits difference',
+ message: isMetric,
+ color: 'blue',
+ })
+
+t.create('Commits difference - incorrect, between commits')
+ .get('/microsoft/vscode.json?base=fffffff&head=82f2db7')
+ .expectBadge({
+ label: 'commits difference',
+ message: 'could not establish commit difference between refs',
+ color: 'red',
+ })
+
+t.create('Commits difference - incorrect, missing head')
+ .get('/microsoft/vscode.json?base=fffffff')
+ .expectBadge({
+ label: 'commits difference',
+ message: 'invalid query parameter: head',
+ color: 'red',
+ })
diff --git a/services/github/github-commits-since.service.js b/services/github/github-commits-since.service.js
index 7fd0cc924b513..f7835ad22f475 100644
--- a/services/github/github-commits-since.service.js
+++ b/services/github/github-commits-since.service.js
@@ -1,15 +1,21 @@
import Joi from 'joi'
+import { pathParam } from '../index.js'
import { metric } from '../text-formatters.js'
import { nonNegativeInteger } from '../validators.js'
import { GithubAuthV3Service } from './github-auth-service.js'
import {
fetchLatestRelease,
queryParamSchema,
+ openApiQueryParams,
} from './github-common-release.js'
-import { documentation, errorMessagesFor } from './github-helpers.js'
+import { documentation, httpErrorsFor } from './github-helpers.js'
const schema = Joi.object({ ahead_by: nonNegativeInteger }).required()
+const latestDocs = `
+The include_prereleases, sort and filter params can be used to configure how we determine the latest version.
+`
+
export default class GithubCommitsSince extends GithubAuthV3Service {
static category = 'activity'
static route = {
@@ -18,112 +24,62 @@ export default class GithubCommitsSince extends GithubAuthV3Service {
queryParamSchema,
}
- static examples = [
- {
- title: 'GitHub commits since tagged version',
- namedParams: {
- user: 'SubtitleEdit',
- repo: 'subtitleedit',
- version: '3.4.7',
- },
- staticPreview: this.render({
- version: '3.4.7',
- commitCount: 4225,
- }),
- documentation,
- },
- {
- title: 'GitHub commits since tagged version (branch)',
- namedParams: {
- user: 'SubtitleEdit',
- repo: 'subtitleedit',
- version: '3.4.7',
- branch: 'master',
- },
- staticPreview: this.render({
- version: '3.4.7',
- commitCount: 4225,
- }),
- documentation,
- },
- {
- title: 'GitHub commits since latest release (by date)',
- namedParams: {
- user: 'SubtitleEdit',
- repo: 'subtitleedit',
- version: 'latest',
- },
- staticPreview: this.render({
- version: '3.5.7',
- commitCount: 157,
- }),
- documentation,
- },
- {
- title: 'GitHub commits since latest release (by date) for a branch',
- namedParams: {
- user: 'SubtitleEdit',
- repo: 'subtitleedit',
- version: 'latest',
- branch: 'master',
+ static openApi = {
+ '/github/commits-since/{user}/{repo}/{version}': {
+ get: {
+ summary: 'GitHub commits since tagged version',
+ description: documentation,
+ parameters: [
+ pathParam({ name: 'user', example: 'SubtitleEdit' }),
+ pathParam({ name: 'repo', example: 'subtitleedit' }),
+ pathParam({
+ name: 'version',
+ example: '3.4.7',
+ }),
+ ],
},
- staticPreview: this.render({
- version: '3.5.7',
- commitCount: 157,
- }),
- documentation,
},
- {
- title:
- 'GitHub commits since latest release (by date including pre-releases)',
- namedParams: {
- user: 'SubtitleEdit',
- repo: 'subtitleedit',
- version: 'latest',
+ '/github/commits-since/{user}/{repo}/{version}/{branch}': {
+ get: {
+ summary: 'GitHub commits since tagged version (branch)',
+ description: documentation,
+ parameters: [
+ pathParam({ name: 'user', example: 'SubtitleEdit' }),
+ pathParam({ name: 'repo', example: 'subtitleedit' }),
+ pathParam({
+ name: 'version',
+ example: '3.4.7',
+ }),
+ pathParam({ name: 'branch', example: 'main' }),
+ ],
},
- queryParams: { include_prereleases: null },
- staticPreview: this.render({
- version: 'v3.5.8-alpha.1',
- isPrerelease: true,
- commitCount: 158,
- }),
- documentation,
},
- {
- title: 'GitHub commits since latest release (by SemVer)',
- namedParams: {
- user: 'SubtitleEdit',
- repo: 'subtitleedit',
- version: 'latest',
+ '/github/commits-since/{user}/{repo}/latest': {
+ get: {
+ summary: 'GitHub commits since latest release',
+ description: documentation + latestDocs,
+ parameters: [
+ pathParam({ name: 'user', example: 'SubtitleEdit' }),
+ pathParam({ name: 'repo', example: 'subtitleedit' }),
+ ...openApiQueryParams,
+ ],
},
- queryParams: { sort: 'semver' },
- staticPreview: this.render({
- version: 'v4.0.1',
- sort: 'semver',
- commitCount: 200,
- }),
- documentation,
},
- {
- title:
- 'GitHub commits since latest release (by SemVer including pre-releases)',
- namedParams: {
- user: 'SubtitleEdit',
- repo: 'subtitleedit',
- version: 'latest',
+ '/github/commits-since/{user}/{repo}/latest/{branch}': {
+ get: {
+ summary: 'GitHub commits since latest release (branch)',
+ description: documentation + latestDocs,
+ parameters: [
+ pathParam({ name: 'user', example: 'SubtitleEdit' }),
+ pathParam({ name: 'repo', example: 'subtitleedit' }),
+ pathParam({ name: 'branch', example: 'main' }),
+ ...openApiQueryParams,
+ ],
},
- queryParams: { sort: 'semver', include_prereleases: null },
- staticPreview: this.render({
- version: 'v4.0.2-alpha.1',
- sort: 'semver',
- isPrerelease: true,
- commitCount: 201,
- }),
- documentation,
},
- ]
+ }
- static defaultBadgeData = { label: 'github', namedLogo: 'github' }
+ static defaultBadgeData = { label: 'github' }
static render({ version, commitCount }) {
return {
@@ -141,7 +97,7 @@ export default class GithubCommitsSince extends GithubAuthV3Service {
user,
repo,
},
- queryParams
+ queryParams,
))
}
@@ -151,7 +107,7 @@ export default class GithubCommitsSince extends GithubAuthV3Service {
const { ahead_by: commitCount } = await this._requestJson({
schema,
url: `/repos/${user}/${repo}/compare/${version}...${branch || 'HEAD'}`,
- errorMessages: errorMessagesFor(notFoundMessage),
+ httpErrors: httpErrorsFor(notFoundMessage),
})
return this.constructor.render({ version, commitCount })
diff --git a/services/github/github-commits-since.tester.js b/services/github/github-commits-since.tester.js
index a16a093d9cebb..35d325cdeb244 100644
--- a/services/github/github-commits-since.tester.js
+++ b/services/github/github-commits-since.tester.js
@@ -14,7 +14,7 @@ t.create('Commits since')
t.create('Commits since (branch)')
.get(
- '/badges/shields/8b87fac3a1538ec20ff20983faf4b6f7e722ef87/historical.json'
+ '/badges/shields/8b87fac3a1538ec20ff20983faf4b6f7e722ef87/historical.json',
)
.expectBadge({
label: isCommitsSince,
@@ -58,7 +58,7 @@ t.create('Commits since (version not found)')
t.create('Commits since (branch not found)')
.get(
- '/badges/shields/a0663d8da53fb712472c02665e6ff7547ba945b7/not-a-branch.json'
+ '/badges/shields/a0663d8da53fb712472c02665e6ff7547ba945b7/not-a-branch.json',
)
.expectBadge({
label: 'github',
diff --git a/services/github/github-common-fetch.js b/services/github/github-common-fetch.js
index 1cf6c72fd1f94..9c1611a7eefa5 100644
--- a/services/github/github-common-fetch.js
+++ b/services/github/github-common-fetch.js
@@ -1,6 +1,6 @@
import Joi from 'joi'
import { InvalidResponse } from '../index.js'
-import { errorMessagesFor } from './github-helpers.js'
+import { httpErrorsFor } from './github-helpers.js'
const issueSchema = Joi.object({
head: Joi.object({
@@ -12,7 +12,7 @@ async function fetchIssue(serviceInstance, { user, repo, number }) {
return serviceInstance._requestJson({
schema: issueSchema,
url: `/repos/${user}/${repo}/pulls/${number}`,
- errorMessages: errorMessagesFor('pull request or repo not found'),
+ httpErrors: httpErrorsFor('pull request or repo not found'),
})
}
@@ -24,17 +24,17 @@ const contentSchema = Joi.object({
async function fetchRepoContent(
serviceInstance,
- { user, repo, branch = 'HEAD', filename }
+ { user, repo, branch = 'HEAD', filename },
) {
- const errorMessages = errorMessagesFor(
- `repo not found, branch not found, or ${filename} missing`
+ const httpErrors = httpErrorsFor(
+ `repo not found, branch not found, or ${filename} missing`,
)
if (serviceInstance.staticAuthConfigured) {
const { content } = await serviceInstance._requestJson({
schema: contentSchema,
url: `/repos/${user}/${repo}/contents/${filename}`,
- options: { qs: { ref: branch } },
- errorMessages,
+ options: { searchParams: { ref: branch } },
+ httpErrors,
})
try {
@@ -45,7 +45,7 @@ async function fetchRepoContent(
} else {
const { buffer } = await serviceInstance._request({
url: `https://raw.githubusercontent.com/${user}/${repo}/${branch}/${filename}`,
- errorMessages,
+ httpErrors,
})
return buffer
}
@@ -53,7 +53,7 @@ async function fetchRepoContent(
async function fetchJsonFromRepo(
serviceInstance,
- { schema, user, repo, branch = 'HEAD', filename }
+ { schema, user, repo, branch = 'HEAD', filename },
) {
if (serviceInstance.staticAuthConfigured) {
const buffer = await fetchRepoContent(serviceInstance, {
@@ -68,8 +68,8 @@ async function fetchJsonFromRepo(
return serviceInstance._requestJson({
schema,
url: `https://raw.githubusercontent.com/${user}/${repo}/${branch}/${filename}`,
- errorMessages: errorMessagesFor(
- `repo not found, branch not found, or ${filename} missing`
+ httpErrors: httpErrorsFor(
+ `repo not found, branch not found, or ${filename} missing`,
),
})
}
diff --git a/services/github/github-common-release.js b/services/github/github-common-release.js
index 81e4f97d92534..d7e7b1444096e 100644
--- a/services/github/github-common-release.js
+++ b/services/github/github-common-release.js
@@ -1,8 +1,9 @@
import Joi from 'joi'
+import { matcher } from 'matcher'
import { nonNegativeInteger } from '../validators.js'
import { latest } from '../version.js'
-import { NotFound } from '../index.js'
-import { errorMessagesFor } from './github-helpers.js'
+import { NotFound, queryParams } from '../index.js'
+import { httpErrorsFor } from './github-helpers.js'
const releaseInfoSchema = Joi.object({
assets: Joi.array()
@@ -12,13 +13,14 @@ const releaseInfoSchema = Joi.object({
})
.required(),
tag_name: Joi.string().required(),
+ name: Joi.string().allow(null).allow(''),
prerelease: Joi.boolean().required(),
}).required()
// Fetch the 'latest' release as defined by the GitHub API
async function fetchLatestGitHubRelease(serviceInstance, { user, repo }) {
const commonAttrs = {
- errorMessages: errorMessagesFor('no releases or repo not found'),
+ httpErrors: httpErrorsFor('no releases or repo not found'),
}
const releaseInfo = await serviceInstance._requestJson({
schema: releaseInfoSchema,
@@ -30,17 +32,18 @@ async function fetchLatestGitHubRelease(serviceInstance, { user, repo }) {
const releaseInfoArraySchema = Joi.alternatives().try(
Joi.array().items(releaseInfoSchema),
- Joi.array().length(0)
+ Joi.array().length(0),
)
async function fetchReleases(serviceInstance, { user, repo }) {
const commonAttrs = {
- errorMessages: errorMessagesFor('repo not found'),
+ httpErrors: httpErrorsFor('repo not found'),
}
const releases = await serviceInstance._requestJson({
url: `/repos/${user}/${repo}/releases`,
schema: releaseInfoArraySchema,
...commonAttrs,
+ options: { searchParams: { per_page: 100 } },
})
return releases
}
@@ -49,7 +52,7 @@ function getLatestRelease({ releases, sort, includePrereleases }) {
if (sort === 'semver') {
const latestTagName = latest(
releases.map(release => release.tag_name),
- { pre: includePrereleases }
+ { pre: includePrereleases },
)
return releases.find(({ tag_name: tagName }) => tagName === latestTagName)
}
@@ -64,21 +67,70 @@ function getLatestRelease({ releases, sort, includePrereleases }) {
return releases[0]
}
+const sortEnum = ['date', 'semver']
+
const queryParamSchema = Joi.object({
include_prereleases: Joi.equal(''),
- sort: Joi.string().valid('date', 'semver').default('date'),
+ sort: Joi.string()
+ .valid(...sortEnum)
+ .default('date'),
+ filter: Joi.string(),
}).required()
+const filterDocs = `
+The filter param can be used to apply a filter to the
+project's tag or release names before selecting the latest from the list.
+Two constructs are available: * is a wildcard matching zero
+or more characters, and if the pattern starts with a !,
+the whole pattern is negated.
+`
+
+const openApiQueryParams = queryParams(
+ {
+ name: 'include_prereleases',
+ example: null,
+ schema: { type: 'boolean' },
+ },
+ {
+ name: 'sort',
+ example: 'semver',
+ schema: { type: 'string', enum: sortEnum },
+ },
+ { name: 'filter', example: '*beta*', description: filterDocs },
+)
+
+function applyFilter({ releases, filter, displayName }) {
+ if (!filter) {
+ return releases
+ }
+ if (displayName === 'tag') {
+ const filteredTagNames = matcher(
+ releases.map(release => release.tag_name),
+ filter,
+ )
+ return releases.filter(release =>
+ filteredTagNames.includes(release.tag_name),
+ )
+ }
+ const filteredReleaseNames = matcher(
+ releases.map(release => release.name),
+ filter,
+ )
+ return releases.filter(release => filteredReleaseNames.includes(release.name))
+}
+
// Fetch the latest release as defined by query params
async function fetchLatestRelease(
serviceInstance,
{ user, repo },
- queryParams
+ queryParams,
) {
const sort = queryParams.sort
const includePrereleases = queryParams.include_prereleases !== undefined
+ const filter = queryParams.filter
+ const displayName = queryParams.display_name
- if (!includePrereleases && sort === 'date') {
+ if (!includePrereleases && sort === 'date' && !filter) {
const releaseInfo = await fetchLatestGitHubRelease(serviceInstance, {
user,
repo,
@@ -86,13 +138,23 @@ async function fetchLatestRelease(
return releaseInfo
}
- const releases = await fetchReleases(serviceInstance, { user, repo })
+ const releases = applyFilter({
+ releases: await fetchReleases(serviceInstance, { user, repo }),
+ filter,
+ displayName,
+ })
if (releases.length === 0) {
- throw new NotFound({ prettyMessage: 'no releases' })
+ const prettyMessage = filter
+ ? 'no matching releases found'
+ : 'no releases found'
+ throw new NotFound({ prettyMessage })
}
const latestRelease = getLatestRelease({ releases, sort, includePrereleases })
return latestRelease
}
-export { fetchLatestRelease, queryParamSchema }
-export const _getLatestRelease = getLatestRelease // currently only used for tests
+export { fetchLatestRelease, queryParamSchema, openApiQueryParams }
+
+// currently only used for tests
+export const _getLatestRelease = getLatestRelease
+export const _applyFilter = applyFilter
diff --git a/services/github/github-common-release.spec.js b/services/github/github-common-release.spec.js
index ed927180ac3f9..536eb38321ebd 100644
--- a/services/github/github-common-release.spec.js
+++ b/services/github/github-common-release.spec.js
@@ -1,5 +1,5 @@
import { test, given } from 'sazerac'
-import { _getLatestRelease } from './github-common-release.js'
+import { _applyFilter, _getLatestRelease } from './github-common-release.js'
describe('GithubRelease', function () {
test(_getLatestRelease, () => {
@@ -42,4 +42,50 @@ describe('GithubRelease', function () {
includePrereleases: false,
}).expect({ tag_name: '1.2.0-beta', prerelease: true })
})
+
+ test(_applyFilter, () => {
+ const releases = [
+ { name: 'release/1.1.0', tag_name: 'tag/1.1.0', prerelease: false },
+ { name: 'release/1.2.0', tag_name: 'tag/1.2.0', prerelease: false },
+ {
+ name: 'release/server-2022-01-01',
+ tag_name: 'tag/server-2022-01-01',
+ prerelease: false,
+ },
+ ]
+
+ given({ releases, filter: undefined }).expect(releases)
+ given({ releases, filter: '' }).expect(releases)
+ given({ releases, filter: '*' }).expect(releases)
+ given({ releases, filter: '!*' }).expect([])
+ given({ releases, filter: 'foo' }).expect([])
+ given({ releases, filter: 'release/server-*' }).expect([
+ {
+ name: 'release/server-2022-01-01',
+ tag_name: 'tag/server-2022-01-01',
+ prerelease: false,
+ },
+ ])
+ given({ releases, filter: '!release/server-*' }).expect([
+ { name: 'release/1.1.0', tag_name: 'tag/1.1.0', prerelease: false },
+ { name: 'release/1.2.0', tag_name: 'tag/1.2.0', prerelease: false },
+ ])
+
+ given({ releases, displayName: 'tag', filter: undefined }).expect(releases)
+ given({ releases, displayName: 'tag', filter: '' }).expect(releases)
+ given({ releases, displayName: 'tag', filter: '*' }).expect(releases)
+ given({ releases, displayName: 'tag', filter: '!*' }).expect([])
+ given({ releases, displayName: 'tag', filter: 'foo' }).expect([])
+ given({ releases, displayName: 'tag', filter: 'tag/server-*' }).expect([
+ {
+ name: 'release/server-2022-01-01',
+ tag_name: 'tag/server-2022-01-01',
+ prerelease: false,
+ },
+ ])
+ given({ releases, displayName: 'tag', filter: '!tag/server-*' }).expect([
+ { name: 'release/1.1.0', tag_name: 'tag/1.1.0', prerelease: false },
+ { name: 'release/1.2.0', tag_name: 'tag/1.2.0', prerelease: false },
+ ])
+ })
})
diff --git a/services/github/github-constellation.js b/services/github/github-constellation.js
index e74114e7a46f9..bfc887b1f82d5 100644
--- a/services/github/github-constellation.js
+++ b/services/github/github-constellation.js
@@ -1,8 +1,7 @@
import { AuthHelper } from '../../core/base-service/auth-helper.js'
-import RedisTokenPersistence from '../../core/token-pooling/redis-token-persistence.js'
+import SqlTokenPersistence from '../../core/token-pooling/sql-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
@@ -16,29 +15,39 @@ class GithubConstellation {
authorizedOrigins: ['https://api.github.com'],
isRequired: true,
},
- config
+ config,
)
}
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) {
- log.log('Token persistence configured with redisUrl')
- this.persistence = new RedisTokenPersistence({
- url: redisUrl,
- key: 'githubUserTokens',
+ this._metricsIntervalSeconds = config.metricsIntervalSeconds
+
+ let authType = GithubApiProvider.AUTH_TYPES.NO_AUTH
+
+ const { postgres_url: pgUrl, gh_token: globalToken } = config.private
+ if (pgUrl) {
+ log.log('Github Token persistence configured with pgUrl')
+ this.persistence = new SqlTokenPersistence({
+ url: pgUrl,
+ table: 'github_user_tokens',
})
+ authType = GithubApiProvider.AUTH_TYPES.TOKEN_POOL
+ }
+
+ if (globalToken) {
+ authType = GithubApiProvider.AUTH_TYPES.GLOBAL_TOKEN
}
+ log.log(`Github using auth type: ${authType}`)
+
this.apiProvider = new GithubApiProvider({
- baseUrl: process.env.GITHUB_URL || 'https://api.github.com',
+ baseUrl: config.service.baseUri,
globalToken,
- withPooling: !globalToken,
+ authType,
onTokenInvalidated: tokenString => this.onTokenInvalidated(tokenString),
+ restApiVersion: config.service.restApiVersion,
})
this.oauthHelper = this.constructor._createOauthHelper(config)
@@ -47,17 +56,30 @@ class GithubConstellation {
scheduleDebugLogging() {
if (this._debugEnabled) {
this.debugInterval = setInterval(() => {
- log.log(this.apiProvider.getTokenDebugInfo())
+ const debugInfo = this.apiProvider.getTokenDebugInfo()
+ log.log(debugInfo)
}, 1000 * this._debugIntervalSeconds)
}
}
- async initialize(server) {
- if (!this.apiProvider.withPooling) {
+ scheduleMetricsCollection() {
+ if (this.metricInstance) {
+ this.metricsInterval = setInterval(() => {
+ const debugInfo = this.apiProvider.getTokenDebugInfo()
+ this.metricInstance.noteGithubTokenPoolMetrics(debugInfo)
+ }, 1000 * this._metricsIntervalSeconds)
+ }
+ }
+
+ async initialize(server, metricInstance) {
+ if (this.apiProvider.authType !== GithubApiProvider.AUTH_TYPES.TOKEN_POOL) {
return
}
+ this.metricInstance = metricInstance
+
this.scheduleDebugLogging()
+ this.scheduleMetricsCollection()
if (!this.persistence) {
return
@@ -74,9 +96,6 @@ class GithubConstellation {
this.apiProvider.addToken(tokenString)
})
- const { shieldsSecret, apiProvider } = this
- setAdminRoutes({ shieldsSecret }, { apiProvider, server })
-
if (this.oauthHelper.isConfigured) {
setAcceptorRoutes({
server,
@@ -118,6 +137,11 @@ class GithubConstellation {
this.debugInterval = undefined
}
+ if (this.metricsInterval) {
+ clearInterval(this.metricsInterval)
+ this.metricsInterval = undefined
+ }
+
if (this.persistence) {
try {
await this.persistence.stop()
diff --git a/services/github/github-contributors.service.js b/services/github/github-contributors.service.js
index 6ad4c713e0a4e..991232d48653a 100644
--- a/services/github/github-contributors.service.js
+++ b/services/github/github-contributors.service.js
@@ -1,31 +1,52 @@
import Joi from 'joi'
import parseLinkHeader from 'parse-link-header'
+import { pathParams } from '../index.js'
import { renderContributorBadge } from '../contributor-count.js'
import { GithubAuthV3Service } from './github-auth-service.js'
-import { documentation, errorMessagesFor } from './github-helpers.js'
+import { documentation, httpErrorsFor } from './github-helpers.js'
// All we do is check its length.
const schema = Joi.array().items(Joi.object())
+const documentationWithNote = [
+ documentation,
+ 'Note: Co-authors are not included in the count due to endpoint limitations.',
+].join('\n\n')
+
export default class GithubContributors extends GithubAuthV3Service {
static category = 'activity'
static route = {
base: 'github',
- pattern: ':variant(contributors|contributors-anon)/:user/:repo',
+ // note we call this param 'metric' instead of 'variant' because of
+ // https://github.com/badges/shields/issues/10323
+ pattern: ':metric(contributors|contributors-anon)/:user/:repo',
}
- static examples = [
- {
- title: 'GitHub contributors',
- namedParams: {
- variant: 'contributors',
- user: 'cdnjs',
- repo: 'cdnjs',
+ static openApi = {
+ '/github/{metric}/{user}/{repo}': {
+ get: {
+ summary: 'GitHub contributors',
+ description: documentationWithNote,
+ parameters: pathParams(
+ {
+ name: 'metric',
+ example: 'contributors',
+ schema: { type: 'string', enum: this.getEnum('metric') },
+ description:
+ '`contributors-anon` includes anonymous commits, whereas `contributors` excludes them.',
+ },
+ {
+ name: 'user',
+ example: 'cdnjs',
+ },
+ {
+ name: 'repo',
+ example: 'cdnjs',
+ },
+ ),
},
- staticPreview: this.render({ contributorCount: 397 }),
- documentation,
},
- ]
+ }
static defaultBadgeData = { label: 'contributors' }
@@ -33,13 +54,13 @@ export default class GithubContributors extends GithubAuthV3Service {
return renderContributorBadge({ contributorCount })
}
- async handle({ variant, user, repo }) {
- const isAnon = variant === 'contributors-anon'
+ async handle({ metric, user, repo }) {
+ const isAnon = metric === 'contributors-anon'
const { res, buffer } = await this._request({
url: `/repos/${user}/${repo}/contributors`,
- options: { qs: { page: '1', per_page: '1', anon: isAnon } },
- errorMessages: errorMessagesFor('repo not found'),
+ options: { searchParams: { page: '1', per_page: '1', anon: isAnon } },
+ httpErrors: httpErrorsFor('repo not found'),
})
const parsed = parseLinkHeader(res.headers.link)
diff --git a/services/github/github-created-at.service.js b/services/github/github-created-at.service.js
new file mode 100644
index 0000000000000..0e4c654dbb2ce
--- /dev/null
+++ b/services/github/github-created-at.service.js
@@ -0,0 +1,44 @@
+import Joi from 'joi'
+import { renderDateBadge } from '../date.js'
+import { pathParams } from '../index.js'
+import { GithubAuthV3Service } from './github-auth-service.js'
+import { documentation, httpErrorsFor } from './github-helpers.js'
+
+const schema = Joi.object({
+ created_at: Joi.date().required(),
+}).required()
+
+export default class GithubCreatedAt extends GithubAuthV3Service {
+ static category = 'activity'
+ static route = { base: 'github/created-at', pattern: ':user/:repo' }
+ static openApi = {
+ '/github/created-at/{user}/{repo}': {
+ get: {
+ summary: 'GitHub Created At',
+ description: documentation,
+ parameters: pathParams(
+ {
+ name: 'user',
+ example: 'mashape',
+ },
+ {
+ name: 'repo',
+ example: 'apistatus',
+ },
+ ),
+ },
+ },
+ }
+
+ static defaultBadgeData = { label: 'created at' }
+
+ async handle({ user, repo }) {
+ const { created_at: createdAt } = await this._requestJson({
+ schema,
+ url: `/repos/${user}/${repo}`,
+ httpErrors: httpErrorsFor('repo not found'),
+ })
+
+ return renderDateBadge(createdAt, true)
+ }
+}
diff --git a/services/github/github-created-at.tester.js b/services/github/github-created-at.tester.js
new file mode 100644
index 0000000000000..0f473468202fc
--- /dev/null
+++ b/services/github/github-created-at.tester.js
@@ -0,0 +1,14 @@
+import { isFormattedDate } from '../test-validators.js'
+import { createServiceTester } from '../tester.js'
+
+export const t = await createServiceTester()
+
+t.create('created at').get('/erayerdin/firereact.json').expectBadge({
+ label: 'created at',
+ message: isFormattedDate,
+})
+
+t.create('created at').get('/erayerdin/not-a-valid-repo.json').expectBadge({
+ label: 'created at',
+ message: 'repo not found',
+})
diff --git a/services/github/github-deployments.service.js b/services/github/github-deployments.service.js
index eed495401cfbb..57c0dffca9cc3 100644
--- a/services/github/github-deployments.service.js
+++ b/services/github/github-deployments.service.js
@@ -1,13 +1,13 @@
import gql from 'graphql-tag'
import Joi from 'joi'
-import { NotFound } from '../index.js'
+import { NotFound, pathParams } from '../index.js'
import { GithubAuthV4Service } from './github-auth-service.js'
import { documentation, transformErrors } from './github-helpers.js'
const greenStates = ['SUCCESS']
const redStates = ['ERROR', 'FAILURE']
const blueStates = ['INACTIVE']
-const otherStates = ['IN_PROGRESS', 'QUEUED', 'PENDING', 'NO_STATUS']
+const otherStates = ['IN_PROGRESS', 'QUEUED', 'PENDING', 'NO_STATUS', 'WAITING']
const stateToMessageMappings = {
IN_PROGRESS: 'in progress',
@@ -34,7 +34,7 @@ const schema = Joi.object({
}),
null,
]),
- })
+ }),
)
.required(),
}).required(),
@@ -49,20 +49,28 @@ export default class GithubDeployments extends GithubAuthV4Service {
pattern: ':user/:repo/:environment',
}
- static examples = [
- {
- title: 'GitHub deployments',
- namedParams: {
- user: 'badges',
- repo: 'shields',
- environment: 'shields-staging',
+ static openApi = {
+ '/github/deployments/{user}/{repo}/{environment}': {
+ get: {
+ summary: 'GitHub deployments',
+ description: documentation,
+ parameters: pathParams(
+ {
+ name: 'user',
+ example: 'badges',
+ },
+ {
+ name: 'repo',
+ example: 'shields',
+ },
+ {
+ name: 'environment',
+ example: 'shields-staging',
+ },
+ ),
},
- staticPreview: this.render({
- state: 'success',
- }),
- documentation,
},
- ]
+ }
static defaultBadgeData = { label: 'state' }
@@ -122,7 +130,7 @@ export default class GithubDeployments extends GithubAuthV4Service {
return { state }
}
- async handle({ user, repo, environment }, queryParams) {
+ async handle({ user, repo, environment }) {
const json = await this.fetch({ user, repo, environment })
const { state } = this.transform({ data: json.data })
return this.constructor.render({ state })
diff --git a/services/github/github-deployments.spec.js b/services/github/github-deployments.spec.js
index d3221706c5740..bddf923af82a4 100644
--- a/services/github/github-deployments.spec.js
+++ b/services/github/github-deployments.spec.js
@@ -21,6 +21,12 @@ describe('GithubDeployments', function () {
message: 'in progress',
color: undefined,
})
+ given({
+ state: 'WAITING',
+ }).expect({
+ message: 'waiting',
+ color: undefined,
+ })
given({
state: 'NO_STATUS',
}).expect({
diff --git a/services/github/github-deployments.tester.js b/services/github/github-deployments.tester.js
index 325ae34faad9b..18fca305c9db8 100644
--- a/services/github/github-deployments.tester.js
+++ b/services/github/github-deployments.tester.js
@@ -37,7 +37,7 @@ t.create('Deployments (status not yet available)')
data: {
repository: { deployments: { nodes: [{ latestStatus: null }] } },
},
- })
+ }),
)
.expectBadge({
label: 'state',
diff --git a/services/github/github-directory-file-count.service.js b/services/github/github-directory-file-count.service.js
index 24aabaa34e870..48ec5b3f65ed3 100644
--- a/services/github/github-directory-file-count.service.js
+++ b/services/github/github-directory-file-count.service.js
@@ -1,49 +1,40 @@
-import path from 'path'
import Joi from 'joi'
+import gql from 'graphql-tag'
import { metric } from '../text-formatters.js'
-import { InvalidParameter } from '../index.js'
-import { ConditionalGithubAuthV3Service } from './github-auth-service.js'
-import {
- documentation as commonDocumentation,
- errorMessagesFor,
-} from './github-helpers.js'
-
-const documentation = `${commonDocumentation}
-
- Note:
- 1. Parameter type accepts either file or dir value. Passing any other value will result in an error.
- 2. Parameter extension accepts file extension without a leading dot.
- For instance for .js extension pass js.
- Only single extension value can be specified.
- extension is applicable for type file only.
- Passing it either without type or along with type dir will result in an error.
- 3. GitHub API has an upper limit of 1,000 files for a directory.
- In case a directory contains files above the limit, a badge might present inaccurate information.
-
-`
-
-const schema = Joi.alternatives(
- /*
- alternative empty object schema to provide a custom error message
- in the event a file path is provided by the user instead of a directory
- */
- Joi.object({}).required(),
- Joi.array()
- .items(
- Joi.object({
- path: Joi.string().required(),
- type: Joi.string().required(),
+import { InvalidParameter, pathParam, queryParam } from '../index.js'
+import { GithubAuthV4Service } from './github-auth-service.js'
+import { documentation, transformErrors } from './github-helpers.js'
+
+const schema = Joi.object({
+ data: Joi.object({
+ repository: Joi.object({
+ object: Joi.object({
+ entries: Joi.array().items(
+ Joi.object({
+ type: Joi.string().required(),
+ extension: Joi.string().allow('').required(),
+ }),
+ ),
})
- )
- .required()
-)
+ .allow(null)
+ .required(),
+ }).required(),
+ }).required(),
+}).required()
+
+const typeEnum = ['dir', 'file']
const queryParamSchema = Joi.object({
- type: Joi.any().valid('dir', 'file'),
+ type: Joi.any().valid(...typeEnum),
extension: Joi.string(),
})
-export default class GithubDirectoryFileCount extends ConditionalGithubAuthV3Service {
+const typeDocs =
+ 'Entity to count: directories or files. If not specified, both files and directories are counted. GitHub API has an upper limit of 1,000 files for a directory. If a directory contains files above the limit, the badge will show an inaccurate count.'
+const extensionDocs =
+ 'Filter to files of type. Specify the extension without a leading dot. For instance for `.js` extension pass `js`. This param is only applicable if type is `file`'
+
+export default class GithubDirectoryFileCount extends GithubAuthV4Service {
static category = 'size'
static route = {
@@ -52,62 +43,51 @@ export default class GithubDirectoryFileCount extends ConditionalGithubAuthV3Ser
queryParamSchema,
}
- static examples = [
- {
- title: 'GitHub repo file count',
- pattern: ':user/:repo',
- namedParams: { user: 'badges', repo: 'shields' },
- staticPreview: this.render({ count: 20 }),
- documentation,
- },
- {
- title: 'GitHub repo file count (custom path)',
- pattern: ':user/:repo/:path',
- namedParams: { user: 'badges', repo: 'shields', path: 'services' },
- staticPreview: this.render({ count: 10 }),
- documentation,
- },
- {
- title: 'GitHub repo directory count',
- pattern: ':user/:repo',
- namedParams: { user: 'badges', repo: 'shields' },
- queryParams: { type: 'dir' },
- staticPreview: this.render({ count: 8 }),
- documentation,
- },
- {
- title: 'GitHub repo directory count (custom path)',
- pattern: ':user/:repo/:path',
- namedParams: { user: 'badges', repo: 'shields', path: 'services' },
- queryParams: { type: 'dir' },
- staticPreview: this.render({ count: 8 }),
- documentation,
+ static openApi = {
+ '/github/directory-file-count/{user}/{repo}': {
+ get: {
+ summary: 'GitHub repo file or directory count',
+ description: documentation,
+ parameters: [
+ pathParam({ name: 'user', example: 'badges' }),
+ pathParam({ name: 'repo', example: 'shields' }),
+ queryParam({
+ name: 'type',
+ example: 'file',
+ schema: { type: 'string', enum: typeEnum },
+ description: typeDocs,
+ }),
+ queryParam({
+ name: 'extension',
+ example: 'js',
+ description: extensionDocs,
+ }),
+ ],
+ },
},
- {
- title: 'GitHub repo file count (file type)',
- pattern: ':user/:repo',
- namedParams: { user: 'badges', repo: 'shields' },
- queryParams: { type: 'file' },
- staticPreview: this.render({ count: 2 }),
- documentation,
+ '/github/directory-file-count/{user}/{repo}/{path}': {
+ get: {
+ summary: 'GitHub repo file or directory count (in path)',
+ description: documentation,
+ parameters: [
+ pathParam({ name: 'user', example: 'badges' }),
+ pathParam({ name: 'repo', example: 'shields' }),
+ pathParam({ name: 'path', example: 'services' }),
+ queryParam({
+ name: 'type',
+ example: 'file',
+ schema: { type: 'string', enum: typeEnum },
+ description: typeDocs,
+ }),
+ queryParam({
+ name: 'extension',
+ example: 'js',
+ description: extensionDocs,
+ }),
+ ],
+ },
},
- {
- title: 'GitHub repo file count (custom path & file type)',
- pattern: ':user/:repo/:path',
- namedParams: { user: 'badges', repo: 'shields', path: 'services' },
- queryParams: { type: 'file' },
- staticPreview: this.render({ count: 2 }),
- documentation,
- },
- {
- title: 'GitHub repo file count (file extension)',
- pattern: ':user/:repo/:path',
- namedParams: { user: 'badges', repo: 'shields', path: 'services' },
- queryParams: { extension: 'js' },
- staticPreview: this.render({ count: 1 }),
- documentation,
- },
- ]
+ }
static defaultBadgeData = { color: 'blue', label: 'files' }
@@ -118,10 +98,25 @@ export default class GithubDirectoryFileCount extends ConditionalGithubAuthV3Ser
}
async fetch({ user, repo, path = '' }) {
- return this._requestJson({
- url: `/repos/${user}/${repo}/contents/${path}`,
+ const expression = `HEAD:${path}`
+ return this._requestGraphql({
+ query: gql`
+ query RepoFiles($user: String!, $repo: String!, $expression: String!) {
+ repository(owner: $user, name: $repo) {
+ object(expression: $expression) {
+ ... on Tree {
+ entries {
+ type
+ extension
+ }
+ }
+ }
+ }
+ }
+ `,
+ variables: { user, repo, expression },
schema,
- errorMessages: errorMessagesFor('repo or directory not found'),
+ transformErrors,
})
}
@@ -137,11 +132,12 @@ export default class GithubDirectoryFileCount extends ConditionalGithubAuthV3Ser
}
if (type) {
- files = files.filter(file => file.type === type)
+ const objectType = type === 'dir' ? 'tree' : 'blob'
+ files = files.filter(file => file.type === objectType)
}
if (extension) {
- files = files.filter(file => path.extname(file.path) === `.${extension}`)
+ files = files.filter(file => file.extension === `.${extension}`)
}
return {
@@ -150,7 +146,13 @@ export default class GithubDirectoryFileCount extends ConditionalGithubAuthV3Ser
}
async handle({ user, repo, path }, { type, extension }) {
- const content = await this.fetch({ user, repo, path })
+ const json = await this.fetch({ user, repo, path })
+ if (json.data.repository.object === null) {
+ throw new InvalidParameter({
+ prettyMessage: 'directory not found',
+ })
+ }
+ const content = json.data.repository.object.entries
const { count } = this.constructor.transform(content, { type, extension })
return this.constructor.render({ count })
}
diff --git a/services/github/github-directory-file-count.spec.js b/services/github/github-directory-file-count.spec.js
index 056a9622a0a8a..17c3afdd076f7 100644
--- a/services/github/github-directory-file-count.spec.js
+++ b/services/github/github-directory-file-count.spec.js
@@ -5,12 +5,12 @@ import GithubDirectoryFileCount from './github-directory-file-count.service.js'
describe('GithubDirectoryFileCount', function () {
const contents = [
- { path: 'a', type: 'dir' },
- { path: 'b', type: 'dir' },
- { path: 'c.js', type: 'file' },
- { path: 'd.js', type: 'file' },
- { path: 'e.txt', type: 'file' },
- { path: 'f', type: 'submodule' },
+ { extension: '', type: 'tree' },
+ { extension: '', type: 'tree' },
+ { extension: '.js', type: 'blob' },
+ { extension: '.js', type: 'blob' },
+ { extension: '.txt', type: 'blob' },
+ { extension: '', type: 'commit' },
]
test(GithubDirectoryFileCount.transform, () => {
@@ -32,34 +32,34 @@ describe('GithubDirectoryFileCount', function () {
})
})
- it('throws InvalidParameter on receving an object as contents instead of an array', function () {
+ it('throws InvalidParameter on receiving an object as contents instead of an array', function () {
expect(() => GithubDirectoryFileCount.transform({}, {}))
.to.throw(InvalidParameter)
.with.property('prettyMessage', 'not a directory')
})
- it('throws InvalidParameter on receving type dir and extension', function () {
+ it('throws InvalidParameter on receiving type dir and extension', function () {
expect(() =>
GithubDirectoryFileCount.transform(contents, {
type: 'dir',
extension: 'js',
- })
+ }),
)
.to.throw(InvalidParameter)
.with.property(
'prettyMessage',
- 'extension is applicable for type file only'
+ 'extension is applicable for type file only',
)
})
- it('throws InvalidParameter on receving no type and extension', function () {
+ it('throws InvalidParameter on receiving no type and extension', function () {
expect(() =>
- GithubDirectoryFileCount.transform(contents, { extension: 'js' })
+ GithubDirectoryFileCount.transform(contents, { extension: 'js' }),
)
.to.throw(InvalidParameter)
.with.property(
'prettyMessage',
- 'extension is applicable for type file only'
+ 'extension is applicable for type file only',
)
})
})
diff --git a/services/github/github-directory-file-count.tester.js b/services/github/github-directory-file-count.tester.js
index b4ac6c6f2c6c4..77c1793994552 100644
--- a/services/github/github-directory-file-count.tester.js
+++ b/services/github/github-directory-file-count.tester.js
@@ -14,11 +14,18 @@ t.create('directory file count (custom path)')
message: isMetric,
})
+t.create('directory file count (repo not found)')
+ .get('/badges/not_existing_repository.json')
+ .expectBadge({
+ label: 'files',
+ message: 'repo not found',
+ })
+
t.create('directory file count (directory not found)')
.get('/badges/shields/not_existing_directory.json')
.expectBadge({
label: 'files',
- message: 'repo or directory not found',
+ message: 'directory not found',
})
t.create('directory file count (not a directory)')
diff --git a/services/github/github-discussions-custom-search.service.js b/services/github/github-discussions-custom-search.service.js
new file mode 100644
index 0000000000000..f116609a8bbde
--- /dev/null
+++ b/services/github/github-discussions-custom-search.service.js
@@ -0,0 +1,115 @@
+import gql from 'graphql-tag'
+import Joi from 'joi'
+import { pathParam, queryParam } from '../index.js'
+import { metric } from '../text-formatters.js'
+import { nonNegativeInteger } from '../validators.js'
+import { GithubAuthV4Service } from './github-auth-service.js'
+import { documentation, transformErrors } from './github-helpers.js'
+
+const discussionsSearchDocs = `
+For a full list of available filters and allowed values,
+see GitHub's documentation on
+[Searching discussions](https://docs.github.com/en/search-github/searching-on-github/searching-discussions).
+`
+
+const discussionCountSchema = Joi.object({
+ data: Joi.object({
+ search: Joi.object({
+ discussionCount: nonNegativeInteger,
+ }).required(),
+ }).required(),
+}).required()
+
+const queryParamSchema = Joi.object({
+ query: Joi.string().required(),
+}).required()
+
+class BaseGithubDiscussionsSearch extends GithubAuthV4Service {
+ static category = 'other'
+ static defaultBadgeData = { label: 'query', color: 'informational' }
+
+ static render({ discussionCount }) {
+ return { message: metric(discussionCount) }
+ }
+
+ async fetch({ query }) {
+ const data = await this._requestGraphql({
+ query: gql`
+ query ($query: String!) {
+ search(query: $query, type: DISCUSSION) {
+ discussionCount
+ }
+ }
+ `,
+ variables: { query },
+ schema: discussionCountSchema,
+ transformErrors,
+ })
+ return data.data.search.discussionCount
+ }
+}
+
+class GithubDiscussionsSearch extends BaseGithubDiscussionsSearch {
+ static route = {
+ base: 'github',
+ pattern: 'discussions-search',
+ queryParamSchema,
+ }
+
+ static openApi = {
+ '/github/discussions-search': {
+ get: {
+ summary: 'GitHub discussions custom search',
+ description: documentation,
+ parameters: [
+ queryParam({
+ name: 'query',
+ description: discussionsSearchDocs,
+ example: 'repo:badges/shields is:answered answered-by:chris48s',
+ required: true,
+ }),
+ ],
+ },
+ },
+ }
+
+ async handle(namedParams, { query }) {
+ const discussionCount = await this.fetch({ query })
+ return this.constructor.render({ discussionCount })
+ }
+}
+
+class GithubRepoDiscussionsSearch extends BaseGithubDiscussionsSearch {
+ static route = {
+ base: 'github',
+ pattern: 'discussions-search/:user/:repo',
+ queryParamSchema,
+ }
+
+ static openApi = {
+ '/github/discussions-search/{user}/{repo}': {
+ get: {
+ summary: 'GitHub discussions custom search in repo',
+ description: documentation,
+ parameters: [
+ pathParam({ name: 'user', example: 'badges' }),
+ pathParam({ name: 'repo', example: 'shields' }),
+ queryParam({
+ name: 'query',
+ description: discussionsSearchDocs,
+ example: 'is:answered answered-by:chris48s',
+ required: true,
+ }),
+ ],
+ },
+ },
+ }
+
+ async handle({ user, repo }, { query }) {
+ query = `repo:${user}/${repo} ${query}`
+ const discussionCount = await this.fetch({ query })
+ return this.constructor.render({ discussionCount })
+ }
+}
+
+export { GithubDiscussionsSearch, GithubRepoDiscussionsSearch }
diff --git a/services/github/github-discussions-custom-search.tester.js b/services/github/github-discussions-custom-search.tester.js
new file mode 100644
index 0000000000000..213b69e4990b3
--- /dev/null
+++ b/services/github/github-discussions-custom-search.tester.js
@@ -0,0 +1,48 @@
+import { isMetric } from '../test-validators.js'
+import { ServiceTester } from '../tester.js'
+export const t = new ServiceTester({
+ id: 'GithubDiscussionsSearch',
+ title: 'Github Discussions Search',
+ pathPrefix: '/github',
+})
+
+t.create('GitHub discussions search (valid query string)')
+ .get(
+ '/discussions-search.json?query=repo%3Abadges%2Fshields%20is%3Aanswered%20author%3Achris48s',
+ )
+ .expectBadge({
+ label: 'query',
+ message: isMetric,
+ })
+
+t.create('GitHub discussions search (invalid query string)')
+ .get('/discussions-search.json?query=')
+ .expectBadge({
+ label: 'query',
+ message: 'invalid query parameter: query',
+ })
+
+t.create('GitHub Repo discussions search (valid query string)')
+ .get(
+ '/discussions-search/badges/shields.json?query=is%3Aanswered%20author%3Achris48s',
+ )
+ .expectBadge({
+ label: 'query',
+ message: isMetric,
+ })
+
+t.create('GitHub Repo discussions search (invalid query string)')
+ .get('/discussions-search/badges/shields.json?query=')
+ .expectBadge({
+ label: 'query',
+ message: 'invalid query parameter: query',
+ })
+
+t.create('GitHub Repo discussions search (invalid repo)')
+ .get(
+ '/discussions-search/badges/helmets.json?query=is%3Aanswered%20author%3Achris48s',
+ )
+ .expectBadge({
+ label: 'query',
+ message: '0',
+ })
diff --git a/services/github/github-discussions-total.service.js b/services/github/github-discussions-total.service.js
index 425563c220a90..6812cb4f5285f 100644
--- a/services/github/github-discussions-total.service.js
+++ b/services/github/github-discussions-total.service.js
@@ -1,5 +1,6 @@
import gql from 'graphql-tag'
import Joi from 'joi'
+import { pathParams } from '../index.js'
import { nonNegativeInteger } from '../validators.js'
import { GithubAuthV4Service } from './github-auth-service.js'
import { transformErrors } from './github-helpers.js'
@@ -21,18 +22,23 @@ export default class GithubTotalDiscussions extends GithubAuthV4Service {
pattern: ':user/:repo',
}
- static examples = [
- {
- title: 'GitHub Discussions',
- namedParams: {
- user: 'vercel',
- repo: 'next.js',
+ static openApi = {
+ '/github/discussions/{user}/{repo}': {
+ get: {
+ summary: 'GitHub Discussions',
+ parameters: pathParams(
+ {
+ name: 'user',
+ example: 'vercel',
+ },
+ {
+ name: 'repo',
+ example: 'next.js',
+ },
+ ),
},
- staticPreview: this.render({
- discussions: '6000 total',
- }),
},
- ]
+ }
static defaultBadgeData = { label: 'discussions', color: 'blue' }
diff --git a/services/github/github-downloads.service.js b/services/github/github-downloads.service.js
index d544a91e743d8..4b853358c02a9 100644
--- a/services/github/github-downloads.service.js
+++ b/services/github/github-downloads.service.js
@@ -1,14 +1,18 @@
import Joi from 'joi'
-import { metric } from '../text-formatters.js'
import { nonNegativeInteger } from '../validators.js'
-import { downloadCount as downloadCountColor } from '../color-formatters.js'
-import { NotFound } from '../index.js'
+import { renderDownloadsBadge } from '../downloads.js'
+import { NotFound, pathParam, queryParam } from '../index.js'
import { GithubAuthV3Service } from './github-auth-service.js'
import { fetchLatestRelease } from './github-common-release.js'
-import { documentation, errorMessagesFor } from './github-helpers.js'
+import { documentation, httpErrorsFor } from './github-helpers.js'
+
+const sortEnum = ['date', 'semver']
const queryParamSchema = Joi.object({
- sort: Joi.string().valid('date', 'semver').default('date'),
+ sort: Joi.string()
+ .valid(...sortEnum)
+ .default('date'),
+ displayAssetName: Joi.boolean().default(true),
}).required()
const releaseSchema = Joi.object({
@@ -22,245 +26,182 @@ const releaseSchema = Joi.object({
const releaseArraySchema = Joi.alternatives().try(
Joi.array().items(releaseSchema),
- Joi.array().length(0)
+ Joi.array().length(0),
)
+const variantParam = pathParam({
+ name: 'variant',
+ example: 'downloads',
+ description: 'downloads including or excluding pre-releases',
+ schema: { type: 'string', enum: ['downloads', 'downloads-pre'] },
+})
+const userParam = pathParam({ name: 'user', example: 'atom' })
+const repoParam = pathParam({ name: 'repo', example: 'atom' })
+const tagParam = pathParam({ name: 'tag', example: 'v0.190.0' })
+const assetNameParam = pathParam({
+ name: 'assetName',
+ example: 'atom-amd64.deb',
+})
+const displayAssetNameParam = queryParam({
+ name: 'displayAssetName',
+ example: 'false',
+ schema: { type: 'boolean' },
+ description: 'Whether to display the asset name in the badge value',
+})
+const sortParam = queryParam({
+ name: 'sort',
+ example: 'semver',
+ schema: { type: 'string', enum: sortEnum },
+ description: 'Method used to determine latest release. Default: `date`',
+})
+
export default class GithubDownloads extends GithubAuthV3Service {
static category = 'downloads'
static route = {
base: 'github',
- pattern: ':kind(downloads|downloads-pre)/:user/:repo/:tag*/:assetName',
+ pattern: ':variant(downloads|downloads-pre)/:user/:repo/:tag*/:assetName',
queryParamSchema,
}
- static examples = [
- {
- title: 'GitHub all releases',
- pattern: 'downloads/:user/:repo/total',
- namedParams: {
- user: 'atom',
- repo: 'atom',
- },
- staticPreview: this.render({
- assetName: 'total',
- downloadCount: 857000,
- }),
- documentation,
- },
- {
- title: 'GitHub release (latest by date)',
- pattern: 'downloads/:user/:repo/:tag/total',
- namedParams: {
- user: 'atom',
- repo: 'atom',
- tag: 'latest',
- },
- staticPreview: this.render({
- tag: 'latest',
- assetName: 'total',
- downloadCount: 27000,
- }),
- documentation,
- },
- {
- title: 'GitHub release (latest by SemVer)',
- pattern: 'downloads/:user/:repo/:tag/total',
- namedParams: {
- user: 'atom',
- repo: 'atom',
- tag: 'latest',
- },
- queryParams: { sort: 'semver' },
- staticPreview: this.render({
- tag: 'latest',
- assetName: 'total',
- downloadCount: 27000,
- }),
- documentation,
- },
- {
- title: 'GitHub release (latest by date including pre-releases)',
- pattern: 'downloads-pre/:user/:repo/:tag/total',
- namedParams: {
- user: 'atom',
- repo: 'atom',
- tag: 'latest',
+ static openApi = {
+ '/github/downloads/{user}/{repo}/total': {
+ get: {
+ summary: 'GitHub Downloads (all assets, all releases)',
+ description: documentation,
+ parameters: [userParam, repoParam],
},
- staticPreview: this.render({
- tag: 'latest',
- assetName: 'total',
- downloadCount: 2000,
- }),
- documentation,
},
- {
- title: 'GitHub release (latest by SemVer including pre-releases)',
- pattern: 'downloads-pre/:user/:repo/:tag/total',
- namedParams: {
- user: 'atom',
- repo: 'atom',
- tag: 'latest',
+ '/github/{variant}/{user}/{repo}/latest/total': {
+ get: {
+ summary: 'GitHub Downloads (all assets, latest release)',
+ description: documentation,
+ parameters: [variantParam, userParam, repoParam, sortParam],
},
- queryParams: { sort: 'semver' },
- staticPreview: this.render({
- tag: 'latest',
- assetName: 'total',
- downloadCount: 2000,
- }),
- documentation,
},
- {
- title: 'GitHub release (by tag)',
- pattern: 'downloads/:user/:repo/:tag/total',
- namedParams: {
- user: 'atom',
- repo: 'atom',
- tag: 'v0.190.0',
+ '/github/downloads/{user}/{repo}/{tag}/total': {
+ get: {
+ summary: 'GitHub Downloads (all assets, specific tag)',
+ description: documentation,
+ parameters: [userParam, repoParam, tagParam],
},
- staticPreview: this.render({
- tag: 'v0.190.0',
- assetName: 'total',
- downloadCount: 490000,
- }),
- documentation,
},
- {
- title: 'GitHub release (latest by date and asset)',
- pattern: 'downloads/:user/:repo/:tag/:assetName',
- namedParams: {
- user: 'atom',
- repo: 'atom',
- tag: 'latest',
- assetName: 'atom-amd64.deb',
+ '/github/downloads/{user}/{repo}/{assetName}': {
+ get: {
+ summary: 'GitHub Downloads (specific asset, all releases)',
+ description: documentation,
+ parameters: [
+ userParam,
+ repoParam,
+ assetNameParam,
+ displayAssetNameParam,
+ ],
},
- staticPreview: this.render({
- tag: 'latest',
- assetName: 'atom-amd64.deb',
- downloadCount: 3000,
- }),
- documentation,
},
- {
- title: 'GitHub release (latest by SemVer and asset)',
- pattern: 'downloads/:user/:repo/:tag/:assetName',
- namedParams: {
- user: 'atom',
- repo: 'atom',
- tag: 'latest',
- assetName: 'atom-amd64.deb',
+ '/github/{variant}/{user}/{repo}/latest/{assetName}': {
+ get: {
+ summary: 'GitHub Downloads (specific asset, latest release)',
+ description: documentation,
+ parameters: [
+ variantParam,
+ userParam,
+ repoParam,
+ assetNameParam,
+ displayAssetNameParam,
+ sortParam,
+ ],
},
- queryParams: { sort: 'semver' },
- staticPreview: this.render({
- tag: 'latest',
- assetName: 'atom-amd64.deb',
- downloadCount: 3000,
- }),
- documentation,
},
- {
- title: 'GitHub release (latest by date and asset including pre-releases)',
- pattern: 'downloads-pre/:user/:repo/:tag/:assetName',
- namedParams: {
- user: 'atom',
- repo: 'atom',
- tag: 'latest',
- assetName: 'atom-amd64.deb',
+ '/github/downloads/{user}/{repo}/{tag}/{assetName}': {
+ get: {
+ summary: 'GitHub Downloads (specific asset, specific tag)',
+ description: documentation,
+ parameters: [
+ userParam,
+ repoParam,
+ tagParam,
+ assetNameParam,
+ displayAssetNameParam,
+ ],
},
- staticPreview: this.render({
- tag: 'latest',
- assetName: 'atom-amd64.deb',
- downloadCount: 237,
- }),
- documentation,
},
- {
- title:
- 'GitHub release (latest by SemVer and asset including pre-releases)',
- pattern: 'downloads-pre/:user/:repo/:tag/:assetName',
- namedParams: {
- user: 'atom',
- repo: 'atom',
- tag: 'latest',
- assetName: 'atom-amd64.deb',
- },
- queryParams: { sort: 'semver' },
- staticPreview: this.render({
- tag: 'latest',
- assetName: 'atom-amd64.deb',
- downloadCount: 237,
- }),
- documentation,
- },
- ]
+ }
- static defaultBadgeData = { label: 'downloads', namedLogo: 'github' }
+ static defaultBadgeData = { label: 'downloads' }
- 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,
+ displayAssetName = true,
+ }) {
+ const messageSuffixOverride =
+ assetName !== 'total' && displayAssetName ? `[${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
: assets.filter(
- ({ name }) => name.toLowerCase() === assetName.toLowerCase()
+ ({ name }) => name.toLowerCase() === assetName.toLowerCase(),
)
return (
accum1 +
filteredAssets.reduce(
- (accum2, { download_count: downloadCount }) => accum2 + downloadCount,
- 0
+ (accum2, { download_count: downloads }) => accum2 + downloads,
+ 0,
)
)
}, 0)
- return { downloadCount }
+ return { downloads }
}
- async handle({ kind, user, repo, tag, assetName }, { sort }) {
+ async handle(
+ { variant, user, repo, tag, assetName },
+ { sort, displayAssetName },
+ ) {
let releases
if (tag === 'latest') {
- const includePre = kind === 'downloads-pre' || undefined
+ const includePre = variant === 'downloads-pre' || undefined
const latestRelease = await fetchLatestRelease(
this,
{ user, repo },
- { sort, include_prereleases: includePre }
+ { sort, include_prereleases: includePre },
)
releases = [latestRelease]
} else if (tag) {
const wantedRelease = await this._requestJson({
schema: releaseSchema,
url: `/repos/${user}/${repo}/releases/tags/${tag}`,
- errorMessages: errorMessagesFor('repo or release not found'),
+ httpErrors: httpErrorsFor('repo or release not found'),
})
releases = [wantedRelease]
} else {
const allReleases = await this._requestJson({
schema: releaseArraySchema,
url: `/repos/${user}/${repo}/releases`,
- options: { qs: { per_page: 500 } },
- errorMessages: errorMessagesFor('repo not found'),
+ options: { searchParams: { per_page: 500 } },
+ httpErrors: httpErrorsFor('repo not found'),
})
releases = allReleases
}
if (releases.length === 0) {
- throw new NotFound({ prettyMessage: 'no releases' })
+ throw new NotFound({ prettyMessage: 'no releases found' })
}
- 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,
+ displayAssetName,
+ })
}
}
diff --git a/services/github/github-downloads.tester.js b/services/github/github-downloads.tester.js
index e002516906684..afbb442294f6f 100644
--- a/services/github/github-downloads.tester.js
+++ b/services/github/github-downloads.tester.js
@@ -10,7 +10,7 @@ const mockLatestRelease = release => nock =>
const mockReleases = releases => nock =>
nock('https://api.github.com')
- .get('/repos/photonstorm/phaser/releases')
+ .get('/repos/photonstorm/phaser/releases?per_page=100')
.reply(200, releases)
t.create('Downloads all releases')
@@ -19,11 +19,11 @@ t.create('Downloads all releases')
t.create('Downloads all releases (no releases)')
.get('/downloads/badges/shields/total.json')
- .expectBadge({ label: 'downloads', message: 'no releases' })
+ .expectBadge({ label: 'downloads', message: 'no releases found' })
t.create('Downloads-pre all releases (no releases)')
.get('/downloads-pre/badges/shields/total.json')
- .expectBadge({ label: 'downloads', message: 'no releases' })
+ .expectBadge({ label: 'downloads', message: 'no releases found' })
t.create('Downloads all releases (repo not found)')
.get('/downloads/badges/helmets/total.json')
@@ -47,7 +47,7 @@ t.create('downloads for latest release (sort by date)')
],
tag_name: 'v3.15.1',
prerelease: false,
- })
+ }),
)
.expectBadge({ label: 'downloads@latest', message: '12' })
@@ -79,7 +79,7 @@ t.create('downloads for latest release (sort by SemVer)')
tag_name: 'v3.15.1',
prerelease: false,
},
- ])
+ ]),
)
.expectBadge({ label: 'downloads@latest', message: '20' })
@@ -111,7 +111,7 @@ t.create('downloads for latest release (sort by date including pre-releases)')
tag_name: 'v3.15.1',
prerelease: false,
},
- ])
+ ]),
)
.expectBadge({ label: 'downloads@latest', message: '4' })
@@ -143,7 +143,7 @@ t.create('downloads for latest release (sort by SemVer including pre-releases)')
tag_name: 'v3.15.0',
prerelease: false,
},
- ])
+ ]),
)
.expectBadge({ label: 'downloads@latest', message: '4' })
@@ -154,7 +154,7 @@ t.create('downloads-pre for latest release')
// https://github.com/badges/shields/issues/3786
t.create('downloads-pre for latest release (no-releases)')
.get('/downloads-pre/badges/shields/latest/total.json')
- .expectBadge({ label: 'downloads', message: 'no releases' })
+ .expectBadge({ label: 'downloads', message: 'no releases found' })
t.create('downloads for release without slash')
.get('/downloads/atom/atom/v0.190.0/total.json')
@@ -165,7 +165,7 @@ t.create('downloads for specific asset without slash')
.expectBadge({
label: 'downloads@v0.190.0',
message: Joi.string().regex(
- /^([0-9]+[kMGTPEZY]?|[1-9]\.[1-9][kMGTPEZY]) \[atom-amd64\.deb\]$/
+ /^([0-9]+[kMGTPEZY]?|[1-9]\.[1-9][kMGTPEZY]) \[atom-amd64\.deb\]$/,
),
})
@@ -174,19 +174,31 @@ t.create('downloads for specific asset from latest release')
.expectBadge({
label: 'downloads@latest',
message: Joi.string().regex(
- /^([0-9]+[kMGTPEZY]?|[1-9]\.[1-9][kMGTPEZY]) \[atom-amd64\.deb\]$/
+ /^([0-9]+[kMGTPEZY]?|[1-9]\.[1-9][kMGTPEZY]) \[atom-amd64\.deb\]$/,
),
})
+t.create('downloads for specific asset from latest release without asset name')
+ .get('/downloads/atom/atom/latest/atom-amd64.deb.json?displayAssetName=false')
+ .expectBadge({ label: 'downloads@latest', message: isMetric })
+
t.create('downloads-pre for specific asset from latest release')
.get('/downloads-pre/atom/atom/latest/atom-amd64.deb.json')
.expectBadge({
label: 'downloads@latest',
message: Joi.string().regex(
- /^([0-9]+[kMGTPEZY]?|[1-9]\.[1-9][kMGTPEZY]) \[atom-amd64\.deb\]$/
+ /^([0-9]+[kMGTPEZY]?|[1-9]\.[1-9][kMGTPEZY]) \[atom-amd64\.deb\]$/,
),
})
+t.create(
+ 'downloads-pre for specific asset from latest release without asset name',
+)
+ .get(
+ '/downloads-pre/atom/atom/latest/atom-amd64.deb.json?displayAssetName=false',
+ )
+ .expectBadge({ label: 'downloads@latest', message: isMetric })
+
t.create('downloads for release with slash')
.get('/downloads/NHellFire/dban/stable/v2.2.8/total.json')
.expectBadge({ label: 'downloads@stable/v2.2.8', message: isMetric })
@@ -196,10 +208,16 @@ t.create('downloads for specific asset with slash')
.expectBadge({
label: 'downloads@stable/v2.2.8',
message: Joi.string().regex(
- /^([0-9]+[kMGTPEZY]?|[1-9]\.[1-9][kMGTPEZY]) \[dban-2\.2\.8_i586\.iso\]$/
+ /^([0-9]+[kMGTPEZY]?|[1-9]\.[1-9][kMGTPEZY]) \[dban-2\.2\.8_i586\.iso\]$/,
),
})
+t.create('downloads for specific asset with slash without asset name')
+ .get(
+ '/downloads/NHellFire/dban/stable/v2.2.8/dban-2.2.8_i586.iso.json?displayAssetName=false',
+ )
+ .expectBadge({ label: 'downloads@stable/v2.2.8', message: isMetric })
+
t.create('downloads for unknown release')
.get('/downloads/atom/atom/does-not-exist/total.json')
.expectBadge({ label: 'downloads', message: 'repo or release not found' })
diff --git a/services/github/github-followers.service.js b/services/github/github-followers.service.js
index 71b3d01b05c9f..9336ba0dd9891 100644
--- a/services/github/github-followers.service.js
+++ b/services/github/github-followers.service.js
@@ -1,8 +1,9 @@
import Joi from 'joi'
+import { pathParams } from '../index.js'
import { metric } from '../text-formatters.js'
import { nonNegativeInteger } from '../validators.js'
import { GithubAuthV3Service } from './github-auth-service.js'
-import { documentation, errorMessagesFor } from './github-helpers.js'
+import { documentation, httpErrorsFor } from './github-helpers.js'
const schema = Joi.object({
followers: nonNegativeInteger,
@@ -11,25 +12,24 @@ const schema = Joi.object({
export default class GithubFollowers extends GithubAuthV3Service {
static category = 'social'
static route = { base: 'github/followers', pattern: ':user' }
- static examples = [
- {
- title: 'GitHub followers',
- namedParams: { user: 'espadrine' },
- staticPreview: Object.assign(this.render({ followers: 150 }), {
- label: 'Follow',
- style: 'social',
- }),
- queryParams: { label: 'Follow' },
- documentation,
+ static openApi = {
+ '/github/followers/{user}': {
+ get: {
+ summary: 'GitHub followers',
+ description: documentation,
+ parameters: pathParams({ name: 'user', example: 'espadrine' }),
+ },
},
- ]
+ }
static defaultBadgeData = { label: 'followers', namedLogo: 'github' }
- static render({ followers }) {
+ static render({ followers, user }) {
return {
message: metric(followers),
+ style: 'social',
color: 'blue',
+ link: [`https://github.com/${user}?tab=followers`],
}
}
@@ -37,8 +37,8 @@ export default class GithubFollowers extends GithubAuthV3Service {
const { followers } = await this._requestJson({
url: `/users/${user}`,
schema,
- errorMessages: errorMessagesFor('user not found'),
+ httpErrors: httpErrorsFor('user not found'),
})
- return this.constructor.render({ followers })
+ return this.constructor.render({ followers, user })
}
}
diff --git a/services/github/github-followers.tester.js b/services/github/github-followers.tester.js
index 4e1babb264c26..97c5844bc2ba5 100644
--- a/services/github/github-followers.tester.js
+++ b/services/github/github-followers.tester.js
@@ -2,11 +2,14 @@ import { isMetric } from '../test-validators.js'
import { createServiceTester } from '../tester.js'
export const t = await createServiceTester()
-t.create('Followers').get('/webcaetano.json').expectBadge({
- label: 'followers',
- message: isMetric,
- color: 'blue',
-})
+t.create('Followers')
+ .get('/webcaetano.json')
+ .expectBadge({
+ label: 'followers',
+ message: isMetric,
+ color: 'blue',
+ link: ['https://github.com/webcaetano?tab=followers'],
+ })
t.create('Followers (user not found)').get('/PyvesB2.json').expectBadge({
label: 'followers',
diff --git a/services/github/github-forks.service.js b/services/github/github-forks.service.js
index 0c60e03c67ba7..143b700119a92 100644
--- a/services/github/github-forks.service.js
+++ b/services/github/github-forks.service.js
@@ -1,5 +1,6 @@
import gql from 'graphql-tag'
import Joi from 'joi'
+import { pathParams } from '../index.js'
import { metric } from '../text-formatters.js'
import { nonNegativeInteger } from '../validators.js'
import { GithubAuthV4Service } from './github-auth-service.js'
@@ -16,35 +17,25 @@ const schema = Joi.object({
export default class GithubForks extends GithubAuthV4Service {
static category = 'social'
static route = { base: 'github/forks', pattern: ':user/:repo' }
- static examples = [
- {
- title: 'GitHub forks',
- namedParams: {
- user: 'badges',
- repo: 'shields',
+ static openApi = {
+ '/github/forks/{user}/{repo}': {
+ get: {
+ summary: 'GitHub forks',
+ description: documentation,
+ parameters: pathParams(
+ { name: 'user', example: 'badges' },
+ { name: 'repo', example: 'shields' },
+ ),
},
- // TODO: This is currently a literal, as `staticPreview` doesn't
- // support `link`.
- staticPreview: {
- label: 'Fork',
- message: '150',
- style: 'social',
- },
- // staticPreview: {
- // ...this.render({ user: 'badges', repo: 'shields', forkCount: 150 }),
- // label: 'fork',
- // style: 'social',
- // },
- queryParams: { label: 'Fork' },
- documentation,
},
- ]
+ }
static defaultBadgeData = { label: 'forks', namedLogo: 'github' }
static render({ user, repo, forkCount }) {
return {
message: metric(forkCount),
+ style: 'social',
color: 'blue',
link: [
`https://github.com/${user}/${repo}/fork`,
diff --git a/services/github/github-go-mod.service.js b/services/github/github-go-mod.service.js
index 9feded9bae79d..937f5f0e4f7dd 100644
--- a/services/github/github-go-mod.service.js
+++ b/services/github/github-go-mod.service.js
@@ -1,6 +1,6 @@
import Joi from 'joi'
import { renderVersionBadge } from '../version.js'
-import { InvalidResponse } from '../index.js'
+import { InvalidResponse, pathParam, queryParam } from '../index.js'
import { ConditionalGithubAuthV3Service } from './github-auth-service.js'
import { fetchRepoContent } from './github-common-fetch.js'
import { documentation } from './github-helpers.js'
@@ -9,54 +9,52 @@ const queryParamSchema = Joi.object({
filename: Joi.string(),
}).required()
-const goVersionRegExp = /^go (.+)$/m
+const goVersionRegExp = /^go ([^/\s]+)(\s*\/.+)?$/m
-const keywords = ['golang']
+const filenameDescription =
+ 'The `filename` param can be used to specify the path to `go.mod`. By default, we look for `go.mod` in the repo root'
export default class GithubGoModGoVersion extends ConditionalGithubAuthV3Service {
- static category = 'version'
+ static category = 'platform-support'
static route = {
base: 'github/go-mod/go-version',
pattern: ':user/:repo/:branch*',
queryParamSchema,
}
- static examples = [
- {
- title: 'GitHub go.mod Go version',
- pattern: ':user/:repo',
- namedParams: { user: 'gohugoio', repo: 'hugo' },
- staticPreview: this.render({ version: '1.12' }),
- documentation,
- keywords,
+ static openApi = {
+ '/github/go-mod/go-version/{user}/{repo}': {
+ get: {
+ summary: 'GitHub go.mod Go version',
+ description: documentation,
+ parameters: [
+ pathParam({ name: 'user', example: 'gohugoio' }),
+ pathParam({ name: 'repo', example: 'hugo' }),
+ queryParam({
+ name: 'filename',
+ example: 'src/go.mod',
+ description: filenameDescription,
+ }),
+ ],
+ },
},
- {
- title: 'GitHub go.mod Go version (branch)',
- pattern: ':user/:repo/:branch',
- namedParams: { user: 'gohugoio', repo: 'hugo', branch: 'master' },
- staticPreview: this.render({ version: '1.12', branch: 'master' }),
- documentation,
- keywords,
+ '/github/go-mod/go-version/{user}/{repo}/{branch}': {
+ get: {
+ summary: 'GitHub go.mod Go version (branch)',
+ description: documentation,
+ parameters: [
+ pathParam({ name: 'user', example: 'gohugoio' }),
+ pathParam({ name: 'repo', example: 'hugo' }),
+ pathParam({ name: 'branch', example: 'master' }),
+ queryParam({
+ name: 'filename',
+ example: 'src/go.mod',
+ description: filenameDescription,
+ }),
+ ],
+ },
},
- {
- title: 'GitHub go.mod Go version (subdirectory of monorepo)',
- pattern: ':user/:repo',
- namedParams: { user: 'golang', repo: 'go' },
- queryParams: { filename: 'src/go.mod' },
- staticPreview: this.render({ version: '1.14' }),
- documentation,
- keywords,
- },
- {
- title: 'GitHub go.mod Go version (branch & subdirectory of monorepo)',
- pattern: ':user/:repo/:branch',
- namedParams: { user: 'golang', repo: 'go', branch: 'master' },
- queryParams: { filename: 'src/go.mod' },
- staticPreview: this.render({ version: '1.14' }),
- documentation,
- keywords,
- },
- ]
+ }
static defaultBadgeData = { label: 'Go' }
diff --git a/services/github/github-go-mod.spec.js b/services/github/github-go-mod.spec.js
new file mode 100644
index 0000000000000..cec2276509d80
--- /dev/null
+++ b/services/github/github-go-mod.spec.js
@@ -0,0 +1,26 @@
+import { expect } from 'chai'
+import { test, given } from 'sazerac'
+import { InvalidResponse } from '../index.js'
+import GithubGoModGoVersion from './github-go-mod.service.js'
+
+describe('GithubGoModGoVersion', function () {
+ describe('valid cases', function () {
+ test(GithubGoModGoVersion.transform, () => {
+ given('go 1.18').expect({ go: '1.18' })
+ given('go 1.18 // inline comment').expect({ go: '1.18' })
+ given('go 1.18// inline comment').expect({ go: '1.18' })
+ given('go 1.18 /* block comment */').expect({ go: '1.18' })
+ given('go 1.18/* block comment */').expect({ go: '1.18' })
+ given('go 1').expect({ go: '1' })
+ given('go 1.2.3').expect({ go: '1.2.3' })
+ given('go string').expect({ go: 'string' })
+ })
+ })
+
+ describe('invalid cases', function () {
+ expect(() => GithubGoModGoVersion.transform('')).to.throw(InvalidResponse)
+ expect(() =>
+ GithubGoModGoVersion.transform("doesn't start with go"),
+ ).to.throw(InvalidResponse)
+ })
+})
diff --git a/services/github/github-hacktoberfest.service.js b/services/github/github-hacktoberfest.service.js
index bf8782c17a5bf..a5766aab671e6 100644
--- a/services/github/github-hacktoberfest.service.js
+++ b/services/github/github-hacktoberfest.service.js
@@ -1,6 +1,8 @@
import gql from 'graphql-tag'
import Joi from 'joi'
-import moment from 'moment'
+import dayjs from 'dayjs'
+import { pathParam, queryParam } from '../index.js'
+import { parseDate } from '../date.js'
import { metric, maybePluralize } from '../text-formatters.js'
import { nonNegativeInteger } from '../validators.js'
import { GithubAuthV4Service } from './github-auth-service.js'
@@ -9,34 +11,25 @@ import {
transformErrors,
} from './github-helpers.js'
-const documentation = `
-
- This badge is designed for projects hosted on GitHub which are
- participating in
- Hacktoberfest ,
- an initiative to encourage participating in open-source projects. The
- badge can be added to the project readme to encourage potential
- contributors to review the suggested issues and to celebrate the
- contributions that have already been made.
+const description = `
+This badge is designed for projects hosted on GitHub which are
+participating in
+[Hacktoberfest](https://hacktoberfest.digitalocean.com),
+an initiative to encourage participating in open-source projects. The
+badge can be added to the project readme to encourage potential
+contributors to review the suggested issues and to celebrate the
+contributions that have already been made.
+The badge displays three pieces of information:
- The badge displays three pieces of information:
-
-
- The number of suggested issues. By default this will count open
- issues with the hacktoberfest label, however you
- can pick a different label (e.g.
- ?suggestion_label=good%20first%20issue).
-
-
- The number of pull requests opened in October. This excludes any
- PR with the invalid label.
-
- The number of days left of October.
-
+- The number of suggested issues. By default this will count open
+ issues with the hacktoberfest label, however you
+ can pick a different label (e.g.
+ \`?suggestion_label=good%20first%20issue\`).
+- The number of pull requests opened in October. This excludes any
+ PR with the invalid label.
+- The number of days left of October.
-
-
- ${githubDocumentation}
+${githubDocumentation}
`
const schema = Joi.object({
@@ -60,44 +53,28 @@ 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|2022|2023|2024|2025)/:user/:repo',
queryParamSchema,
}
- static examples = [
- {
- title: 'GitHub Hacktoberfest combined status',
- namedParams: {
- year: '2020',
- user: 'snyk',
- repo: 'snyk',
- },
- staticPreview: this.render({
- suggestedIssueCount: 12,
- contributionCount: 8,
- daysLeft: 15,
- }),
- documentation,
- },
- {
- title: 'GitHub Hacktoberfest combined status (suggestion label override)',
- namedParams: {
- year: '2020',
- user: 'tmrowco',
- repo: 'tmrowapp-contrib',
+ static openApi = {
+ '/github/hacktoberfest/{year}/{user}/{repo}': {
+ get: {
+ summary: 'GitHub Hacktoberfest combined status',
+ description,
+ parameters: [
+ pathParam({
+ name: 'year',
+ example: '2025',
+ schema: { type: 'string', enum: this.getEnum('year') },
+ }),
+ pathParam({ name: 'user', example: 'tmrowco' }),
+ pathParam({ name: 'repo', example: 'tmrowapp-contrib' }),
+ queryParam({ name: 'suggestion_label', example: 'help wanted' }),
+ ],
},
- queryParams: {
- suggestion_label: 'help wanted',
- },
- staticPreview: this.render({
- year: '2020',
- suggestedIssueCount: 12,
- contributionCount: 8,
- daysLeft: 15,
- }),
- documentation,
},
- ]
+ }
static defaultBadgeData = { label: 'hacktoberfest', color: 'orange' }
@@ -113,7 +90,7 @@ export default class GithubHacktoberfestCombinedStatus extends GithubAuthV4Servi
return {
message: `${daysToStart} ${maybePluralize(
'day',
- daysToStart
+ daysToStart,
)} till kickoff!`,
}
}
@@ -121,13 +98,13 @@ export default class GithubHacktoberfestCombinedStatus extends GithubAuthV4Servi
// The global cutoff time is 11/1 noon UTC.
// https://github.com/badges/shields/pull/4109#discussion_r330782093
// We want to show "1 day left" on the last day so we add 1.
- daysLeft = moment(`${year}-11-01 12:00:00 Z`).diff(moment(), 'days') + 1
+ daysLeft = parseDate(`${year}-11-01 12:00:00 Z`).diff(dayjs(), 'days') + 1
}
if (daysLeft < 0) {
return {
message: `is over! (${metric(contributionCount)} ${maybePluralize(
'PR',
- contributionCount
+ contributionCount,
)} opened)`,
}
}
@@ -136,13 +113,13 @@ export default class GithubHacktoberfestCombinedStatus extends GithubAuthV4Servi
suggestedIssueCount
? `${metric(suggestedIssueCount)} ${maybePluralize(
'open issue',
- suggestedIssueCount
+ suggestedIssueCount,
)}`
: '',
contributionCount
? `${metric(contributionCount)} ${maybePluralize(
'PR',
- contributionCount
+ contributionCount,
)}`
: '',
daysLeft > 0
@@ -160,7 +137,7 @@ export default class GithubHacktoberfestCombinedStatus extends GithubAuthV4Servi
`repo:${user}/${repo}`,
'is:pr',
`created:${year}-10-01..${year}-10-31`,
- `-label:invalid`,
+ '-label:invalid',
]
.filter(Boolean)
.join(' ')
@@ -205,18 +182,17 @@ export default class GithubHacktoberfestCombinedStatus extends GithubAuthV4Servi
}
static getCalendarPosition(year) {
- const daysToStart = moment(`${year}-10-01 00:00:00 Z`).diff(
- moment(),
- 'days'
+ const daysToStart = parseDate(`${year}-10-01 00:00:00 Z`).diff(
+ dayjs(),
+ 'days',
)
const isBefore = daysToStart > 0
return { daysToStart, isBefore }
}
async handle({ user, repo, year }, { suggestion_label: suggestionLabel }) {
- const { isBefore, daysToStart } = this.constructor.getCalendarPosition(
- +year
- )
+ const { isBefore, daysToStart } =
+ this.constructor.getCalendarPosition(+year)
if (isBefore) {
return this.constructor.render({ hasStarted: false, daysToStart, year })
}
diff --git a/services/github/github-hacktoberfest.tester.js b/services/github/github-hacktoberfest.tester.js
index 5fb0212a9f710..6192fee41ceeb 100644
--- a/services/github/github-hacktoberfest.tester.js
+++ b/services/github/github-hacktoberfest.tester.js
@@ -3,19 +3,19 @@ import { createServiceTester } from '../tester.js'
export const t = await createServiceTester()
const isHacktoberfestNoIssuesStatus = Joi.string().regex(
- /^[0-9]+ PRs?(, [0-9]+ days? left)?$/
+ /^[0-9]+ PRs?(, [0-9]+ days? left)?$/,
)
const isHacktoberfestNoPRsStatus = Joi.string().regex(
- /^([0-9]+ open issues?)?[0-9]+ days? left$/
+ /^([0-9]+ open issues?)?[0-9]+ days? left$/,
)
const isHacktoberfestCombinedStatus = Joi.string().regex(
- /^[0-9]+ open issues?(, [0-9]+ PRs?)?(, [0-9]+ days? left)?$/
+ /^[0-9]+ open issues?(, [0-9]+ PRs?)?(, [0-9]+ days? left)?$/,
)
const isHacktoberfestStatus = Joi.alternatives().try(
isHacktoberfestNoIssuesStatus,
isHacktoberfestNoPRsStatus,
isHacktoberfestCombinedStatus,
- /^is over! \([0-9]+ PRs? opened\)$/
+ /^is over! \([0-9]+ PRs? opened\)$/,
)
t.create('GitHub Hacktoberfest combined status')
@@ -28,8 +28,8 @@ t.create('GitHub Hacktoberfest combined status')
t.create('GitHub Hacktoberfest combined status (suggestion label override)')
.get(
`/2019/badges/shields.json?suggestion_label=${encodeURIComponent(
- 'good first issue'
- )}`
+ 'good first issue',
+ )}`,
)
.expectBadge({
label: 'hacktoberfest',
diff --git a/services/github/github-helpers.js b/services/github/github-helpers.js
index 9bbeda52c6961..674b699dbfc68 100644
--- a/services/github/github-helpers.js
+++ b/services/github/github-helpers.js
@@ -2,19 +2,16 @@ import { colorScale } from '../color-formatters.js'
import { InvalidResponse, NotFound } from '../index.js'
const documentation = `
-
- If your GitHub badge errors, it might be because you hit GitHub's rate limits.
- You can increase Shields.io's rate limit by
- adding the Shields GitHub
- application using your GitHub account.
-
+You can help increase Shields.io's rate limit by
+[authorizing the Shields.io GitHub application](https://img.shields.io/github-auth).
+Read more about [how it works](/blog/token-pool).
`
-function stateColor(s) {
- return { open: '2cbe4e', closed: 'cb2431', merged: '6f42c1' }[s]
+function issueStateColor(s) {
+ return { open: '2cbe4e', closed: '6f42c1' }[s]
}
-function errorMessagesFor(notFoundMessage = 'repo not found') {
+function httpErrorsFor(notFoundMessage = 'repo not found') {
return {
404: notFoundMessage,
422: notFoundMessage,
@@ -33,8 +30,8 @@ const commentsColor = colorScale([1, 3, 10, 25], undefined, true)
export {
documentation,
- stateColor,
+ issueStateColor,
commentsColor,
- errorMessagesFor,
+ httpErrorsFor,
transformErrors,
}
diff --git a/services/github/github-issue-detail-redirect.service.js b/services/github/github-issue-detail-redirect.service.js
index 8bf174f7b67d9..530087242ee2d 100644
--- a/services/github/github-issue-detail-redirect.service.js
+++ b/services/github/github-issue-detail-redirect.service.js
@@ -1,20 +1,15 @@
-import { redirector } from '../index.js'
-
-const variantMap = {
- s: 'state',
- u: 'author',
-}
+import { deprecatedService } from '../index.js'
export default [
- redirector({
+ deprecatedService({
category: 'issue-tracking',
+ label: 'github',
route: {
base: 'github',
pattern:
':issueKind(issues|pulls)/detail/:variant(s|u)/:user/:repo/:number([0-9]+)',
},
- transformPath: ({ issueKind, variant, user, repo, number }) =>
- `/github/${issueKind}/detail/${variantMap[variant]}/${user}/${repo}/${number}`,
- dateAdded: new Date('2019-04-04'),
+ dateAdded: new Date('2025-12-20'),
+ issueUrl: 'https://github.com/badges/shields/pull/11583',
}),
]
diff --git a/services/github/github-issue-detail-redirect.tester.js b/services/github/github-issue-detail-redirect.tester.js
index 1be7ba22c6b96..2ad24d5319e7f 100644
--- a/services/github/github-issue-detail-redirect.tester.js
+++ b/services/github/github-issue-detail-redirect.tester.js
@@ -7,17 +7,29 @@ export const t = new ServiceTester({
})
t.create('github issue detail (s shorthand)')
- .get('/issues/detail/s/badges/shields/979.svg')
- .expectRedirect('/github/issues/detail/state/badges/shields/979.svg')
+ .get('/issues/detail/s/badges/shields/979.json')
+ .expectBadge({
+ label: 'github',
+ message: 'https://github.com/badges/shields/pull/11583',
+ })
t.create('github issue detail (u shorthand)')
- .get('/issues/detail/u/badges/shields/979.svg')
- .expectRedirect('/github/issues/detail/author/badges/shields/979.svg')
+ .get('/issues/detail/u/badges/shields/979.json')
+ .expectBadge({
+ label: 'github',
+ message: 'https://github.com/badges/shields/pull/11583',
+ })
t.create('github pulls detail (s shorthand)')
- .get('/pulls/detail/s/badges/shields/979.svg')
- .expectRedirect('/github/pulls/detail/state/badges/shields/979.svg')
+ .get('/pulls/detail/s/badges/shields/979.json')
+ .expectBadge({
+ label: 'github',
+ message: 'https://github.com/badges/shields/pull/11583',
+ })
t.create('github pulls detail (u shorthand)')
- .get('/pulls/detail/u/badges/shields/979.svg')
- .expectRedirect('/github/pulls/detail/author/badges/shields/979.svg')
+ .get('/pulls/detail/u/badges/shields/979.json')
+ .expectBadge({
+ label: 'github',
+ message: 'https://github.com/badges/shields/pull/11583',
+ })
diff --git a/services/github/github-issue-detail.service.js b/services/github/github-issue-detail.service.js
index 48359527c6793..a03421af805da 100644
--- a/services/github/github-issue-detail.service.js
+++ b/services/github/github-issue-detail.service.js
@@ -1,13 +1,13 @@
import Joi from 'joi'
import { nonNegativeInteger } from '../validators.js'
-import { formatDate, metric } from '../text-formatters.js'
-import { age } from '../color-formatters.js'
-import { InvalidResponse } from '../index.js'
+import { metric } from '../text-formatters.js'
+import { renderDateBadge } from '../date.js'
+import { InvalidResponse, pathParams } from '../index.js'
import { GithubAuthV3Service } from './github-auth-service.js'
import {
documentation,
- errorMessagesFor,
- stateColor,
+ httpErrorsFor,
+ issueStateColor,
commentsColor,
} from './github-helpers.js'
@@ -19,21 +19,23 @@ const commonSchemaFields = {
const stateMap = {
schema: Joi.object({
...commonSchemaFields,
- state: Joi.string().allow('open', 'closed').required(),
+ state: Joi.equal('open', 'closed').required(),
merged_at: Joi.string().allow(null),
}).required(),
- transform: ({ json }) => ({
- state: json.state,
- // Because eslint will not be happy with this snake_case name :(
- merged: json.merged_at !== null,
- }),
+ transform: ({ json }) => {
+ const mergedAt = json.pull_request?.merged_at ?? json.merged_at
+ return {
+ state: json.state,
+ merged: mergedAt != null,
+ }
+ },
render: ({ value, isPR, number }) => {
const state = value.state
const label = `${isPR ? 'pull request' : 'issue'} ${number}`
if (!isPR || state === 'open') {
return {
- color: stateColor(state),
+ color: issueStateColor(state),
label,
message: state,
}
@@ -86,7 +88,7 @@ const labelMap = {
Joi.object({
name: Joi.string().required(),
color: Joi.string().required(),
- })
+ }),
)
.required(),
}).required(),
@@ -133,10 +135,32 @@ const ageUpdateMap = {
}).required(),
transform: ({ json, property }) =>
property === 'age' ? json.created_at : json.updated_at,
- render: ({ property, value }) => ({
- color: age(value),
- label: property === 'age' ? 'created' : 'updated',
- message: formatDate(value),
+ render: ({ property, value }) => {
+ const label = property === 'age' ? 'created' : 'updated'
+ return {
+ ...renderDateBadge(value),
+ label,
+ }
+ },
+}
+
+const milestoneMap = {
+ schema: Joi.object({
+ ...commonSchemaFields,
+ milestone: Joi.object({
+ title: Joi.string().required(),
+ }).allow(null),
+ }).required(),
+ transform: ({ json }) => {
+ if (!json.milestone) {
+ throw new InvalidResponse({ prettyMessage: 'no milestone' })
+ }
+ return json.milestone.title
+ },
+ render: ({ value }) => ({
+ label: 'milestone',
+ message: value,
+ color: 'informational',
}),
}
@@ -148,6 +172,7 @@ const propertyMap = {
comments: commentsMap,
age: ageUpdateMap,
'last-update': ageUpdateMap,
+ milestone: milestoneMap,
}
export default class GithubIssueDetail extends GithubAuthV3Service {
@@ -155,37 +180,41 @@ export default class GithubIssueDetail extends GithubAuthV3Service {
static route = {
base: 'github',
pattern:
- ':issueKind(issues|pulls)/detail/:property(state|title|author|label|comments|age|last-update)/:user/:repo/:number([0-9]+)',
+ ':issueKind(issues|pulls)/detail/:property(state|title|author|label|comments|age|last-update|milestone)/:user/:repo/:number([0-9]+)',
}
- static examples = [
- {
- title: 'GitHub issue/pull request detail',
- namedParams: {
- issueKind: 'issues',
- property: 'state',
- user: 'badges',
- repo: 'shields',
- number: '979',
+ static openApi = {
+ '/github/{issueKind}/detail/{property}/{user}/{repo}/{number}': {
+ get: {
+ summary: 'GitHub issue/pull request detail',
+ description: documentation,
+ parameters: pathParams(
+ {
+ name: 'issueKind',
+ example: 'issues',
+ schema: { type: 'string', enum: this.getEnum('issueKind') },
+ },
+ {
+ name: 'property',
+ example: 'state',
+ schema: { type: 'string', enum: this.getEnum('property') },
+ },
+ {
+ name: 'user',
+ example: 'badges',
+ },
+ {
+ name: 'repo',
+ example: 'shields',
+ },
+ {
+ name: 'number',
+ example: '979',
+ },
+ ),
},
- staticPreview: this.render({
- property: 'state',
- value: { state: 'closed' },
- isPR: false,
- number: '979',
- }),
- keywords: [
- 'state',
- 'title',
- 'author',
- 'label',
- 'comments',
- 'age',
- 'last update',
- ],
- documentation,
},
- ]
+ }
static defaultBadgeData = {
label: 'issue/pull request',
@@ -200,7 +229,7 @@ export default class GithubIssueDetail extends GithubAuthV3Service {
return this._requestJson({
url: `/repos/${user}/${repo}/${issueKind}/${number}`,
schema: propertyMap[property].schema,
- errorMessages: errorMessagesFor('issue, pull request or repo not found'),
+ httpErrors: httpErrorsFor('issue, pull request or repo not found'),
})
}
diff --git a/services/github/github-issue-detail.spec.js b/services/github/github-issue-detail.spec.js
index e639bbd4cca42..6d112f7e49caa 100644
--- a/services/github/github-issue-detail.spec.js
+++ b/services/github/github-issue-detail.spec.js
@@ -1,10 +1,10 @@
import { expect } from 'chai'
import { test, given } from 'sazerac'
-import { age } from '../color-formatters.js'
-import { formatDate, metric } from '../text-formatters.js'
+import { age, formatDate } from '../date.js'
+import { metric } from '../text-formatters.js'
import { InvalidResponse } from '../index.js'
import GithubIssueDetail from './github-issue-detail.service.js'
-import { stateColor, commentsColor } from './github-helpers.js'
+import { issueStateColor, commentsColor } from './github-helpers.js'
describe('GithubIssueDetail', function () {
test(GithubIssueDetail.render, () => {
@@ -16,7 +16,7 @@ describe('GithubIssueDetail', function () {
}).expect({
label: 'pull request 12',
message: 'open',
- color: stateColor('open'),
+ color: issueStateColor('open'),
})
given({
property: 'state',
@@ -26,7 +26,7 @@ describe('GithubIssueDetail', function () {
}).expect({
label: 'issue 15',
message: 'closed',
- color: stateColor('closed'),
+ color: issueStateColor('closed'),
})
given({
property: 'title',
@@ -90,6 +90,14 @@ describe('GithubIssueDetail', function () {
message: formatDate('2019-04-02T20:09:31Z'),
color: age('2019-04-02T20:09:31Z'),
})
+ given({
+ property: 'milestone',
+ value: 'MS 1',
+ }).expect({
+ label: 'milestone',
+ message: 'MS 1',
+ color: 'informational',
+ })
})
test(GithubIssueDetail.prototype.transform, () => {
@@ -98,9 +106,26 @@ describe('GithubIssueDetail', function () {
json: { state: 'closed' },
}).expect({
// Since it's a PR, the "merged" value is not crucial here.
- value: { state: 'closed', merged: true },
+ value: { state: 'closed', merged: false },
isPR: false,
})
+ given({
+ property: 'state',
+ json: { state: 'closed', pull_request: { merged_at: null } },
+ }).expect({
+ value: { state: 'closed', merged: false },
+ isPR: true,
+ })
+ given({
+ property: 'state',
+ json: {
+ state: 'closed',
+ pull_request: { merged_at: '2025-01-01T00:00:00Z' },
+ },
+ }).expect({
+ value: { state: 'closed', merged: true },
+ isPR: true,
+ })
given({
property: 'state',
issueKind: 'pulls',
@@ -178,6 +203,13 @@ describe('GithubIssueDetail', function () {
value: '2019-04-02T20:09:31Z',
isPR: false,
})
+ given({
+ property: 'milestone',
+ json: { milestone: { title: 'MS 1' } },
+ }).expect({
+ value: 'MS 1',
+ isPR: false,
+ })
})
context('transform()', function () {
@@ -194,4 +226,19 @@ describe('GithubIssueDetail', function () {
}
})
})
+
+ context('transform()', function () {
+ it('throws InvalidResponse error when issue has no milestone', function () {
+ try {
+ GithubIssueDetail.prototype.transform({
+ property: 'milestone',
+ json: { milestone: null },
+ })
+ expect.fail('Expected to throw')
+ } catch (e) {
+ expect(e).to.be.an.instanceof(InvalidResponse)
+ expect(e.prettyMessage).to.equal('no milestone')
+ }
+ })
+ })
})
diff --git a/services/github/github-issue-detail.tester.js b/services/github/github-issue-detail.tester.js
index bb0103e48ca48..e46d25e8cf537 100644
--- a/services/github/github-issue-detail.tester.js
+++ b/services/github/github-issue-detail.tester.js
@@ -34,7 +34,7 @@ t.create('github issue label')
label: 'label',
message: Joi.equal(
'bug | developer-experience',
- 'developer-experience | bug'
+ 'developer-experience | bug',
),
})
@@ -64,3 +64,16 @@ t.create('github pull request merge state (pull request not found)')
label: 'issue/pull request',
message: 'issue, pull request or repo not found',
})
+
+t.create('github issue milestone')
+ .get('/issues/detail/milestone/badges/shields/745.json')
+ .expectBadge({
+ label: 'milestone',
+ message: 'Next Deploy',
+ })
+
+t.create('github issue milestone (without milestone)')
+ .get('/issues/detail/milestone/badges/shields/979.json')
+ .expectBadge({
+ message: 'no milestone',
+ })
diff --git a/services/github/github-issues-search.service.js b/services/github/github-issues-search.service.js
index 68e1b2a5eb766..298cb152bf639 100644
--- a/services/github/github-issues-search.service.js
+++ b/services/github/github-issues-search.service.js
@@ -1,10 +1,17 @@
import gql from 'graphql-tag'
import Joi from 'joi'
+import { pathParam, queryParam } from '../index.js'
import { metric } from '../text-formatters.js'
import { nonNegativeInteger } from '../validators.js'
import { GithubAuthV4Service } from './github-auth-service.js'
import { documentation, transformErrors } from './github-helpers.js'
+const issuesSearchDocs = `
+For a full list of available filters and allowed values,
+see GitHub's documentation on
+[Searching issues and pull requests](https://docs.github.com/en/search-github/searching-on-github/searching-issues-and-pull-requests)
+`
+
const issueCountSchema = Joi.object({
data: Joi.object({
search: Joi.object({
@@ -49,21 +56,23 @@ class GithubIssuesSearch extends BaseGithubIssuesSearch {
queryParamSchema,
}
- static examples = [
- {
- title: 'GitHub issue custom search',
- namedParams: {},
- queryParams: {
- query: 'repo:badges/shields is:closed label:bug author:app/sentry-io',
- },
- staticPreview: {
- label: 'query',
- message: '10',
- color: 'blue',
+ static openApi = {
+ '/github/issues-search': {
+ get: {
+ summary: 'GitHub issue custom search',
+ description: documentation,
+ parameters: [
+ queryParam({
+ name: 'query',
+ description: issuesSearchDocs,
+ example:
+ 'repo:badges/shields is:closed label:bug author:app/sentry-io',
+ required: true,
+ }),
+ ],
},
- documentation,
},
- ]
+ }
async handle(namedParams, { query }) {
const issueCount = await this.fetch({ query })
@@ -78,24 +87,24 @@ class GithubRepoIssuesSearch extends BaseGithubIssuesSearch {
queryParamSchema,
}
- static examples = [
- {
- title: 'GitHub issue custom search in repo',
- namedParams: {
- user: 'badges',
- repo: 'shields',
+ static openApi = {
+ '/github/issues-search/{user}/{repo}': {
+ get: {
+ summary: 'GitHub issue custom search in repo',
+ description: documentation,
+ parameters: [
+ pathParam({ name: 'user', example: 'badges' }),
+ pathParam({ name: 'repo', example: 'shields' }),
+ queryParam({
+ name: 'query',
+ description: issuesSearchDocs,
+ example: 'is:closed label:bug author:app/sentry-io',
+ required: true,
+ }),
+ ],
},
- queryParams: {
- query: 'is:closed label:bug author:app/sentry-io',
- },
- staticPreview: {
- label: 'query',
- message: '10',
- color: 'blue',
- },
- documentation,
},
- ]
+ }
async handle({ user, repo }, { query }) {
query = `repo:${user}/${repo} ${query}`
diff --git a/services/github/github-issues-search.tester.js b/services/github/github-issues-search.tester.js
index 57bd0c11a778b..d83acfdf7a55a 100644
--- a/services/github/github-issues-search.tester.js
+++ b/services/github/github-issues-search.tester.js
@@ -8,7 +8,7 @@ export const t = new ServiceTester({
t.create('GitHub issue search (valid query string)')
.get(
- '/issues-search.json?query=repo%3Abadges%2Fshields%20is%3Aclosed%20label%3Ablocker%20'
+ '/issues-search.json?query=repo%3Abadges%2Fshields%20is%3Aclosed%20label%3Ablocker%20',
)
.expectBadge({
label: 'query',
@@ -24,7 +24,7 @@ t.create('GitHub issue search (invalid query string)')
t.create('GitHub Repo issue search (valid query string)')
.get(
- '/issues-search/badges/shields.json?query=is%3Aclosed%20label%3Ablocker%20'
+ '/issues-search/badges/shields.json?query=is%3Aclosed%20label%3Ablocker%20',
)
.expectBadge({
label: 'query',
@@ -40,7 +40,7 @@ t.create('GitHub Repo issue search (invalid query string)')
t.create('GitHub Repo issue search (invalid repo)')
.get(
- '/issues-search/badges/helmets.json?query=is%3Aclosed%20label%3Ablocker%20'
+ '/issues-search/badges/helmets.json?query=is%3Aclosed%20label%3Ablocker%20',
)
.expectBadge({
label: 'query',
diff --git a/services/github/github-issues.service.js b/services/github/github-issues.service.js
index d9bfe3d2adecc..96d550350bbb2 100644
--- a/services/github/github-issues.service.js
+++ b/services/github/github-issues.service.js
@@ -1,5 +1,6 @@
import gql from 'graphql-tag'
import Joi from 'joi'
+import { pathParams } from '../index.js'
import { metric } from '../text-formatters.js'
import { nonNegativeInteger } from '../validators.js'
import { GithubAuthV4Service } from './github-auth-service.js'
@@ -27,12 +28,16 @@ const pullRequestCountSchema = Joi.object({
const isPRVariant = {
'issues-pr': true,
+ 'issues-pr-raw': true,
'issues-pr-closed': true,
+ 'issues-pr-closed-raw': true,
}
const isClosedVariant = {
'issues-closed': true,
+ 'issues-closed-raw': true,
'issues-pr-closed': true,
+ 'issues-pr-closed-raw': true,
}
export default class GithubIssues extends GithubAuthV4Service {
@@ -40,251 +45,42 @@ export default class GithubIssues extends GithubAuthV4Service {
static route = {
base: 'github',
pattern:
- ':variant(issues|issues-closed|issues-pr|issues-pr-closed):raw(-raw)?/:user/:repo/:label*',
+ ':variant(issues|issues-raw|issues-closed|issues-closed-raw|issues-pr|issues-pr-raw|issues-pr-closed|issues-pr-closed-raw)/:user/:repo/:label*',
}
- static examples = [
- {
- title: 'GitHub issues',
- pattern: 'issues/:user/:repo',
- namedParams: {
- user: 'badges',
- repo: 'shields',
- },
- staticPreview: {
- label: 'issues',
- message: '167 open',
- color: 'yellow',
- },
- documentation,
- },
- {
- title: 'GitHub issues',
- pattern: 'issues-raw/:user/:repo',
- namedParams: {
- user: 'badges',
- repo: 'shields',
- },
- staticPreview: {
- label: 'open issues',
- message: '167',
- color: 'yellow',
- },
- documentation,
- },
- {
- title: 'GitHub issues by-label',
- pattern: 'issues/:user/:repo/:label',
- namedParams: {
- user: 'badges',
- repo: 'shields',
- label: 'service-badge',
- },
- staticPreview: {
- label: 'service-badge issues',
- message: '110 open',
- color: 'yellow',
- },
- documentation,
- },
- {
- title: 'GitHub issues by-label',
- pattern: 'issues-raw/:user/:repo/:label',
- namedParams: {
- user: 'badges',
- repo: 'shields',
- label: 'service-badge',
- },
- staticPreview: {
- label: 'open service-badge issues',
- message: '110',
- color: 'yellow',
- },
- documentation,
- },
- {
- title: 'GitHub closed issues',
- pattern: 'issues-closed/:user/:repo',
- namedParams: {
- user: 'badges',
- repo: 'shields',
- },
- staticPreview: {
- label: 'issues',
- message: '899 closed',
- color: 'yellow',
- },
- documentation,
- },
- {
- title: 'GitHub closed issues',
- pattern: 'issues-closed-raw/:user/:repo',
- namedParams: {
- user: 'badges',
- repo: 'shields',
- },
- staticPreview: {
- label: 'closed issues',
- message: '899',
- color: 'yellow',
- },
- documentation,
- },
- {
- title: 'GitHub closed issues by-label',
- pattern: 'issues-closed/:user/:repo/:label',
- namedParams: {
- user: 'badges',
- repo: 'shields',
- label: 'service-badge',
- },
- staticPreview: {
- label: 'service-badge issues',
- message: '452 closed',
- color: 'yellow',
- },
- documentation,
- },
- {
- title: 'GitHub closed issues by-label',
- pattern: 'issues-closed-raw/:user/:repo/:label',
- namedParams: {
- user: 'badges',
- repo: 'shields',
- label: 'service-badge',
- },
- staticPreview: {
- label: 'closed service-badge issues',
- message: '452',
- color: 'yellow',
- },
- documentation,
- },
- {
- title: 'GitHub pull requests',
- pattern: 'issues-pr/:user/:repo',
- namedParams: {
- user: 'cdnjs',
- repo: 'cdnjs',
- },
- staticPreview: {
- label: 'pull requests',
- message: '136 open',
- color: 'yellow',
- },
- keywords: ['pullrequest', 'pr'],
- documentation,
- },
- {
- title: 'GitHub pull requests',
- pattern: 'issues-pr-raw/:user/:repo',
- namedParams: {
- user: 'cdnjs',
- repo: 'cdnjs',
- },
- staticPreview: {
- label: 'open pull requests',
- message: '136',
- color: 'yellow',
- },
- keywords: ['pullrequest', 'pr'],
- documentation,
- },
- {
- title: 'GitHub closed pull requests',
- pattern: 'issues-pr-closed/:user/:repo',
- namedParams: {
- user: 'cdnjs',
- repo: 'cdnjs',
- },
- staticPreview: {
- label: 'pull requests',
- message: '7k closed',
- color: 'yellow',
- },
- keywords: ['pullrequest', 'pr'],
- documentation,
- },
- {
- title: 'GitHub closed pull requests',
- pattern: 'issues-pr-closed-raw/:user/:repo',
- namedParams: {
- user: 'cdnjs',
- repo: 'cdnjs',
- },
- staticPreview: {
- label: 'closed pull requests',
- message: '7k',
- color: 'yellow',
- },
- keywords: ['pullrequest', 'pr'],
- documentation,
- },
- {
- title: 'GitHub pull requests by-label',
- pattern: 'issues-pr/:user/:repo/:label',
- namedParams: {
- user: 'badges',
- repo: 'shields',
- label: 'service-badge',
- },
- staticPreview: {
- label: 'service-badge pull requests',
- message: '8 open',
- color: 'yellow',
- },
- keywords: ['pullrequest', 'pr'],
- documentation,
- },
- {
- title: 'GitHub pull requests by-label',
- pattern: 'issues-pr-raw/:user/:repo/:label',
- namedParams: {
- user: 'badges',
- repo: 'shields',
- label: 'service-badge',
- },
- staticPreview: {
- label: 'open service-badge pull requests',
- message: '8',
- color: 'yellow',
- },
- keywords: ['pullrequest', 'pr'],
- documentation,
- },
- {
- title: 'GitHub closed pull requests by-label',
- pattern: 'issues-pr-closed/:user/:repo/:label',
- namedParams: {
- user: 'badges',
- repo: 'shields',
- label: 'service-badge',
- },
- staticPreview: {
- label: 'service-badge pull requests',
- message: '835 closed',
- color: 'yellow',
- },
- keywords: ['pullrequest', 'pr'],
- documentation,
- },
- {
- title: 'GitHub closed pull requests by-label',
- pattern: 'issues-pr-closed-raw/:user/:repo/:label',
- namedParams: {
- user: 'badges',
- repo: 'shields',
- label: 'service-badge',
- },
- staticPreview: {
- label: 'closed service-badge pull requests',
- message: '835',
- color: 'yellow',
+ static openApi = {
+ '/github/{variant}/{user}/{repo}': {
+ get: {
+ summary: 'GitHub Issues or Pull Requests',
+ description: documentation,
+ parameters: pathParams(
+ {
+ name: 'variant',
+ example: 'issues',
+ schema: { type: 'string', enum: this.getEnum('variant') },
+ },
+ { name: 'user', example: 'badges' },
+ { name: 'repo', example: 'shields' },
+ ),
+ },
+ },
+ '/github/{variant}/{user}/{repo}/{label}': {
+ get: {
+ summary: 'GitHub Issues or Pull Requests by label',
+ description: documentation,
+ parameters: pathParams(
+ {
+ name: 'variant',
+ example: 'issues',
+ schema: { type: 'string', enum: this.getEnum('variant') },
+ },
+ { name: 'user', example: 'badges' },
+ { name: 'repo', example: 'shields' },
+ { name: 'label', example: 'service-badge' },
+ ),
},
- keywords: ['pullrequest', 'pr'],
- documentation,
},
- ]
+ }
static defaultBadgeData = { label: 'issues', color: 'informational' }
@@ -307,7 +103,9 @@ export default class GithubIssues extends GithubAuthV4Service {
return {
label: `${labelPrefix}${labelText}${labelSuffix}`,
- message: `${metric(issueCount)} ${messageSuffix}`,
+ message: `${metric(issueCount)}${
+ messageSuffix ? ' ' : ''
+ }${messageSuffix}`,
color: issueCount > 0 ? 'yellow' : 'brightgreen',
}
}
@@ -381,7 +179,8 @@ export default class GithubIssues extends GithubAuthV4Service {
}
}
- async handle({ variant, raw, user, repo, label }) {
+ async handle({ variant, user, repo, label }) {
+ const raw = variant.endsWith('-raw')
const isPR = isPRVariant[variant]
const isClosed = isClosedVariant[variant]
const { issueCount } = await this.fetch({
diff --git a/services/github/github-issues.tester.js b/services/github/github-issues.tester.js
index f84a99f432ac5..fc39641dba15a 100644
--- a/services/github/github-issues.tester.js
+++ b/services/github/github-issues.tester.js
@@ -8,7 +8,7 @@ t.create('GitHub closed pull requests')
.expectBadge({
label: 'pull requests',
message: Joi.string().regex(
- /^([0-9]+[kMGTPEZY]?|[1-9]\.[1-9][kMGTPEZY]) closed$/
+ /^([0-9]+[kMGTPEZY]?|[1-9]\.[1-9][kMGTPEZY]) closed$/,
),
})
@@ -38,7 +38,7 @@ t.create('GitHub closed issues')
.expectBadge({
label: 'issues',
message: Joi.string().regex(
- /^([0-9]+[kMGTPEZY]?|[1-9]\.[1-9][kMGTPEZY]) closed$/
+ /^([0-9]+[kMGTPEZY]?|[1-9]\.[1-9][kMGTPEZY]) closed$/,
),
})
diff --git a/services/github/github-labels.service.js b/services/github/github-labels.service.js
index 70176517bf507..85913f1f10d77 100644
--- a/services/github/github-labels.service.js
+++ b/services/github/github-labels.service.js
@@ -1,6 +1,7 @@
import Joi from 'joi'
+import { pathParams } from '../index.js'
import { GithubAuthV3Service } from './github-auth-service.js'
-import { documentation, errorMessagesFor } from './github-helpers.js'
+import { documentation, httpErrorsFor } from './github-helpers.js'
const schema = Joi.object({
color: Joi.string().hex().required(),
@@ -9,18 +10,28 @@ const schema = Joi.object({
export default class GithubLabels extends GithubAuthV3Service {
static category = 'issue-tracking'
static route = { base: 'github/labels', pattern: ':user/:repo/:name' }
- static examples = [
- {
- title: 'GitHub labels',
- namedParams: {
- user: 'atom',
- repo: 'atom',
- name: 'help-wanted',
+ static openApi = {
+ '/github/labels/{user}/{repo}/{name}': {
+ get: {
+ summary: 'GitHub labels',
+ description: documentation,
+ parameters: pathParams(
+ {
+ name: 'user',
+ example: 'atom',
+ },
+ {
+ name: 'repo',
+ example: 'atom',
+ },
+ {
+ name: 'name',
+ example: 'help-wanted',
+ },
+ ),
},
- staticPreview: this.render({ name: 'help-wanted', color: '#159818' }),
- documentation,
},
- ]
+ }
static defaultBadgeData = { label: ' ' }
@@ -35,7 +46,7 @@ export default class GithubLabels extends GithubAuthV3Service {
return this._requestJson({
url: `/repos/${user}/${repo}/labels/${name}`,
schema,
- errorMessages: errorMessagesFor(`repo or label not found`),
+ httpErrors: httpErrorsFor('repo or label not found'),
})
}
diff --git a/services/github/github-language-count.service.js b/services/github/github-language-count.service.js
index b730372221517..29f2e57990c55 100644
--- a/services/github/github-language-count.service.js
+++ b/services/github/github-language-count.service.js
@@ -1,26 +1,35 @@
+import { pathParams } from '../index.js'
+import { metric } from '../text-formatters.js'
import { BaseGithubLanguage } from './github-languages-base.js'
import { documentation } from './github-helpers.js'
export default class GithubLanguageCount extends BaseGithubLanguage {
static category = 'analysis'
static route = { base: 'github/languages/count', pattern: ':user/:repo' }
- static examples = [
- {
- title: 'GitHub language count',
- namedParams: {
- user: 'badges',
- repo: 'shields',
+ static openApi = {
+ '/github/languages/count/{user}/{repo}': {
+ get: {
+ summary: 'GitHub language count',
+ description: documentation,
+ parameters: pathParams(
+ {
+ name: 'user',
+ example: 'badges',
+ },
+ {
+ name: 'repo',
+ example: 'shields',
+ },
+ ),
},
- staticPreview: this.render({ count: 5 }),
- documentation,
},
- ]
+ }
static defaultBadgeData = { label: 'languages' }
static render({ count }) {
return {
- message: count,
+ message: metric(count),
color: 'blue',
}
}
diff --git a/services/github/github-languages-base.js b/services/github/github-languages-base.js
index 92427519feb91..667f12a47e7bb 100644
--- a/services/github/github-languages-base.js
+++ b/services/github/github-languages-base.js
@@ -1,7 +1,7 @@
import Joi from 'joi'
import { nonNegativeInteger } from '../validators.js'
import { GithubAuthV3Service } from './github-auth-service.js'
-import { errorMessagesFor } from './github-helpers.js'
+import { httpErrorsFor } from './github-helpers.js'
/*
We're expecting a response like { "Python": 39624, "Shell": 104 }
@@ -14,7 +14,7 @@ class BaseGithubLanguage extends GithubAuthV3Service {
return this._requestJson({
url: `/repos/${user}/${repo}/languages`,
schema,
- errorMessages: errorMessagesFor(),
+ httpErrors: httpErrorsFor(),
})
}
diff --git a/services/github/github-last-commit.service.js b/services/github/github-last-commit.service.js
index b452fbe96b6d4..bc90ac914b741 100644
--- a/services/github/github-last-commit.service.js
+++ b/services/github/github-last-commit.service.js
@@ -1,12 +1,9 @@
import Joi from 'joi'
-import { formatDate } from '../text-formatters.js'
-import { age as ageColor } from '../color-formatters.js'
+import { renderDateBadge } from '../date.js'
+import { NotFound, pathParam, queryParam } from '../index.js'
+import { relativeUri } from '../validators.js'
import { GithubAuthV3Service } from './github-auth-service.js'
-import { documentation, errorMessagesFor } from './github-helpers.js'
-const commonExampleAttrs = {
- keywords: ['latest'],
- documentation,
-}
+import { documentation, httpErrorsFor } from './github-helpers.js'
const schema = Joi.array()
.items(
@@ -15,58 +12,97 @@ const schema = Joi.array()
author: Joi.object({
date: Joi.string().required(),
}).required(),
+ committer: Joi.object({
+ date: Joi.string().required(),
+ }).required(),
}).required(),
- }).required()
+ }),
)
.required()
+const displayEnum = ['author', 'committer']
+
+const queryParamSchema = Joi.object({
+ path: relativeUri,
+ display_timestamp: Joi.string()
+ .valid(...displayEnum)
+ .default('author'),
+}).required()
+
export default class GithubLastCommit extends GithubAuthV3Service {
static category = 'activity'
- static route = { base: 'github/last-commit', pattern: ':user/:repo/:branch*' }
- static examples = [
- {
- title: 'GitHub last commit',
- pattern: ':user/:repo',
- namedParams: {
- user: 'google',
- repo: 'skia',
+ static route = {
+ base: 'github/last-commit',
+ pattern: ':user/:repo/:branch*',
+ queryParamSchema,
+ }
+
+ static openApi = {
+ '/github/last-commit/{user}/{repo}': {
+ get: {
+ summary: 'GitHub last commit',
+ description: documentation,
+ parameters: [
+ pathParam({ name: 'user', example: 'google' }),
+ pathParam({ name: 'repo', example: 'skia' }),
+ queryParam({
+ name: 'path',
+ example: 'README.md',
+ schema: { type: 'string' },
+ description: 'File path to resolve the last commit for.',
+ }),
+ queryParam({
+ name: 'display_timestamp',
+ example: 'committer',
+ schema: { type: 'string', enum: displayEnum },
+ description: 'Defaults to `author` if not specified',
+ }),
+ ],
},
- staticPreview: this.render({ commitDate: '2013-07-31T20:01:41Z' }),
- ...commonExampleAttrs,
},
- {
- title: 'GitHub last commit (branch)',
- pattern: ':user/:repo/:branch',
- namedParams: {
- user: 'google',
- repo: 'skia',
- branch: 'infra/config',
+ '/github/last-commit/{user}/{repo}/{branch}': {
+ get: {
+ summary: 'GitHub last commit (branch)',
+ description: documentation,
+ parameters: [
+ pathParam({ name: 'user', example: 'google' }),
+ pathParam({ name: 'repo', example: 'skia' }),
+ pathParam({ name: 'branch', example: 'infra/config' }),
+ queryParam({
+ name: 'path',
+ example: 'README.md',
+ schema: { type: 'string' },
+ description: 'File path to resolve the last commit for.',
+ }),
+ queryParam({
+ name: 'display_timestamp',
+ example: 'committer',
+ schema: { type: 'string', enum: displayEnum },
+ description: 'Defaults to `author` if not specified',
+ }),
+ ],
},
- staticPreview: this.render({ commitDate: '2013-07-31T20:01:41Z' }),
- ...commonExampleAttrs,
},
- ]
+ }
static defaultBadgeData = { label: 'last commit' }
- static render({ commitDate }) {
- return {
- message: formatDate(commitDate),
- color: ageColor(Date.parse(commitDate)),
- }
- }
-
- async fetch({ user, repo, branch }) {
+ async fetch({ user, repo, branch, path }) {
return this._requestJson({
url: `/repos/${user}/${repo}/commits`,
- options: { qs: { sha: branch } },
+ options: { searchParams: { sha: branch, path, per_page: 1 } },
schema,
- errorMessages: errorMessagesFor(),
+ httpErrors: httpErrorsFor(),
})
}
- async handle({ user, repo, branch }) {
- const body = await this.fetch({ user, repo, branch })
- return this.constructor.render({ commitDate: body[0].commit.author.date })
+ async handle({ user, repo, branch }, queryParams) {
+ const { path, display_timestamp: displayTimestamp } = queryParams
+ const body = await this.fetch({ user, repo, branch, path })
+ const [commit] = body.map(obj => obj.commit)
+
+ if (!commit) throw new NotFound({ prettyMessage: 'no commits found' })
+
+ return renderDateBadge(commit[displayTimestamp].date)
}
}
diff --git a/services/github/github-last-commit.tester.js b/services/github/github-last-commit.tester.js
index 23eed9fb37bb6..fe82cc7425178 100644
--- a/services/github/github-last-commit.tester.js
+++ b/services/github/github-last-commit.tester.js
@@ -14,6 +14,34 @@ t.create('last commit (on branch)')
.get('/badges/badgr.co/shielded.json')
.expectBadge({ label: 'last commit', message: 'july 2013' })
+t.create('last commit (by top-level file path)')
+ .get('/badges/badgr.co.json?path=README.md')
+ .expectBadge({ label: 'last commit', message: 'september 2013' })
+
+t.create('last commit (by top-level dir path)')
+ .get('/badges/badgr.co.json?path=badgr')
+ .expectBadge({ label: 'last commit', message: 'june 2013' })
+
+t.create('last commit (by top-level dir path with trailing slash)')
+ .get('/badges/badgr.co.json?path=badgr/')
+ .expectBadge({ label: 'last commit', message: 'june 2013' })
+
+t.create('last commit (by nested file path)')
+ .get('/badges/badgr.co.json?path=badgr/colors.py')
+ .expectBadge({ label: 'last commit', message: 'june 2013' })
+
+t.create('last commit (on branch) (by top-level file path)')
+ .get('/badges/badgr.co/shielded.json?path=README.md')
+ .expectBadge({ label: 'last commit', message: 'june 2013' })
+
+t.create('last commit (by committer)')
+ .get('/badges/badgr.co/shielded.json?display_timestamp=committer')
+ .expectBadge({ label: 'last commit', message: 'july 2013' })
+
t.create('last commit (repo not found)')
.get('/badges/helmets.json')
.expectBadge({ label: 'last commit', message: 'repo not found' })
+
+t.create('last commit (no commits found)')
+ .get('/badges/badgr.co/shielded.json?path=not/a/dir')
+ .expectBadge({ label: 'last commit', message: 'no commits found' })
diff --git a/services/github/github-lerna-json.service.js b/services/github/github-lerna-json.service.js
index f306563a5912f..b523c1b34120b 100644
--- a/services/github/github-lerna-json.service.js
+++ b/services/github/github-lerna-json.service.js
@@ -1,4 +1,5 @@
import Joi from 'joi'
+import { pathParams } from '../index.js'
import { renderVersionBadge } from '../version.js'
import { semver } from '../validators.js'
import { ConditionalGithubAuthV3Service } from './github-auth-service.js'
@@ -16,25 +17,44 @@ export default class GithubLernaJson extends ConditionalGithubAuthV3Service {
pattern: ':user/:repo/:branch*',
}
- static examples = [
- {
- title: 'Github lerna version',
- pattern: ':user/:repo',
- namedParams: { user: 'babel', repo: 'babel' },
- staticPreview: this.render({ version: '7.6.4' }),
- documentation,
+ static openApi = {
+ '/github/lerna-json/v/{user}/{repo}': {
+ get: {
+ summary: 'GitHub lerna version',
+ description: documentation,
+ parameters: pathParams(
+ {
+ name: 'user',
+ example: 'babel',
+ },
+ {
+ name: 'repo',
+ example: 'babel',
+ },
+ ),
+ },
},
- {
- title: 'Github lerna version (branch)',
- pattern: ':user/:repo/:branch',
- namedParams: { user: 'jneander', repo: 'jneander', branch: 'colors' },
- staticPreview: this.render({
- version: 'independent',
- branch: 'colors',
- }),
- documentation,
+ '/github/lerna-json/v/{user}/{repo}/{branch}': {
+ get: {
+ summary: 'GitHub lerna version (branch)',
+ description: documentation,
+ parameters: pathParams(
+ {
+ name: 'user',
+ example: 'jneander',
+ },
+ {
+ name: 'repo',
+ example: 'jneander',
+ },
+ {
+ name: 'branch',
+ example: 'colors',
+ },
+ ),
+ },
},
- ]
+ }
static defaultBadgeData = { label: 'lerna' }
diff --git a/services/github/github-lerna-json.tester.js b/services/github/github-lerna-json.tester.js
index d4097a99c9673..95694ee27d72a 100644
--- a/services/github/github-lerna-json.tester.js
+++ b/services/github/github-lerna-json.tester.js
@@ -7,12 +7,10 @@ t.create('Lerna version').get('/facebook/jest.json').expectBadge({
message: isSemver,
})
-t.create('Lerna version (independent)')
- .get('/jneander/jneander.json')
- .expectBadge({
- label: 'lerna',
- message: 'independent',
- })
+t.create('Lerna version (independent)').get('/imba/imba.json').expectBadge({
+ label: 'lerna',
+ message: 'independent',
+})
t.create('Lerna version (branch)').get('/facebook/jest/main.json').expectBadge({
label: 'lerna@main',
diff --git a/services/github/github-license.service.js b/services/github/github-license.service.js
index 360153f29e61f..c1410e7196ebe 100644
--- a/services/github/github-license.service.js
+++ b/services/github/github-license.service.js
@@ -1,7 +1,8 @@
import Joi from 'joi'
+import { pathParams } from '../index.js'
import { renderLicenseBadge } from '../licenses.js'
import { GithubAuthV3Service } from './github-auth-service.js'
-import { documentation, errorMessagesFor } from './github-helpers.js'
+import { documentation, httpErrorsFor } from './github-helpers.js'
const schema = Joi.object({
// Some repos do not have a license, in which case GitHub returns `{ license: null }`.
@@ -11,18 +12,24 @@ const schema = Joi.object({
export default class GithubLicense extends GithubAuthV3Service {
static category = 'license'
static route = { base: 'github/license', pattern: ':user/:repo' }
- static examples = [
- {
- title: 'GitHub',
- namedParams: { user: 'mashape', repo: 'apistatus' },
- staticPreview: {
- label: 'license',
- message: 'MIT',
- color: 'green',
+ static openApi = {
+ '/github/license/{user}/{repo}': {
+ get: {
+ summary: 'GitHub License',
+ description: documentation,
+ parameters: pathParams(
+ {
+ name: 'user',
+ example: 'mashape',
+ },
+ {
+ name: 'repo',
+ example: 'apistatus',
+ },
+ ),
},
- documentation,
},
- ]
+ }
static defaultBadgeData = { label: 'license' }
@@ -40,7 +47,7 @@ export default class GithubLicense extends GithubAuthV3Service {
const { license: licenseObject } = await this._requestJson({
schema,
url: `/repos/${user}/${repo}`,
- errorMessages: errorMessagesFor('repo not found'),
+ httpErrors: httpErrorsFor('repo not found'),
})
const license = licenseObject ? licenseObject.spdx_id : undefined
diff --git a/services/github/github-license.tester.js b/services/github/github-license.tester.js
index 0cef52f4c75b8..cb36d7b3dbb5e 100644
--- a/services/github/github-license.tester.js
+++ b/services/github/github-license.tester.js
@@ -44,7 +44,7 @@ t.create('License with SPDX id not appearing in configuration')
url: 'https://api.github.com/licenses/efl-1.0',
featured: true,
},
- })
+ }),
)
.expectBadge({
label: 'license',
diff --git a/services/github/github-manifest.service.js b/services/github/github-manifest.service.js
index 5919507398597..4d88b49f2f3a6 100644
--- a/services/github/github-manifest.service.js
+++ b/services/github/github-manifest.service.js
@@ -1,4 +1,5 @@
import Joi from 'joi'
+import { pathParam, queryParam } from '../index.js'
import { renderVersionBadge } from '../version.js'
import {
individualValueSchema,
@@ -27,56 +28,31 @@ class GithubManifestVersion extends ConditionalGithubAuthV3Service {
queryParamSchema,
}
- static examples = [
- {
- title: 'GitHub manifest version',
- pattern: ':user/:repo',
- namedParams: {
- user: 'sindresorhus',
- repo: 'show-all-github-issues',
+ static openApi = {
+ '/github/manifest-json/v/{user}/{repo}': {
+ get: {
+ summary: 'GitHub manifest version',
+ description: documentation,
+ parameters: [
+ pathParam({ name: 'user', example: 'sindresorhus' }),
+ pathParam({ name: 'repo', example: 'show-all-github-issues' }),
+ queryParam({ name: 'filename', example: 'extension/manifest.json' }),
+ ],
},
- staticPreview: this.render({ version: '1.0.3' }),
- documentation,
},
- {
- title: 'GitHub manifest version',
- pattern: ':user/:repo/:branch',
- namedParams: {
- user: 'sindresorhus',
- repo: 'show-all-github-issues',
- branch: 'master',
+ '/github/manifest-json/v/{user}/{repo}/{branch}': {
+ get: {
+ summary: 'GitHub manifest version (branch)',
+ description: documentation,
+ parameters: [
+ pathParam({ name: 'user', example: 'sindresorhus' }),
+ pathParam({ name: 'repo', example: 'show-all-github-issues' }),
+ pathParam({ name: 'branch', example: 'master' }),
+ queryParam({ name: 'filename', example: 'extension/manifest.json' }),
+ ],
},
- staticPreview: this.render({ version: '1.0.3', branch: 'master' }),
- documentation,
},
- {
- title: 'GitHub manifest version (path)',
- pattern: ':user/:repo',
- namedParams: {
- user: 'RedSparr0w',
- repo: 'IndieGala-Helper',
- },
- queryParams: {
- filename: 'extension/manifest.json',
- },
- staticPreview: this.render({ version: 2 }),
- documentation,
- },
- {
- title: 'GitHub manifest version (path)',
- pattern: ':user/:repo/:branch',
- namedParams: {
- user: 'RedSparr0w',
- repo: 'IndieGala-Helper',
- branch: 'master',
- },
- queryParams: {
- filename: 'extension/manifest.json',
- },
- staticPreview: this.render({ version: 2, branch: 'master' }),
- documentation,
- },
- ]
+ }
static render({ version, branch }) {
return renderVersionBadge({
@@ -106,74 +82,33 @@ class DynamicGithubManifest extends ConditionalGithubAuthV3Service {
queryParamSchema,
}
- static examples = [
- {
- title: 'GitHub manifest.json dynamic',
- pattern: ':key/:user/:repo',
- namedParams: {
- key: 'permissions',
- user: 'sindresorhus',
- repo: 'show-all-github-issues',
+ static openApi = {
+ '/github/manifest-json/{key}/{user}/{repo}': {
+ get: {
+ summary: 'GitHub manifest.json dynamic',
+ description: documentation,
+ parameters: [
+ pathParam({ name: 'key', example: 'permissions' }),
+ pathParam({ name: 'user', example: 'sindresorhus' }),
+ pathParam({ name: 'repo', example: 'show-all-github-issues' }),
+ queryParam({ name: 'filename', example: 'extension/manifest.json' }),
+ ],
},
- staticPreview: this.render({
- key: 'permissions',
- value: ['webRequest', 'webRequestBlocking'],
- }),
- documentation,
},
- {
- title: 'GitHub manifest.json dynamic',
- pattern: ':key/:user/:repo/:branch',
- namedParams: {
- key: 'permissions',
- user: 'sindresorhus',
- repo: 'show-all-github-issues',
- branch: 'master',
+ '/github/manifest-json/{key}/{user}/{repo}/{branch}': {
+ get: {
+ summary: 'GitHub manifest.json dynamic (branch)',
+ description: documentation,
+ parameters: [
+ pathParam({ name: 'key', example: 'permissions' }),
+ pathParam({ name: 'user', example: 'sindresorhus' }),
+ pathParam({ name: 'repo', example: 'show-all-github-issues' }),
+ pathParam({ name: 'branch', example: 'main' }),
+ queryParam({ name: 'filename', example: 'extension/manifest.json' }),
+ ],
},
- staticPreview: this.render({
- key: 'permissions',
- value: ['webRequest', 'webRequestBlocking'],
- branch: 'master',
- }),
- documentation,
},
- {
- title: 'GitHub manifest.json dynamic (path)',
- pattern: ':key/:user/:repo',
- namedParams: {
- key: 'permissions',
- user: 'RedSparr0w',
- repo: 'IndieGala-Helper',
- },
- queryParams: {
- filename: 'extension/manifest.json',
- },
- staticPreview: this.render({
- key: 'permissions',
- value: ['bundle', 'rollup', 'micro library'],
- }),
- documentation,
- },
- {
- title: 'GitHub manifest.json dynamic (path)',
- pattern: ':key/:user/:repo/:branch',
- namedParams: {
- key: 'permissions',
- user: 'RedSparr0w',
- repo: 'IndieGala-Helper',
- branch: 'master',
- },
- queryParams: {
- filename: 'extension/manifest.json',
- },
- staticPreview: this.render({
- key: 'permissions',
- value: ['bundle', 'rollup', 'micro library'],
- branch: 'master',
- }),
- documentation,
- },
- ]
+ }
static defaultBadgeData = { label: 'manifest' }
diff --git a/services/github/github-manifest.tester.js b/services/github/github-manifest.tester.js
index a1e9849f9779e..827bbe7acf4d2 100644
--- a/services/github/github-manifest.tester.js
+++ b/services/github/github-manifest.tester.js
@@ -11,20 +11,20 @@ export const t = new ServiceTester({
t.create('Manifest version')
.get('/v/sindresorhus/show-all-github-issues.json')
.expectBadge({
- label: 'version',
+ label: 'manifest',
message: isVPlusDottedVersionAtLeastOne,
})
t.create('Manifest version (path)')
.get('/v/RedSparr0w/IndieGala-Helper.json?filename=extension/manifest.json')
.expectBadge({
- label: 'version',
+ label: 'manifest',
message: isVPlusDottedVersionAtLeastOne,
})
t.create('Manifest version (path not found)')
.get(
- '/v/RedSparr0w/IndieGala-Helper.json?filename=invalid-directory/manifest.json'
+ '/v/RedSparr0w/IndieGala-Helper.json?filename=invalid-directory/manifest.json',
)
.expectBadge({
label: 'version',
@@ -38,7 +38,7 @@ t.create('Manifest name (path)')
t.create('Manifest array (path)')
.get(
- '/permissions/RedSparr0w/IndieGala-Helper.json?filename=extension/manifest.json'
+ '/permissions/RedSparr0w/IndieGala-Helper.json?filename=extension/manifest.json',
)
.expectBadge({
label: 'permissions',
@@ -47,7 +47,7 @@ t.create('Manifest array (path)')
t.create('Manifest object (path)')
.get(
- '/background/RedSparr0w/IndieGala-Helper.json?filename=extension/manifest.json'
+ '/background/RedSparr0w/IndieGala-Helper.json?filename=extension/manifest.json',
)
.expectBadge({ label: 'manifest', message: 'invalid key value' })
diff --git a/services/github/github-milestone-detail.service.js b/services/github/github-milestone-detail.service.js
index ac3b071c5dd6c..c0c6b1f92494b 100644
--- a/services/github/github-milestone-detail.service.js
+++ b/services/github/github-milestone-detail.service.js
@@ -1,8 +1,9 @@
import Joi from 'joi'
+import { pathParams } from '../index.js'
import { metric } from '../text-formatters.js'
import { nonNegativeInteger } from '../validators.js'
import { GithubAuthV3Service } from './github-auth-service.js'
-import { documentation, errorMessagesFor } from './github-helpers.js'
+import { documentation, httpErrorsFor } from './github-helpers.js'
const schema = Joi.object({
open_issues: nonNegativeInteger,
@@ -18,27 +19,37 @@ export default class GithubMilestoneDetail extends GithubAuthV3Service {
':variant(issues-closed|issues-open|issues-total|progress|progress-percent)/:user/:repo/:number([0-9]+)',
}
- static examples = [
- {
- title: 'GitHub milestone',
- namedParams: {
- variant: 'issues-open',
- user: 'badges',
- repo: 'shields',
- number: '1',
+ static openApi = {
+ '/github/milestones/{variant}/{user}/{repo}/{number}': {
+ get: {
+ summary: 'GitHub milestone details',
+ description: documentation,
+ parameters: pathParams(
+ {
+ name: 'variant',
+ example: 'issues-open',
+ schema: { type: 'string', enum: this.getEnum('variant') },
+ },
+ {
+ name: 'user',
+ example: 'badges',
+ },
+ {
+ name: 'repo',
+ example: 'shields',
+ },
+ {
+ name: 'number',
+ example: '1',
+ },
+ ),
},
- staticPreview: {
- label: 'milestone issues',
- message: '17/22',
- color: 'blue',
- },
- documentation,
},
- ]
+ }
static defaultBadgeData = { label: 'milestones', color: 'informational' }
- static render({ user, repo, variant, number, milestone }) {
+ static render({ variant, milestone }) {
let milestoneMetric
let color
let label = ''
@@ -69,13 +80,13 @@ export default class GithubMilestoneDetail extends GithubAuthV3Service {
milestoneMetric = `${Math.floor(
(milestone.closed_issues /
(milestone.open_issues + milestone.closed_issues)) *
- 100
+ 100,
)}%`
color = 'blue'
}
return {
- label: `${milestone.title} ${label}`,
+ label: `${milestone.title}${label ? ' ' : ''}${label}`,
message: metric(milestoneMetric),
color,
}
@@ -85,12 +96,12 @@ export default class GithubMilestoneDetail extends GithubAuthV3Service {
return this._requestJson({
url: `/repos/${user}/${repo}/milestones/${number}`,
schema,
- errorMessages: errorMessagesFor(`repo or milestone not found`),
+ httpErrors: httpErrorsFor('repo or milestone not found'),
})
}
async handle({ user, repo, variant, number }) {
const milestone = await this.fetch({ user, repo, number })
- return this.constructor.render({ user, repo, variant, number, milestone })
+ return this.constructor.render({ variant, milestone })
}
}
diff --git a/services/github/github-milestone.service.js b/services/github/github-milestone.service.js
index 62b623aed7fd4..f7874ecbd1d9d 100644
--- a/services/github/github-milestone.service.js
+++ b/services/github/github-milestone.service.js
@@ -1,13 +1,14 @@
import Joi from 'joi'
+import { pathParams } from '../index.js'
import { metric } from '../text-formatters.js'
import { GithubAuthV3Service } from './github-auth-service.js'
-import { documentation, errorMessagesFor } from './github-helpers.js'
+import { documentation, httpErrorsFor } from './github-helpers.js'
const schema = Joi.array()
.items(
Joi.object({
state: Joi.string().required(),
- })
+ }),
)
.required()
@@ -18,32 +19,39 @@ export default class GithubMilestone extends GithubAuthV3Service {
pattern: ':variant(open|closed|all)/:user/:repo',
}
- static examples = [
- {
- title: 'GitHub milestones',
- namedParams: {
- user: 'badges',
- repo: 'shields',
- variant: 'open',
- },
- staticPreview: {
- label: 'milestones',
- message: '2',
- color: 'red',
+ static openApi = {
+ '/github/milestones/{variant}/{user}/{repo}': {
+ get: {
+ summary: 'GitHub number of milestones',
+ description: documentation,
+ parameters: pathParams(
+ {
+ name: 'variant',
+ example: 'open',
+ schema: { type: 'string', enum: this.getEnum('variant') },
+ },
+ {
+ name: 'user',
+ example: 'badges',
+ },
+ {
+ name: 'repo',
+ example: 'shields',
+ },
+ ),
},
- documentation,
},
- ]
+ }
static defaultBadgeData = {
label: 'milestones',
color: 'informational',
}
- static render({ user, repo, variant, milestones }) {
+ static render({ variant, milestones }) {
const milestoneLength = milestones.length
let color
- let label = ''
+ let qualifier = ''
switch (variant) {
case 'all':
@@ -51,16 +59,16 @@ export default class GithubMilestone extends GithubAuthV3Service {
break
case 'open':
color = 'red'
- label = 'active'
+ qualifier = 'active'
break
case 'closed':
color = 'green'
- label = 'completed'
+ qualifier = 'completed'
break
}
return {
- label: `${label} milestones`,
+ label: `${qualifier}${qualifier ? ' ' : ''}milestones`,
message: metric(milestoneLength),
color,
}
@@ -70,12 +78,12 @@ export default class GithubMilestone extends GithubAuthV3Service {
return this._requestJson({
url: `/repos/${user}/${repo}/milestones?state=${variant}`,
schema,
- errorMessages: errorMessagesFor(`repo not found`),
+ httpErrors: httpErrorsFor('repo not found'),
})
}
async handle({ user, repo, variant }) {
const milestones = await this.fetch({ user, repo, variant })
- return this.constructor.render({ user, repo, variant, milestones })
+ return this.constructor.render({ variant, milestones })
}
}
diff --git a/services/github/github-package-json.service.js b/services/github/github-package-json.service.js
index 4b8042f507dd6..95a8d8d6d9aa2 100644
--- a/services/github/github-package-json.service.js
+++ b/services/github/github-package-json.service.js
@@ -1,4 +1,5 @@
import Joi from 'joi'
+import { pathParam, pathParams, queryParam } from '../index.js'
import { renderVersionBadge } from '../version.js'
import { transformAndValidate, renderDynamicBadge } from '../dynamic-common.js'
import {
@@ -10,41 +11,47 @@ import { ConditionalGithubAuthV3Service } from './github-auth-service.js'
import { fetchJsonFromRepo } from './github-common-fetch.js'
import { documentation } from './github-helpers.js'
-const keywords = ['npm', 'node']
-
const versionSchema = Joi.object({
version: semver,
}).required()
+const subfolderQueryParamSchema = Joi.object({
+ filename: Joi.string(),
+}).required()
+
class GithubPackageJsonVersion extends ConditionalGithubAuthV3Service {
static category = 'version'
static route = {
base: 'github/package-json/v',
pattern: ':user/:repo/:branch*',
+ queryParamSchema: subfolderQueryParamSchema,
}
- static examples = [
- {
- title: 'GitHub package.json version',
- pattern: ':user/:repo',
- namedParams: { user: 'IcedFrisby', repo: 'IcedFrisby' },
- staticPreview: this.render({ version: '2.0.0-alpha.2' }),
- documentation,
- keywords,
+ static openApi = {
+ '/github/package-json/v/{user}/{repo}': {
+ get: {
+ summary: 'GitHub package.json version',
+ description: documentation,
+ parameters: [
+ pathParam({ name: 'user', example: 'badges' }),
+ pathParam({ name: 'repo', example: 'shields' }),
+ queryParam({ name: 'filename', example: 'badge-maker/package.json' }),
+ ],
+ },
},
- {
- title: 'GitHub package.json version (branch)',
- pattern: ':user/:repo/:branch',
- namedParams: {
- user: 'IcedFrisby',
- repo: 'IcedFrisby',
- branch: 'master',
+ '/github/package-json/v/{user}/{repo}/{branch}': {
+ get: {
+ summary: 'GitHub package.json version (branch)',
+ description: documentation,
+ parameters: [
+ pathParam({ name: 'user', example: 'badges' }),
+ pathParam({ name: 'repo', example: 'shields' }),
+ pathParam({ name: 'branch', example: 'master' }),
+ queryParam({ name: 'filename', example: 'badge-maker/package.json' }),
+ ],
},
- staticPreview: this.render({ version: '2.0.0-alpha.2' }),
- documentation,
- keywords,
},
- ]
+ }
static render({ version, branch }) {
return renderVersionBadge({
@@ -54,21 +61,20 @@ class GithubPackageJsonVersion extends ConditionalGithubAuthV3Service {
})
}
- async handle({ user, repo, branch }) {
+ async handle({ user, repo, branch }, { filename = 'package.json' }) {
const { version } = await fetchJsonFromRepo(this, {
schema: versionSchema,
user,
repo,
branch,
- filename: 'package.json',
+ filename,
})
return this.constructor.render({ version, branch })
}
}
-const dependencyQueryParamSchema = Joi.object({
- filename: Joi.string(),
-}).required()
+const packageNameDescription =
+ 'This may be the name of an unscoped package like `package-name` or a [scoped package](https://docs.npmjs.com/about-scopes) like `@author/package-name`'
class GithubPackageJsonDependencyVersion extends ConditionalGithubAuthV3Service {
static category = 'platform-support'
@@ -76,61 +82,103 @@ class GithubPackageJsonDependencyVersion extends ConditionalGithubAuthV3Service
base: 'github/package-json/dependency-version',
pattern:
':user/:repo/:kind(dev|peer|optional)?/:scope(@[^/]+)?/:packageName/:branch*',
- queryParamSchema: dependencyQueryParamSchema,
+ queryParamSchema: subfolderQueryParamSchema,
}
- static examples = [
- {
- title: 'GitHub package.json dependency version (prod)',
- pattern: ':user/:repo/:packageName',
- namedParams: {
- user: 'developit',
- repo: 'microbundle',
- packageName: 'rollup',
+ static openApi = {
+ '/github/package-json/dependency-version/{user}/{repo}/{packageName}': {
+ get: {
+ summary: 'GitHub package.json prod dependency version',
+ description: documentation,
+ parameters: [
+ pathParam({ name: 'user', example: 'badges' }),
+ pathParam({ name: 'repo', example: 'shields' }),
+ pathParam({
+ name: 'packageName',
+ example: 'dayjs',
+ description: packageNameDescription,
+ }),
+ queryParam({
+ name: 'filename',
+ example: 'badge-maker/package.json',
+ }),
+ ],
},
- staticPreview: this.render({
- dependency: 'rollup',
- range: '^0.67.3',
- }),
- documentation,
- keywords,
},
- {
- title: 'GitHub package.json dependency version (dev dep on branch)',
- pattern: ':user/:repo/dev/:scope?/:packageName/:branch*',
- namedParams: {
- user: 'zeit',
- repo: 'next.js',
- branch: 'canary',
- scope: '@babel',
- packageName: 'preset-react',
+ '/github/package-json/dependency-version/{user}/{repo}/{packageName}/{branch}':
+ {
+ get: {
+ summary: 'GitHub package.json prod dependency version (branch)',
+ description: documentation,
+ parameters: [
+ pathParam({ name: 'user', example: 'badges' }),
+ pathParam({ name: 'repo', example: 'shields' }),
+ pathParam({
+ name: 'packageName',
+ example: 'dayjs',
+ description: packageNameDescription,
+ }),
+ pathParam({ name: 'branch', example: 'master' }),
+ queryParam({
+ name: 'filename',
+ example: 'badge-maker/package.json',
+ }),
+ ],
+ },
},
- staticPreview: this.render({
- dependency: '@babel/preset-react',
- range: '7.0.0',
- }),
- documentation,
- keywords,
- },
- {
- title: 'GitHub package.json dependency version (subfolder of monorepo)',
- pattern: ':user/:repo/:packageName',
- namedParams: {
- user: 'metabolize',
- repo: 'anafanafo',
- packageName: 'puppeteer',
+ '/github/package-json/dependency-version/{user}/{repo}/{kind}/{packageName}':
+ {
+ get: {
+ summary: 'GitHub package.json dev/peer/optional dependency version',
+ description: documentation,
+ parameters: [
+ pathParam({ name: 'user', example: 'gatsbyjs' }),
+ pathParam({ name: 'repo', example: 'gatsby' }),
+ pathParam({
+ name: 'kind',
+ example: 'dev',
+ schema: { type: 'string', enum: this.getEnum('kind') },
+ }),
+ pathParam({
+ name: 'packageName',
+ example: 'cross-env',
+ description: packageNameDescription,
+ }),
+ queryParam({
+ name: 'filename',
+ example: 'packages/gatsby-cli/package.json',
+ }),
+ ],
+ },
},
- queryParams: {
- filename: 'packages/char-width-table-builder/package.json',
+ '/github/package-json/dependency-version/{user}/{repo}/{kind}/{packageName}/{branch}':
+ {
+ get: {
+ summary:
+ 'GitHub package.json dev/peer/optional dependency version (branch)',
+ description: documentation,
+ parameters: [
+ pathParam({ name: 'user', example: 'gatsbyjs' }),
+ pathParam({ name: 'repo', example: 'gatsby' }),
+ pathParam({
+ name: 'kind',
+ example: 'dev',
+ schema: { type: 'string', enum: this.getEnum('kind') },
+ }),
+ pathParam({
+ name: 'packageName',
+ example: 'cross-env',
+ description: packageNameDescription,
+ }),
+ pathParam({ name: 'branch', example: 'master' }),
+ queryParam({
+ name: 'filename',
+ example: 'packages/gatsby-cli/package.json',
+ }),
+ ],
+ },
},
- staticPreview: this.render({
- dependency: 'puppeteer',
- range: '^1.14.0',
- }),
- documentation,
- keywords,
- },
- ]
+ }
static defaultBadgeData = { label: 'dependency' }
@@ -144,7 +192,7 @@ class GithubPackageJsonDependencyVersion extends ConditionalGithubAuthV3Service
async handle(
{ user, repo, kind, branch = 'HEAD', scope, packageName },
- { filename = 'package.json' }
+ { filename = 'package.json' },
) {
const {
dependencies,
@@ -160,7 +208,7 @@ class GithubPackageJsonDependencyVersion extends ConditionalGithubAuthV3Service
})
const wantedDependency = scope ? `${scope}/${packageName}` : packageName
- const { range } = getDependencyVersion({
+ const range = getDependencyVersion({
kind,
wantedDependency,
dependencies,
@@ -185,40 +233,39 @@ class DynamicGithubPackageJson extends ConditionalGithubAuthV3Service {
pattern: ':key/:user/:repo/:branch*',
}
- static examples = [
- {
- title: 'GitHub package.json dynamic',
- pattern: ':key/:user/:repo',
- namedParams: {
- key: 'keywords',
- user: 'developit',
- repo: 'microbundle',
+ static openApi = {
+ '/github/package-json/{key}/{user}/{repo}': {
+ get: {
+ summary: 'GitHub package.json dynamic',
+ description: documentation,
+ parameters: pathParams(
+ {
+ name: 'key',
+ example: 'keywords',
+ description: 'any key in package.json',
+ },
+ { name: 'user', example: 'developit' },
+ { name: 'repo', example: 'microbundle' },
+ ),
},
- staticPreview: this.render({
- key: 'keywords',
- value: ['bundle', 'rollup', 'micro library'],
- }),
- documentation,
- keywords,
},
- {
- title: 'GitHub package.json dynamic',
- pattern: ':key/:user/:repo/:branch',
- namedParams: {
- key: 'keywords',
- user: 'developit',
- repo: 'microbundle',
- branch: 'master',
+ '/github/package-json/{key}/{user}/{repo}/{branch}': {
+ get: {
+ summary: 'GitHub package.json dynamic (branch)',
+ description: documentation,
+ parameters: pathParams(
+ {
+ name: 'key',
+ example: 'keywords',
+ description: 'any key in package.json',
+ },
+ { name: 'user', example: 'developit' },
+ { name: 'repo', example: 'microbundle' },
+ { name: 'branch', example: 'master' },
+ ),
},
- staticPreview: this.render({
- key: 'keywords',
- value: ['bundle', 'rollup', 'micro library'],
- branch: 'master',
- }),
- documentation,
- keywords,
},
- ]
+ }
static defaultBadgeData = { label: 'package.json' }
diff --git a/services/github/github-package-json.tester.js b/services/github/github-package-json.tester.js
index ec60dfe3e9806..28fa94408f5f4 100644
--- a/services/github/github-package-json.tester.js
+++ b/services/github/github-package-json.tester.js
@@ -21,6 +21,17 @@ t.create('Package version (repo not found)')
message: 'repo not found, branch not found, or package.json missing',
})
+t.create('Package version (monorepo)')
+ .get(
+ `/v/metabolize/anafanafo.json?filename=${encodeURIComponent(
+ 'packages/char-width-table-builder/package.json',
+ )}`,
+ )
+ .expectBadge({
+ label: 'version',
+ message: isSemver,
+ })
+
t.create('Package name')
.get('/n/badges/shields.json')
.expectBadge({ label: 'name', message: 'shields.io' })
@@ -56,7 +67,7 @@ t.create('Optional dependency version')
t.create('Dev dependency version')
.get(
- '/dependency-version/paulmelnikow/react-boxplot/dev/react.json?label=react%20tested'
+ '/dependency-version/paulmelnikow/react-boxplot/dev/react.json?label=react%20tested',
)
.expectBadge({
label: 'react tested',
@@ -73,8 +84,8 @@ t.create('Prod dependency version')
t.create('Prod dependency version (monorepo)')
.get(
`/dependency-version/metabolize/anafanafo/puppeteer.json?filename=${encodeURIComponent(
- 'packages/char-width-table-builder/package.json'
- )}`
+ 'packages/char-width-table-builder/package.json',
+ )}`,
)
.expectBadge({
label: 'puppeteer',
@@ -82,16 +93,16 @@ t.create('Prod dependency version (monorepo)')
})
t.create('Scoped dependency')
- .get('/dependency-version/badges/shields/dev/@babel/core.json')
+ .get('/dependency-version/badges/shields/dev/@docusaurus/core.json')
.expectBadge({
- label: '@babel/core',
+ label: '@docusaurus/core',
message: semverRange,
})
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,
})
diff --git a/services/github/github-pipenv.service.js b/services/github/github-pipenv.service.js
index 758903747a49d..eb5be7253603b 100644
--- a/services/github/github-pipenv.service.js
+++ b/services/github/github-pipenv.service.js
@@ -1,41 +1,32 @@
+import { pep440VersionColor } from '../color-formatters.js'
import { renderVersionBadge } from '../version.js'
import { isLockfile, getDependencyVersion } from '../pipenv-helpers.js'
import { addv } from '../text-formatters.js'
-import { NotFound } from '../index.js'
+import { NotFound, pathParams } from '../index.js'
import { ConditionalGithubAuthV3Service } from './github-auth-service.js'
import { fetchJsonFromRepo } from './github-common-fetch.js'
import { documentation as githubDocumentation } from './github-helpers.js'
-const keywords = ['pipfile']
-
-const documentation = `
-
- Pipenv is a dependency
- manager for Python which manages a
- virtualenv for
- projects. It adds/removes packages from your Pipfile as
- you install/uninstall packages and generates the ever-important
- Pipfile.lock, which can be checked in to source control
- in order to produce deterministic builds.
-
-
-
- The GitHub Pipenv badges are intended for applications using Pipenv
- which are hosted on GitHub.
-
-
-
- When Pipfile.lock is checked in, the GitHub Pipenv
- locked dependency version badge displays the locked version of
- a dependency listed in [packages] or
- [dev-packages] (or any of their transitive dependencies).
-
-
-
- Usually a Python version is specified in the Pipfile, which
- pipenv lock then places in Pipfile.lock. The
- GitHub Pipenv Python version badge displays that version.
-
+const description = `
+[Pipenv](https://github.com/pypa/pipenv) is a dependency
+manager for Python which manages a
+[virtualenv](https://virtualenv.pypa.io/en/latest/) for
+projects. It adds/removes packages from your \`Pipfile\` as
+you install/uninstall packages and generates the ever-important
+\`Pipfile.lock\`, which can be checked in to source control
+in order to produce deterministic builds.
+
+The GitHub Pipenv badges are intended for applications using Pipenv
+which are hosted on GitHub.
+
+When \`Pipfile.lock\` is checked in, the GitHub Pipenv
+locked dependency version badge displays the locked version of
+a dependency listed in \`[packages]\` or
+\`[dev-packages]\` (or any of their transitive dependencies).
+
+Usually a Python version is specified in the \`Pipfile\`, which
+\`pipenv lock\` then places in \`Pipfile.lock\`.
+The GitHub Pipenv Python version badge displays that version.
${githubDocumentation}
`
@@ -47,31 +38,29 @@ class GithubPipenvLockedPythonVersion extends ConditionalGithubAuthV3Service {
pattern: ':user/:repo/:branch*',
}
- static examples = [
- {
- title: 'GitHub Pipenv locked Python version',
- pattern: ':user/:repo',
- namedParams: {
- user: 'metabolize',
- repo: 'rq-dashboard-on-heroku',
+ static openApi = {
+ '/github/pipenv/locked/python-version/{user}/{repo}': {
+ get: {
+ summary: 'GitHub Pipenv locked Python version',
+ description,
+ parameters: pathParams(
+ { name: 'user', example: 'metabolize' },
+ { name: 'repo', example: 'rq-dashboard-on-heroku' },
+ ),
},
- staticPreview: this.render({ version: '3.7' }),
- documentation,
- keywords,
},
- {
- title: 'GitHub Pipenv locked Python version (branch)',
- pattern: ':user/:repo/:branch',
- namedParams: {
- user: 'metabolize',
- repo: 'rq-dashboard-on-heroku',
- branch: 'master',
+ '/github/pipenv/locked/python-version/{user}/{repo}/{branch}': {
+ get: {
+ summary: 'GitHub Pipenv locked Python version (branch)',
+ description,
+ parameters: pathParams(
+ { name: 'user', example: 'metabolize' },
+ { name: 'repo', example: 'rq-dashboard-on-heroku' },
+ { name: 'branch', example: 'main' },
+ ),
},
- staticPreview: this.render({ version: '3.7', branch: 'master' }),
- documentation,
- keywords,
},
- ]
+ }
static defaultBadgeData = { label: 'python' }
@@ -80,6 +69,7 @@ class GithubPipenvLockedPythonVersion extends ConditionalGithubAuthV3Service {
version,
tag: branch,
defaultLabel: 'python',
+ versionFormatter: pep440VersionColor,
})
}
@@ -109,37 +99,57 @@ class GithubPipenvLockedDependencyVersion extends ConditionalGithubAuthV3Service
pattern: ':user/:repo/:kind(dev)?/:packageName/:branch*',
}
- static examples = [
- {
- title: 'GitHub Pipenv locked dependency version',
- pattern: ':user/:repo/:kind(dev)?/:packageName',
- namedParams: {
- user: 'metabolize',
- repo: 'rq-dashboard-on-heroku',
- packageName: 'flask',
+ static openApi = {
+ '/github/pipenv/locked/dependency-version/{user}/{repo}/{packageName}': {
+ get: {
+ summary: 'GitHub Pipenv locked dependency version',
+ description,
+ parameters: pathParams(
+ { name: 'user', example: 'metabolize' },
+ { name: 'repo', example: 'rq-dashboard-on-heroku' },
+ { name: 'packageName', example: 'flask' },
+ ),
},
- staticPreview: this.render({
- dependency: 'flask',
- version: '1.1.1',
- }),
- documentation,
- keywords: ['python', ...keywords],
},
- {
- title: 'GitHub Pipenv locked dependency version (branch)',
- pattern: ':user/:repo/:kind(dev)?/:packageName/:branch',
- namedParams: {
- user: 'metabolize',
- repo: 'rq-dashboard-on-heroku',
- kind: 'dev',
- packageName: 'black',
- branch: 'master',
+ '/github/pipenv/locked/dependency-version/{user}/{repo}/{packageName}/{branch}':
+ {
+ get: {
+ summary: 'GitHub Pipenv locked dependency version (branch)',
+ description,
+ parameters: pathParams(
+ { name: 'user', example: 'metabolize' },
+ { name: 'repo', example: 'rq-dashboard-on-heroku' },
+ { name: 'packageName', example: 'flask' },
+ { name: 'branch', example: 'main' },
+ ),
+ },
},
- staticPreview: this.render({ dependency: 'black', version: '19.3b0' }),
- documentation,
- keywords: ['python', ...keywords],
- },
- ]
+ '/github/pipenv/locked/dependency-version/{user}/{repo}/dev/{packageName}':
+ {
+ get: {
+ summary: 'GitHub Pipenv locked dev dependency version',
+ description,
+ parameters: pathParams(
+ { name: 'user', example: 'metabolize' },
+ { name: 'repo', example: 'rq-dashboard-on-heroku' },
+ { name: 'packageName', example: 'black' },
+ ),
+ },
+ },
+ '/github/pipenv/locked/dependency-version/{user}/{repo}/dev/{packageName}/{branch}':
+ {
+ get: {
+ summary: 'GitHub Pipenv locked dev dependency version (branch)',
+ description,
+ parameters: pathParams(
+ { name: 'user', example: 'metabolize' },
+ { name: 'repo', example: 'rq-dashboard-on-heroku' },
+ { name: 'packageName', example: 'black' },
+ { name: 'branch', example: 'main' },
+ ),
+ },
+ },
+ }
static defaultBadgeData = { label: 'dependency' }
@@ -147,7 +157,7 @@ class GithubPipenvLockedDependencyVersion extends ConditionalGithubAuthV3Service
return {
label: dependency,
message: version ? addv(version) : ref,
- color: 'blue',
+ color: version ? pep440VersionColor(version) : 'blue',
}
}
diff --git a/services/github/github-pipenv.tester.js b/services/github/github-pipenv.tester.js
index c3fa864748eb3..bb291e8615aef 100644
--- a/services/github/github-pipenv.tester.js
+++ b/services/github/github-pipenv.tester.js
@@ -1,13 +1,13 @@
import Joi from 'joi'
import { ServiceTester } from '../tester.js'
import {
+ isCommitHash,
isVPlusDottedVersionAtLeastOne,
isVPlusDottedVersionNClausesWithOptionalSuffix,
} from '../test-validators.js'
// e.g. v19.3b0
const isBlackVersion = Joi.string().regex(/^v\d+(\.\d+)*(.*)?$/)
-const isShortSha = Joi.string().regex(/[0-9a-f]{7}/)
export const t = new ServiceTester({
id: 'GithubPipenv',
@@ -38,7 +38,7 @@ t.create('Locked Python version (pipfile.lock has no python version)')
t.create('Locked version of default dependency')
.get(
- '/locked/dependency-version/metabolize/rq-dashboard-on-heroku/rq-dashboard.json'
+ '/locked/dependency-version/metabolize/rq-dashboard-on-heroku/rq-dashboard.json',
)
.expectBadge({
label: 'rq-dashboard',
@@ -47,7 +47,7 @@ t.create('Locked version of default dependency')
t.create('Locked version of default dependency (branch)')
.get(
- '/locked/dependency-version/metabolize/rq-dashboard-on-heroku/rq-dashboard/master.json'
+ '/locked/dependency-version/metabolize/rq-dashboard-on-heroku/rq-dashboard/main.json',
)
.expectBadge({
label: 'rq-dashboard',
@@ -56,7 +56,7 @@ t.create('Locked version of default dependency (branch)')
t.create('Locked version of dev dependency')
.get(
- '/locked/dependency-version/metabolize/rq-dashboard-on-heroku/dev/black.json'
+ '/locked/dependency-version/metabolize/rq-dashboard-on-heroku/dev/black.json',
)
.expectBadge({
label: 'black',
@@ -65,7 +65,7 @@ t.create('Locked version of dev dependency')
t.create('Locked version of dev dependency (branch)')
.get(
- '/locked/dependency-version/metabolize/rq-dashboard-on-heroku/dev/black/master.json'
+ '/locked/dependency-version/metabolize/rq-dashboard-on-heroku/dev/black/main.json',
)
.expectBadge({
label: 'black',
@@ -74,7 +74,7 @@ t.create('Locked version of dev dependency (branch)')
t.create('Locked version of unknown dependency')
.get(
- '/locked/dependency-version/metabolize/rq-dashboard-on-heroku/dev/i-made-this-up.json'
+ '/locked/dependency-version/metabolize/rq-dashboard-on-heroku/dev/i-made-this-up.json',
)
.expectBadge({
label: 'dependency',
@@ -82,10 +82,8 @@ t.create('Locked version of unknown dependency')
})
t.create('Locked version of VCS dependency')
- .get(
- '/locked/dependency-version/DemocracyClub/aggregator-api/dc-base-theme.json'
- )
+ .get('/locked/dependency-version/ykdojo/editdojo/tweepy.json')
.expectBadge({
- label: 'dc-base-theme',
- message: isShortSha,
+ label: 'tweepy',
+ message: isCommitHash,
})
diff --git a/services/github/github-pull-request-check-state.service.js b/services/github/github-pull-request-check-state.service.js
index 969c75d91be9d..badf6fb236f1c 100644
--- a/services/github/github-pull-request-check-state.service.js
+++ b/services/github/github-pull-request-check-state.service.js
@@ -1,8 +1,23 @@
import Joi from 'joi'
import countBy from 'lodash.countby'
+import { pathParams } from '../index.js'
import { GithubAuthV3Service } from './github-auth-service.js'
import { fetchIssue } from './github-common-fetch.js'
-import { documentation, errorMessagesFor } from './github-helpers.js'
+import {
+ documentation as commonDocumentation,
+ httpErrorsFor,
+} from './github-helpers.js'
+
+const description = `
+Displays the status of a pull request, as reported by the Commit Status API.
+
+Note: Nowadays, GitHub Actions and many third party integrations report state via
+the Checks API. If this badge does not show expected values, please try out one of our
+[Check Runs badges](https://shields.io/search/?q=GitHub+check+runs) instead. You can read more about status checks in
+the [GitHub documentation](https://docs.github.com/en/pull-requests/collaborating-with-pull-requests/collaborating-on-repositories-with-code-quality-features/about-status-checks).
+
+${commonDocumentation}
+`
const schema = Joi.object({
state: Joi.equal('failure', 'pending', 'success').required(),
@@ -10,13 +25,11 @@ const schema = Joi.object({
.items(
Joi.object({
state: Joi.equal('error', 'failure', 'pending', 'success').required(),
- })
+ }),
)
.default([]),
}).required()
-const keywords = ['pullrequest', 'detail']
-
export default class GithubPullRequestCheckState extends GithubAuthV3Service {
static category = 'build'
static route = {
@@ -24,38 +37,50 @@ export default class GithubPullRequestCheckState extends GithubAuthV3Service {
pattern: ':variant(s|contexts)/pulls/:user/:repo/:number(\\d+)',
}
- static examples = [
- {
- title: 'GitHub pull request check state',
- pattern: 's/pulls/:user/:repo/:number',
- namedParams: {
- user: 'badges',
- repo: 'shields',
- number: '1110',
+ static openApi = {
+ '/github/status/s/pulls/{user}/{repo}/{number}': {
+ get: {
+ summary: 'GitHub pull request status',
+ description,
+ parameters: pathParams(
+ {
+ name: 'user',
+ example: 'badges',
+ },
+ {
+ name: 'repo',
+ example: 'shields',
+ },
+ {
+ name: 'number',
+ example: '1110',
+ },
+ ),
},
- staticPreview: this.render({ variant: 's', state: 'pending' }),
- keywords,
- documentation,
},
- {
- title: 'GitHub pull request check contexts',
- pattern: 'contexts/pulls/:user/:repo/:number',
- namedParams: {
- user: 'badges',
- repo: 'shields',
- number: '1110',
+ '/github/status/contexts/pulls/{user}/{repo}/{number}': {
+ get: {
+ summary: 'GitHub pull request check contexts',
+ description,
+ parameters: pathParams(
+ {
+ name: 'user',
+ example: 'badges',
+ },
+ {
+ name: 'repo',
+ example: 'shields',
+ },
+ {
+ name: 'number',
+ example: '1110',
+ },
+ ),
},
- staticPreview: this.render({
- variant: 'contexts',
- state: 'pending',
- stateCounts: { passed: 5, pending: 1 },
- }),
- keywords,
- documentation,
},
- ]
+ }
- static defaultBadgeData = { label: 'checks', namedLogo: 'github' }
+ static defaultBadgeData = { label: 'checks' }
static render({ variant, state, stateCounts }) {
let message
@@ -92,7 +117,7 @@ export default class GithubPullRequestCheckState extends GithubAuthV3Service {
const json = await this._requestJson({
schema,
url: `/repos/${user}/${repo}/commits/${ref}/status`,
- errorMessages: errorMessagesFor('commit not found'),
+ httpErrors: httpErrorsFor('commit not found'),
})
const { state, stateCounts } = this.constructor.transform(json)
diff --git a/services/github/github-pull-request-check-state.tester.js b/services/github/github-pull-request-check-state.tester.js
index 9095f266f95a1..d079d166b6971 100644
--- a/services/github/github-pull-request-check-state.tester.js
+++ b/services/github/github-pull-request-check-state.tester.js
@@ -2,22 +2,22 @@ import { createServiceTester } from '../tester.js'
export const t = await createServiceTester()
t.create('github pull request check state')
- .get('/s/pulls/badges/shields/1110.json')
- .expectBadge({ label: 'checks', message: 'failure' })
+ .get('/s/pulls/badges/shields/11053.json')
+ .expectBadge({ label: 'checks', message: 'success' })
t.create('github pull request check state (pull request not found)')
.get('/s/pulls/badges/shields/5101.json')
.expectBadge({ label: 'checks', message: 'pull request or repo not found' })
t.create(
- "github pull request check state (ref returned by github doesn't exist"
+ "github pull request check state (ref returned by github doesn't exist)",
)
.get('/s/pulls/badges/shields/1110.json')
.intercept(
nock =>
nock('https://api.github.com', { allowUnmocked: true })
.get('/repos/badges/shields/pulls/1110')
- .reply(200, JSON.stringify({ head: { sha: 'abc123' } })) // Looks like a real ref, but isn't.
+ .reply(200, JSON.stringify({ head: { sha: 'abcde12356' } })), // Looks like a real ref, but isn't.
)
.networkOn()
.expectBadge({
@@ -26,5 +26,5 @@ t.create(
})
t.create('github pull request check contexts')
- .get('/contexts/pulls/badges/shields/1110.json')
- .expectBadge({ label: 'checks', message: '1 failure' })
+ .get('/contexts/pulls/badges/shields/11053.json')
+ .expectBadge({ label: 'checks', message: '1 success' })
diff --git a/services/github/github-r-package.service.js b/services/github/github-r-package.service.js
index 837abf6151c94..566a773067dc1 100644
--- a/services/github/github-r-package.service.js
+++ b/services/github/github-r-package.service.js
@@ -1,6 +1,6 @@
import Joi from 'joi'
import { renderVersionBadge } from '../version.js'
-import { InvalidResponse } from '../index.js'
+import { InvalidResponse, pathParam, queryParam } from '../index.js'
import { ConditionalGithubAuthV3Service } from './github-auth-service.js'
import { fetchRepoContent } from './github-common-fetch.js'
import { documentation } from './github-helpers.js'
@@ -11,6 +11,9 @@ const queryParamSchema = Joi.object({
const versionRegExp = /^Version:[\s]*(.+)$/m
+const filenameDescription =
+ 'The `filename` param can be used to specify the path to `DESCRIPTION`. By default, we look for `DESCRIPTION` in the repo root'
+
export default class GithubRPackageVersion extends ConditionalGithubAuthV3Service {
static category = 'version'
@@ -20,38 +23,39 @@ export default class GithubRPackageVersion extends ConditionalGithubAuthV3Servic
queryParamSchema,
}
- static examples = [
- {
- title: 'GitHub R package version',
- pattern: ':user/:repo',
- namedParams: { user: 'mixOmicsTeam', repo: 'mixOmics' },
- staticPreview: this.render({ version: '6.10.9' }),
- documentation,
- },
- {
- title: 'GitHub R package version (branch)',
- pattern: ':user/:repo/:branch',
- namedParams: { user: 'mixOmicsTeam', repo: 'mixOmics', branch: 'master' },
- staticPreview: this.render({ version: '6.10.9', branch: 'master' }),
- documentation,
+ static openApi = {
+ '/github/r-package/v/{user}/{repo}': {
+ get: {
+ summary: 'GitHub R package version',
+ description: documentation,
+ parameters: [
+ pathParam({ name: 'user', example: 'mixOmicsTeam' }),
+ pathParam({ name: 'repo', example: 'mixOmics' }),
+ queryParam({
+ name: 'filename',
+ example: 'subdirectory/DESCRIPTION',
+ description: filenameDescription,
+ }),
+ ],
+ },
},
- {
- title: 'GitHub R package version (subdirectory of monorepo)',
- pattern: ':user/:repo',
- namedParams: { user: 'mixOmicsTeam', repo: 'mixOmics' },
- queryParams: { filename: 'subdirectory/DESCRIPTION' },
- staticPreview: this.render({ version: '6.10.9' }),
- documentation,
+ '/github/r-package/v/{user}/{repo}/{branch}': {
+ get: {
+ summary: 'GitHub R package version (branch)',
+ description: documentation,
+ parameters: [
+ pathParam({ name: 'user', example: 'mixOmicsTeam' }),
+ pathParam({ name: 'repo', example: 'mixOmics' }),
+ pathParam({ name: 'branch', example: 'master' }),
+ queryParam({
+ name: 'filename',
+ example: 'subdirectory/DESCRIPTION',
+ description: filenameDescription,
+ }),
+ ],
+ },
},
- {
- title: 'GitHub R package version (branch & subdirectory of monorepo)',
- pattern: ':user/:repo/:branch',
- namedParams: { user: 'mixOmicsTeam', repo: 'mixOmics', branch: 'master' },
- queryParams: { filename: 'subdirectory/DESCRIPTION' },
- staticPreview: this.render({ version: '6.10.9', branch: 'master' }),
- documentation,
- },
- ]
+ }
static defaultBadgeData = { label: 'R' }
diff --git a/services/github/github-r-package.spec.js b/services/github/github-r-package.spec.js
index cc6516685e441..23828d5b745cc 100644
--- a/services/github/github-r-package.spec.js
+++ b/services/github/github-r-package.spec.js
@@ -20,7 +20,7 @@ describe('GithubRPackageVersion', function () {
it('throws InvalidResponse if a file does not contain version specification', function () {
expect(() =>
- GithubRPackageVersion.transform(content('Versio: 6.10.9'), 'DESCRIPTION')
+ GithubRPackageVersion.transform(content('Versio: 6.10.9'), 'DESCRIPTION'),
)
.to.throw(InvalidResponse)
.with.property('prettyMessage', 'Version missing in DESCRIPTION')
diff --git a/services/github/github-r-package.tester.js b/services/github/github-r-package.tester.js
index 18247e1918a2d..7f499b651d072 100644
--- a/services/github/github-r-package.tester.js
+++ b/services/github/github-r-package.tester.js
@@ -17,8 +17,8 @@ t.create('R package version (from branch)')
t.create('R package version (monorepo)')
.get(
`/wch/r-source.json?filename=${encodeURIComponent(
- 'src/gnuwin32/windlgs/DESCRIPTION'
- )}`
+ 'src/gnuwin32/windlgs/DESCRIPTION',
+ )}`,
)
.expectBadge({
label: 'R',
diff --git a/services/github/github-release-date.service.js b/services/github/github-release-date.service.js
index 098bb7c11cc84..4d61429843d44 100644
--- a/services/github/github-release-date.service.js
+++ b/services/github/github-release-date.service.js
@@ -1,63 +1,66 @@
-import moment from 'moment'
import Joi from 'joi'
-import { age } from '../color-formatters.js'
-import { formatDate } from '../text-formatters.js'
+import { pathParam, queryParam } from '../index.js'
+import { renderDateBadge } from '../date.js'
import { GithubAuthV3Service } from './github-auth-service.js'
-import { documentation, errorMessagesFor } from './github-helpers.js'
+import { documentation, httpErrorsFor } from './github-helpers.js'
const schema = Joi.alternatives(
Joi.object({
created_at: Joi.date().required(),
+ published_at: Joi.date().required(),
}).required(),
Joi.array()
.items(
Joi.object({
created_at: Joi.date().required(),
- }).required()
+ published_at: Joi.date().required(),
+ }).required(),
)
- .min(1)
+ .min(1),
)
+const displayDateEnum = ['created_at', 'published_at']
+
+const queryParamSchema = Joi.object({
+ display_date: Joi.string()
+ .valid(...displayDateEnum)
+ .default('published_at'),
+}).required()
+
export default class GithubReleaseDate extends GithubAuthV3Service {
static category = 'activity'
static route = {
base: 'github',
pattern: ':variant(release-date|release-date-pre)/:user/:repo',
+ queryParamSchema,
}
- static examples = [
- {
- title: 'GitHub Release Date',
- pattern: 'release-date/:user/:repo',
- namedParams: {
- user: 'SubtitleEdit',
- repo: 'subtitleedit',
- },
- staticPreview: this.render({ date: '2017-04-13T07:50:27.000Z' }),
- documentation,
- },
- {
- title: 'GitHub (Pre-)Release Date',
- pattern: 'release-date-pre/:user/:repo',
- namedParams: {
- user: 'Cockatrice',
- repo: 'Cockatrice',
+ static openApi = {
+ '/github/{variant}/{user}/{repo}': {
+ get: {
+ summary: 'GitHub Release Date',
+ description: documentation,
+ parameters: [
+ pathParam({
+ name: 'variant',
+ example: 'release-date',
+ schema: { type: 'string', enum: this.getEnum('variant') },
+ }),
+ pathParam({ name: 'user', example: 'SubtitleEdit' }),
+ pathParam({ name: 'repo', example: 'subtitleedit' }),
+ queryParam({
+ name: 'display_date',
+ example: 'published_at',
+ schema: { type: 'string', enum: displayDateEnum },
+ description: 'Default value is `created_at` if not specified',
+ }),
+ ],
},
- staticPreview: this.render({ date: '2017-04-13T07:50:27.000Z' }),
- documentation,
},
- ]
+ }
static defaultBadgeData = { label: 'release date' }
- static render({ date }) {
- const releaseDate = moment(date)
- return {
- message: formatDate(releaseDate),
- color: age(releaseDate),
- }
- }
-
async fetch({ variant, user, repo }) {
const url =
variant === 'release-date'
@@ -66,15 +69,15 @@ export default class GithubReleaseDate extends GithubAuthV3Service {
return this._requestJson({
url,
schema,
- errorMessages: errorMessagesFor('no releases or repo not found'),
+ httpErrors: httpErrorsFor('no releases or repo not found'),
})
}
- async handle({ variant, user, repo }) {
+ async handle({ variant, user, repo }, queryParams) {
const body = await this.fetch({ variant, user, repo })
if (Array.isArray(body)) {
- return this.constructor.render({ date: body[0].created_at })
+ return renderDateBadge(body[0][queryParams.display_date])
}
- return this.constructor.render({ date: body.created_at })
+ return renderDateBadge(body[queryParams.display_date])
}
}
diff --git a/services/github/github-release-date.tester.js b/services/github/github-release-date.tester.js
index 3cf255fb731ee..eb0033d637f9c 100644
--- a/services/github/github-release-date.tester.js
+++ b/services/github/github-release-date.tester.js
@@ -9,8 +9,29 @@ t.create('Release Date. e.g release date|today')
message: isFormattedDate,
})
+t.create('Release Date - display_date by `created_at` (default)')
+ .get('/release-date/microsoft/vscode.json?display_date=created_at')
+ .expectBadge({
+ label: 'release date',
+ message: isFormattedDate,
+ })
+
+t.create('Release Date - display_date by `published_at`')
+ .get('/release-date/microsoft/vscode.json?display_date=published_at')
+ .expectBadge({
+ label: 'release date',
+ message: isFormattedDate,
+ })
+
+t.create('Release Date - display_date by `published_at`, incorrect query param')
+ .get('/release-date/microsoft/vscode.json?display_date=published_attttttttt')
+ .expectBadge({
+ label: 'release date',
+ message: 'invalid query parameter: display_date',
+ })
+
t.create(
- 'Release Date - Should return `no releases or repo not found` for invalid repo'
+ 'Release Date - Should return `no releases or repo not found` for invalid repo',
)
.get('/release-date/not-valid-name/not-valid-repo.json')
.expectBadge({
@@ -26,7 +47,7 @@ t.create('(Pre-)Release Date. e.g release date|today')
})
t.create(
- '(Pre-)Release Date - Should return `no releases or repo not found` for invalid repo'
+ '(Pre-)Release Date - Should return `no releases or repo not found` for invalid repo',
)
.get('/release-date-pre/not-valid-name/not-valid-repo.json')
.expectBadge({
diff --git a/services/github/github-release.service.js b/services/github/github-release.service.js
index 94343ad530a7d..aaed99ebea9fa 100644
--- a/services/github/github-release.service.js
+++ b/services/github/github-release.service.js
@@ -1,87 +1,72 @@
-import { addv } from '../text-formatters.js'
-import { version as versionColor } from '../color-formatters.js'
-import { redirector } from '../index.js'
+import Joi from 'joi'
+import { redirector, pathParam, queryParam } from '../index.js'
+import { renderVersionBadge } from '../version.js'
import { GithubAuthV3Service } from './github-auth-service.js'
import {
fetchLatestRelease,
queryParamSchema,
+ openApiQueryParams,
} from './github-common-release.js'
import { documentation } from './github-helpers.js'
+const displayNameEnum = ['tag', 'release']
+const extendedQueryParamSchema = Joi.object({
+ display_name: Joi.string()
+ .valid(...displayNameEnum)
+ .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: {},
- staticPreview: this.render({
- version: 'v4.16.4',
- sort: 'date',
- isPrerelease: false,
- }),
- documentation,
- },
- {
- title: 'GitHub release (latest by date including pre-releases)',
- namedParams: { user: 'expressjs', repo: 'express' },
- queryParams: { include_prereleases: null },
- staticPreview: this.render({
- version: 'v5.0.0-alpha.7',
- sort: 'date',
- isPrerelease: true,
- }),
- documentation,
- },
- {
- title: 'GitHub release (latest SemVer)',
- namedParams: { user: 'expressjs', repo: 'express' },
- queryParams: { sort: 'semver' },
- staticPreview: this.render({
- version: 'v4.16.4',
- sort: 'semver',
- isPrerelease: false,
- }),
- documentation,
- },
- {
- title: 'GitHub release (latest SemVer including pre-releases)',
- namedParams: { user: 'expressjs', repo: 'express' },
- queryParams: { sort: 'semver', include_prereleases: null },
- staticPreview: this.render({
- version: 'v5.0.0-alpha.7',
- sort: 'semver',
- isPrerelease: true,
- }),
- documentation,
+ static openApi = {
+ '/github/v/release/{user}/{repo}': {
+ get: {
+ summary: 'GitHub Release',
+ description: documentation,
+ parameters: [
+ pathParam({ name: 'user', example: 'expressjs' }),
+ pathParam({ name: 'repo', example: 'express' }),
+ ...openApiQueryParams,
+ queryParam({
+ name: 'display_name',
+ example: 'tag',
+ schema: { type: 'string', enum: displayNameEnum },
+ }),
+ ],
+ },
},
- ]
+ }
+
+ static defaultBadgeData = { label: 'release' }
- static defaultBadgeData = { label: 'release', namedLogo: 'github' }
+ static transform(latestRelease, display) {
+ const { name, tag_name: tagName, prerelease: isPrerelease } = latestRelease
+ if (display === 'tag') {
+ return { isPrerelease, version: tagName }
+ }
- static render({ version, sort, isPrerelease }) {
- let color = 'blue'
- color = sort === 'semver' ? versionColor(version) : color
- color = isPrerelease ? 'orange' : color
- return { message: addv(version), color }
+ return { version: name || tagName, isPrerelease }
}
async handle({ user, repo }, queryParams) {
const latestRelease = await fetchLatestRelease(
this,
{ user, repo },
- queryParams
+ queryParams,
+ )
+ const { version, isPrerelease } = this.constructor.transform(
+ latestRelease,
+ queryParams.display_name,
)
- return this.constructor.render({
- version: latestRelease.tag_name,
- sort: queryParams.sort,
- isPrerelease: latestRelease.prerelease,
+ return renderVersionBadge({
+ version,
+ isPrerelease,
})
}
}
diff --git a/services/github/github-release.spec.js b/services/github/github-release.spec.js
new file mode 100644
index 0000000000000..dc63a49216189
--- /dev/null
+++ b/services/github/github-release.spec.js
@@ -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,
+ })
+ })
+})
diff --git a/services/github/github-release.tester.js b/services/github/github-release.tester.js
index 629eafeecb50f..e7fdb632db731 100644
--- a/services/github/github-release.tester.js
+++ b/services/github/github-release.tester.js
@@ -17,16 +17,22 @@ t.create('Prerelease')
.expectBadge({
label: 'release',
message: isSemver,
- color: Joi.string().allow('blue', 'orange').required(),
+ color: Joi.equal('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' })
t.create('Prerelease (No releases)')
.get('/v/release/badges/daily-tests.json?include_prereleases')
- .expectBadge({ label: 'release', message: 'no releases' })
+ .expectBadge({ label: 'release', message: 'no releases found' })
t.create('Release (repo not found)')
.get('/v/release/badges/helmets.json')
@@ -40,11 +46,11 @@ t.create('Release (legacy route: release)')
t.create('(pre-)Release (legacy route: release/all)')
.get('/release/photonstorm/phaser/all.svg')
.expectRedirect(
- '/github/v/release/photonstorm/phaser.svg?include_prereleases'
+ '/github/v/release/photonstorm/phaser.svg?include_prereleases',
)
t.create('(pre-)Release (legacy route: release-pre)')
.get('/release-pre/photonstorm/phaser.svg')
.expectRedirect(
- '/github/v/release/photonstorm/phaser.svg?include_prereleases'
+ '/github/v/release/photonstorm/phaser.svg?include_prereleases',
)
diff --git a/services/github/github-repo-size.service.js b/services/github/github-repo-size.service.js
index 774ddad426399..75fd71dc677d0 100644
--- a/services/github/github-repo-size.service.js
+++ b/services/github/github-repo-size.service.js
@@ -1,8 +1,9 @@
import Joi from 'joi'
-import prettyBytes from 'pretty-bytes'
+import { pathParams } from '../index.js'
+import { renderSizeBadge } from '../size.js'
import { nonNegativeInteger } from '../validators.js'
import { GithubAuthV3Service } from './github-auth-service.js'
-import { documentation, errorMessagesFor } from './github-helpers.js'
+import { documentation, httpErrorsFor } from './github-helpers.js'
const schema = Joi.object({
size: nonNegativeInteger,
@@ -11,38 +12,39 @@ const schema = Joi.object({
export default class GithubRepoSize extends GithubAuthV3Service {
static category = 'size'
static route = { base: 'github/repo-size', pattern: ':user/:repo' }
- static examples = [
- {
- title: 'GitHub repo size',
- namedParams: {
- user: 'atom',
- repo: 'atom',
+ static openApi = {
+ '/github/repo-size/{user}/{repo}': {
+ get: {
+ summary: 'GitHub repo size',
+ description: documentation,
+ parameters: pathParams(
+ {
+ name: 'user',
+ example: 'atom',
+ },
+ {
+ name: 'repo',
+ example: 'atom',
+ },
+ ),
},
- staticPreview: this.render({ size: 319488 }),
- documentation,
},
- ]
+ }
static defaultBadgeData = { label: 'repo size' }
- static render({ size }) {
- return {
- // note the GH API returns size in Kb
- message: prettyBytes(size * 1024),
- color: 'blue',
- }
- }
-
async fetch({ user, repo }) {
return this._requestJson({
url: `/repos/${user}/${repo}`,
schema,
- errorMessages: errorMessagesFor(),
+ httpErrors: httpErrorsFor(),
})
}
async handle({ user, repo }) {
const { size } = await this.fetch({ user, repo })
- return this.constructor.render({ size })
+ // note the GH API returns size in KiB
+ // so we multiply by 1024 to get a size in bytes and then format that in IEC bytes
+ return renderSizeBadge(size * 1024, 'iec', 'repo size')
}
}
diff --git a/services/github/github-repo-size.tester.js b/services/github/github-repo-size.tester.js
index c39f5c700f4a2..7de73bc446dcd 100644
--- a/services/github/github-repo-size.tester.js
+++ b/services/github/github-repo-size.tester.js
@@ -1,10 +1,10 @@
-import { isFileSize } from '../test-validators.js'
+import { isIecFileSize } from '../test-validators.js'
import { createServiceTester } from '../tester.js'
export const t = await createServiceTester()
t.create('repository size').get('/badges/shields.json').expectBadge({
label: 'repo size',
- message: isFileSize,
+ message: isIecFileSize,
})
t.create('repository size (repo not found)')
diff --git a/services/github/github-search.service.js b/services/github/github-search.service.js
index 361d1b9eb737a..8d89cada76fd9 100644
--- a/services/github/github-search.service.js
+++ b/services/github/github-search.service.js
@@ -1,32 +1,44 @@
import Joi from 'joi'
+import { queryParams, redirector } from '../index.js'
import { metric } from '../text-formatters.js'
import { nonNegativeInteger } from '../validators.js'
import { GithubAuthV3Service } from './github-auth-service.js'
-import { errorMessagesFor, documentation } from './github-helpers.js'
+import { documentation } from './github-helpers.js'
const schema = Joi.object({ total_count: nonNegativeInteger }).required()
-export default class GithubSearch extends GithubAuthV3Service {
+const queryParamSchema = Joi.object({
+ query: Joi.string().required(),
+}).required()
+
+const codeSearchDocs = `
+For a full list of available filters and allowed values,
+see GitHub's documentation on
+[Searching code](https://docs.github.com/en/search-github/github-code-search/understanding-github-code-search-syntax)`
+
+class GitHubCodeSearch extends GithubAuthV3Service {
static category = 'analysis'
static route = {
- base: 'github/search',
- pattern: ':user/:repo/:query+',
+ base: 'github',
+ pattern: 'search',
+ queryParamSchema,
}
- static examples = [
- {
- title: 'GitHub search hit counter',
- pattern: ':user/:repo/:query',
- namedParams: {
- user: 'torvalds',
- repo: 'linux',
- query: 'goto',
+ static openApi = {
+ '/github/search': {
+ get: {
+ summary: 'GitHub code search count',
+ description: documentation,
+ parameters: queryParams({
+ name: 'query',
+ description: codeSearchDocs,
+ example: 'goto language:javascript NOT is:fork NOT is:archived',
+ required: true,
+ }),
},
- staticPreview: this.render({ query: 'goto', totalCount: 14000 }),
- documentation,
},
- ]
+ }
static defaultBadgeData = {
label: 'counter',
@@ -40,17 +52,35 @@ export default class GithubSearch extends GithubAuthV3Service {
}
}
- async handle({ user, repo, query }) {
+ async handle(_routeParams, { query }) {
const { total_count: totalCount } = await this._requestJson({
url: '/search/code',
options: {
- qs: {
- q: `${query} repo:${user}/${repo}`,
+ searchParams: {
+ q: query,
},
},
schema,
- errorMessages: errorMessagesFor('repo not found'),
+ httpErrors: {
+ 401: 'auth required for search api',
+ },
})
+
return this.constructor.render({ query, totalCount })
}
}
+
+const GitHubCodeSearchRedirect = redirector({
+ category: 'analysis',
+ route: {
+ base: 'github/search',
+ pattern: ':user/:repo/:query+',
+ },
+ transformPath: () => '/github/search',
+ transformQueryParams: ({ query, user, repo }) => ({
+ query: `${query} repo:${user}/${repo}`,
+ }),
+ dateAdded: new Date('2024-11-29'),
+})
+
+export { GitHubCodeSearch, GitHubCodeSearchRedirect }
diff --git a/services/github/github-search.tester.js b/services/github/github-search.tester.js
index cb3fa80564a1c..060eb4766ea39 100644
--- a/services/github/github-search.tester.js
+++ b/services/github/github-search.tester.js
@@ -3,9 +3,18 @@ import { createServiceTester } from '../tester.js'
export const t = await createServiceTester()
t.create('hit counter')
- .get('/badges/shields/async%20handle.json')
+ .get('/search.json?query=async%20handle')
.expectBadge({ label: 'async handle counter', message: isMetric })
-t.create('hit counter for nonexistent repo')
- .get('/badges/puppets/async%20handle.json')
- .expectBadge({ label: 'counter', message: 'repo not found' })
+t.create('hit counter, zero results')
+ .get('/search.json?query=async%20handle%20repo%3Abadges%2Fpuppets')
+ .expectBadge({
+ label: 'async handle repo:badges/puppets counter',
+ message: '0',
+ })
+
+t.create('legacy redirect')
+ .get('/search/badges/shields/async%20handle.svg')
+ .expectRedirect(
+ '/github/search.svg?query=async%20handle%20repo%3Abadges%2Fshields',
+ )
diff --git a/services/github/github-size.service.js b/services/github/github-size.service.js
index b932faf17e21a..3b28e4b7d7ca9 100644
--- a/services/github/github-size.service.js
+++ b/services/github/github-size.service.js
@@ -1,15 +1,19 @@
import Joi from 'joi'
-import prettyBytes from 'pretty-bytes'
+import { renderSizeBadge } from '../size.js'
import { nonNegativeInteger } from '../validators.js'
-import { NotFound } from '../index.js'
+import { NotFound, pathParam, queryParam } from '../index.js'
import { GithubAuthV3Service } from './github-auth-service.js'
-import { documentation, errorMessagesFor } from './github-helpers.js'
+import { documentation, httpErrorsFor } from './github-helpers.js'
+
+const queryParamSchema = Joi.object({
+ branch: Joi.string(),
+}).required()
const schema = Joi.alternatives(
Joi.object({
size: nonNegativeInteger,
}).required(),
- Joi.array().required()
+ Joi.array().required(),
)
export default class GithubSize extends GithubAuthV3Service {
@@ -17,43 +21,51 @@ export default class GithubSize extends GithubAuthV3Service {
static route = {
base: 'github/size',
- pattern: ':user/:repo/:path*',
+ pattern: ':user/:repo/:path+',
+ queryParamSchema,
}
- static examples = [
- {
- title: 'GitHub file size in bytes',
- namedParams: {
- user: 'webcaetano',
- repo: 'craft',
- path: 'build/phaser-craft.min.js',
+ static openApi = {
+ '/github/size/{user}/{repo}/{path}': {
+ get: {
+ summary: 'GitHub file size in bytes',
+ description: documentation,
+ parameters: [
+ pathParam({ name: 'user', example: 'webcaetano' }),
+ pathParam({ name: 'repo', example: 'craft' }),
+ pathParam({ name: 'path', example: 'build/phaser-craft.min.js' }),
+ queryParam({
+ name: 'branch',
+ example: 'master',
+ description: 'Can be a branch, a tag or a commit hash.',
+ }),
+ ],
},
- staticPreview: this.render({ size: 9170 }),
- keywords: ['repo'],
- documentation,
},
- ]
-
- static render({ size }) {
- return {
- message: prettyBytes(size),
- color: 'blue',
- }
}
- async fetch({ user, repo, path }) {
- return this._requestJson({
- url: `/repos/${user}/${repo}/contents/${path}`,
- schema,
- errorMessages: errorMessagesFor('repo or file not found'),
- })
+ async fetch({ user, repo, path, branch }) {
+ if (branch) {
+ return this._requestJson({
+ url: `/repos/${user}/${repo}/contents/${path}?ref=${branch}`,
+ schema,
+ httpErrors: httpErrorsFor('repo, branch or file not found'),
+ })
+ } else {
+ return this._requestJson({
+ url: `/repos/${user}/${repo}/contents/${path}`,
+ schema,
+ httpErrors: httpErrorsFor('repo or file not found'),
+ })
+ }
}
- async handle({ user, repo, path }) {
- const body = await this.fetch({ user, repo, path })
+ async handle({ user, repo, path }, queryParams) {
+ const branch = queryParams.branch
+ const body = await this.fetch({ user, repo, path, branch })
if (Array.isArray(body)) {
throw new NotFound({ prettyMessage: 'not a regular file' })
}
- return this.constructor.render({ size: body.size })
+ return renderSizeBadge(body.size, 'iec')
}
}
diff --git a/services/github/github-size.tester.js b/services/github/github-size.tester.js
index db61aa9ac9211..5a425112e3cd7 100644
--- a/services/github/github-size.tester.js
+++ b/services/github/github-size.tester.js
@@ -1,15 +1,31 @@
-import { isFileSize } from '../test-validators.js'
+import { isIecFileSize } from '../test-validators.js'
import { createServiceTester } from '../tester.js'
export const t = await createServiceTester()
t.create('File size')
.get('/webcaetano/craft/build/phaser-craft.min.js.json')
- .expectBadge({ label: 'size', message: isFileSize })
+ .expectBadge({ label: 'size', message: isIecFileSize })
t.create('File size 404')
.get('/webcaetano/craft/build/does-not-exist.min.js.json')
.expectBadge({ label: 'size', message: 'repo or file not found' })
+t.create('File size for nonexisting branch')
+ .get('/webcaetano/craft/build/phaser-craft.min.js.json?branch=notARealBranch')
+ .expectBadge({ label: 'size', message: 'repo, branch or file not found' })
+
t.create('File size for "not a regular file"')
.get('/webcaetano/craft/build.json')
.expectBadge({ label: 'size', message: 'not a regular file' })
+
+t.create('File size for a specified branch')
+ .get('/webcaetano/craft/build/craft.min.js.json?branch=version-2')
+ .expectBadge({ label: 'size', message: isIecFileSize })
+
+t.create('File size for a specified tag')
+ .get('/webcaetano/craft/build/phaser-craft.min.js.json?branch=2.1.2')
+ .expectBadge({ label: 'size', message: isIecFileSize })
+
+t.create('File size for a specified commit')
+ .get('/webcaetano/craft/build/phaser-craft.min.js.json?branch=b848dbb')
+ .expectBadge({ label: 'size', message: isIecFileSize })
diff --git a/services/github/github-sponsors.service.js b/services/github/github-sponsors.service.js
index 825104094c7aa..88e112a323de3 100644
--- a/services/github/github-sponsors.service.js
+++ b/services/github/github-sponsors.service.js
@@ -2,7 +2,7 @@ import gql from 'graphql-tag'
import Joi from 'joi'
import { metric } from '../text-formatters.js'
import { nonNegativeInteger } from '../validators.js'
-import { NotFound } from '../index.js'
+import { NotFound, pathParams } from '../index.js'
import { GithubAuthV4Service } from './github-auth-service.js'
import { documentation, transformErrors } from './github-helpers.js'
@@ -19,14 +19,18 @@ const schema = Joi.object({
export default class GithubSponsors extends GithubAuthV4Service {
static category = 'funding'
static route = { base: 'github/sponsors', pattern: ':user' }
- static examples = [
- {
- title: 'GitHub Sponsors',
- namedParams: { user: 'Homebrew' },
- staticPreview: this.render({ count: 217 }),
- documentation,
+ static openApi = {
+ '/github/sponsors/{user}': {
+ get: {
+ summary: 'GitHub Sponsors',
+ description: documentation,
+ parameters: pathParams({
+ name: 'user',
+ example: 'Homebrew',
+ }),
+ },
},
- ]
+ }
static defaultBadgeData = {
label: 'sponsors',
@@ -45,12 +49,12 @@ export default class GithubSponsors extends GithubAuthV4Service {
query ($user: String!) {
repositoryOwner(login: $user) {
... on User {
- sponsorshipsAsMaintainer {
+ sponsorshipsAsMaintainer(includePrivate: true) {
totalCount
}
}
... on Organization {
- sponsorshipsAsMaintainer {
+ sponsorshipsAsMaintainer(includePrivate: true) {
totalCount
}
}
diff --git a/services/github/github-stars.service.js b/services/github/github-stars.service.js
index c790da3f23815..15a951df80b34 100644
--- a/services/github/github-stars.service.js
+++ b/services/github/github-stars.service.js
@@ -1,8 +1,9 @@
import Joi from 'joi'
+import { pathParams } from '../index.js'
import { metric } from '../text-formatters.js'
import { nonNegativeInteger } from '../validators.js'
import { GithubAuthV3Service } from './github-auth-service.js'
-import { documentation, errorMessagesFor } from './github-helpers.js'
+import { documentation, httpErrorsFor } from './github-helpers.js'
const schema = Joi.object({
stargazers_count: nonNegativeInteger,
@@ -16,24 +17,18 @@ export default class GithubStars extends GithubAuthV3Service {
pattern: ':user/:repo',
}
- static examples = [
- {
- title: 'GitHub Repo stars',
- namedParams: {
- user: 'badges',
- repo: 'shields',
+ static openApi = {
+ '/github/stars/{user}/{repo}': {
+ get: {
+ summary: 'GitHub Repo stars',
+ description: documentation,
+ parameters: pathParams(
+ { name: 'user', example: 'badges' },
+ { name: 'repo', example: 'shields' },
+ ),
},
- queryParams: { style: 'social' },
- // TODO: This is currently a literal, as `staticPreview` doesn't
- // support `link`.
- staticPreview: {
- label: 'Stars',
- message: '7k',
- style: 'social',
- },
- documentation,
},
- ]
+ }
static defaultBadgeData = {
label: 'stars',
@@ -44,6 +39,7 @@ export default class GithubStars extends GithubAuthV3Service {
const slug = `${encodeURIComponent(user)}/${encodeURIComponent(repo)}`
return {
message: metric(stars),
+ style: 'social',
color: 'blue',
link: [
`https://github.com/${slug}`,
@@ -56,7 +52,7 @@ export default class GithubStars extends GithubAuthV3Service {
const { stargazers_count: stars } = await this._requestJson({
url: `/repos/${user}/${repo}`,
schema,
- errorMessages: errorMessagesFor(),
+ httpErrors: httpErrorsFor(),
})
return this.constructor.render({ user, repo, stars })
}
diff --git a/services/github/github-tag.service.js b/services/github/github-tag.service.js
index d97201d518853..42a9ced1e7f66 100644
--- a/services/github/github-tag.service.js
+++ b/services/github/github-tag.service.js
@@ -1,11 +1,13 @@
import gql from 'graphql-tag'
import Joi from 'joi'
-import { addv } from '../text-formatters.js'
-import { version as versionColor } from '../color-formatters.js'
-import { latest } from '../version.js'
-import { NotFound, redirector } from '../index.js'
+import { matcher } from 'matcher'
+import { latest, renderVersionBadge } from '../version.js'
+import { NotFound, redirector, pathParam } from '../index.js'
import { GithubAuthV4Service } from './github-auth-service.js'
-import { queryParamSchema } from './github-common-release.js'
+import {
+ queryParamSchema,
+ openApiQueryParams,
+} from './github-common-release.js'
import { documentation, transformErrors } from './github-helpers.js'
const schema = Joi.object({
@@ -33,48 +35,39 @@ class GithubTag extends GithubAuthV4Service {
queryParamSchema,
}
- static examples = [
- {
- title: 'GitHub tag (latest by date)',
- namedParams: { user: 'expressjs', repo: 'express' },
- staticPreview: this.render({
- version: 'v5.0.0-alpha.7',
- sort: 'date',
- }),
- documentation,
- },
- {
- title: 'GitHub tag (latest SemVer)',
- namedParams: { user: 'expressjs', repo: 'express' },
- queryParams: { sort: 'semver' },
- staticPreview: this.render({ version: 'v4.16.4', sort: 'semver' }),
- documentation,
+ static openApi = {
+ '/github/v/tag/{user}/{repo}': {
+ get: {
+ summary: 'GitHub Tag',
+ description: documentation,
+ parameters: [
+ pathParam({ name: 'user', example: 'expressjs' }),
+ pathParam({ name: 'repo', example: 'express' }),
+ ...openApiQueryParams,
+ ],
+ },
},
- {
- title: 'GitHub tag (latest SemVer pre-release)',
- namedParams: { user: 'expressjs', repo: 'express' },
- queryParams: { sort: 'semver', include_prereleases: null },
- staticPreview: this.render({
- version: 'v5.0.0-alpha.7',
- sort: 'semver',
- }),
- documentation,
- },
- ]
+ }
static defaultBadgeData = {
label: 'tag',
}
- static render({ version, sort }) {
- return {
- message: addv(version),
- color: sort === 'semver' ? versionColor(version) : 'blue',
+ static getLimit({ sort, filter }) {
+ if (!filter && sort === 'date') {
+ return 1
}
+ return 100
}
- async fetch({ user, repo, sort }) {
- const limit = sort === 'semver' ? 100 : 1
+ static applyFilter({ tags, filter }) {
+ if (!filter) {
+ return tags
+ }
+ return matcher(tags, filter)
+ }
+
+ async fetch({ user, repo, limit }) {
return this._requestGraphql({
query: gql`
query ($user: String!, $repo: String!, $limit: Int!) {
@@ -109,19 +102,24 @@ class GithubTag extends GithubAuthV4Service {
async handle({ user, repo }, queryParams) {
const sort = queryParams.sort
const includePrereleases = queryParams.include_prereleases !== undefined
+ const filter = queryParams.filter
+ const limit = this.constructor.getLimit({ sort, filter })
- const json = await this.fetch({ user, repo, sort })
- const tags = json.data.repository.refs.edges.map(edge => edge.node.name)
+ const json = await this.fetch({ user, repo, limit })
+ const tags = this.constructor.applyFilter({
+ tags: json.data.repository.refs.edges.map(edge => edge.node.name),
+ filter,
+ })
if (tags.length === 0) {
- throw new NotFound({ prettyMessage: 'no tags found' })
+ const prettyMessage = filter ? 'no matching tags found' : 'no tags found'
+ throw new NotFound({ prettyMessage })
}
- return this.constructor.render({
+ return renderVersionBadge({
version: this.constructor.getLatestTag({
tags,
sort,
includePrereleases,
}),
- sort,
})
}
}
diff --git a/services/github/github-tag.spec.js b/services/github/github-tag.spec.js
index c0fbd51e1b14e..9645a7f3930e9 100644
--- a/services/github/github-tag.spec.js
+++ b/services/github/github-tag.spec.js
@@ -43,14 +43,23 @@ describe('GithubTag', function () {
}).expect('1.2.0-beta')
})
- test(GithubTag.render, () => {
- given({ usingSemver: false, version: '1.2.3' }).expect({
- message: 'v1.2.3',
- color: 'blue',
- })
- given({ usingSemver: true, version: '2.0.0' }).expect({
- message: 'v2.0.0',
- color: 'blue',
- })
+ test(GithubTag.getLimit, () => {
+ given({ sort: 'date', filter: undefined }).expect(1)
+ given({ sort: 'date', filter: '' }).expect(1)
+ given({ sort: 'date', filter: '!*-dev' }).expect(100)
+ given({ sort: 'semver', filter: undefined }).expect(100)
+ given({ sort: 'semver', filter: '' }).expect(100)
+ given({ sort: 'semver', filter: '!*-dev' }).expect(100)
+ })
+
+ test(GithubTag.applyFilter, () => {
+ const tags = ['v1.1.0', 'v1.2.0', 'server-2022-01-01']
+ given({ tags, filter: undefined }).expect(tags)
+ given({ tags, filter: '' }).expect(tags)
+ given({ tags, filter: '*' }).expect(tags)
+ given({ tags, filter: '!*' }).expect([])
+ given({ tags, filter: 'foo' }).expect([])
+ given({ tags, filter: 'server-*' }).expect(['server-2022-01-01'])
+ given({ tags, filter: '!server-*' }).expect(['v1.1.0', 'v1.2.0'])
})
})
diff --git a/services/github/github-tag.tester.js b/services/github/github-tag.tester.js
index 1a804c288621d..2908f93bfb5fc 100644
--- a/services/github/github-tag.tester.js
+++ b/services/github/github-tag.tester.js
@@ -17,7 +17,7 @@ t.create('Tag (inc pre-release)')
.expectBadge({
label: 'tag',
message: isSemver,
- color: Joi.string().allow('blue', 'orange').required(),
+ color: Joi.equal('blue', 'orange').required(),
})
t.create('Tag (no tags)')
@@ -36,7 +36,7 @@ t.create('Tag (legacy route: tag)')
t.create('Tag (legacy route: tag-pre)')
.get('/tag-pre/photonstorm/phaser.svg')
.expectRedirect(
- '/github/v/tag/photonstorm/phaser.svg?include_prereleases&sort=semver'
+ '/github/v/tag/photonstorm/phaser.svg?sort=semver&include_prereleases',
)
t.create('Tag (legacy route: tag-date)')
diff --git a/services/github/github-top-language.service.js b/services/github/github-top-language.service.js
index 21aac82113531..8d0dfcc6f43b8 100644
--- a/services/github/github-top-language.service.js
+++ b/services/github/github-top-language.service.js
@@ -1,3 +1,4 @@
+import { pathParams } from '../index.js'
import { BaseGithubLanguage } from './github-languages-base.js'
import { documentation } from './github-helpers.js'
@@ -9,21 +10,24 @@ export default class GithubTopLanguage extends BaseGithubLanguage {
pattern: ':user/:repo',
}
- static examples = [
- {
- title: 'GitHub top language',
- namedParams: {
- user: 'badges',
- repo: 'shields',
+ static openApi = {
+ '/github/languages/top/{user}/{repo}': {
+ get: {
+ summary: 'GitHub top language',
+ description: documentation,
+ parameters: pathParams(
+ {
+ name: 'user',
+ example: 'badges',
+ },
+ {
+ name: 'repo',
+ example: 'shields',
+ },
+ ),
},
- staticPreview: this.render({
- language: 'javascript',
- languageSize: 99.5,
- totalSize: 100,
- }),
- documentation,
},
- ]
+ }
static defaultBadgeData = {
label: 'language',
@@ -41,7 +45,7 @@ export default class GithubTopLanguage extends BaseGithubLanguage {
const data = await this.fetch({ user, repo })
const language = Object.keys(data).reduce(
(a, b) => (data[a] > data[b] ? a : b),
- 'language'
+ 'language',
)
return this.constructor.render({
language,
diff --git a/services/github/github-top-language.tester.js b/services/github/github-top-language.tester.js
index 5bfea8d71299a..e4d84e1f5a439 100644
--- a/services/github/github-top-language.tester.js
+++ b/services/github/github-top-language.tester.js
@@ -1,13 +1,12 @@
-import Joi from 'joi'
import { createServiceTester } from '../tester.js'
+import { isDecimalPercentage } from '../test-validators.js'
+
export const t = await createServiceTester()
-t.create('top language')
- .get('/badges/shields.json')
- .expectBadge({
- label: 'javascript',
- message: Joi.string().regex(/^([1-9]?[0-9]\.[0-9]|100\.0)%$/),
- })
+t.create('top language').get('/badges/shields.json').expectBadge({
+ label: 'javascript',
+ message: isDecimalPercentage,
+})
t.create('top language (empty repo)')
.get('/pyvesb/emptyrepo.json')
diff --git a/services/github/github-total-star.service.js b/services/github/github-total-star.service.js
index 70461fe72c64f..a70c1cd727c8b 100644
--- a/services/github/github-total-star.service.js
+++ b/services/github/github-total-star.service.js
@@ -1,5 +1,6 @@
import Joi from 'joi'
import gql from 'graphql-tag'
+import { pathParam, queryParam } from '../index.js'
import { nonNegativeInteger } from '../validators.js'
import { metric } from '../text-formatters.js'
import { GithubAuthV4Service } from './github-auth-service.js'
@@ -10,21 +11,14 @@ import {
const MAX_REPO_LIMIT = 200
-const customDocumentation = `This badge takes into account up to ${MAX_REPO_LIMIT} of the most starred repositories of given user / org.`
+const description = `${commonDocumentation}
-const userDocumentation = `${commonDocumentation}
-
- Note:
- 1. ${customDocumentation}
- 2. affiliations query param accepts three values (must be UPPER case) OWNER, COLLABORATOR, ORGANIZATION_MEMBER.
- One can pass comma separated combinations of these values (no spaces) e.g. OWNER,COLLABORATOR or OWNER,COLLABORATOR,ORGANIZATION_MEMBER.
- Default value is OWNER. See the explanation of these values here .
-
+Note: This badge takes into account up to ${MAX_REPO_LIMIT} of the most starred repositories of given user / org.
`
-const orgDocumentation = `${commonDocumentation}
-
- Note: ${customDocumentation}
-
`
+
+const affiliationsDescription = `This param accepts three values (must be UPPER case) OWNER, COLLABORATOR, ORGANIZATION_MEMBER.
+One can pass comma separated combinations of these values (no spaces) e.g. OWNER,COLLABORATOR or OWNER,COLLABORATOR,ORGANIZATION_MEMBER.
+Default value is OWNER. See the explanation of these values here .`
const pageInfoSchema = Joi.object({
hasNextPage: Joi.boolean().required(),
@@ -37,7 +31,7 @@ const nodesSchema = Joi.array()
stargazers: Joi.object({
totalCount: nonNegativeInteger,
}).required(),
- })
+ }),
)
.default([])
@@ -57,7 +51,7 @@ const schema = Joi.object({
organization: Joi.object({
repositories: repositoriesSchema,
}).required(),
- }).required()
+ }).required(),
).required(),
}).required()
@@ -108,7 +102,7 @@ const query = gql`
const affiliationsAllowedValues = [
'OWNER',
- `COLLABORATOR`,
+ 'COLLABORATOR',
'ORGANIZATION_MEMBER',
]
/**
@@ -141,34 +135,29 @@ export default class GithubTotalStarService extends GithubAuthV4Service {
queryParamSchema,
}
- static examples = [
- {
- title: "GitHub User's stars",
- namedParams: {
- user: 'chris48s',
+ static openApi = {
+ '/github/stars/{user}': {
+ get: {
+ summary: "GitHub User's stars",
+ description,
+ parameters: [
+ pathParam({ name: 'user', example: 'chris48s' }),
+ queryParam({
+ name: 'affiliations',
+ example: 'OWNER,COLLABORATOR',
+ description: affiliationsDescription,
+ }),
+ ],
},
- queryParams: { affiliations: 'OWNER,COLLABORATOR' },
- staticPreview: {
- label: this.defaultLabel,
- message: 54,
- style: 'social',
- },
- documentation: userDocumentation,
},
- {
- title: "GitHub Org's stars",
- pattern: ':org',
- namedParams: {
- org: 'badges',
- },
- staticPreview: {
- label: this.defaultLabel,
- message: metric(7000),
- style: 'social',
+ '/github/stars/{org}': {
+ get: {
+ summary: "GitHub Org's stars",
+ description,
+ parameters: [pathParam({ name: 'org', example: 'badges' })],
},
- documentation: orgDocumentation,
},
- ]
+ }
static defaultBadgeData = {
label: this.defaultLabel,
@@ -178,6 +167,7 @@ export default class GithubTotalStarService extends GithubAuthV4Service {
static render({ totalStars, user }) {
return {
message: metric(totalStars),
+ style: 'social',
color: 'blue',
link: [`https://github.com/${user}`],
}
diff --git a/services/github/github-total-star.tester.js b/services/github/github-total-star.tester.js
index e67516275fe2f..cce7ab308c676 100644
--- a/services/github/github-total-star.tester.js
+++ b/services/github/github-total-star.tester.js
@@ -51,6 +51,7 @@ t.create('Stars (Org)')
})
t.create('Stars (Org) Lots of repo')
+ .timeout(15000)
.get('/github.json')
.expectBadge({
label: 'stars',
diff --git a/services/github/github-watchers.service.js b/services/github/github-watchers.service.js
index 088757ff93a50..624d8aa9aead4 100644
--- a/services/github/github-watchers.service.js
+++ b/services/github/github-watchers.service.js
@@ -1,8 +1,9 @@
import Joi from 'joi'
+import { pathParams } from '../index.js'
import { metric } from '../text-formatters.js'
import { nonNegativeInteger } from '../validators.js'
import { GithubAuthV3Service } from './github-auth-service.js'
-import { documentation, errorMessagesFor } from './github-helpers.js'
+import { documentation, httpErrorsFor } from './github-helpers.js'
const schema = Joi.object({
subscribers_count: nonNegativeInteger,
@@ -16,24 +17,18 @@ export default class GithubWatchers extends GithubAuthV3Service {
pattern: ':user/:repo',
}
- static examples = [
- {
- title: 'GitHub watchers',
- namedParams: {
- user: 'badges',
- repo: 'shields',
+ static openApi = {
+ '/github/watchers/{user}/{repo}': {
+ get: {
+ summary: 'GitHub watchers',
+ description: documentation,
+ parameters: pathParams(
+ { name: 'user', example: 'badges' },
+ { name: 'repo', example: 'shields' },
+ ),
},
- // TODO: This is currently a literal, as `staticPreview` doesn't
- // support `link`.
- staticPreview: {
- label: 'Watch',
- message: '96',
- style: 'social',
- },
- queryParams: { label: 'Watch' },
- documentation,
},
- ]
+ }
static defaultBadgeData = {
label: 'watchers',
@@ -43,6 +38,7 @@ export default class GithubWatchers extends GithubAuthV3Service {
static render({ watchers, user, repo }) {
return {
message: metric(watchers),
+ style: 'social',
color: 'blue',
link: [
`https://github.com/${user}/${repo}`,
@@ -55,7 +51,7 @@ export default class GithubWatchers extends GithubAuthV3Service {
const { subscribers_count: watchers } = await this._requestJson({
url: `/repos/${user}/${repo}`,
schema,
- errorMessages: errorMessagesFor(),
+ httpErrors: httpErrorsFor(),
})
return this.constructor.render({ user, repo, watchers })
}
diff --git a/services/github/github-workflow-status.service.js b/services/github/github-workflow-status.service.js
index f5467d7b2aac8..26b34727dbb52 100644
--- a/services/github/github-workflow-status.service.js
+++ b/services/github/github-workflow-status.service.js
@@ -1,100 +1,12 @@
-import Joi from 'joi'
-import { isBuildStatus, renderBuildStatusBadge } from '../build-status.js'
-import { BaseSvgScrapingService } from '../index.js'
-import { documentation } from './github-helpers.js'
+import { deprecatedService } from '../index.js'
-const schema = Joi.object({
- message: Joi.alternatives()
- .try(isBuildStatus, Joi.equal('no status'))
- .required(),
-}).required()
-
-const queryParamSchema = Joi.object({
- event: Joi.string(),
-}).required()
-
-const keywords = ['action', 'actions']
-
-export default class GithubWorkflowStatus extends BaseSvgScrapingService {
- static category = 'build'
-
- static route = {
+export default deprecatedService({
+ category: 'build',
+ route: {
base: 'github/workflow/status',
- pattern: ':user/:repo/:workflow/:branch*',
- queryParamSchema,
- }
-
- static examples = [
- {
- title: 'GitHub Workflow Status',
- pattern: ':user/:repo/:workflow',
- namedParams: {
- user: 'actions',
- repo: 'toolkit',
- workflow: 'toolkit-unit-tests',
- },
- staticPreview: renderBuildStatusBadge({
- status: 'passing',
- }),
- documentation,
- keywords,
- },
- {
- title: 'GitHub Workflow Status (branch)',
- pattern: ':user/:repo/:workflow/:branch',
- namedParams: {
- user: 'actions',
- repo: 'toolkit',
- workflow: 'toolkit-unit-tests',
- branch: 'master',
- },
- staticPreview: renderBuildStatusBadge({
- status: 'passing',
- }),
- documentation,
- keywords,
- },
- {
- title: 'GitHub Workflow Status (event)',
- pattern: ':user/:repo/:workflow',
- namedParams: {
- user: 'actions',
- repo: 'toolkit',
- workflow: 'toolkit-unit-tests',
- },
- queryParams: {
- event: 'push',
- },
- staticPreview: renderBuildStatusBadge({
- status: 'passing',
- }),
- documentation,
- keywords,
- },
- ]
-
- static defaultBadgeData = {
- label: 'build',
- }
-
- async fetch({ user, repo, workflow, branch, event }) {
- const { message: status } = await this._requestSvg({
- schema,
- url: `https://github.com/${user}/${repo}/workflows/${encodeURIComponent(
- workflow
- )}/badge.svg`,
- options: { qs: { branch, event } },
- valueMatcher: />([^<>]+)<\/tspan><\/text><\/g>
+ this.fetchPage({ page: ++i + 1, requestParams, schema }),
+ ),
+ )
+ return [...data].concat(...pageData)
+ }
}
diff --git a/services/gitlab/gitlab-contributors-redirect.service.js b/services/gitlab/gitlab-contributors-redirect.service.js
new file mode 100644
index 0000000000000..ca1f2587bf891
--- /dev/null
+++ b/services/gitlab/gitlab-contributors-redirect.service.js
@@ -0,0 +1,13 @@
+import { deprecatedService } from '../index.js'
+
+// https://github.com/badges/shields/issues/8138
+export default deprecatedService({
+ category: 'build',
+ label: 'gitlab',
+ route: {
+ base: 'gitlab/v/contributor',
+ pattern: ':project+',
+ },
+ dateAdded: new Date('2025-12-20'),
+ issueUrl: 'https://github.com/badges/shields/pull/11583',
+})
diff --git a/services/gitlab/gitlab-contributors-redirect.tester.js b/services/gitlab/gitlab-contributors-redirect.tester.js
new file mode 100644
index 0000000000000..ddc26541ab95e
--- /dev/null
+++ b/services/gitlab/gitlab-contributors-redirect.tester.js
@@ -0,0 +1,7 @@
+import { createServiceTester } from '../tester.js'
+export const t = await createServiceTester()
+
+t.create('Contributors deprecated').get('/gitlab-org/gitlab.json').expectBadge({
+ label: 'gitlab',
+ message: 'https://github.com/badges/shields/pull/11583',
+})
diff --git a/services/gitlab/gitlab-contributors.service.js b/services/gitlab/gitlab-contributors.service.js
new file mode 100644
index 0000000000000..d9fdc2d2cffa6
--- /dev/null
+++ b/services/gitlab/gitlab-contributors.service.js
@@ -0,0 +1,64 @@
+import Joi from 'joi'
+import { pathParam, queryParam } from '../index.js'
+import { optionalUrl, nonNegativeInteger } from '../validators.js'
+import { renderContributorBadge } from '../contributor-count.js'
+import { description, httpErrorsFor } from './gitlab-helper.js'
+import GitLabBase from './gitlab-base.js'
+
+const schema = Joi.object({ 'x-total': nonNegativeInteger }).required()
+
+const queryParamSchema = Joi.object({
+ gitlab_url: optionalUrl,
+}).required()
+
+export default class GitlabContributors extends GitLabBase {
+ static category = 'activity'
+ static route = {
+ base: 'gitlab/contributors',
+ pattern: ':project+',
+ queryParamSchema,
+ }
+
+ static openApi = {
+ '/gitlab/contributors/{project}': {
+ get: {
+ summary: 'GitLab Contributors',
+ description,
+ parameters: [
+ pathParam({
+ name: 'project',
+ example: 'gitlab-org/gitlab',
+ }),
+ queryParam({
+ name: 'gitlab_url',
+ example: 'https://gitlab.com',
+ }),
+ ],
+ },
+ },
+ }
+
+ static defaultBadgeData = { label: 'contributors' }
+
+ static render({ contributorCount }) {
+ return renderContributorBadge({ contributorCount })
+ }
+
+ async handle({ project }, { gitlab_url: baseUrl = 'https://gitlab.com' }) {
+ // https://docs.gitlab.com/ee/api/repositories.html#contributors
+ const { res } = await this._request(
+ this.authHelper.withBearerAuthHeader({
+ url: `${baseUrl}/api/v4/projects/${encodeURIComponent(
+ project,
+ )}/repository/contributors`,
+ options: { searchParams: { page: '1', per_page: '1' } },
+ httpErrors: httpErrorsFor('project not found'),
+ }),
+ )
+ const data = this.constructor._validate(res.headers, schema)
+ // The total number of contributors is in the `x-total` field in the headers.
+ // https://docs.gitlab.com/ee/api/index.html#other-pagination-headers
+ const contributorCount = data['x-total']
+ return this.constructor.render({ contributorCount })
+ }
+}
diff --git a/services/gitlab/gitlab-contributors.tester.js b/services/gitlab/gitlab-contributors.tester.js
new file mode 100644
index 0000000000000..1d93b88be6426
--- /dev/null
+++ b/services/gitlab/gitlab-contributors.tester.js
@@ -0,0 +1,42 @@
+import { createServiceTester } from '../tester.js'
+import { isMetric } from '../test-validators.js'
+import { noToken } from '../test-helpers.js'
+import _noGitLabToken from './gitlab-contributors.service.js'
+export const t = await createServiceTester()
+const noGitLabToken = noToken(_noGitLabToken)
+
+t.create('Contributors')
+ .get('/guoxudong.io/shields-test/licenced-test.json')
+ .expectBadge({
+ label: 'contributors',
+ message: isMetric,
+ })
+
+t.create('Contributors (repo not found)')
+ .get('/guoxudong.io/shields-test/do-not-exist.json')
+ .expectBadge({
+ label: 'contributors',
+ message: 'project not found',
+ })
+
+t.create('Mocking the missing x-total header')
+ .get('/group/project.json')
+ .intercept(nock =>
+ nock('https://gitlab.com')
+ .get(
+ '/api/v4/projects/group%2Fproject/repository/contributors?page=1&per_page=1',
+ )
+ .reply(200),
+ )
+ .expectBadge({
+ label: 'contributors',
+ message: 'invalid response data',
+ })
+
+t.create('Contributors (private repo)')
+ .skipWhen(noGitLabToken)
+ .get('/shields-ops-group/test.json')
+ .expectBadge({
+ label: 'contributors',
+ message: isMetric,
+ })
diff --git a/services/gitlab/gitlab-coverage-redirect.service.js b/services/gitlab/gitlab-coverage-redirect.service.js
new file mode 100644
index 0000000000000..baed5d89d920a
--- /dev/null
+++ b/services/gitlab/gitlab-coverage-redirect.service.js
@@ -0,0 +1,12 @@
+import { deprecatedService } from '../index.js'
+
+export default deprecatedService({
+ category: 'coverage',
+ label: 'gitlab',
+ route: {
+ base: 'gitlab/coverage',
+ pattern: ':user/:repo/:branch',
+ },
+ dateAdded: new Date('2025-12-20'),
+ issueUrl: 'https://github.com/badges/shields/pull/11583',
+})
diff --git a/services/gitlab/gitlab-coverage-redirect.tester.js b/services/gitlab/gitlab-coverage-redirect.tester.js
new file mode 100644
index 0000000000000..69be7ec5a0c2e
--- /dev/null
+++ b/services/gitlab/gitlab-coverage-redirect.tester.js
@@ -0,0 +1,25 @@
+import { createServiceTester } from '../tester.js'
+export const t = await createServiceTester()
+
+t.create('Coverage deprecated (with branch)')
+ .get('/gitlab-org/gitlab-runner/master.json')
+ .expectBadge({
+ label: 'gitlab',
+ message: 'https://github.com/badges/shields/pull/11583',
+ })
+
+t.create('Coverage deprecated (with branch and job_name)')
+ .get('/gitlab-org/gitlab-runner/master.json?job_name=test coverage report')
+ .expectBadge({
+ label: 'gitlab',
+ message: 'https://github.com/badges/shields/pull/11583',
+ })
+
+t.create('Coverage deprecated (with branch and gitlab_url)')
+ .get(
+ '/gitlab-org/gitlab-runner/master.json?gitlab_url=https://gitlab.gnome.org',
+ )
+ .expectBadge({
+ label: 'gitlab',
+ message: 'https://github.com/badges/shields/pull/11583',
+ })
diff --git a/services/gitlab/gitlab-coverage.service.js b/services/gitlab/gitlab-coverage.service.js
deleted file mode 100644
index f7b4c045d8b9b..0000000000000
--- a/services/gitlab/gitlab-coverage.service.js
+++ /dev/null
@@ -1,139 +0,0 @@
-import Joi from 'joi'
-import { coveragePercentage } from '../color-formatters.js'
-import { optionalUrl } from '../validators.js'
-import { BaseSvgScrapingService, NotFound } from '../index.js'
-
-const schema = Joi.object({
- message: Joi.string()
- .regex(/^([0-9]+\.[0-9]+%)|unknown$/)
- .required(),
-}).required()
-
-const queryParamSchema = Joi.object({
- gitlab_url: optionalUrl,
- job_name: Joi.string(),
-}).required()
-
-const documentation = `
-
- Important: If your project is publicly visible, but the badge is like this:
-
-
-
- Check if your pipelines are publicly visible as well.
- Navigate to your project settings on GitLab and choose General Pipelines under CI/CD.
- Then tick the setting Public pipelines.
-
-
- Now your settings should look like this:
-
-
-
-Also make sure you have set up code covrage parsing as described here
-
-
- Your badge should be working fine now.
-
-`
-
-export default class GitlabCoverage extends BaseSvgScrapingService {
- static category = 'coverage'
-
- static route = {
- base: 'gitlab/coverage',
- pattern: ':user/:repo/:branch',
- queryParamSchema,
- }
-
- static examples = [
- {
- title: 'Gitlab code coverage',
- namedParams: {
- user: 'gitlab-org',
- repo: 'gitlab-runner',
- branch: 'master',
- },
- staticPreview: this.render({ coverage: 67 }),
- documentation,
- },
- {
- title: 'Gitlab code coverage (specific job)',
- namedParams: {
- user: 'gitlab-org',
- repo: 'gitlab-runner',
- branch: 'master',
- },
- queryParams: { job_name: 'test coverage report' },
- staticPreview: this.render({ coverage: 96 }),
- documentation,
- },
- {
- title: 'Gitlab code coverage (self-hosted)',
- namedParams: { user: 'GNOME', repo: 'libhandy', branch: 'master' },
- queryParams: { gitlab_url: 'https://gitlab.gnome.org' },
- staticPreview: this.render({ coverage: 93 }),
- documentation,
- },
- {
- title: 'Gitlab code coverage (self-hosted, specific job)',
- namedParams: { user: 'GNOME', repo: 'libhandy', branch: 'master' },
- queryParams: {
- gitlab_url: 'https://gitlab.gnome.org',
- job_name: 'unit-test',
- },
- staticPreview: this.render({ coverage: 93 }),
- documentation,
- },
- ]
-
- static defaultBadgeData = { label: 'coverage' }
-
- static render({ coverage }) {
- return {
- message: `${coverage.toFixed(0)}%`,
- color: coveragePercentage(coverage),
- }
- }
-
- async fetch({
- user,
- repo,
- branch,
- gitlab_url: baseUrl = 'https://gitlab.com',
- job_name: jobName,
- }) {
- // Since the URL doesn't return a usable value when an invalid job name is specified,
- // it is recommended to not use the query param at all if not required
- jobName = jobName ? `?job=${jobName}` : ''
- const url = `${baseUrl}/${user}/${repo}/badges/${branch}/coverage.svg${jobName}`
- const errorMessages = {
- 401: 'repo not found',
- 404: 'repo not found',
- }
- return this._requestSvg({
- schema,
- url,
- errorMessages,
- })
- }
-
- static transform({ coverage }) {
- if (coverage === 'unknown') {
- throw new NotFound({ prettyMessage: 'not set up' })
- }
- return Number(coverage.slice(0, -1))
- }
-
- async handle({ user, repo, branch }, { gitlab_url, job_name }) {
- const { message: coverage } = await this.fetch({
- user,
- repo,
- branch,
- gitlab_url,
- job_name,
- })
- return this.constructor.render({
- coverage: this.constructor.transform({ coverage }),
- })
- }
-}
diff --git a/services/gitlab/gitlab-forks.service.js b/services/gitlab/gitlab-forks.service.js
new file mode 100644
index 0000000000000..fcfe0f1e42bc1
--- /dev/null
+++ b/services/gitlab/gitlab-forks.service.js
@@ -0,0 +1,76 @@
+import Joi from 'joi'
+import { pathParam, queryParam } from '../index.js'
+import { optionalUrl, nonNegativeInteger } from '../validators.js'
+import { metric } from '../text-formatters.js'
+import GitLabBase from './gitlab-base.js'
+import { description } from './gitlab-helper.js'
+
+const schema = Joi.object({
+ forks_count: nonNegativeInteger,
+}).required()
+
+const queryParamSchema = Joi.object({
+ gitlab_url: optionalUrl,
+}).required()
+
+export default class GitlabForks extends GitLabBase {
+ static category = 'social'
+
+ static route = {
+ base: 'gitlab/forks',
+ pattern: ':project+',
+ queryParamSchema,
+ }
+
+ static openApi = {
+ '/gitlab/forks/{project}': {
+ get: {
+ summary: 'GitLab Forks',
+ description,
+ parameters: [
+ pathParam({
+ name: 'project',
+ example: 'gitlab-org/gitlab',
+ }),
+ queryParam({
+ name: 'gitlab_url',
+ example: 'https://gitlab.com',
+ }),
+ ],
+ },
+ },
+ }
+
+ static defaultBadgeData = { label: 'forks', namedLogo: 'gitlab' }
+
+ static render({ baseUrl, project, forkCount }) {
+ return {
+ message: metric(forkCount),
+ style: 'social',
+ color: 'blue',
+ link: [
+ `${baseUrl}/${project}/-/forks/new`,
+ `${baseUrl}/${project}/-/forks`,
+ ],
+ }
+ }
+
+ async fetch({ project, baseUrl }) {
+ // https://docs.gitlab.com/ee/api/projects.html#get-single-project
+ return super.fetch({
+ schema,
+ url: `${baseUrl}/api/v4/projects/${encodeURIComponent(project)}`,
+ httpErrors: {
+ 404: 'project not found',
+ },
+ })
+ }
+
+ async handle({ project }, { gitlab_url: baseUrl = 'https://gitlab.com' }) {
+ const { forks_count: forkCount } = await this.fetch({
+ project,
+ baseUrl,
+ })
+ return this.constructor.render({ baseUrl, project, forkCount })
+ }
+}
diff --git a/services/gitlab/gitlab-forks.tester.js b/services/gitlab/gitlab-forks.tester.js
new file mode 100644
index 0000000000000..ca30f03b2c280
--- /dev/null
+++ b/services/gitlab/gitlab-forks.tester.js
@@ -0,0 +1,35 @@
+import { isMetric } from '../test-validators.js'
+import { createServiceTester } from '../tester.js'
+
+export const t = await createServiceTester()
+
+t.create('Forks')
+ .get('/gitlab-org/gitlab.json')
+ .expectBadge({
+ label: 'forks',
+ message: isMetric,
+ color: 'blue',
+ link: [
+ 'https://gitlab.com/gitlab-org/gitlab/-/forks/new',
+ 'https://gitlab.com/gitlab-org/gitlab/-/forks',
+ ],
+ })
+
+t.create('Forks (self-managed)')
+ .get('/gitlab-cn/gitlab.json?gitlab_url=https://jihulab.com')
+ .expectBadge({
+ label: 'forks',
+ message: isMetric,
+ color: 'blue',
+ link: [
+ 'https://jihulab.com/gitlab-cn/gitlab/-/forks/new',
+ 'https://jihulab.com/gitlab-cn/gitlab/-/forks',
+ ],
+ })
+
+t.create('Forks (project not found)')
+ .get('/user1/gitlab-does-not-have-this-repo.json')
+ .expectBadge({
+ label: 'forks',
+ message: 'project not found',
+ })
diff --git a/services/gitlab/gitlab-go-mod.service.js b/services/gitlab/gitlab-go-mod.service.js
new file mode 100644
index 0000000000000..eeb9d310ca431
--- /dev/null
+++ b/services/gitlab/gitlab-go-mod.service.js
@@ -0,0 +1,137 @@
+import Joi from 'joi'
+import { renderVersionBadge } from '../version.js'
+import { InvalidResponse, pathParam, queryParam } from '../index.js'
+import { description, httpErrorsFor } from './gitlab-helper.js'
+import GitLabBase from './gitlab-base.js'
+
+const queryParamSchema = Joi.object({
+ filename: Joi.string(),
+ gitlabUrl: Joi.string(),
+}).required()
+
+const goVersionRegExp = /^go ([^/\s]+)(\s*\/.+)?$/m
+
+const filenameDescription =
+ 'The `filename` param can be used to specify the path to `go.mod`. By default, we look for `go.mod` in the repo root'
+
+export default class GitlabGoModGoVersion extends GitLabBase {
+ static category = 'platform-support'
+ static route = {
+ base: 'gitlab/go-mod/go-version',
+ pattern: ':user/:repo/:branch*',
+ queryParamSchema,
+ }
+
+ static openApi = {
+ '/gitlab/go-mod/go-version/{user}/{repo}': {
+ get: {
+ summary: 'GitLab go.mod Go version',
+ description,
+ parameters: [
+ pathParam({
+ name: 'user',
+ example: 'gitlab-org',
+ }),
+ pathParam({
+ name: 'repo',
+ example: 'gitlab-runner',
+ }),
+ queryParam({
+ name: 'filename',
+ example: 'src/go.mod',
+ description: filenameDescription,
+ }),
+ queryParam({
+ name: 'gitlabUrl',
+ example: 'https://gitlab.example.com',
+ }),
+ ],
+ },
+ },
+ '/gitlab/go-mod/go-version/{user}/{repo}/{branch}': {
+ get: {
+ summary: 'GitLab go.mod Go version (branch)',
+ description,
+ parameters: [
+ pathParam({
+ name: 'user',
+ example: 'gitlab-org',
+ }),
+ pathParam({
+ name: 'repo',
+ example: 'gitlab-runner',
+ }),
+ pathParam({
+ name: 'branch',
+ example: 'main',
+ }),
+ queryParam({
+ name: 'filename',
+ example: 'src/go.mod',
+ description: filenameDescription,
+ }),
+ queryParam({
+ name: 'gitlabUrl',
+ example: 'https://gitlab.example.com',
+ }),
+ ],
+ },
+ },
+ }
+
+ static defaultBadgeData = { label: 'Go' }
+
+ static render({ version, branch }) {
+ return renderVersionBadge({
+ version,
+ tag: branch,
+ defaultLabel: 'Go',
+ })
+ }
+
+ async fetch({ user, repo, branch, filename, gitlabUrl }) {
+ const project = `${user}/${repo}`
+ // https://docs.gitlab.com/ee/api/repository_files.html#get-raw-file-from-repository
+ const url = `${gitlabUrl}/api/v4/projects/${encodeURIComponent(
+ project,
+ )}/repository/files/${encodeURIComponent(filename)}/raw`
+ const options = { searchParams: { ref: branch || 'HEAD' } }
+ const httpErrors = httpErrorsFor('project or file not found')
+ const { buffer } = await this._request(
+ this.authHelper.withBearerAuthHeader({
+ url,
+ options,
+ httpErrors,
+ }),
+ )
+ return buffer
+ }
+
+ static transform(content) {
+ const match = goVersionRegExp.exec(content)
+ if (!match) {
+ throw new InvalidResponse({
+ prettyMessage: 'Go version missing in go.mod',
+ })
+ }
+
+ return {
+ go: match[1],
+ }
+ }
+
+ async handle(
+ { user, repo, branch },
+ { filename = 'go.mod', gitlabUrl = 'https://gitlab.com' },
+ ) {
+ const content = await this.fetch({
+ user,
+ repo,
+ branch,
+ filename,
+ gitlabUrl,
+ })
+ const { go } = this.constructor.transform(content.toString())
+ return this.constructor.render({ version: go, branch })
+ }
+}
diff --git a/services/gitlab/gitlab-go-mod.tester.js b/services/gitlab/gitlab-go-mod.tester.js
new file mode 100644
index 0000000000000..97891ac249d68
--- /dev/null
+++ b/services/gitlab/gitlab-go-mod.tester.js
@@ -0,0 +1,29 @@
+import { isVPlusDottedVersionAtLeastOne } from '../test-validators.js'
+import { createServiceTester } from '../tester.js'
+export const t = await createServiceTester()
+
+const user = 'gitlab-org'
+const repo = 'gitlab-runner'
+
+t.create('go version in root')
+ .get(`/${user}/${repo}.json`)
+ .expectBadge({ label: 'Go', message: isVPlusDottedVersionAtLeastOne })
+
+t.create('go version in root (branch)')
+ .get(`/${user}/${repo}/main.json`)
+ .expectBadge({ label: 'Go@main', message: isVPlusDottedVersionAtLeastOne })
+
+t.create('project not found')
+ .get('/some-project/that-doesnt-exist.json')
+ .expectBadge({ label: 'Go', message: 'project or file not found' })
+
+t.create('go version in subdirectory')
+ .get(`/${user}/${repo}/main.json?filename=helpers/runner_wrapper/api/go.mod`)
+ .expectBadge({
+ label: 'Go@main',
+ message: isVPlusDottedVersionAtLeastOne,
+ })
+
+t.create('file not found')
+ .get(`/${user}/${repo}/main.json?filename=nonexistent/go.mod`)
+ .expectBadge({ label: 'Go', message: 'project or file not found' })
diff --git a/services/gitlab/gitlab-helper.js b/services/gitlab/gitlab-helper.js
new file mode 100644
index 0000000000000..389ecc4cbdf84
--- /dev/null
+++ b/services/gitlab/gitlab-helper.js
@@ -0,0 +1,17 @@
+const description = `
+You may use your GitLab Project Id (e.g. 278964) or your Project Path (e.g.
+[gitlab-org/gitlab](https://gitlab.com/gitlab-org/gitlab) ).
+Note that only internet-accessible GitLab instances are supported, for example
+[https://jihulab.com](https://jihulab.com),
+[https://gitlab.gnome.org](https://gitlab.gnome.org), or
+[https://gitlab.com](https://gitlab.com).
+`
+
+function httpErrorsFor(notFoundMessage = 'project not found') {
+ return {
+ 401: notFoundMessage,
+ 404: notFoundMessage,
+ }
+}
+
+export { description, httpErrorsFor }
diff --git a/services/gitlab/gitlab-issues.service.js b/services/gitlab/gitlab-issues.service.js
new file mode 100644
index 0000000000000..599cc718a8b57
--- /dev/null
+++ b/services/gitlab/gitlab-issues.service.js
@@ -0,0 +1,134 @@
+import Joi from 'joi'
+import { pathParam, queryParam } from '../index.js'
+import { optionalUrl, nonNegativeInteger } from '../validators.js'
+import { metric } from '../text-formatters.js'
+import { description, httpErrorsFor } from './gitlab-helper.js'
+import GitLabBase from './gitlab-base.js'
+
+const schema = Joi.object({
+ statistics: Joi.object({
+ counts: Joi.object({
+ all: nonNegativeInteger,
+ closed: nonNegativeInteger,
+ opened: nonNegativeInteger,
+ }).required(),
+ }).allow(null),
+}).required()
+
+const queryParamSchema = Joi.object({
+ labels: Joi.string(),
+ gitlab_url: optionalUrl,
+}).required()
+
+export default class GitlabIssues extends GitLabBase {
+ static category = 'issue-tracking'
+
+ static route = {
+ base: 'gitlab/issues',
+ pattern: ':variant(all|all-raw|open|open-raw|closed|closed-raw)/:project+',
+ queryParamSchema,
+ }
+
+ static openApi = {
+ '/gitlab/issues/{variant}/{project}': {
+ get: {
+ summary: 'GitLab Issues',
+ description,
+ parameters: [
+ pathParam({
+ name: 'variant',
+ example: 'all',
+ schema: { type: 'string', enum: this.getEnum('variant') },
+ }),
+ pathParam({
+ name: 'project',
+ example: 'gitlab-org/gitlab',
+ }),
+ queryParam({
+ name: 'gitlab_url',
+ example: 'https://gitlab.com',
+ }),
+ queryParam({
+ name: 'labels',
+ example: 'test,failure::new',
+ description:
+ 'If you want to use multiple labels, you can use a comma (,) to separate them, e.g. foo,bar',
+ }),
+ ],
+ },
+ },
+ }
+
+ static defaultBadgeData = { label: 'issues', color: 'informational' }
+
+ static render({ variant, raw, labels, issueCount }) {
+ const state = variant
+ const isMultiLabel = labels && labels.includes(',')
+ const labelText = labels ? `${isMultiLabel ? `${labels}` : labels} ` : ''
+
+ let labelPrefix = ''
+ let messageSuffix = ''
+ if (raw) {
+ labelPrefix = `${state} `
+ } else {
+ messageSuffix = state
+ }
+ return {
+ label: `${labelPrefix}${labelText}issues`,
+ message: `${metric(issueCount)}${
+ messageSuffix ? ' ' : ''
+ }${messageSuffix}`,
+ color: issueCount > 0 ? 'yellow' : 'brightgreen',
+ }
+ }
+
+ async fetch({ project, baseUrl, labels }) {
+ // https://docs.gitlab.com/ee/api/issues_statistics.html#get-project-issues-statistics
+ return super.fetch({
+ schema,
+ url: `${baseUrl}/api/v4/projects/${encodeURIComponent(
+ project,
+ )}/issues_statistics`,
+ options: labels ? { searchParams: { labels } } : undefined,
+ httpErrors: httpErrorsFor('project not found'),
+ })
+ }
+
+ static transform({ variant, statistics }) {
+ const state = variant
+ let issueCount
+ switch (state) {
+ case 'open':
+ case 'open-raw':
+ issueCount = statistics.counts.opened
+ break
+ case 'closed':
+ case 'closed-raw':
+ issueCount = statistics.counts.closed
+ break
+ case 'all':
+ case 'all-raw':
+ issueCount = statistics.counts.all
+ break
+ }
+
+ return issueCount
+ }
+
+ async handle(
+ { variant, project },
+ { gitlab_url: baseUrl = 'https://gitlab.com', labels },
+ ) {
+ const { statistics } = await this.fetch({
+ project,
+ baseUrl,
+ labels,
+ })
+ return this.constructor.render({
+ variant: variant.split('-')[0],
+ raw: variant.endsWith('-raw'),
+ labels,
+ issueCount: this.constructor.transform({ variant, statistics }),
+ })
+ }
+}
diff --git a/services/gitlab/gitlab-issues.tester.js b/services/gitlab/gitlab-issues.tester.js
new file mode 100644
index 0000000000000..511fa51d18120
--- /dev/null
+++ b/services/gitlab/gitlab-issues.tester.js
@@ -0,0 +1,147 @@
+import Joi from 'joi'
+import { createServiceTester } from '../tester.js'
+import {
+ isMetric,
+ isMetricOpenIssues,
+ isMetricClosedIssues,
+} from '../test-validators.js'
+
+export const t = await createServiceTester()
+
+t.create('Issues (project not found)')
+ .get('/open/guoxudong.io/shields-test/do-not-exist.json')
+ .expectBadge({
+ label: 'issues',
+ message: 'project not found',
+ })
+
+/**
+ * Opened issue number case
+ */
+t.create('Opened issues')
+ .get('/open/guoxudong.io/shields-test/issue-test.json')
+ .expectBadge({
+ label: 'issues',
+ message: isMetricOpenIssues,
+ })
+
+t.create('Open issues raw')
+ .get('/open-raw/guoxudong.io/shields-test/issue-test.json')
+ .expectBadge({
+ label: 'open issues',
+ message: isMetric,
+ })
+
+t.create('Open issues by label is > zero')
+ .get('/open/guoxudong.io/shields-test/issue-test.json?labels=discussion')
+ .expectBadge({
+ label: 'discussion issues',
+ message: isMetricOpenIssues,
+ })
+
+t.create('Open issues by multi-word label is > zero')
+ .get(
+ '/open/guoxudong.io/shields-test/issue-test.json?labels=discussion,enhancement',
+ )
+ .expectBadge({
+ label: 'discussion,enhancement issues',
+ message: isMetricOpenIssues,
+ })
+
+t.create('Open issues by label (raw)')
+ .get('/open-raw/guoxudong.io/shields-test/issue-test.json?labels=discussion')
+ .expectBadge({
+ label: 'open discussion issues',
+ message: isMetric,
+ })
+
+t.create('Opened issues by Scoped labels')
+ .get('/open/gitlab-org%2Fgitlab.json?labels=test,failure::new')
+ .expectBadge({
+ label: 'test,failure::new issues',
+ message: isMetricOpenIssues,
+ })
+
+/**
+ * Closed issue number case
+ */
+t.create('Closed issues')
+ .get('/closed/guoxudong.io/shields-test/issue-test.json')
+ .expectBadge({
+ label: 'issues',
+ message: isMetricClosedIssues,
+ })
+
+t.create('Closed issues raw')
+ .get('/closed-raw/guoxudong.io/shields-test/issue-test.json')
+ .expectBadge({
+ label: 'closed issues',
+ message: isMetric,
+ })
+
+t.create('Closed issues by label is > zero')
+ .get('/closed/guoxudong.io/shields-test/issue-test.json?labels=bug')
+ .expectBadge({
+ label: 'bug issues',
+ message: isMetricClosedIssues,
+ })
+
+t.create('Closed issues by multi-word label is > zero')
+ .get('/closed/guoxudong.io/shields-test/issue-test.json?labels=bug,critical')
+ .expectBadge({
+ label: 'bug,critical issues',
+ message: isMetricClosedIssues,
+ })
+
+t.create('Closed issues by label (raw)')
+ .get('/closed-raw/guoxudong.io/shields-test/issue-test.json?labels=bug')
+ .expectBadge({
+ label: 'closed bug issues',
+ message: isMetric,
+ })
+
+/**
+ * All issue number case
+ */
+t.create('All issues')
+ .get('/all/guoxudong.io/shields-test/issue-test.json')
+ .expectBadge({
+ label: 'issues',
+ message: Joi.string().regex(
+ /^([0-9]+[kMGTPEZY]?|[1-9]\.[1-9][kMGTPEZY]) all$/,
+ ),
+ })
+
+t.create('All issues raw')
+ .get('/all-raw/guoxudong.io/shields-test/issue-test.json')
+ .expectBadge({
+ label: 'all issues',
+ message: isMetric,
+ })
+
+t.create('All issues by label is > zero')
+ .get('/all/guoxudong.io/shields-test/issue-test.json?labels=discussion')
+ .expectBadge({
+ label: 'discussion issues',
+ message: Joi.string().regex(
+ /^([0-9]+[kMGTPEZY]?|[1-9]\.[1-9][kMGTPEZY]) all$/,
+ ),
+ })
+
+t.create('All issues by multi-word label is > zero')
+ .get(
+ '/all/guoxudong.io/shields-test/issue-test.json?labels=discussion,enhancement',
+ )
+ .expectBadge({
+ label: 'discussion,enhancement issues',
+ message: Joi.string().regex(
+ /^([0-9]+[kMGTPEZY]?|[1-9]\.[1-9][kMGTPEZY]) all$/,
+ ),
+ })
+
+t.create('All issues by label (raw)')
+ .get('/all-raw/guoxudong.io/shields-test/issue-test.json?labels=discussion')
+ .expectBadge({
+ label: 'all discussion issues',
+ message: isMetric,
+ })
diff --git a/services/gitlab/gitlab-languages-count.service.js b/services/gitlab/gitlab-languages-count.service.js
new file mode 100644
index 0000000000000..faeb33995b047
--- /dev/null
+++ b/services/gitlab/gitlab-languages-count.service.js
@@ -0,0 +1,73 @@
+import Joi from 'joi'
+import { pathParam, queryParam } from '../index.js'
+import { optionalUrl } from '../validators.js'
+import { metric } from '../text-formatters.js'
+import { description, httpErrorsFor } from './gitlab-helper.js'
+import GitLabBase from './gitlab-base.js'
+
+/*
+We're expecting a response like { "Ruby": 67.13, "JavaScript": 19.66 }
+The keys could be anything and {} is a valid response (e.g: for an empty project)
+*/
+const schema = Joi.object().pattern(/./, Joi.number().min(0).max(100))
+
+const queryParamSchema = Joi.object({
+ gitlab_url: optionalUrl,
+}).required()
+
+export default class GitlabLanguageCount extends GitLabBase {
+ static category = 'analysis'
+
+ static route = {
+ base: 'gitlab/languages/count',
+ pattern: ':project+',
+ queryParamSchema,
+ }
+
+ static openApi = {
+ '/gitlab/languages/count/{project}': {
+ get: {
+ summary: 'GitLab Language Count',
+ description,
+ parameters: [
+ pathParam({
+ name: 'project',
+ example: 'gitlab-org/gitlab',
+ }),
+ queryParam({
+ name: 'gitlab_url',
+ example: 'https://gitlab.com',
+ }),
+ ],
+ },
+ },
+ }
+
+ static defaultBadgeData = { label: 'languages' }
+
+ static render({ languagesCount }) {
+ return {
+ message: metric(languagesCount),
+ color: 'blue',
+ }
+ }
+
+ async fetch({ project, baseUrl }) {
+ // https://docs.gitlab.com/ee/api/projects.html#languages
+ return super.fetch({
+ schema,
+ url: `${baseUrl}/api/v4/projects/${encodeURIComponent(
+ project,
+ )}/languages`,
+ httpErrors: httpErrorsFor('project not found'),
+ })
+ }
+
+ async handle({ project }, { gitlab_url: baseUrl = 'https://gitlab.com' }) {
+ const data = await this.fetch({
+ project,
+ baseUrl,
+ })
+ return this.constructor.render({ languagesCount: Object.keys(data).length })
+ }
+}
diff --git a/services/gitlab/gitlab-languages-count.tester.js b/services/gitlab/gitlab-languages-count.tester.js
new file mode 100644
index 0000000000000..13a7b0ba054d2
--- /dev/null
+++ b/services/gitlab/gitlab-languages-count.tester.js
@@ -0,0 +1,25 @@
+import { isMetric } from '../test-validators.js'
+import { createServiceTester } from '../tester.js'
+
+export const t = await createServiceTester()
+
+t.create('language count').get('/gitlab-org/gitlab.json').expectBadge({
+ label: 'languages',
+ message: isMetric,
+ color: 'blue',
+})
+
+t.create('language count (self-managed)')
+ .get('/gitlab-cn/gitlab.json?gitlab_url=https://jihulab.com')
+ .expectBadge({
+ label: 'languages',
+ message: isMetric,
+ color: 'blue',
+ })
+
+t.create('language count (project not found)')
+ .get('/open/guoxudong.io/shields-test/do-not-exist.json')
+ .expectBadge({
+ label: 'languages',
+ message: 'project not found',
+ })
diff --git a/services/gitlab/gitlab-last-commit.service.js b/services/gitlab/gitlab-last-commit.service.js
new file mode 100644
index 0000000000000..3f44197bfd4e2
--- /dev/null
+++ b/services/gitlab/gitlab-last-commit.service.js
@@ -0,0 +1,91 @@
+import Joi from 'joi'
+import { renderDateBadge } from '../date.js'
+import { NotFound, pathParam, queryParam } from '../index.js'
+import { optionalUrl, relativeUri } from '../validators.js'
+import GitLabBase from './gitlab-base.js'
+import { description, httpErrorsFor } from './gitlab-helper.js'
+
+const schema = Joi.array()
+ .items(
+ Joi.object({
+ committed_date: Joi.string().required(),
+ }),
+ )
+ .required()
+
+const queryParamSchema = Joi.object({
+ ref: Joi.string(),
+ gitlab_url: optionalUrl,
+ path: relativeUri,
+}).required()
+
+const refText = `
+ref can be filled with the name of a branch, tag or revision range of the repository.
+`
+
+const lastCommitDescription = description + refText
+
+export default class GitlabLastCommit extends GitLabBase {
+ static category = 'activity'
+
+ static route = {
+ base: 'gitlab/last-commit',
+ pattern: ':project+',
+ queryParamSchema,
+ }
+
+ static openApi = {
+ '/gitlab/last-commit/{project}': {
+ get: {
+ summary: 'GitLab Last Commit',
+ description: lastCommitDescription,
+ parameters: [
+ pathParam({
+ name: 'project',
+ example: 'gitlab-org/gitlab',
+ }),
+ queryParam({
+ name: 'gitlab_url',
+ example: 'https://gitlab.com',
+ }),
+ queryParam({
+ name: 'ref',
+ example: 'master',
+ }),
+ queryParam({
+ name: 'path',
+ example: 'README.md',
+ schema: { type: 'string' },
+ description: 'File path to resolve the last commit for.',
+ }),
+ ],
+ },
+ },
+ }
+
+ static defaultBadgeData = { label: 'last commit' }
+
+ async fetch({ project, baseUrl, ref, path }) {
+ // https://docs.gitlab.com/ee/api/commits.html#list-repository-commits
+ return super.fetch({
+ url: `${baseUrl}/api/v4/projects/${encodeURIComponent(
+ project,
+ )}/repository/commits`,
+ options: { searchParams: { ref_name: ref, path, per_page: 1 } },
+ schema,
+ httpErrors: httpErrorsFor('project not found'),
+ })
+ }
+
+ async handle(
+ { project },
+ { gitlab_url: baseUrl = 'https://gitlab.com', ref, path },
+ ) {
+ const data = await this.fetch({ project, baseUrl, ref, path })
+ const [commit] = data
+
+ if (!commit) throw new NotFound({ prettyMessage: 'no commits found' })
+
+ return renderDateBadge(commit.committed_date)
+ }
+}
diff --git a/services/gitlab/gitlab-last-commit.tester.js b/services/gitlab/gitlab-last-commit.tester.js
new file mode 100644
index 0000000000000..08ab428468737
--- /dev/null
+++ b/services/gitlab/gitlab-last-commit.tester.js
@@ -0,0 +1,67 @@
+import { isFormattedDate } from '../test-validators.js'
+import { createServiceTester } from '../tester.js'
+
+export const t = await createServiceTester()
+
+t.create('last commit (recent)').get('/gitlab-org/gitlab.json').expectBadge({
+ label: 'last commit',
+ message: isFormattedDate,
+})
+
+t.create('last commit (on ref) (ancient)')
+ .get('/gitlab-org/gitlab.json?ref=v13.8.6-ee')
+ .expectBadge({
+ label: 'last commit',
+ message: 'march 2021',
+ })
+
+t.create('last commit (on ref) (ancient) (by top-level file path)')
+ .get('/gitlab-org/gitlab.json?ref=v13.8.6-ee&path=README.md')
+ .expectBadge({
+ label: 'last commit',
+ message: 'december 2020',
+ })
+
+t.create('last commit (on ref) (ancient) (by top-level dir path)')
+ .get('/gitlab-org/gitlab.json?ref=v13.8.6-ee&path=changelogs')
+ .expectBadge({
+ label: 'last commit',
+ message: 'march 2021',
+ })
+
+t.create(
+ 'last commit (on ref) (ancient) (by top-level dir path with trailing slash)',
+)
+ .get('/gitlab-org/gitlab.json?ref=v13.8.6-ee&path=changelogs/')
+ .expectBadge({
+ label: 'last commit',
+ message: 'march 2021',
+ })
+
+t.create('last commit (on ref) (ancient) (by nested file path)')
+ .get('/gitlab-org/gitlab.json?ref=v13.8.6-ee&path=changelogs/README.md')
+ .expectBadge({
+ label: 'last commit',
+ message: 'september 2020',
+ })
+
+t.create('last commit (self-managed)')
+ .get('/gitlab-cn/gitlab.json?gitlab_url=https://jihulab.com')
+ .expectBadge({
+ label: 'last commit',
+ message: isFormattedDate,
+ })
+
+t.create('last commit (project not found)')
+ .get('/open/guoxudong.io/shields-test/do-not-exist.json')
+ .expectBadge({
+ label: 'last commit',
+ message: 'project not found',
+ })
+
+t.create('last commit (no commits found)')
+ .get('/gitlab-org/gitlab.json?path=not/a/dir')
+ .expectBadge({
+ label: 'last commit',
+ message: 'no commits found',
+ })
diff --git a/services/gitlab/gitlab-license-redirect.service.js b/services/gitlab/gitlab-license-redirect.service.js
new file mode 100644
index 0000000000000..27b0ff0306d75
--- /dev/null
+++ b/services/gitlab/gitlab-license-redirect.service.js
@@ -0,0 +1,13 @@
+import { deprecatedService } from '../index.js'
+
+// https://github.com/badges/shields/issues/8138
+export default deprecatedService({
+ category: 'build',
+ label: 'gitlab',
+ route: {
+ base: 'gitlab/v/license',
+ pattern: ':project+',
+ },
+ dateAdded: new Date('2025-12-20'),
+ issueUrl: 'https://github.com/badges/shields/pull/11583',
+})
diff --git a/services/gitlab/gitlab-license-redirect.tester.js b/services/gitlab/gitlab-license-redirect.tester.js
new file mode 100644
index 0000000000000..5c957a52cc463
--- /dev/null
+++ b/services/gitlab/gitlab-license-redirect.tester.js
@@ -0,0 +1,7 @@
+import { createServiceTester } from '../tester.js'
+export const t = await createServiceTester()
+
+t.create('License deprecated').get('/gitlab-org/gitlab.json').expectBadge({
+ label: 'gitlab',
+ message: 'https://github.com/badges/shields/pull/11583',
+})
diff --git a/services/gitlab/gitlab-license.service.js b/services/gitlab/gitlab-license.service.js
new file mode 100644
index 0000000000000..54aa99fb980c1
--- /dev/null
+++ b/services/gitlab/gitlab-license.service.js
@@ -0,0 +1,74 @@
+import Joi from 'joi'
+import { pathParam, queryParam } from '../index.js'
+import { optionalUrl } from '../validators.js'
+import { renderLicenseBadge } from '../licenses.js'
+import { description, httpErrorsFor } from './gitlab-helper.js'
+import GitLabBase from './gitlab-base.js'
+
+const schema = Joi.object({
+ license: Joi.object({
+ name: Joi.string().required(),
+ }).allow(null),
+}).required()
+
+const queryParamSchema = Joi.object({
+ gitlab_url: optionalUrl,
+}).required()
+
+export default class GitlabLicense extends GitLabBase {
+ static category = 'license'
+
+ static route = {
+ base: 'gitlab/license',
+ pattern: ':project+',
+ queryParamSchema,
+ }
+
+ static openApi = {
+ '/gitlab/license/{project}': {
+ get: {
+ summary: 'GitLab License',
+ description,
+ parameters: [
+ pathParam({
+ name: 'project',
+ example: 'gitlab-org/gitlab',
+ }),
+ queryParam({
+ name: 'gitlab_url',
+ example: 'https://gitlab.com',
+ }),
+ ],
+ },
+ },
+ }
+
+ static defaultBadgeData = { label: 'license' }
+
+ static render({ license }) {
+ if (license) {
+ return renderLicenseBadge({ license })
+ } else {
+ return { message: 'not specified' }
+ }
+ }
+
+ async fetch({ project, baseUrl }) {
+ // https://docs.gitlab.com/ee/api/projects.html#get-single-project
+ return super.fetch({
+ schema,
+ url: `${baseUrl}/api/v4/projects/${encodeURIComponent(project)}`,
+ options: { searchParams: { license: '1' } },
+ httpErrors: httpErrorsFor('project not found'),
+ })
+ }
+
+ async handle({ project }, { gitlab_url: baseUrl = 'https://gitlab.com' }) {
+ const { license: licenseObject } = await this.fetch({
+ project,
+ baseUrl,
+ })
+ const license = licenseObject ? licenseObject.name : undefined
+ return this.constructor.render({ license })
+ }
+}
diff --git a/services/gitlab/gitlab-license.tester.js b/services/gitlab/gitlab-license.tester.js
new file mode 100644
index 0000000000000..8ddfaf25af37a
--- /dev/null
+++ b/services/gitlab/gitlab-license.tester.js
@@ -0,0 +1,80 @@
+import { licenseToColor } from '../licenses.js'
+import { createServiceTester } from '../tester.js'
+import { noToken } from '../test-helpers.js'
+import _noGitLabToken from './gitlab-license.service.js'
+export const t = await createServiceTester()
+const noGitLabToken = noToken(_noGitLabToken)
+
+const publicDomainLicenseColor = licenseToColor('MIT License')
+const unknownLicenseColor = licenseToColor()
+
+t.create('License')
+ .get('/guoxudong.io/shields-test/licenced-test.json')
+ .expectBadge({
+ label: 'license',
+ message: 'MIT License',
+ color: `${publicDomainLicenseColor}`,
+ })
+
+t.create('License for repo without a license')
+ .get('/guoxudong.io/shields-test/no-license-test.json')
+ .expectBadge({
+ label: 'license',
+ message: 'not specified',
+ color: 'lightgrey',
+ })
+
+t.create('Other license')
+ .get('/group/project.json')
+ .intercept(nock =>
+ nock('https://gitlab.com')
+ .get('/api/v4/projects/group%2Fproject?license=1')
+ .reply(200, {
+ license: {
+ name: 'Other',
+ },
+ }),
+ )
+ .expectBadge({
+ label: 'license',
+ message: 'Other',
+ color: unknownLicenseColor,
+ })
+
+t.create('License for unknown repo')
+ .get('/user1/gitlab-does-not-have-this-repo.json')
+ .expectBadge({
+ label: 'license',
+ message: 'project not found',
+ color: 'red',
+ })
+
+t.create('Mocking License')
+ .get('/group/project.json')
+ .intercept(nock =>
+ nock('https://gitlab.com')
+ .get('/api/v4/projects/group%2Fproject?license=1')
+ .reply(200, {
+ license: {
+ key: 'apache-2.0',
+ name: 'Apache License 2.0',
+ nickname: '',
+ html_url: 'http://choosealicense.com/licenses/apache-2.0/',
+ source_url: '',
+ },
+ }),
+ )
+ .expectBadge({
+ label: 'license',
+ message: 'Apache License 2.0',
+ color: unknownLicenseColor,
+ })
+
+t.create('License (private repo)')
+ .skipWhen(noGitLabToken)
+ .get('/shields-ops-group/test.json')
+ .expectBadge({
+ label: 'license',
+ message: 'MIT License',
+ color: `${publicDomainLicenseColor}`,
+ })
diff --git a/services/gitlab/gitlab-merge-requests.service.js b/services/gitlab/gitlab-merge-requests.service.js
new file mode 100644
index 0000000000000..861c878ce8b20
--- /dev/null
+++ b/services/gitlab/gitlab-merge-requests.service.js
@@ -0,0 +1,140 @@
+import Joi from 'joi'
+import { pathParam, queryParam } from '../index.js'
+import { optionalUrl, nonNegativeInteger } from '../validators.js'
+import { metric } from '../text-formatters.js'
+import { description, httpErrorsFor } from './gitlab-helper.js'
+import GitLabBase from './gitlab-base.js'
+
+// The total number of MR is in the `x-total` field in the headers.
+// https://docs.gitlab.com/ee/api/index.html#other-pagination-headers
+const schema = Joi.object({
+ 'x-total': Joi.number().integer(),
+ 'x-page': nonNegativeInteger,
+})
+
+const queryParamSchema = Joi.object({
+ labels: Joi.string(),
+ gitlab_url: optionalUrl,
+}).required()
+
+const more = `
+GitLab's API only reports up to 10k Merge Requests, so badges for projects that have more than 10k will not have an exact count.
+`
+
+const mergeRequestsDescription = description + more
+
+export default class GitlabMergeRequests extends GitLabBase {
+ static category = 'issue-tracking'
+
+ static route = {
+ base: 'gitlab/merge-requests',
+ pattern:
+ ':variant(all|all-raw|open|open-raw|closed|closed-raw|locked|locked-raw|merged|merged-raw)/:project+',
+ queryParamSchema,
+ }
+
+ static openApi = {
+ '/gitlab/merge-requests/{variant}/{project}': {
+ get: {
+ summary: 'GitLab Merge Requests',
+ description: mergeRequestsDescription,
+ parameters: [
+ pathParam({
+ name: 'variant',
+ example: 'all',
+ schema: { type: 'string', enum: this.getEnum('variant') },
+ }),
+ pathParam({
+ name: 'project',
+ example: 'gitlab-org/gitlab',
+ }),
+ queryParam({
+ name: 'gitlab_url',
+ example: 'https://gitlab.com',
+ }),
+ queryParam({
+ name: 'labels',
+ example: 'test,type::feature',
+ description:
+ 'If you want to use multiple labels, you can use a comma (,) to separate them, e.g. foo,bar',
+ }),
+ ],
+ },
+ },
+ }
+
+ static defaultBadgeData = { label: 'merge requests' }
+
+ static render({ variant, raw, labels, mergeRequestCount }) {
+ const state = variant
+ const isMultiLabel = labels && labels.includes(',')
+ const labelText = labels ? `${isMultiLabel ? `${labels}` : labels} ` : ''
+
+ let labelPrefix = ''
+ let messageSuffix = ''
+ if (raw) {
+ labelPrefix = `${state} `
+ } else {
+ messageSuffix = state
+ }
+ const message = `${mergeRequestCount > 10000 ? 'more than ' : ''}${metric(
+ mergeRequestCount,
+ )}${messageSuffix ? ' ' : ''}${messageSuffix}`
+ return {
+ label: `${labelPrefix}${labelText}merge requests`,
+ message,
+ color: 'blue',
+ }
+ }
+
+ async fetch({ project, baseUrl, variant, labels }) {
+ // https://docs.gitlab.com/ee/api/merge_requests.html#list-project-merge-requests
+ const { res } = await this._request(
+ this.authHelper.withBearerAuthHeader({
+ url: `${baseUrl}/api/v4/projects/${encodeURIComponent(
+ project,
+ )}/merge_requests`,
+ options: {
+ searchParams: {
+ state: variant === 'open' ? 'opened' : variant,
+ page: '1',
+ per_page: '1',
+ labels,
+ },
+ },
+ httpErrors: httpErrorsFor('project not found'),
+ }),
+ )
+ return this.constructor._validate(res.headers, schema)
+ }
+
+ static transform(data) {
+ if (data['x-total'] !== undefined) {
+ return data['x-total']
+ } else {
+ // https://docs.gitlab.com/ee/api/index.html#pagination-response-headers
+ // For performance reasons, if a query returns more than 10,000 records, GitLab doesn’t return `x-total` header.
+ // Displayed on the page as "more than 10k".
+ return 10001
+ }
+ }
+
+ async handle(
+ { variant, project },
+ { gitlab_url: baseUrl = 'https://gitlab.com', labels },
+ ) {
+ const data = await this.fetch({
+ project,
+ baseUrl,
+ variant: variant.split('-')[0],
+ labels,
+ })
+ const mergeRequestCount = this.constructor.transform(data)
+ return this.constructor.render({
+ variant: variant.split('-')[0],
+ raw: variant.endsWith('-raw'),
+ labels,
+ mergeRequestCount,
+ })
+ }
+}
diff --git a/services/gitlab/gitlab-merge-requests.spec.js b/services/gitlab/gitlab-merge-requests.spec.js
new file mode 100644
index 0000000000000..cb809ed17fc3b
--- /dev/null
+++ b/services/gitlab/gitlab-merge-requests.spec.js
@@ -0,0 +1,92 @@
+import { test, given } from 'sazerac'
+import nock from 'nock'
+import { expect } from 'chai'
+import { cleanUpNockAfterEach, defaultContext } from '../test-helpers.js'
+import GitlabMergeRequests from './gitlab-merge-requests.service.js'
+
+describe('GitlabMergeRequests', function () {
+ test(GitlabMergeRequests.render, () => {
+ given({ variant: 'open', mergeRequestCount: 1399 }).expect({
+ label: 'merge requests',
+ message: '1.4k open',
+ color: 'blue',
+ })
+ given({ variant: 'open', raw: '-raw', mergeRequestCount: 1399 }).expect({
+ label: 'open merge requests',
+ message: '1.4k',
+ color: 'blue',
+ })
+ given({
+ variant: 'open',
+ labels: 'discussion,enhancement',
+ mergeRequestCount: 15,
+ }).expect({
+ label: 'discussion,enhancement merge requests',
+ message: '15 open',
+ color: 'blue',
+ })
+ given({
+ variant: 'open',
+ raw: '-raw',
+ labels: 'discussion,enhancement',
+ mergeRequestCount: 15,
+ }).expect({
+ label: 'open discussion,enhancement merge requests',
+ message: '15',
+ color: 'blue',
+ })
+ given({ variant: 'open', mergeRequestCount: 0 }).expect({
+ label: 'merge requests',
+ message: '0 open',
+ color: 'blue',
+ })
+ given({ variant: 'open', mergeRequestCount: 10001 }).expect({
+ label: 'merge requests',
+ message: 'more than 10k open',
+ color: 'blue',
+ })
+ })
+ describe('auth', function () {
+ cleanUpNockAfterEach()
+
+ const fakeToken = 'abc123'
+ const config = {
+ public: {
+ services: {
+ gitlab: {
+ authorizedOrigins: ['https://gitlab.com'],
+ },
+ },
+ },
+ private: {
+ gitlab_token: fakeToken,
+ },
+ }
+
+ it('sends the auth information as configured', async function () {
+ const scope = nock('https://gitlab.com/')
+ .get(
+ '/api/v4/projects/foo%2Fbar/merge_requests?state=opened&page=1&per_page=1',
+ )
+ // 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.
+ .matchHeader('Authorization', `Bearer ${fakeToken}`)
+ .reply(200, {}, { 'x-total': '100', 'x-page': '1' })
+
+ expect(
+ await GitlabMergeRequests.invoke(
+ defaultContext,
+ config,
+ { project: 'foo/bar', variant: 'open' },
+ {},
+ ),
+ ).to.deep.equal({
+ label: 'merge requests',
+ message: '100 open',
+ color: 'blue',
+ })
+
+ scope.done()
+ })
+ })
+})
diff --git a/services/gitlab/gitlab-merge-requests.tester.js b/services/gitlab/gitlab-merge-requests.tester.js
new file mode 100644
index 0000000000000..2a72e501d622f
--- /dev/null
+++ b/services/gitlab/gitlab-merge-requests.tester.js
@@ -0,0 +1,172 @@
+import Joi from 'joi'
+import { createServiceTester } from '../tester.js'
+import {
+ isMetric,
+ isMetricOpenIssues,
+ isMetricClosedIssues,
+} from '../test-validators.js'
+
+export const t = await createServiceTester()
+
+t.create('Merge Requests (project not found)')
+ .get('/open/guoxudong.io/shields-test/do-not-exist.json')
+ .expectBadge({
+ label: 'merge requests',
+ message: 'project not found',
+ })
+
+/**
+ * Opened issue number case
+ */
+t.create('Opened merge requests')
+ .get('/open/guoxudong.io/shields-test/issue-test.json')
+ .expectBadge({
+ label: 'merge requests',
+ message: isMetricOpenIssues,
+ })
+
+t.create('Open merge requests raw')
+ .get('/open-raw/guoxudong.io/shields-test/issue-test.json')
+ .expectBadge({
+ label: 'open merge requests',
+ message: isMetric,
+ })
+
+t.create('Open merge requests by label is > zero')
+ .get('/open/guoxudong.io/shields-test/issue-test.json?labels=discussion')
+ .expectBadge({
+ label: 'discussion merge requests',
+ message: isMetricOpenIssues,
+ })
+
+t.create('Open merge requests by multi-word label is > zero')
+ .get(
+ '/open/guoxudong.io/shields-test/issue-test.json?labels=discussion,enhancement',
+ )
+ .expectBadge({
+ label: 'discussion,enhancement merge requests',
+ message: isMetricOpenIssues,
+ })
+
+t.create('Open merge requests by label (raw)')
+ .get('/open-raw/guoxudong.io/shields-test/issue-test.json?labels=discussion')
+ .expectBadge({
+ label: 'open discussion merge requests',
+ message: isMetric,
+ })
+
+t.create('Opened merge requests by Scoped labels')
+ .get('/open/gitlab-org%2Fgitlab.json?labels=test,failure::new')
+ .expectBadge({
+ label: 'test,failure::new merge requests',
+ message: Joi.alternatives(isMetricOpenIssues, Joi.equal('0 open')),
+ })
+
+/**
+ * Closed issue number case
+ */
+t.create('Closed merge requests')
+ .get('/closed/guoxudong.io/shields-test/issue-test.json')
+ .expectBadge({
+ label: 'merge requests',
+ message: isMetricClosedIssues,
+ })
+
+t.create('Closed merge requests raw')
+ .get('/closed-raw/guoxudong.io/shields-test/issue-test.json')
+ .expectBadge({
+ label: 'closed merge requests',
+ message: isMetric,
+ })
+
+t.create('Closed merge requests by label is > zero')
+ .get('/closed/guoxudong.io/shields-test/issue-test.json?labels=bug')
+ .expectBadge({
+ label: 'bug merge requests',
+ message: Joi.alternatives(isMetricClosedIssues, Joi.equal('0 closed')),
+ })
+
+t.create('Closed merge requests by multi-word label is > zero')
+ .get('/closed/guoxudong.io/shields-test/issue-test.json?labels=bug,critical')
+ .expectBadge({
+ label: 'bug,critical merge requests',
+ message: Joi.alternatives(isMetricClosedIssues, Joi.equal('0 closed')),
+ })
+
+t.create('Closed merge requests by label (raw)')
+ .get(
+ '/closed-raw/guoxudong.io/shields-test/issue-test.json?labels=enhancement',
+ )
+ .expectBadge({
+ label: 'closed enhancement merge requests',
+ message: isMetric,
+ })
+
+/**
+ * All issue number case
+ */
+t.create('All merge requests')
+ .get('/all/guoxudong.io/shields-test/issue-test.json')
+ .expectBadge({
+ label: 'merge requests',
+ message: Joi.string().regex(
+ /^([0-9]+[kMGTPEZY]?|[1-9]\.[1-9][kMGTPEZY]) all$/,
+ ),
+ })
+
+t.create('All merge requests raw')
+ .get('/all-raw/guoxudong.io/shields-test/issue-test.json')
+ .expectBadge({
+ label: 'all merge requests',
+ message: isMetric,
+ })
+
+t.create('All merge requests by label is > zero')
+ .get('/all/guoxudong.io/shields-test/issue-test.json?labels=discussion')
+ .expectBadge({
+ label: 'discussion merge requests',
+ message: Joi.string().regex(
+ /^([0-9]+[kMGTPEZY]?|[1-9]\.[1-9][kMGTPEZY]) all$/,
+ ),
+ })
+
+t.create('All merge requests by multi-word label is > zero')
+ .get(
+ '/all/guoxudong.io/shields-test/issue-test.json?labels=discussion,enhancement',
+ )
+ .expectBadge({
+ label: 'discussion,enhancement merge requests',
+ message: Joi.string().regex(
+ /^([0-9]+[kMGTPEZY]?|[1-9]\.[1-9][kMGTPEZY]) all$/,
+ ),
+ })
+
+t.create('All merge requests by label (raw)')
+ .get('/all-raw/guoxudong.io/shields-test/issue-test.json?labels=discussion')
+ .expectBadge({
+ label: 'all discussion merge requests',
+ message: isMetric,
+ })
+
+t.create('more than 10k merge requests')
+ .get('/all/gitlab-org%2Fgitlab.json')
+ .expectBadge({
+ label: 'merge requests',
+ message: 'more than 10k all',
+ })
+
+t.create('locked merge requests')
+ .get('/locked/gitlab-org%2Fgitlab.json')
+ .expectBadge({
+ label: 'merge requests',
+ message: Joi.string().regex(
+ /^([0-9]+[kMGTPEZY]?|[1-9]\.[1-9][kMGTPEZY]) locked$/,
+ ),
+ })
+
+t.create('Opened merge requests (self-managed)')
+ .get('/open/gitlab-cn/gitlab.json?gitlab_url=https://jihulab.com')
+ .expectBadge({
+ label: 'merge requests',
+ message: isMetricOpenIssues,
+ })
diff --git a/services/gitlab/gitlab-pipeline-coverage.service.js b/services/gitlab/gitlab-pipeline-coverage.service.js
new file mode 100644
index 0000000000000..62845a5c0fce1
--- /dev/null
+++ b/services/gitlab/gitlab-pipeline-coverage.service.js
@@ -0,0 +1,122 @@
+import Joi from 'joi'
+import { coveragePercentage } from '../color-formatters.js'
+import { optionalUrl } from '../validators.js'
+import {
+ BaseSvgScrapingService,
+ NotFound,
+ pathParam,
+ queryParam,
+} from '../index.js'
+import { description, httpErrorsFor } from './gitlab-helper.js'
+
+const schema = Joi.object({
+ message: Joi.string()
+ .regex(/^([0-9]+\.[0-9]+%)|unknown$/)
+ .required(),
+}).required()
+
+const queryParamSchema = Joi.object({
+ gitlab_url: optionalUrl,
+ job_name: Joi.string(),
+ branch: Joi.string(),
+}).required()
+
+const moreDocs = `
+Important: If your project is publicly visible, but the badge is like this:
+
+
+Check if your pipelines are publicly visible as well.
+Navigate to your project settings on GitLab and choose General Pipelines under CI/CD.
+Then tick the setting Public pipelines.
+
+Now your settings should look like this:
+
+
+
+Also make sure you have set up code covrage parsing as described here
+
+Your badge should be working fine now.
+`
+
+export default class GitlabPipelineCoverage extends BaseSvgScrapingService {
+ static category = 'coverage'
+
+ static route = {
+ base: 'gitlab/pipeline-coverage',
+ pattern: ':project+',
+ queryParamSchema,
+ }
+
+ static openApi = {
+ '/gitlab/pipeline-coverage/{project}': {
+ get: {
+ summary: 'Gitlab Code Coverage',
+ description: description + moreDocs,
+ parameters: [
+ pathParam({
+ name: 'project',
+ example: 'gitlab-org/gitlab',
+ }),
+ queryParam({
+ name: 'gitlab_url',
+ example: 'https://gitlab.com',
+ }),
+ queryParam({
+ name: 'job_name',
+ example: 'jest-integration',
+ }),
+ queryParam({
+ name: 'branch',
+ example: 'master',
+ }),
+ ],
+ },
+ },
+ }
+
+ static defaultBadgeData = { label: 'coverage' }
+
+ static render({ coverage }) {
+ return {
+ message: `${coverage.toFixed(0)}%`,
+ color: coveragePercentage(coverage),
+ }
+ }
+
+ async fetch({ project, baseUrl = 'https://gitlab.com', jobName, branch }) {
+ // Since the URL doesn't return a usable value when an invalid job name is specified,
+ // it is recommended to not use the query param at all if not required
+ jobName = jobName ? `?job=${jobName}` : ''
+ const url = `${baseUrl}/${decodeURIComponent(
+ project,
+ )}/badges/${branch}/coverage.svg${jobName}`
+ const httpErrors = httpErrorsFor('project not found')
+ return this._requestSvg({
+ schema,
+ url,
+ httpErrors,
+ })
+ }
+
+ static transform({ coverage }) {
+ if (coverage === 'unknown') {
+ throw new NotFound({ prettyMessage: 'not set up' })
+ }
+ return Number(coverage.slice(0, -1))
+ }
+
+ async handle(
+ { project },
+ { gitlab_url: baseUrl, job_name: jobName, branch },
+ ) {
+ const { message: coverage } = await this.fetch({
+ project,
+ branch,
+ baseUrl,
+ jobName,
+ })
+ return this.constructor.render({
+ coverage: this.constructor.transform({ coverage }),
+ })
+ }
+}
diff --git a/services/gitlab/gitlab-coverage.tester.js b/services/gitlab/gitlab-pipeline-coverage.tester.js
similarity index 51%
rename from services/gitlab/gitlab-coverage.tester.js
rename to services/gitlab/gitlab-pipeline-coverage.tester.js
index 576c5a8972807..c0917a445f2fe 100644
--- a/services/gitlab/gitlab-coverage.tester.js
+++ b/services/gitlab/gitlab-pipeline-coverage.tester.js
@@ -3,36 +3,44 @@ import { createServiceTester } from '../tester.js'
export const t = await createServiceTester()
t.create('Coverage (branch)')
- .get('/gitlab-org/gitlab-runner/12-0-stable.json')
+ .get('/gitlab-org/gitlab-runner.json?branch=12-0-stable')
.expectBadge({
label: 'coverage',
message: isIntegerPercentage,
})
t.create('Coverage (existent branch but coverage not set up)')
- .get('/gitlab-org/gitlab-git-http-server/master.json')
+ .get('/gitlab-org/gitlab-git-http-server.json?branch=master')
.expectBadge({
label: 'coverage',
message: 'not set up',
})
t.create('Coverage (nonexistent branch)')
- .get('/gitlab-org/gitlab-runner/nope-not-a-branch.json')
+ .get('/gitlab-org/gitlab-runner.json?branch=nope-not-a-branch')
.expectBadge({
label: 'coverage',
message: 'not set up',
})
+// Gitlab will redirect users to a sign-in page
+// (which we ultimately see as a 403 error) in the event
+// a nonexistent, or private, repository is specified.
+// Given the additional complexity that would've been required to
+// present users with a more traditional and friendly 'Not Found'
+// error message, we will simply display invalid
+// https://github.com/badges/shields/pull/5538
+// https://github.com/badges/shields/pull/9752
t.create('Coverage (nonexistent repo)')
- .get('/this-repo/does-not-exist/neither-branch.json')
+ .get('/this-repo/does-not-exist.json')
.expectBadge({
label: 'coverage',
- message: 'inaccessible',
+ message: 'invalid',
})
t.create('Coverage (custom job)')
.get(
- '/gitlab-org/gitlab-runner/12-0-stable.json?job_name=test coverage report'
+ '/gitlab-org/gitlab-runner.json?branch=12-0-stable&job_name=test coverage report',
)
.expectBadge({
label: 'coverage',
@@ -40,14 +48,16 @@ t.create('Coverage (custom job)')
})
t.create('Coverage (custom invalid job)')
- .get('/gitlab-org/gitlab-runner/12-0-stable.json?job_name=i dont exist')
+ .get(
+ '/gitlab-org/gitlab-runner.json?branch=12-0-stable&job_name=i dont exist',
+ )
.expectBadge({
label: 'coverage',
message: 'not set up',
})
t.create('Coverage (custom gitlab URL)')
- .get('/GNOME/libhandy/master.json?gitlab_url=https://gitlab.gnome.org')
+ .get('/sdk/kde-builder.json?gitlab_url=https://invent.kde.org&branch=master')
.expectBadge({
label: 'coverage',
message: isIntegerPercentage,
@@ -55,7 +65,7 @@ t.create('Coverage (custom gitlab URL)')
t.create('Coverage (custom gitlab URL and job)')
.get(
- '/GNOME/libhandy/master.json?gitlab_url=https://gitlab.gnome.org&job_name=unit-test'
+ '/sdk/kde-builder.json?gitlab_url=https://invent.kde.org&branch=master&job_name=unit_and_integration_tests',
)
.expectBadge({
label: 'coverage',
diff --git a/services/gitlab/gitlab-pipeline-status.service.js b/services/gitlab/gitlab-pipeline-status.service.js
index 70494b1a6c21c..30b5c484ec645 100644
--- a/services/gitlab/gitlab-pipeline-status.service.js
+++ b/services/gitlab/gitlab-pipeline-status.service.js
@@ -1,7 +1,14 @@
import Joi from 'joi'
import { isBuildStatus, renderBuildStatusBadge } from '../build-status.js'
import { optionalUrl } from '../validators.js'
-import { BaseSvgScrapingService, NotFound, redirector } from '../index.js'
+import {
+ BaseSvgScrapingService,
+ NotFound,
+ redirector,
+ pathParam,
+ queryParam,
+} from '../index.js'
+import { description, httpErrorsFor } from './gitlab-helper.js'
const badgeSchema = Joi.object({
message: Joi.alternatives()
@@ -11,90 +18,120 @@ const badgeSchema = Joi.object({
const queryParamSchema = Joi.object({
gitlab_url: optionalUrl,
+ branch: Joi.string(),
}).required()
-const documentation = `
-
- Important: If your project is publicly visible, but the badge is like this:
-
-
-
- Check if your pipelines are publicly visible as well.
- Navigate to your project settings on GitLab and choose General Pipelines under CI/CD.
- Then tick the setting Public pipelines.
-
-
- Now your settings should look like this:
-
+const moreDocs = `
+Important: You must use the Project Path, not the Project Id. Additionally, if your project is publicly visible, but the badge is like this:
+
+
+Check if your pipelines are publicly visible as well.
+Navigate to your project settings on GitLab and choose General Pipelines under CI/CD.
+Then tick the setting Public pipelines.
+
+Now your settings should look like this:
+
-
- Your badge should be working fine now.
-
-
- NB - The badge will display 'inaccessible' if the specified repo was not found on the target Gitlab instance.
-
+
+Your badge should be working fine now.
+
+NB - The badge will display 'inaccessible' if the specified repo was not found on the target Gitlab instance.
`
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',
+ static openApi = {
+ '/gitlab/pipeline-status/{project}': {
+ get: {
+ summary: 'Gitlab Pipeline Status',
+ description: description + moreDocs,
+ parameters: [
+ pathParam({
+ name: 'project',
+ example: 'gitlab-org/gitlab',
+ }),
+ queryParam({
+ name: 'gitlab_url',
+ example: 'https://gitlab.com',
+ }),
+ queryParam({
+ name: 'branch',
+ example: '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' },
- staticPreview: this.render({ status: 'passed' }),
- documentation,
- },
- ]
+ }
static render({ status }) {
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`,
- errorMessages: {
- 401: 'repo not found',
- 404: 'repo not found',
- },
+ url: `${baseUrl}/${decodeURIComponent(
+ project,
+ )}/badges/${branch}/pipeline.svg`,
+ httpErrors: httpErrorsFor('project 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,
+}
diff --git a/services/gitlab/gitlab-pipeline-status.tester.js b/services/gitlab/gitlab-pipeline-status.tester.js
index 2edadbb000e15..f4fd9d410d8c0 100644
--- a/services/gitlab/gitlab-pipeline-status.tester.js
+++ b/services/gitlab/gitlab-pipeline-status.tester.js
@@ -3,42 +3,62 @@ 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=ruby-next')
+ .expectBadge({
+ label: 'build',
+ message: isBuildStatus,
+ })
+
+t.create('Pipeline status (nested groups)')
+ .get(
+ '/pipeline-status/megabyte-labs/docker/ci-pipeline/ansible.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',
})
// Gitlab will redirect users to a sign-in page
-// (which we ultimately see as a 503 error) in the event
+// (which we ultimately see as a 403 error) in the event
// a nonexistent, or private, repository is specified.
// Given the additional complexity that would've been required to
// present users with a more traditional and friendly 'Not Found'
-// error message, we will simply display inaccessible
+// error message, we will simply display invalid
// https://github.com/badges/shields/pull/5538
+// https://github.com/badges/shields/pull/9752
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',
+ message: 'invalid',
})
t.create('Pipeline status (custom gitlab URL)')
- .get('/GNOME/pango/main.json?gitlab_url=https://gitlab.gnome.org')
+ .get(
+ '/pipeline-status/sdk/kde-builder.json?gitlab_url=https://invent.kde.org&branch=master',
+ )
.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?style=flat&branch=v10.7.6',
+ )
diff --git a/services/gitlab/gitlab-release.service.js b/services/gitlab/gitlab-release.service.js
new file mode 100644
index 0000000000000..81cac9ce19044
--- /dev/null
+++ b/services/gitlab/gitlab-release.service.js
@@ -0,0 +1,133 @@
+import Joi from 'joi'
+import { optionalUrl } from '../validators.js'
+import { latest, renderVersionBadge } from '../version.js'
+import { NotFound, pathParam, queryParam } from '../index.js'
+import { description, httpErrorsFor } from './gitlab-helper.js'
+import GitLabBase from './gitlab-base.js'
+
+const schema = Joi.array().items(
+ Joi.object({
+ name: Joi.string().required(),
+ tag_name: Joi.string().required(),
+ }),
+)
+
+const sortEnum = ['date', 'semver']
+const displayNameEnum = ['tag', 'release']
+const dateOrderByEnum = ['created_at', 'released_at']
+
+const queryParamSchema = Joi.object({
+ gitlab_url: optionalUrl,
+ include_prereleases: Joi.equal(''),
+ sort: Joi.string()
+ .valid(...sortEnum)
+ .default('date'),
+ display_name: Joi.string()
+ .valid(...displayNameEnum)
+ .default('tag'),
+ date_order_by: Joi.string()
+ .valid(...dateOrderByEnum)
+ .default('created_at'),
+}).required()
+
+export default class GitLabRelease extends GitLabBase {
+ static category = 'version'
+
+ static route = {
+ base: 'gitlab/v/release',
+ pattern: ':project+',
+ queryParamSchema,
+ }
+
+ static openApi = {
+ '/gitlab/v/release/{project}': {
+ get: {
+ summary: 'GitLab Release',
+ description,
+ parameters: [
+ pathParam({
+ name: 'project',
+ example: 'gitlab-org/gitlab',
+ }),
+ queryParam({
+ name: 'gitlab_url',
+ example: 'https://gitlab.com',
+ }),
+ queryParam({
+ name: 'include_prereleases',
+ schema: { type: 'boolean' },
+ example: null,
+ }),
+ queryParam({
+ name: 'sort',
+ schema: { type: 'string', enum: sortEnum },
+ example: 'semver',
+ }),
+ queryParam({
+ name: 'display_name',
+ schema: { type: 'string', enum: displayNameEnum },
+ example: 'release',
+ }),
+ queryParam({
+ name: 'date_order_by',
+ schema: { type: 'string', enum: dateOrderByEnum },
+ example: 'created_at',
+ }),
+ ],
+ },
+ },
+ }
+
+ 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`,
+ httpErrors: httpErrorsFor('project not found'),
+ options: {
+ searchParams: { 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 })
+ }
+}
diff --git a/services/gitlab/gitlab-release.spec.js b/services/gitlab/gitlab-release.spec.js
new file mode 100644
index 0000000000000..676e4ef74cd3e
--- /dev/null
+++ b/services/gitlab/gitlab-release.spec.js
@@ -0,0 +1,48 @@
+import { expect } from 'chai'
+import nock from 'nock'
+import { cleanUpNockAfterEach, defaultContext } from '../test-helpers.js'
+import GitLabRelease from './gitlab-release.service.js'
+
+describe('GitLabRelease', function () {
+ describe('auth', function () {
+ cleanUpNockAfterEach()
+
+ const fakeToken = 'abc123'
+ const config = {
+ public: {
+ services: {
+ gitlab: {
+ authorizedOrigins: ['https://gitlab.com'],
+ },
+ },
+ },
+ private: {
+ gitlab_token: fakeToken,
+ },
+ }
+
+ it('sends the auth information as configured', async function () {
+ const scope = nock('https://gitlab.com/')
+ .get('/api/v4/projects/foo%2Fbar/releases?page=1')
+ // 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.
+ .matchHeader('Authorization', `Bearer ${fakeToken}`)
+ .reply(200, [{ name: '1.9', tag_name: '1.9' }])
+
+ expect(
+ await GitLabRelease.invoke(
+ defaultContext,
+ config,
+ { project: 'foo/bar' },
+ {},
+ ),
+ ).to.deep.equal({
+ label: undefined,
+ message: 'v1.9',
+ color: 'blue',
+ })
+
+ scope.done()
+ })
+ })
+})
diff --git a/services/gitlab/gitlab-release.tester.js b/services/gitlab/gitlab-release.tester.js
new file mode 100644
index 0000000000000..ce226926eaee0
--- /dev/null
+++ b/services/gitlab/gitlab-release.tester.js
@@ -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' })
diff --git a/services/gitlab/gitlab-stars.service.js b/services/gitlab/gitlab-stars.service.js
new file mode 100644
index 0000000000000..7401fb0bc228b
--- /dev/null
+++ b/services/gitlab/gitlab-stars.service.js
@@ -0,0 +1,73 @@
+import Joi from 'joi'
+import { pathParam, queryParam } from '../index.js'
+import { optionalUrl, nonNegativeInteger } from '../validators.js'
+import { metric } from '../text-formatters.js'
+import GitLabBase from './gitlab-base.js'
+import { description } from './gitlab-helper.js'
+
+const schema = Joi.object({
+ star_count: nonNegativeInteger,
+}).required()
+
+const queryParamSchema = Joi.object({
+ gitlab_url: optionalUrl,
+}).required()
+
+export default class GitlabStars extends GitLabBase {
+ static category = 'social'
+
+ static route = {
+ base: 'gitlab/stars',
+ pattern: ':project+',
+ queryParamSchema,
+ }
+
+ static openApi = {
+ '/gitlab/stars/{project}': {
+ get: {
+ summary: 'GitLab Stars',
+ description,
+ parameters: [
+ pathParam({
+ name: 'project',
+ example: 'gitlab-org/gitlab',
+ }),
+ queryParam({
+ name: 'gitlab_url',
+ example: 'https://gitlab.com',
+ }),
+ ],
+ },
+ },
+ }
+
+ static defaultBadgeData = { label: 'stars', namedLogo: 'gitlab' }
+
+ static render({ baseUrl, project, starCount }) {
+ return {
+ message: metric(starCount),
+ style: 'social',
+ color: 'blue',
+ link: [`${baseUrl}/${project}`, `${baseUrl}/${project}/-/starrers`],
+ }
+ }
+
+ async fetch({ project, baseUrl }) {
+ // https://docs.gitlab.com/ee/api/projects.html#get-single-project
+ return super.fetch({
+ schema,
+ url: `${baseUrl}/api/v4/projects/${encodeURIComponent(project)}`,
+ httpErrors: {
+ 404: 'project not found',
+ },
+ })
+ }
+
+ async handle({ project }, { gitlab_url: baseUrl = 'https://gitlab.com' }) {
+ const { star_count: starCount } = await this.fetch({
+ project,
+ baseUrl,
+ })
+ return this.constructor.render({ baseUrl, project, starCount })
+ }
+}
diff --git a/services/gitlab/gitlab-stars.tester.js b/services/gitlab/gitlab-stars.tester.js
new file mode 100644
index 0000000000000..11fc18aa6f49c
--- /dev/null
+++ b/services/gitlab/gitlab-stars.tester.js
@@ -0,0 +1,35 @@
+import { isMetric } from '../test-validators.js'
+import { createServiceTester } from '../tester.js'
+
+export const t = await createServiceTester()
+
+t.create('Stars')
+ .get('/gitlab-org/gitlab.json')
+ .expectBadge({
+ label: 'stars',
+ message: isMetric,
+ color: 'blue',
+ link: [
+ 'https://gitlab.com/gitlab-org/gitlab',
+ 'https://gitlab.com/gitlab-org/gitlab/-/starrers',
+ ],
+ })
+
+t.create('Stars (self-managed)')
+ .get('/gitlab-cn/gitlab.json?gitlab_url=https://jihulab.com')
+ .expectBadge({
+ label: 'stars',
+ message: isMetric,
+ color: 'blue',
+ link: [
+ 'https://jihulab.com/gitlab-cn/gitlab',
+ 'https://jihulab.com/gitlab-cn/gitlab/-/starrers',
+ ],
+ })
+
+t.create('Stars (project not found)')
+ .get('/user1/gitlab-does-not-have-this-repo.json')
+ .expectBadge({
+ label: 'stars',
+ message: 'project not found',
+ })
diff --git a/services/gitlab/gitlab-tag.service.js b/services/gitlab/gitlab-tag.service.js
index 26e63b31f44c5..e013fb7763238 100644
--- a/services/gitlab/gitlab-tag.service.js
+++ b/services/gitlab/gitlab-tag.service.js
@@ -1,21 +1,24 @@
import Joi from 'joi'
-import { version as versionColor } from '../color-formatters.js'
import { optionalUrl } from '../validators.js'
-import { latest } from '../version.js'
-import { addv } from '../text-formatters.js'
-import { NotFound } from '../index.js'
+import { latest, renderVersionBadge } from '../version.js'
+import { NotFound, pathParam, queryParam } from '../index.js'
+import { description, httpErrorsFor } from './gitlab-helper.js'
import GitLabBase from './gitlab-base.js'
const schema = Joi.array().items(
Joi.object({
name: Joi.string().required(),
- })
+ }),
)
+const sortEnum = ['date', 'semver']
+
const queryParamSchema = Joi.object({
gitlab_url: optionalUrl,
include_prereleases: Joi.equal(''),
- sort: Joi.string().valid('date', 'semver').default('date'),
+ sort: Joi.string()
+ .valid(...sortEnum)
+ .default('date'),
}).required()
export default class GitlabTag extends GitLabBase {
@@ -23,77 +26,53 @@ export default class GitlabTag extends GitLabBase {
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',
- },
- queryParams: { sort: 'date' },
- staticPreview: this.render({ version: 'v2.0.0' }),
- },
- {
- title: 'GitLab tag (latest by SemVer)',
- namedParams: {
- user: 'shields-ops-group',
- repo: 'tag-test',
- },
- 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',
- },
- queryParams: {
- sort: 'semver',
- include_prereleases: null,
- },
- staticPreview: this.render({ version: 'v5.0.0-beta.1', sort: 'semver' }),
- },
- {
- title: 'GitLab tag (custom instance)',
- namedParams: {
- user: 'GNOME',
- repo: 'librsvg',
- },
- queryParams: {
- sort: 'semver',
- include_prereleases: null,
- gitlab_url: 'https://gitlab.gnome.org',
+ static openApi = {
+ '/gitlab/v/tag/{project}': {
+ get: {
+ summary: 'GitLab Tag',
+ description,
+ parameters: [
+ pathParam({
+ name: 'project',
+ example: 'shields-ops-group/tag-test',
+ }),
+ queryParam({
+ name: 'gitlab_url',
+ example: 'https://gitlab.com',
+ }),
+ queryParam({
+ name: 'include_prereleases',
+ schema: { type: 'boolean' },
+ example: null,
+ }),
+ queryParam({
+ name: 'sort',
+ schema: { type: 'string', enum: sortEnum },
+ example: 'semver',
+ }),
+ ],
},
- staticPreview: this.render({ version: 'v2.51.4' }),
},
- ]
+ }
static defaultBadgeData = { label: 'tag' }
- static render({ version, sort }) {
- return {
- message: addv(version),
- color: sort === 'semver' ? versionColor(version) : 'blue',
- }
- }
-
- 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`,
- options: { qs: { order_by: 'updated' } },
- errorMessages: {
- 404: 'repo not found',
- },
+ url: `${baseUrl}/api/v4/projects/${encodeURIComponent(
+ project,
+ )}/repository/tags`,
+ options: { searchParams: { order_by: 'updated' } },
+ httpErrors: httpErrorsFor('project not found'),
})
}
@@ -108,24 +87,24 @@ export default class GitlabTag extends GitLabBase {
return latest(
tags.map(t => t.name),
- { pre: includePrereleases }
+ { pre: includePrereleases },
)
}
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,
includePrereleases: pre !== undefined,
})
- return this.constructor.render({ version, sort })
+ return renderVersionBadge({ version })
}
}
diff --git a/services/gitlab/gitlab-tag.spec.js b/services/gitlab/gitlab-tag.spec.js
index b539a5a0d1e5e..b0c1da012b2ba 100644
--- a/services/gitlab/gitlab-tag.spec.js
+++ b/services/gitlab/gitlab-tag.spec.js
@@ -26,19 +26,20 @@ describe('GitLabTag', function () {
.get('/api/v4/projects/foo%2Fbar/repository/tags?order_by=updated')
// 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.
- .basicAuth({ user: '', pass: fakeToken })
+ .matchHeader('Authorization', `Bearer ${fakeToken}`)
.reply(200, [{ name: '1.9' }])
expect(
await GitLabTag.invoke(
defaultContext,
config,
- { user: 'foo', repo: 'bar' },
- {}
- )
+ { project: 'foo/bar' },
+ {},
+ ),
).to.deep.equal({
message: 'v1.9',
color: 'blue',
+ label: undefined,
})
scope.done()
diff --git a/services/gitlab/gitlab-tag.tester.js b/services/gitlab/gitlab-tag.tester.js
index 524d1e820bf4c..f3d78bf249d20 100644
--- a/services/gitlab/gitlab-tag.tester.js
+++ b/services/gitlab/gitlab-tag.tester.js
@@ -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/docker/ci-pipeline/ansible.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,13 +22,13 @@ 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' })
t.create('Tag (repo not found)')
.get('/fdroid/nonexistant.json')
- .expectBadge({ label: 'tag', message: 'repo not found' })
+ .expectBadge({ label: 'tag', message: 'project not found' })
t.create('Tag (no tags)')
.get('/fdroid/fdroiddata.json')
diff --git a/services/gitlab/gitlab-top-language.service.js b/services/gitlab/gitlab-top-language.service.js
new file mode 100644
index 0000000000000..e68d5690a791c
--- /dev/null
+++ b/services/gitlab/gitlab-top-language.service.js
@@ -0,0 +1,79 @@
+import Joi from 'joi'
+import { optionalUrl } from '../validators.js'
+import { InvalidResponse, pathParam, queryParam } from '../index.js'
+import GitLabBase from './gitlab-base.js'
+import { description, httpErrorsFor } from './gitlab-helper.js'
+
+const schema = Joi.object()
+ .pattern(
+ Joi.string().required(),
+ Joi.number().min(0).max(100).precision(2).required(),
+ )
+ .required()
+
+const queryParamSchema = Joi.object({
+ gitlab_url: optionalUrl,
+}).required()
+
+export default class GitlabTopLanguage extends GitLabBase {
+ static category = 'analysis'
+
+ static route = {
+ base: 'gitlab/languages',
+ pattern: ':project+',
+ queryParamSchema,
+ }
+
+ static openApi = {
+ '/gitlab/languages/{project}': {
+ get: {
+ summary: 'GitLab Top Language',
+ description,
+ parameters: [
+ pathParam({
+ name: 'project',
+ example: 'gitlab-org/gitlab',
+ }),
+ queryParam({
+ name: 'gitlab_url',
+ example: 'https://gitlab.com',
+ }),
+ ],
+ },
+ },
+ }
+
+ static defaultBadgeData = { label: 'language' }
+
+ static render({ languageData }) {
+ const topLanguage = Object.keys(languageData).reduce((a, b) =>
+ languageData[a] > languageData[b] ? a : b,
+ )
+ return {
+ label: topLanguage.toLowerCase(),
+ message: `${languageData[topLanguage].toFixed(1)}%`,
+ color: 'blue',
+ }
+ }
+
+ async fetch({ project, baseUrl }) {
+ return super.fetch({
+ schema,
+ url: `${baseUrl}/api/v4/projects/${encodeURIComponent(project)}/languages`,
+ httpErrors: httpErrorsFor('project not found'),
+ })
+ }
+
+ async handle({ project }, { gitlab_url: baseUrl = 'https://gitlab.com' }) {
+ const languageData = await this.fetch({
+ project,
+ baseUrl,
+ })
+
+ if (Object.keys(languageData).length > 0) {
+ return this.constructor.render({ languageData })
+ } else {
+ throw new InvalidResponse({ prettyMessage: 'no languages found' })
+ }
+ }
+}
diff --git a/services/gitlab/gitlab-top-language.tester.js b/services/gitlab/gitlab-top-language.tester.js
new file mode 100644
index 0000000000000..43951fd615bbc
--- /dev/null
+++ b/services/gitlab/gitlab-top-language.tester.js
@@ -0,0 +1,23 @@
+import { createServiceTester } from '../tester.js'
+import { isDecimalPercentage } from '../test-validators.js'
+
+export const t = await createServiceTester()
+
+t.create('Valid Repository').get('/wireshark/wireshark.json').expectBadge({
+ label: 'c',
+ message: isDecimalPercentage,
+})
+
+t.create('Valid Blank Repo')
+ .get('/KoruptTinker/gitlab-blank-repo.json')
+ .expectBadge({
+ label: 'language',
+ message: 'no languages found',
+ })
+
+t.create('Invalid Repository')
+ .get('/wireshark/invalidexample.json')
+ .expectBadge({
+ label: 'language',
+ message: 'project not found',
+ })
diff --git a/services/gitter/gitter.service.js b/services/gitter/gitter.service.js
index 55e5d119f7ce7..70abfd96ea500 100644
--- a/services/gitter/gitter.service.js
+++ b/services/gitter/gitter.service.js
@@ -1,4 +1,4 @@
-import { BaseStaticService } from '../index.js'
+import { BaseStaticService, pathParams } from '../index.js'
export default class Gitter extends BaseStaticService {
static category = 'chat'
@@ -8,16 +8,23 @@ export default class Gitter extends BaseStaticService {
pattern: ':user/:repo',
}
- static examples = [
- {
- title: 'Gitter',
- namedParams: {
- user: 'nwjs',
- repo: 'nw.js',
+ static openApi = {
+ '/gitter/room/{user}/{repo}': {
+ get: {
+ summary: 'Gitter',
+ parameters: pathParams(
+ {
+ name: 'user',
+ example: 'nwjs',
+ },
+ {
+ name: 'repo',
+ example: 'nw.js',
+ },
+ ),
},
- staticPreview: this.render(),
},
- ]
+ }
static defaultBadgeData = { label: 'chat' }
diff --git a/services/gnome-extensions/gnome-extensions-downloads.service.js b/services/gnome-extensions/gnome-extensions-downloads.service.js
new file mode 100644
index 0000000000000..c7551da501149
--- /dev/null
+++ b/services/gnome-extensions/gnome-extensions-downloads.service.js
@@ -0,0 +1,50 @@
+import Joi from 'joi'
+import { renderDownloadsBadge } from '../downloads.js'
+import { BaseJsonService, pathParams } from '../index.js'
+
+const extensionSchema = Joi.object({
+ downloads: Joi.number().required(),
+})
+
+export default class GnomeExtensionsDownloads extends BaseJsonService {
+ static category = 'downloads'
+
+ static route = {
+ base: 'gnome-extensions/dt',
+ pattern: ':extensionId',
+ }
+
+ static openApi = {
+ '/gnome-extensions/dt/{extensionId}': {
+ get: {
+ summary: 'Gnome Extensions Downloads',
+ parameters: pathParams({
+ name: 'extensionId',
+ description: 'Id of the Gnome Extension',
+ example: 'just-perfection-desktop@just-perfection',
+ }),
+ },
+ },
+ }
+
+ static defaultBadgeData = { label: 'downloads' }
+
+ static render({ downloads }) {
+ return renderDownloadsBadge({ downloads })
+ }
+
+ async getExtension({ extensionId }) {
+ return await this._requestJson({
+ schema: extensionSchema,
+ url: `https://extensions.gnome.org/api/v1/extensions/${extensionId}/`,
+ httpErrors: {
+ 404: 'extension not found',
+ },
+ })
+ }
+
+ async handle({ extensionId }) {
+ const { downloads } = await this.getExtension({ extensionId })
+ return this.constructor.render({ downloads })
+ }
+}
diff --git a/services/gnome-extensions/gnome-extensions-downloads.tester.js b/services/gnome-extensions/gnome-extensions-downloads.tester.js
new file mode 100644
index 0000000000000..856e915081e68
--- /dev/null
+++ b/services/gnome-extensions/gnome-extensions-downloads.tester.js
@@ -0,0 +1,13 @@
+import { createServiceTester } from '../tester.js'
+import { isMetric } from '../test-validators.js'
+
+export const t = await createServiceTester()
+
+t.create('Downloads')
+ .get('/just-perfection-desktop@just-perfection.json')
+ .expectBadge({ label: 'downloads', message: isMetric })
+
+t.create('Downloads (not found)').get('/non-existent.json').expectBadge({
+ label: 'downloads',
+ message: 'extension not found',
+})
diff --git a/services/gradle-plugin-portal/gradle-plugin-portal.service.js b/services/gradle-plugin-portal/gradle-plugin-portal.service.js
index c1602647f25e2..4992c7e40fa24 100644
--- a/services/gradle-plugin-portal/gradle-plugin-portal.service.js
+++ b/services/gradle-plugin-portal/gradle-plugin-portal.service.js
@@ -1,5 +1,5 @@
-import { redirector } from '../index.js'
-import { documentation } from '../maven-metadata/maven-metadata.js'
+import { redirector, pathParam } from '../index.js'
+import { commonParams } from '../maven-metadata/maven-metadata.js'
export default redirector({
category: 'version',
@@ -8,25 +8,18 @@ export default redirector({
base: 'gradle-plugin-portal/v',
pattern: ':pluginId',
},
- examples: [
- {
- title: 'Gradle Plugin Portal',
- queryParams: {
- versionSuffix: '.1',
- versionPrefix: '0.10',
+ openApi: {
+ '/gradle-plugin-portal/v/{pluginId}': {
+ get: {
+ summary: 'Gradle Plugin Portal Version',
+ parameters: [
+ pathParam({ name: 'pluginId', example: 'com.gradle.plugin-publish' }),
+ ...commonParams,
+ ],
},
- namedParams: {
- pluginId: 'com.gradle.plugin-publish',
- },
- staticPreview: {
- label: 'plugin portal',
- message: 'v0.10.1',
- color: 'blue',
- },
- documentation,
},
- ],
- transformPath: () => `/maven-metadata/v`,
+ },
+ transformPath: () => '/maven-metadata/v',
transformQueryParams: ({ pluginId }) => {
const groupPath = pluginId.replace(/\./g, '/')
const artifactId = `${pluginId}.gradle.plugin`
diff --git a/services/gradle-plugin-portal/gradle-plugin-portal.tester.js b/services/gradle-plugin-portal/gradle-plugin-portal.tester.js
index a662dc4f197e2..316359c1351ff 100644
--- a/services/gradle-plugin-portal/gradle-plugin-portal.tester.js
+++ b/services/gradle-plugin-portal/gradle-plugin-portal.tester.js
@@ -4,23 +4,23 @@ export const t = await createServiceTester()
t.create('gradle plugin portal')
.get('/com.gradle.plugin-publish')
.expectRedirect(
- `/maven-metadata/v.svg?label=plugin%20portal&metadataUrl=${encodeURIComponent(
- 'https://plugins.gradle.org/m2/com/gradle/plugin-publish/com.gradle.plugin-publish.gradle.plugin/maven-metadata.xml'
- )}`
+ `/maven-metadata/v.svg?metadataUrl=${encodeURIComponent(
+ 'https://plugins.gradle.org/m2/com/gradle/plugin-publish/com.gradle.plugin-publish.gradle.plugin/maven-metadata.xml',
+ )}&label=plugin%20portal`,
)
t.create('gradle plugin portal with custom labels')
.get('/com.gradle.plugin-publish?label=custom%20label')
.expectRedirect(
- `/maven-metadata/v.svg?label=custom%20label&metadataUrl=${encodeURIComponent(
- 'https://plugins.gradle.org/m2/com/gradle/plugin-publish/com.gradle.plugin-publish.gradle.plugin/maven-metadata.xml'
- )}`
+ `/maven-metadata/v.svg?metadataUrl=${encodeURIComponent(
+ 'https://plugins.gradle.org/m2/com/gradle/plugin-publish/com.gradle.plugin-publish.gradle.plugin/maven-metadata.xml',
+ )}&label=custom%20label`,
)
t.create('gradle plugin portal with custom color')
.get('/com.gradle.plugin-publish?color=gray')
.expectRedirect(
- `/maven-metadata/v.svg?color=gray&label=plugin%20portal&metadataUrl=${encodeURIComponent(
- 'https://plugins.gradle.org/m2/com/gradle/plugin-publish/com.gradle.plugin-publish.gradle.plugin/maven-metadata.xml'
- )}`
+ `/maven-metadata/v.svg?metadataUrl=${encodeURIComponent(
+ 'https://plugins.gradle.org/m2/com/gradle/plugin-publish/com.gradle.plugin-publish.gradle.plugin/maven-metadata.xml',
+ )}&label=plugin%20portal&color=gray`,
)
diff --git a/services/greasyfork/greasyfork-base.js b/services/greasyfork/greasyfork-base.js
new file mode 100644
index 0000000000000..ff1ae77394836
--- /dev/null
+++ b/services/greasyfork/greasyfork-base.js
@@ -0,0 +1,32 @@
+import Joi from 'joi'
+import { nonNegativeInteger } from '../validators.js'
+import { BaseJsonService, NotFound } from '../index.js'
+
+const schema = Joi.object({
+ daily_installs: nonNegativeInteger,
+ total_installs: nonNegativeInteger,
+ good_ratings: nonNegativeInteger,
+ ok_ratings: nonNegativeInteger,
+ bad_ratings: nonNegativeInteger,
+ version: Joi.string().required(),
+ license: Joi.string().allow(null).required(),
+}).required()
+
+export default class BaseGreasyForkService extends BaseJsonService {
+ static defaultBadgeData = { label: 'greasy fork' }
+
+ async fetch({ scriptId }) {
+ try {
+ return await this._requestJson({
+ schema,
+ url: `https://greasyfork.org/scripts/${scriptId}.json`,
+ })
+ } catch (e) {
+ if (!(e instanceof NotFound)) throw e
+ return this._requestJson({
+ schema,
+ url: `https://sleazyfork.org/scripts/${scriptId}.json`,
+ })
+ }
+ }
+}
diff --git a/services/greasyfork/greasyfork-downloads.service.js b/services/greasyfork/greasyfork-downloads.service.js
new file mode 100644
index 0000000000000..c49c056252007
--- /dev/null
+++ b/services/greasyfork/greasyfork-downloads.service.js
@@ -0,0 +1,41 @@
+import { pathParams } from '../index.js'
+import { renderDownloadsBadge } from '../downloads.js'
+import BaseGreasyForkService from './greasyfork-base.js'
+
+export default class GreasyForkInstalls extends BaseGreasyForkService {
+ static category = 'downloads'
+ static route = { base: 'greasyfork', pattern: ':variant(dt|dd)/:scriptId' }
+
+ static openApi = {
+ '/greasyfork/{variant}/{scriptId}': {
+ get: {
+ summary: 'Greasy Fork Downloads',
+ parameters: pathParams(
+ {
+ name: 'variant',
+ example: 'dt',
+ description: 'total downloads or daily downloads',
+ schema: { type: 'string', enum: this.getEnum('variant') },
+ },
+ {
+ name: 'scriptId',
+ example: '406540',
+ },
+ ),
+ },
+ },
+ }
+
+ static defaultBadgeData = { label: 'installs' }
+
+ async handle({ variant, scriptId }) {
+ const data = await this.fetch({ scriptId })
+ if (variant === 'dd') {
+ const downloads = data.daily_installs
+ const interval = 'day'
+ return renderDownloadsBadge({ downloads, interval })
+ }
+ const downloads = data.total_installs
+ return renderDownloadsBadge({ downloads })
+ }
+}
diff --git a/services/greasyfork/greasyfork-downloads.tester.js b/services/greasyfork/greasyfork-downloads.tester.js
new file mode 100644
index 0000000000000..da6c49f4259d0
--- /dev/null
+++ b/services/greasyfork/greasyfork-downloads.tester.js
@@ -0,0 +1,23 @@
+import { createServiceTester } from '../tester.js'
+import { isMetric, isMetricOverTimePeriod } from '../test-validators.js'
+export const t = await createServiceTester()
+
+t.create('Daily Installs')
+ .get('/dd/406540.json')
+ .expectBadge({ label: 'installs', message: isMetricOverTimePeriod })
+
+t.create('Daily Installs (not found)')
+ .get('/dd/000000.json')
+ .expectBadge({ label: 'installs', message: 'not found' })
+
+t.create('Total Installs')
+ .get('/dt/406540.json')
+ .expectBadge({ label: 'installs', message: isMetric })
+
+t.create('Total Installs (not found)')
+ .get('/dt/000000.json')
+ .expectBadge({ label: 'installs', message: 'not found' })
+
+t.create('Total Installs (sleazyfork)')
+ .get('/dt/374903.json')
+ .expectBadge({ label: 'installs', message: isMetric })
diff --git a/services/greasyfork/greasyfork-license.service.js b/services/greasyfork/greasyfork-license.service.js
new file mode 100644
index 0000000000000..c9906b4929f97
--- /dev/null
+++ b/services/greasyfork/greasyfork-license.service.js
@@ -0,0 +1,38 @@
+import { renderLicenseBadge } from '../licenses.js'
+import { InvalidResponse, pathParams } from '../index.js'
+import BaseGreasyForkService from './greasyfork-base.js'
+
+export default class GreasyForkLicense extends BaseGreasyForkService {
+ static category = 'license'
+ static route = { base: 'greasyfork', pattern: 'l/:scriptId' }
+
+ static openApi = {
+ '/greasyfork/l/{scriptId}': {
+ get: {
+ summary: 'Greasy Fork License',
+ parameters: pathParams({
+ name: 'scriptId',
+ example: '406540',
+ }),
+ },
+ },
+ }
+
+ static defaultBadgeData = { label: 'license' }
+
+ transform({ data }) {
+ if (data.license === null) {
+ throw new InvalidResponse({
+ prettyMessage: 'license not found',
+ })
+ }
+ // remove suffix " License" from data.license
+ return { license: data.license.replace(/ License$/, '') }
+ }
+
+ async handle({ scriptId }) {
+ const data = await this.fetch({ scriptId })
+ const { license } = this.transform({ data })
+ return renderLicenseBadge({ licenses: [license] })
+ }
+}
diff --git a/services/greasyfork/greasyfork-license.tester.js b/services/greasyfork/greasyfork-license.tester.js
new file mode 100644
index 0000000000000..ee764424041d2
--- /dev/null
+++ b/services/greasyfork/greasyfork-license.tester.js
@@ -0,0 +1,11 @@
+import { createServiceTester } from '../tester.js'
+export const t = await createServiceTester()
+
+t.create('License (valid)').get('/l/406540.json').expectBadge({
+ label: 'license',
+ message: 'MIT',
+})
+
+t.create('License (not found)')
+ .get('/l/000000.json')
+ .expectBadge({ label: 'license', message: 'not found' })
diff --git a/services/greasyfork/greasyfork-rating.service.js b/services/greasyfork/greasyfork-rating.service.js
new file mode 100644
index 0000000000000..f56e79441438a
--- /dev/null
+++ b/services/greasyfork/greasyfork-rating.service.js
@@ -0,0 +1,45 @@
+import { pathParams } from '../index.js'
+import { floorCount as floorCountColor } from '../color-formatters.js'
+import { metric } from '../text-formatters.js'
+import BaseGreasyForkService from './greasyfork-base.js'
+
+export default class GreasyForkRatingCount extends BaseGreasyForkService {
+ static category = 'rating'
+ static route = { base: 'greasyfork', pattern: 'rating-count/:scriptId' }
+
+ static openApi = {
+ '/greasyfork/rating-count/{scriptId}': {
+ get: {
+ summary: 'Greasy Fork Rating',
+ parameters: pathParams({
+ name: 'scriptId',
+ example: '406540',
+ }),
+ },
+ },
+ }
+
+ static defaultBadgeData = { label: 'rating' }
+
+ static render({ good, ok, bad }) {
+ let color = 'lightgrey'
+ const total = good + bad + ok
+ if (total > 0) {
+ const score = (good * 3 + ok * 2 + bad * 1) / total - 1
+ color = floorCountColor(score, 1, 1.5, 2)
+ }
+ return {
+ message: `${metric(good)} good, ${metric(ok)} ok, ${metric(bad)} bad`,
+ color,
+ }
+ }
+
+ async handle({ scriptId }) {
+ const data = await this.fetch({ scriptId })
+ return this.constructor.render({
+ good: data.good_ratings,
+ ok: data.ok_ratings,
+ bad: data.bad_ratings,
+ })
+ }
+}
diff --git a/services/greasyfork/greasyfork-rating.spec.js b/services/greasyfork/greasyfork-rating.spec.js
new file mode 100644
index 0000000000000..459cb72982ee0
--- /dev/null
+++ b/services/greasyfork/greasyfork-rating.spec.js
@@ -0,0 +1,31 @@
+import { test, given } from 'sazerac'
+import GreasyForkRatingCount from './greasyfork-rating.service.js'
+
+describe('GreasyForkRatingCount', function () {
+ test(GreasyForkRatingCount.render, () => {
+ given({ good: 0, ok: 0, bad: 30 }).expect({
+ message: '0 good, 0 ok, 30 bad',
+ color: 'red',
+ })
+ given({ good: 10, ok: 20, bad: 30 }).expect({
+ message: '10 good, 20 ok, 30 bad',
+ color: 'yellow',
+ })
+ given({ good: 10, ok: 20, bad: 10 }).expect({
+ message: '10 good, 20 ok, 10 bad',
+ color: 'yellowgreen',
+ })
+ given({ good: 20, ok: 10, bad: 0 }).expect({
+ message: '20 good, 10 ok, 0 bad',
+ color: 'green',
+ })
+ given({ good: 30, ok: 0, bad: 0 }).expect({
+ message: '30 good, 0 ok, 0 bad',
+ color: 'brightgreen',
+ })
+ given({ good: 0, ok: 0, bad: 0 }).expect({
+ message: '0 good, 0 ok, 0 bad',
+ color: 'lightgrey',
+ })
+ })
+})
diff --git a/services/greasyfork/greasyfork-rating.tester.js b/services/greasyfork/greasyfork-rating.tester.js
new file mode 100644
index 0000000000000..41dee4c6e545e
--- /dev/null
+++ b/services/greasyfork/greasyfork-rating.tester.js
@@ -0,0 +1,14 @@
+import Joi from 'joi'
+import { createServiceTester } from '../tester.js'
+export const t = await createServiceTester()
+
+t.create('Rating Count')
+ .get('/rating-count/406540.json')
+ .expectBadge({
+ label: 'rating',
+ message: Joi.string().regex(/^\d+ good, \d+ ok, \d+ bad$/),
+ })
+
+t.create('Rating Count (not found)')
+ .get('/rating-count/000000.json')
+ .expectBadge({ label: 'rating', message: 'not found' })
diff --git a/services/greasyfork/greasyfork-version.service.js b/services/greasyfork/greasyfork-version.service.js
new file mode 100644
index 0000000000000..9831badd4efbc
--- /dev/null
+++ b/services/greasyfork/greasyfork-version.service.js
@@ -0,0 +1,25 @@
+import { pathParams } from '../index.js'
+import { renderVersionBadge } from '../version.js'
+import BaseGreasyForkService from './greasyfork-base.js'
+
+export default class GreasyForkVersion extends BaseGreasyForkService {
+ static category = 'version'
+ static route = { base: 'greasyfork', pattern: 'v/:scriptId' }
+
+ static openApi = {
+ '/greasyfork/v/{scriptId}': {
+ get: {
+ summary: 'Greasy Fork Version',
+ parameters: pathParams({
+ name: 'scriptId',
+ example: '406540',
+ }),
+ },
+ },
+ }
+
+ async handle({ scriptId }) {
+ const data = await this.fetch({ scriptId })
+ return renderVersionBadge({ version: data.version })
+ }
+}
diff --git a/services/greasyfork/greasyfork-version.tester.js b/services/greasyfork/greasyfork-version.tester.js
new file mode 100644
index 0000000000000..5ea8330d56898
--- /dev/null
+++ b/services/greasyfork/greasyfork-version.tester.js
@@ -0,0 +1,12 @@
+import { isVPlusDottedVersionAtLeastOne } from '../test-validators.js'
+import { createServiceTester } from '../tester.js'
+export const t = await createServiceTester()
+
+t.create('Version').get('/v/406540.json').expectBadge({
+ label: 'greasy fork',
+ message: isVPlusDottedVersionAtLeastOne,
+})
+
+t.create('Version (not found)')
+ .get('/v/000000.json')
+ .expectBadge({ label: 'greasy fork', message: 'not found' })
diff --git a/services/hackage/hackage-deps.service.js b/services/hackage/hackage-deps.service.js
index f3cbd1deb996b..07e3b01360240 100644
--- a/services/hackage/hackage-deps.service.js
+++ b/services/hackage/hackage-deps.service.js
@@ -1,44 +1,11 @@
-import { BaseService } from '../index.js'
+import { deprecatedService } from '../index.js'
-export default class HackageDeps extends BaseService {
- static category = 'dependencies'
-
- static route = {
+export const HackageDeps = deprecatedService({
+ category: 'dependencies',
+ route: {
base: 'hackage-deps/v',
pattern: ':packageName',
- }
-
- static examples = [
- {
- title: 'Hackage-Deps',
- namedParams: { packageName: 'lens' },
- staticPreview: this.render({ isOutdated: false }),
- },
- ]
-
- static defaultBadgeData = { label: 'dependencies' }
-
- static render({ isOutdated }) {
- if (isOutdated) {
- return { message: 'outdated', color: 'orange' }
- } else {
- return { message: 'up to date', color: 'brightgreen' }
- }
- }
-
- async handle({ packageName }) {
- const reverseUrl = `http://packdeps.haskellers.com/licenses/${packageName}`
- const feedUrl = `http://packdeps.haskellers.com/feed/${packageName}`
-
- // first call /reverse to check if the package exists
- // this will throw a 404 if it doesn't
- await this._request({ url: reverseUrl })
-
- // if the package exists, then query /feed to check the dependencies
- const { buffer } = await this._request({ url: feedUrl })
-
- const outdatedStr = `Outdated dependencies for ${packageName} `
- const isOutdated = buffer.includes(outdatedStr)
- return this.constructor.render({ isOutdated })
- }
-}
+ },
+ label: 'hackagedeps',
+ dateAdded: new Date('2024-10-18'),
+})
diff --git a/services/hackage/hackage-deps.tester.js b/services/hackage/hackage-deps.tester.js
index 6133b776e6099..aa9e065e8cb3d 100644
--- a/services/hackage/hackage-deps.tester.js
+++ b/services/hackage/hackage-deps.tester.js
@@ -1,14 +1,10 @@
-import Joi from 'joi'
-import { createServiceTester } from '../tester.js'
-export const t = await createServiceTester()
+import { ServiceTester } from '../tester.js'
+export const t = new ServiceTester({
+ id: 'hackagedeps',
+ title: 'Hackage Dependencies',
+ pathPrefix: '/hackage-deps/v',
+})
-t.create('hackage deps (valid)')
- .get('/lens.json')
- .expectBadge({
- label: 'dependencies',
- message: Joi.string().regex(/^(up to date|outdated)$/),
- })
-
-t.create('hackage deps (not found)')
- .get('/not-a-package.json')
- .expectBadge({ label: 'dependencies', message: 'not found' })
+t.create('hackage deps (deprecated)')
+ .get('/package.json')
+ .expectBadge({ label: 'hackagedeps', message: 'no longer available' })
diff --git a/services/hackage/hackage-version.service.js b/services/hackage/hackage-version.service.js
index e1333a14e6510..9a586d9e77e72 100644
--- a/services/hackage/hackage-version.service.js
+++ b/services/hackage/hackage-version.service.js
@@ -1,5 +1,5 @@
import { renderVersionBadge } from '../version.js'
-import { BaseService, InvalidResponse } from '../index.js'
+import { BaseService, InvalidResponse, pathParams } from '../index.js'
export default class HackageVersion extends BaseService {
static category = 'version'
@@ -9,13 +9,17 @@ export default class HackageVersion extends BaseService {
pattern: ':packageName',
}
- static examples = [
- {
- title: 'Hackage',
- namedParams: { packageName: 'lens' },
- staticPreview: renderVersionBadge({ version: '4.1.7' }),
+ static openApi = {
+ '/hackage/v/{packageName}': {
+ get: {
+ summary: 'Hackage Version',
+ parameters: pathParams({
+ name: 'packageName',
+ example: 'lens',
+ }),
+ },
},
- ]
+ }
static defaultBadgeData = { label: 'hackage' }
diff --git a/services/hackage/hackage-version.tester.js b/services/hackage/hackage-version.tester.js
index f4074ec9c1a99..5f66211a3774e 100644
--- a/services/hackage/hackage-version.tester.js
+++ b/services/hackage/hackage-version.tester.js
@@ -16,6 +16,6 @@ t.create('hackage version (unexpected response)')
.intercept(nock =>
nock('https://hackage.haskell.org')
.get('/package/lens/lens.cabal')
- .reply(200, '')
+ .reply(200, ''),
)
.expectBadge({ label: 'hackage', message: 'invalid response data' })
diff --git a/services/hackernews/hackernews-user-karma.service.js b/services/hackernews/hackernews-user-karma.service.js
new file mode 100644
index 0000000000000..7444662a4ffef
--- /dev/null
+++ b/services/hackernews/hackernews-user-karma.service.js
@@ -0,0 +1,68 @@
+import Joi from 'joi'
+import { metric } from '../text-formatters.js'
+import { BaseJsonService, NotFound, pathParams } from '../index.js'
+import { anyInteger } from '../validators.js'
+
+const schema = Joi.object({
+ karma: anyInteger,
+})
+ .allow(null)
+ .required()
+
+export default class HackerNewsUserKarma extends BaseJsonService {
+ static category = 'social'
+
+ static route = {
+ base: 'hackernews/user-karma',
+ pattern: ':id',
+ }
+
+ static openApi = {
+ '/hackernews/user-karma/{id}': {
+ get: {
+ summary: 'HackerNews User Karma',
+ parameters: pathParams({
+ name: 'id',
+ example: 'pg',
+ }),
+ },
+ },
+ }
+
+ static defaultBadgeData = {
+ label: 'Karma',
+ namedLogo: 'ycombinator',
+ }
+
+ static render({ karma, id }) {
+ const color = karma > 0 ? 'brightgreen' : karma === 0 ? 'orange' : 'red'
+ return {
+ label: `U/${id} karma`,
+ message: metric(karma),
+ color,
+ style: 'social',
+ }
+ }
+
+ async fetch({ id }) {
+ return this._requestJson({
+ schema,
+ url: `https://hacker-news.firebaseio.com/v0/user/${id}.json`,
+ httpErrors: {
+ 404: 'user not found',
+ },
+ })
+ }
+
+ async handle({ id }) {
+ const json = await this.fetch({ id })
+ if (json == null) {
+ throw new NotFound({ prettyMessage: 'user not found' })
+ }
+ const { karma } = json
+ return this.constructor.render({
+ karma,
+ id,
+ })
+ }
+}
diff --git a/services/hackernews/hackernews-user-karma.tester.js b/services/hackernews/hackernews-user-karma.tester.js
new file mode 100644
index 0000000000000..d4eb227bfaa90
--- /dev/null
+++ b/services/hackernews/hackernews-user-karma.tester.js
@@ -0,0 +1,26 @@
+import { createServiceTester } from '../tester.js'
+import { isMetricAllowNegative } from '../test-validators.js'
+
+export const t = await createServiceTester()
+
+t.create('valid repo').get('/pg.json').expectBadge({
+ label: 'U/pg karma',
+ message: isMetricAllowNegative,
+})
+
+t.create('valid repo -- negative karma')
+ .get('/negative.json')
+ .intercept(nock =>
+ nock('https://hacker-news.firebaseio.com/v0/user')
+ .get('/negative.json')
+ .reply(200, { karma: -1234 }),
+ )
+ .expectBadge({
+ label: 'U/negative karma',
+ message: isMetricAllowNegative,
+ })
+
+t.create('invalid user').get('/hopefullythisdoesnotexist.json').expectBadge({
+ label: 'Karma',
+ message: 'user not found',
+})
diff --git a/services/hangar/hangar-base.js b/services/hangar/hangar-base.js
new file mode 100644
index 0000000000000..54f3b11b63c01
--- /dev/null
+++ b/services/hangar/hangar-base.js
@@ -0,0 +1,37 @@
+import Joi from 'joi'
+import { BaseJsonService } from '../index.js'
+
+const description = `
+Hangar is a plugin repository for the Paper, Waterfall and Folia platforms.
`
+
+const resourceSchema = Joi.object({
+ stats: Joi.object({
+ views: Joi.number().required(),
+ downloads: Joi.number().required(),
+ recentViews: Joi.number().required(),
+ recentDownloads: Joi.number().required(),
+ stars: Joi.number().required(),
+ watchers: Joi.number().required(),
+ }).required(),
+}).required()
+
+class BaseHangarService extends BaseJsonService {
+ static _cacheLength = 3600
+
+ async fetch({
+ slug,
+ schema = resourceSchema,
+ url = `https://hangar.papermc.io/api/v1/projects/${slug}`,
+ }) {
+ return this._requestJson({
+ schema,
+ url,
+ httpErrors: {
+ 401: 'Api session missing, invalid or expired',
+ 403: 'Not enough permission to use this endpoint',
+ },
+ })
+ }
+}
+
+export { description, BaseHangarService }
diff --git a/services/hangar/hangar-downloads.service.js b/services/hangar/hangar-downloads.service.js
new file mode 100644
index 0000000000000..b5089b8bfe435
--- /dev/null
+++ b/services/hangar/hangar-downloads.service.js
@@ -0,0 +1,34 @@
+import { pathParams } from '../index.js'
+import { renderDownloadsBadge } from '../downloads.js'
+import { BaseHangarService, description } from './hangar-base.js'
+
+export default class HangarDownloads extends BaseHangarService {
+ static category = 'downloads'
+
+ static route = {
+ base: 'hangar/dt',
+ pattern: ':slug',
+ }
+
+ static openApi = {
+ '/hangar/dt/{slug}': {
+ get: {
+ summary: 'Hangar Downloads',
+ description,
+ parameters: pathParams({
+ name: 'slug',
+ example: 'Essentials',
+ }),
+ },
+ },
+ }
+
+ static defaultBadgeData = { label: 'downloads' }
+
+ async handle({ slug }) {
+ const {
+ stats: { downloads },
+ } = await this.fetch({ slug })
+ return renderDownloadsBadge({ downloads })
+ }
+}
diff --git a/services/hangar/hangar-downloads.tester.js b/services/hangar/hangar-downloads.tester.js
new file mode 100644
index 0000000000000..ee8c8b6aa2e3f
--- /dev/null
+++ b/services/hangar/hangar-downloads.tester.js
@@ -0,0 +1,13 @@
+import { isMetric } from '../test-validators.js'
+import { createServiceTester } from '../tester.js'
+export const t = await createServiceTester()
+
+t.create('Essentials').get('/Essentials.json').expectBadge({
+ label: 'downloads',
+ message: isMetric,
+})
+
+t.create('Invalid Resource').get('/does-not-exist.json').expectBadge({
+ label: 'downloads',
+ message: 'not found',
+})
diff --git a/services/hangar/hangar-stars.service.js b/services/hangar/hangar-stars.service.js
new file mode 100644
index 0000000000000..715caa63f0ca3
--- /dev/null
+++ b/services/hangar/hangar-stars.service.js
@@ -0,0 +1,43 @@
+import { pathParams } from '../index.js'
+import { metric } from '../text-formatters.js'
+import { BaseHangarService, description } from './hangar-base.js'
+
+export default class HangarStars extends BaseHangarService {
+ static category = 'social'
+
+ static route = {
+ base: 'hangar/stars',
+ pattern: ':slug',
+ }
+
+ static openApi = {
+ '/hangar/stars/{slug}': {
+ get: {
+ summary: 'Hangar Stars',
+ description,
+ parameters: pathParams({
+ name: 'slug',
+ example: 'Essentials',
+ }),
+ },
+ },
+ }
+
+ static defaultBadgeData = {
+ label: 'stars',
+ color: 'blue',
+ }
+
+ static render({ stars }) {
+ return {
+ message: metric(stars),
+ }
+ }
+
+ async handle({ slug }) {
+ const {
+ stats: { stars },
+ } = await this.fetch({ slug })
+ return this.constructor.render({ stars })
+ }
+}
diff --git a/services/hangar/hangar-stars.tester.js b/services/hangar/hangar-stars.tester.js
new file mode 100644
index 0000000000000..d8bc3b496eaea
--- /dev/null
+++ b/services/hangar/hangar-stars.tester.js
@@ -0,0 +1,13 @@
+import { isMetric } from '../test-validators.js'
+import { createServiceTester } from '../tester.js'
+export const t = await createServiceTester()
+
+t.create('Essentials').get('/Essentials.json').expectBadge({
+ label: 'stars',
+ message: isMetric,
+})
+
+t.create('Invalid Resource').get('/does-not-exist.json').expectBadge({
+ label: 'stars',
+ message: 'not found',
+})
diff --git a/services/hangar/hangar-views.service.js b/services/hangar/hangar-views.service.js
new file mode 100644
index 0000000000000..3794d7a7786e4
--- /dev/null
+++ b/services/hangar/hangar-views.service.js
@@ -0,0 +1,43 @@
+import { pathParams } from '../index.js'
+import { metric } from '../text-formatters.js'
+import { BaseHangarService, description } from './hangar-base.js'
+
+export default class HangarViews extends BaseHangarService {
+ static category = 'other'
+
+ static route = {
+ base: 'hangar/views',
+ pattern: ':slug',
+ }
+
+ static openApi = {
+ '/hangar/views/{slug}': {
+ get: {
+ summary: 'Hangar Views',
+ description,
+ parameters: pathParams({
+ name: 'slug',
+ example: 'Essentials',
+ }),
+ },
+ },
+ }
+
+ static defaultBadgeData = {
+ label: 'views',
+ color: 'blue',
+ }
+
+ static render({ views }) {
+ return {
+ message: metric(views),
+ }
+ }
+
+ async handle({ slug }) {
+ const {
+ stats: { views },
+ } = await this.fetch({ slug })
+ return this.constructor.render({ views })
+ }
+}
diff --git a/services/hangar/hangar-views.tester.js b/services/hangar/hangar-views.tester.js
new file mode 100644
index 0000000000000..af899f7c50b22
--- /dev/null
+++ b/services/hangar/hangar-views.tester.js
@@ -0,0 +1,13 @@
+import { isMetric } from '../test-validators.js'
+import { createServiceTester } from '../tester.js'
+export const t = await createServiceTester()
+
+t.create('Essentials').get('/Essentials.json').expectBadge({
+ label: 'views',
+ message: isMetric,
+})
+
+t.create('Invalid Resource').get('/does-not-exist.json').expectBadge({
+ label: 'views',
+ message: 'not found',
+})
diff --git a/services/hangar/hangar-watchers.service.js b/services/hangar/hangar-watchers.service.js
new file mode 100644
index 0000000000000..38939e659b2a1
--- /dev/null
+++ b/services/hangar/hangar-watchers.service.js
@@ -0,0 +1,43 @@
+import { pathParams } from '../index.js'
+import { metric } from '../text-formatters.js'
+import { BaseHangarService, description } from './hangar-base.js'
+
+export default class HangarWatchers extends BaseHangarService {
+ static category = 'social'
+
+ static route = {
+ base: 'hangar/watchers',
+ pattern: ':slug',
+ }
+
+ static openApi = {
+ '/hangar/watchers/{slug}': {
+ get: {
+ summary: 'Hangar Watchers',
+ description,
+ parameters: pathParams({
+ name: 'slug',
+ example: 'Essentials',
+ }),
+ },
+ },
+ }
+
+ static defaultBadgeData = {
+ label: 'watchers',
+ color: 'blue',
+ }
+
+ static render({ watchers }) {
+ return {
+ message: metric(watchers),
+ }
+ }
+
+ async handle({ slug }) {
+ const {
+ stats: { watchers },
+ } = await this.fetch({ slug })
+ return this.constructor.render({ watchers })
+ }
+}
diff --git a/services/hangar/hangar-watchers.tester.js b/services/hangar/hangar-watchers.tester.js
new file mode 100644
index 0000000000000..fdc6c23c4fef7
--- /dev/null
+++ b/services/hangar/hangar-watchers.tester.js
@@ -0,0 +1,13 @@
+import { isMetric } from '../test-validators.js'
+import { createServiceTester } from '../tester.js'
+export const t = await createServiceTester()
+
+t.create('Essentials').get('/Essentials.json').expectBadge({
+ label: 'watchers',
+ message: isMetric,
+})
+
+t.create('Invalid Resource').get('/does-not-exist.json').expectBadge({
+ label: 'watchers',
+ message: 'not found',
+})
diff --git a/services/hexpm/hexpm.service.js b/services/hexpm/hexpm.service.js
index fe03981abe80e..1f5d5762db239 100644
--- a/services/hexpm/hexpm.service.js
+++ b/services/hexpm/hexpm.service.js
@@ -1,7 +1,8 @@
import Joi from 'joi'
-import { metric, addv, maybePluralize } from '../text-formatters.js'
-import { downloadCount, version as versionColor } from '../color-formatters.js'
-import { BaseJsonService } from '../index.js'
+import { renderDownloadsBadge } from '../downloads.js'
+import { maybePluralize } from '../text-formatters.js'
+import { renderVersionBadge } from '../version.js'
+import { BaseJsonService, pathParams } from '../index.js'
const hexSchema = Joi.object({
downloads: Joi.object({
@@ -13,9 +14,12 @@ const hexSchema = Joi.object({
meta: Joi.object({
licenses: Joi.array().required(),
}).required(),
- latest_stable_version: Joi.string().required(),
+ latest_stable_version: Joi.string().allow(null),
+ latest_version: Joi.string().required(),
}).required()
+const description = '[Hex.pm](https://hex.pm/) is a package registry for Erlang'
+
class BaseHexPmService extends BaseJsonService {
static defaultBadgeData = { label: 'hex' }
@@ -35,13 +39,18 @@ class HexPmLicense extends BaseHexPmService {
pattern: ':packageName',
}
- static examples = [
- {
- title: 'Hex.pm',
- namedParams: { packageName: 'plug' },
- staticPreview: this.render({ licenses: ['Apache 2'] }),
+ static openApi = {
+ '/hexpm/l/{packageName}': {
+ get: {
+ summary: 'Hex.pm License',
+ description,
+ parameters: pathParams({
+ name: 'packageName',
+ example: 'plug',
+ }),
+ },
},
- ]
+ }
static defaultBadgeData = { label: 'license' }
@@ -74,77 +83,84 @@ class HexPmVersion extends BaseHexPmService {
pattern: ':packageName',
}
- static examples = [
- {
- title: 'Hex.pm',
- namedParams: { packageName: 'plug' },
- staticPreview: this.render({ version: '1.6.4' }),
+ static openApi = {
+ '/hexpm/v/{packageName}': {
+ get: {
+ summary: 'Hex.pm Version',
+ description,
+ parameters: pathParams({
+ name: 'packageName',
+ example: 'plug',
+ }),
+ },
},
- ]
+ }
static render({ version }) {
- return { message: addv(version), color: versionColor(version) }
+ return renderVersionBadge({ version })
}
async handle({ packageName }) {
const json = await this.fetch({ packageName })
- return this.constructor.render({ version: json.latest_stable_version })
+ return this.constructor.render({
+ version: json.latest_stable_version || json.latest_version,
+ })
}
}
-function DownloadsForInterval(interval) {
- const { base, messageSuffix, name } = {
- day: {
- base: 'hexpm/dd',
- messageSuffix: '/day',
- name: 'HexPmDownloadsDay',
- },
- week: {
- base: 'hexpm/dw',
- messageSuffix: '/week',
- name: 'HexPmDownloadsWeek',
- },
- all: {
- base: 'hexpm/dt',
- messageSuffix: '',
- name: 'HexPmDownloadsTotal',
- },
- }[interval]
-
- return class HexPmDownloads extends BaseHexPmService {
- static name = name
+const periodMap = {
+ dd: {
+ field: 'day',
+ label: 'day',
+ },
+ dw: {
+ field: 'week',
+ label: 'week',
+ },
+ dt: {
+ field: 'all',
+ },
+}
- static category = 'downloads'
+class HexPmDownloads extends BaseHexPmService {
+ static category = 'downloads'
- static route = {
- base,
- pattern: ':packageName',
- }
+ static route = {
+ base: 'hexpm',
+ pattern: ':interval(dd|dw|dt)/:packageName',
+ }
- static examples = [
- {
- title: 'Hex.pm',
- namedParams: { packageName: 'plug' },
- staticPreview: this.render({ downloads: 85000 }),
+ static openApi = {
+ '/hexpm/{interval}/{packageName}': {
+ get: {
+ summary: 'Hex.pm Downloads',
+ description,
+ parameters: pathParams(
+ {
+ name: 'interval',
+ example: 'dw',
+ schema: { type: 'string', enum: this.getEnum('interval') },
+ description: 'Daily, Weekly, or Total downloads',
+ },
+ {
+ name: 'packageName',
+ example: 'plug',
+ },
+ ),
},
- ]
-
- static defaultBadgeData = { label: 'downloads' }
+ },
+ }
- static render({ downloads }) {
- return {
- message: `${metric(downloads)}${messageSuffix}`,
- color: downloadCount(downloads),
- }
- }
+ static defaultBadgeData = { label: 'downloads' }
- async handle({ packageName }) {
- const json = await this.fetch({ packageName })
- return this.constructor.render({ downloads: json.downloads[interval] })
- }
+ async handle({ interval, packageName }) {
+ const json = await this.fetch({ packageName })
+ const downloads = json.downloads[periodMap[interval].field]
+ return renderDownloadsBadge({
+ downloads,
+ interval: periodMap[interval].label,
+ })
}
}
-const downloadsServices = ['day', 'week', 'all'].map(DownloadsForInterval)
-
-export default [...downloadsServices, HexPmLicense, HexPmVersion]
+export default [HexPmDownloads, HexPmLicense, HexPmVersion]
diff --git a/services/hexpm/hexpm.tester.js b/services/hexpm/hexpm.tester.js
index d7f1d661b77f2..51fd800b9a43a 100644
--- a/services/hexpm/hexpm.tester.js
+++ b/services/hexpm/hexpm.tester.js
@@ -1,8 +1,10 @@
import Joi from 'joi'
import { ServiceTester } from '../tester.js'
-import { isMetric, isMetricOverTimePeriod } from '../test-validators.js'
-
-const isHexpmVersion = Joi.string().regex(/^v\d+.\d+.?\d?$/)
+import {
+ isMetric,
+ isMetricOverTimePeriod,
+ isVPlusDottedVersionNClausesWithOptionalSuffix,
+} from '../test-validators.js'
export const t = new ServiceTester({ id: 'hexpm', title: 'Hex.pm' })
@@ -22,8 +24,9 @@ t.create('downloads (zero for period)')
.reply(200, {
downloads: { all: 100 }, // there is no 'day' key here
latest_stable_version: '1.0',
+ latest_version: '1.0',
meta: { licenses: ['MIT'] },
- })
+ }),
)
.expectBadge({ label: 'downloads', message: '0/day' })
@@ -35,9 +38,27 @@ t.create('downloads (not found)')
.get('/dt/this-package-does-not-exist.json')
.expectBadge({ label: 'downloads', message: 'not found' })
-t.create('version')
- .get('/v/cowboy.json')
- .expectBadge({ label: 'hex', message: isHexpmVersion })
+t.create('version').get('/v/cowboy.json').expectBadge({
+ label: 'hex',
+ message: isVPlusDottedVersionNClausesWithOptionalSuffix,
+})
+
+t.create('version (no stable version)')
+ .get('/v/prima_opentelemetry_ex.json')
+ .intercept(nock =>
+ nock('https://hex.pm/')
+ .get('/api/packages/prima_opentelemetry_ex')
+ .reply(200, {
+ downloads: { all: 100 },
+ latest_stable_version: null,
+ latest_version: '1.0.0-rc.3',
+ meta: { licenses: ['MIT'] },
+ }),
+ )
+ .expectBadge({
+ label: 'hex',
+ message: isVPlusDottedVersionNClausesWithOptionalSuffix,
+ })
t.create('version (not found)')
.get('/v/this-package-does-not-exist.json')
@@ -57,8 +78,9 @@ t.create('license (multiple licenses)')
.reply(200, {
downloads: { all: 100 },
latest_stable_version: '1.0',
+ latest_version: '1.0',
meta: { licenses: ['GPLv2', 'MIT'] },
- })
+ }),
)
.expectBadge({
label: 'licenses',
@@ -74,8 +96,9 @@ t.create('license (no license)')
.reply(200, {
downloads: { all: 100 },
latest_stable_version: '1.0',
+ latest_version: '1.0',
meta: { licenses: [] },
- })
+ }),
)
.expectBadge({
label: 'license',
diff --git a/services/homebrew/homebrew-cask-downloads.service.js b/services/homebrew/homebrew-cask-downloads.service.js
new file mode 100644
index 0000000000000..7b11aea067760
--- /dev/null
+++ b/services/homebrew/homebrew-cask-downloads.service.js
@@ -0,0 +1,82 @@
+import Joi from 'joi'
+import { renderDownloadsBadge } from '../downloads.js'
+import { BaseJsonService, pathParams } from '../index.js'
+import { nonNegativeInteger } from '../validators.js'
+
+function getSchema({ cask }) {
+ return Joi.object({
+ analytics: Joi.object({
+ install: Joi.object({
+ '30d': Joi.object({ [cask]: nonNegativeInteger }).required(),
+ '90d': Joi.object({ [cask]: nonNegativeInteger }).required(),
+ '365d': Joi.object({ [cask]: nonNegativeInteger }).required(),
+ }).required(),
+ }).required(),
+ }).required()
+}
+
+const periodMap = {
+ dm: {
+ api_field: '30d',
+ interval: 'month',
+ },
+ dq: {
+ api_field: '90d',
+ interval: 'quarter',
+ },
+ dy: {
+ api_field: '365d',
+ interval: 'year',
+ },
+}
+
+export default class HomebrewCaskDownloads extends BaseJsonService {
+ static category = 'downloads'
+
+ static route = {
+ base: 'homebrew/cask/installs',
+ pattern: ':interval(dm|dq|dy)/:cask',
+ }
+
+ static openApi = {
+ '/homebrew/cask/installs/{interval}/{cask}': {
+ get: {
+ summary: 'Homebrew Cask Downloads',
+ parameters: pathParams(
+ {
+ name: 'interval',
+ example: 'dm',
+ schema: { type: 'string', enum: this.getEnum('interval') },
+ description: 'Monthly, Quarterly or Yearly downloads',
+ },
+ {
+ name: 'cask',
+ example: 'freetube',
+ },
+ ),
+ },
+ },
+ }
+
+ static defaultBadgeData = { label: 'downloads' }
+
+ async fetch({ cask }) {
+ const schema = getSchema({ cask })
+ return this._requestJson({
+ schema,
+ url: `https://formulae.brew.sh/api/cask/${cask}.json`,
+ httpErrors: { 404: 'cask not found' },
+ })
+ }
+
+ async handle({ interval, cask }) {
+ const {
+ analytics: { install },
+ } = await this.fetch({ cask })
+
+ return renderDownloadsBadge({
+ downloads: install[periodMap[interval].api_field][cask],
+ interval: periodMap[interval].interval,
+ })
+ }
+}
diff --git a/services/homebrew/homebrew-cask-downloads.tester.js b/services/homebrew/homebrew-cask-downloads.tester.js
new file mode 100644
index 0000000000000..57d1ff96369fc
--- /dev/null
+++ b/services/homebrew/homebrew-cask-downloads.tester.js
@@ -0,0 +1,28 @@
+import { createServiceTester } from '../tester.js'
+import { isMetricOverTimePeriod } from '../test-validators.js'
+
+export const t = await createServiceTester()
+
+t.create('daily downloads (valid)')
+ .get('/dm/freetube.json')
+ .expectBadge({ label: 'downloads', message: isMetricOverTimePeriod })
+
+t.create('yearly downloads (valid)')
+ .get('/dq/freetube.json')
+ .expectBadge({ label: 'downloads', message: isMetricOverTimePeriod })
+
+t.create('yearly downloads (valid)')
+ .get('/dy/freetube.json')
+ .expectBadge({ label: 'downloads', message: isMetricOverTimePeriod })
+
+t.create('daily downloads (not found)')
+ .get('/dm/not-a-package.json')
+ .expectBadge({ label: 'downloads', message: 'cask not found' })
+
+t.create('yearly downloads (not found)')
+ .get('/dq/not-a-package.json')
+ .expectBadge({ label: 'downloads', message: 'cask not found' })
+
+t.create('yearly downloads (not found)')
+ .get('/dy/not-a-package.json')
+ .expectBadge({ label: 'downloads', message: 'cask not found' })
diff --git a/services/homebrew/homebrew-cask.service.js b/services/homebrew/homebrew-cask-version.service.js
similarity index 70%
rename from services/homebrew/homebrew-cask.service.js
rename to services/homebrew/homebrew-cask-version.service.js
index bb08a435b4ed1..8c2e127ae922e 100644
--- a/services/homebrew/homebrew-cask.service.js
+++ b/services/homebrew/homebrew-cask-version.service.js
@@ -1,6 +1,6 @@
import Joi from 'joi'
import { renderVersionBadge } from '../version.js'
-import { BaseJsonService } from '../index.js'
+import { BaseJsonService, pathParams } from '../index.js'
const schema = Joi.object({
version: Joi.string().required(),
@@ -10,13 +10,17 @@ export default class HomebrewCask extends BaseJsonService {
static category = 'version'
static route = { base: 'homebrew/cask/v', pattern: ':cask' }
- static examples = [
- {
- title: 'homebrew cask',
- namedParams: { cask: 'iterm2' },
- staticPreview: renderVersionBadge({ version: 'v3.2.5' }),
+ static openApi = {
+ '/homebrew/cask/v/{cask}': {
+ get: {
+ summary: 'Homebrew Cask Version',
+ parameters: pathParams({
+ name: 'cask',
+ example: 'iterm2',
+ }),
+ },
},
- ]
+ }
static defaultBadgeData = { label: 'homebrew cask' }
diff --git a/services/homebrew/homebrew-cask.tester.js b/services/homebrew/homebrew-cask-version.tester.js
similarity index 94%
rename from services/homebrew/homebrew-cask.tester.js
rename to services/homebrew/homebrew-cask-version.tester.js
index 7535c3a8e5cc1..2d3918e3716b4 100644
--- a/services/homebrew/homebrew-cask.tester.js
+++ b/services/homebrew/homebrew-cask-version.tester.js
@@ -12,7 +12,7 @@ t.create('homebrew cask (valid)')
.intercept(nock =>
nock('https://formulae.brew.sh')
.get('/api/cask/iterm2.json')
- .reply(200, { version: '3.3.6' })
+ .reply(200, { version: '3.3.6' }),
)
.expectBadge({ label: 'homebrew cask', message: 'v3.3.6' })
diff --git a/services/homebrew/homebrew-downloads.service.js b/services/homebrew/homebrew-formula-downloads.service.js
similarity index 54%
rename from services/homebrew/homebrew-downloads.service.js
rename to services/homebrew/homebrew-formula-downloads.service.js
index b4e39cbee8a20..37a1e1789079a 100644
--- a/services/homebrew/homebrew-downloads.service.js
+++ b/services/homebrew/homebrew-formula-downloads.service.js
@@ -1,7 +1,6 @@
import Joi from 'joi'
-import { downloadCount } from '../color-formatters.js'
-import { metric } from '../text-formatters.js'
-import { BaseJsonService } from '../index.js'
+import { renderDownloadsBadge } from '../downloads.js'
+import { BaseJsonService, pathParams } from '../index.js'
import { nonNegativeInteger } from '../validators.js'
function getSchema({ formula }) {
@@ -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',
},
}
@@ -39,37 +38,44 @@ export default class HomebrewDownloads extends BaseJsonService {
pattern: 'installs/:interval(dm|dq|dy)/:formula',
}
- static examples = [
- {
- title: 'homebrew downloads',
- namedParams: { interval: 'dm', formula: 'cake' },
- staticPreview: this.render({ interval: 'dm', downloads: 93 }),
+ static openApi = {
+ '/homebrew/installs/{interval}/{formula}': {
+ get: {
+ summary: 'Homebrew Formula Downloads',
+ parameters: pathParams(
+ {
+ name: 'interval',
+ example: 'dm',
+ schema: { type: 'string', enum: this.getEnum('interval') },
+ description: 'Monthly, Quarterly or Yearly downloads',
+ },
+ {
+ name: 'formula',
+ example: 'cake',
+ },
+ ),
+ },
},
- ]
+ }
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({
schema,
url: `https://formulae.brew.sh/api/formula/${formula}.json`,
- errorMessages: { 404: 'formula not found' },
+ httpErrors: { 404: 'formula not found' },
})
}
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,
})
}
}
diff --git a/services/homebrew/homebrew-downloads.tester.js b/services/homebrew/homebrew-formula-downloads.tester.js
similarity index 100%
rename from services/homebrew/homebrew-downloads.tester.js
rename to services/homebrew/homebrew-formula-downloads.tester.js
diff --git a/services/homebrew/homebrew-version.service.js b/services/homebrew/homebrew-formula-version.service.js
similarity index 71%
rename from services/homebrew/homebrew-version.service.js
rename to services/homebrew/homebrew-formula-version.service.js
index f32e77e518d76..283b645de3c97 100644
--- a/services/homebrew/homebrew-version.service.js
+++ b/services/homebrew/homebrew-formula-version.service.js
@@ -1,6 +1,6 @@
import Joi from 'joi'
import { renderVersionBadge } from '../version.js'
-import { BaseJsonService } from '../index.js'
+import { BaseJsonService, pathParams } from '../index.js'
const schema = Joi.object({
versions: Joi.object({
@@ -13,13 +13,17 @@ export default class HomebrewVersion extends BaseJsonService {
static route = { base: 'homebrew/v', pattern: ':formula' }
- static examples = [
- {
- title: 'homebrew version',
- namedParams: { formula: 'cake' },
- staticPreview: renderVersionBadge({ version: 'v0.32.0' }),
+ static openApi = {
+ '/homebrew/v/{formula}': {
+ get: {
+ summary: 'Homebrew Formula Version',
+ parameters: pathParams({
+ name: 'formula',
+ example: 'cake',
+ }),
+ },
},
- ]
+ }
static defaultBadgeData = { label: 'homebrew' }
diff --git a/services/homebrew/homebrew-version.tester.js b/services/homebrew/homebrew-formula-version.tester.js
similarity index 97%
rename from services/homebrew/homebrew-version.tester.js
rename to services/homebrew/homebrew-formula-version.tester.js
index 1e69f15cfb6b9..5441404151795 100644
--- a/services/homebrew/homebrew-version.tester.js
+++ b/services/homebrew/homebrew-formula-version.tester.js
@@ -12,7 +12,7 @@ t.create('homebrew (valid)')
.intercept(nock =>
nock('https://formulae.brew.sh')
.get('/api/formula/cake.json')
- .reply(200, { versions: { stable: '0.23.0', devel: null, head: null } })
+ .reply(200, { versions: { stable: '0.23.0', devel: null, head: null } }),
)
.expectBadge({ label: 'homebrew', message: 'v0.23.0' })
diff --git a/services/hsts/hsts.service.js b/services/hsts/hsts.service.js
index c768815d5639e..f809145f595e2 100644
--- a/services/hsts/hsts.service.js
+++ b/services/hsts/hsts.service.js
@@ -1,22 +1,20 @@
import Joi from 'joi'
-import { BaseJsonService } from '../index.js'
+import { BaseJsonService, pathParams } from '../index.js'
+
const label = 'hsts preloaded'
const schema = Joi.object({
status: Joi.string().required(),
}).required()
-const documentation = `
-
-
- Strict-Transport-Security is an HTTP response header that signals that browsers should
- only access the site using HTTPS.
-
-
- For a higher level of security, it's possible for a domain owner to
- preload
- this behavior into participating web browsers . Chromium maintains the HSTS preload list , which
- is the de facto standard that has been adopted by several browsers. This service checks a domain's status in that list.
-
+const description = `
+[\`Strict-Transport-Security\` is an HTTP response header](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Strict-Transport-Security)
+that signals that browsers should only access the site using HTTPS.
+
+For a higher level of security, it's possible for a domain owner to
+[preload this behavior into participating web browsers](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Strict-Transport-Security#Preloading_Strict_Transport_Security).
+Chromium maintains the [HSTS preload list](https://www.chromium.org/hsts), which
+is the de facto standard that has been adopted by several browsers.
+This service checks a domain's status in that list.
`
export default class HSTS extends BaseJsonService {
@@ -27,15 +25,18 @@ export default class HSTS extends BaseJsonService {
pattern: ':domain',
}
- static examples = [
- {
- title: 'Chromium HSTS preload',
- namedParams: { domain: 'github.com' },
- staticPreview: this.render({ status: 'preloaded' }),
- keywords: ['security'],
- documentation,
+ static openApi = {
+ '/hsts/preload/{domain}': {
+ get: {
+ summary: 'Chromium HSTS preload',
+ description,
+ parameters: pathParams({
+ name: 'domain',
+ example: 'github.com',
+ }),
+ },
},
- ]
+ }
static render({ status }) {
let color = 'red'
@@ -55,8 +56,8 @@ export default class HSTS extends BaseJsonService {
async fetch({ domain }) {
return this._requestJson({
schema,
- url: `https://hstspreload.org/api/v2/status`,
- options: { qs: { domain } },
+ url: 'https://hstspreload.org/api/v2/status',
+ options: { searchParams: { domain } },
})
}
diff --git a/services/hsts/hsts.tester.js b/services/hsts/hsts.tester.js
index 0f55a07b79359..215e3d3379c93 100644
--- a/services/hsts/hsts.tester.js
+++ b/services/hsts/hsts.tester.js
@@ -29,7 +29,7 @@ t.create('gets the hsts status of github (mock)')
.intercept(nock =>
nock('https://hstspreload.org')
.get('/api/v2/status?domain=github.com')
- .reply(200, { status: 'preloaded' })
+ .reply(200, { status: 'preloaded' }),
)
.expectBadge({
label,
@@ -42,7 +42,7 @@ t.create('gets the hsts status of httpforever (mock)')
.intercept(nock =>
nock('https://hstspreload.org')
.get('/api/v2/status?domain=httpforever.com')
- .reply(200, { status: 'unknown' })
+ .reply(200, { status: 'unknown' }),
)
.expectBadge({
label,
@@ -55,7 +55,7 @@ t.create('gets the hsts status of a pending site (mock)')
.intercept(nock =>
nock('https://hstspreload.org')
.get('/api/v2/status?domain=pending.mock')
- .reply(200, { status: 'pending' })
+ .reply(200, { status: 'pending' }),
)
.expectBadge({
label,
@@ -68,7 +68,7 @@ t.create('gets the status of an invalid uri (mock)')
.intercept(nock =>
nock('https://hstspreload.org')
.get('/api/v2/status?domain=does-not-exist')
- .reply(200, { status: 'unknown' })
+ .reply(200, { status: 'unknown' }),
)
.expectBadge({
label,
diff --git a/services/itunes/itunes.service.js b/services/itunes/itunes.service.js
index bcb367bc8abd0..541ba5a8f988f 100644
--- a/services/itunes/itunes.service.js
+++ b/services/itunes/itunes.service.js
@@ -1,7 +1,7 @@
import Joi from 'joi'
import { renderVersionBadge } from '../version.js'
import { nonNegativeInteger } from '../validators.js'
-import { BaseJsonService, NotFound } from '../index.js'
+import { BaseJsonService, NotFound, pathParams } from '../index.js'
const schema = Joi.object({
resultCount: nonNegativeInteger,
@@ -18,13 +18,17 @@ export default class Itunes extends BaseJsonService {
pattern: ':bundleId',
}
- static examples = [
- {
- title: 'iTunes App Store',
- namedParams: { bundleId: '803453959' },
- staticPreview: renderVersionBadge({ version: 'v3.3.3' }),
+ static openApi = {
+ '/itunes/v/{bundleId}': {
+ get: {
+ summary: 'iTunes App Store',
+ parameters: pathParams({
+ name: 'bundleId',
+ example: '803453959',
+ }),
+ },
},
- ]
+ }
static defaultBadgeData = { label: 'itunes app store' }
diff --git a/services/jenkins/jenkins-base.js b/services/jenkins/jenkins-base.js
index 4579e99773448..942c3bf567acd 100644
--- a/services/jenkins/jenkins-base.js
+++ b/services/jenkins/jenkins-base.js
@@ -10,16 +10,16 @@ export default class JenkinsBase extends BaseJsonService {
async fetch({
url,
schema,
- qs,
- errorMessages = { 404: 'instance or job not found' },
+ searchParams,
+ httpErrors = { 404: 'instance or job not found' },
}) {
return this._requestJson(
this.authHelper.withBasicAuth({
url,
- options: { qs },
+ options: { searchParams },
schema,
- errorMessages,
- })
+ httpErrors,
+ }),
)
}
}
diff --git a/services/jenkins/jenkins-build-redirect.service.js b/services/jenkins/jenkins-build-redirect.service.js
index b7666291c7152..ffc66d345f8dc 100644
--- a/services/jenkins/jenkins-build-redirect.service.js
+++ b/services/jenkins/jenkins-build-redirect.service.js
@@ -1,37 +1,32 @@
-import { redirector } from '../index.js'
-import { buildRedirectUrl } from './jenkins-common.js'
+import { deprecatedService } from '../index.js'
const commonProps = {
category: 'build',
- transformPath: () => '/jenkins/build',
- transformQueryParams: ({ protocol, host, job }) => ({
- jobUrl: buildRedirectUrl({ protocol, host, job }),
- }),
+ label: 'jenkins',
+ dateAdded: new Date('2025-12-20'),
+ issueUrl: 'https://github.com/badges/shields/pull/11583',
}
export default [
- redirector({
+ deprecatedService({
route: {
base: 'jenkins-ci/s',
pattern: ':protocol(http|https)/:host/:job+',
},
- dateAdded: new Date('2019-04-20'),
...commonProps,
}),
- redirector({
+ deprecatedService({
route: {
base: 'jenkins/s',
pattern: ':protocol(http|https)/:host/:job+',
},
- dateAdded: new Date('2019-04-20'),
...commonProps,
}),
- redirector({
+ deprecatedService({
route: {
base: 'jenkins/build',
pattern: ':protocol(http|https)/:host/:job+',
},
- dateAdded: new Date('2019-11-29'),
...commonProps,
}),
]
diff --git a/services/jenkins/jenkins-build-redirect.tester.js b/services/jenkins/jenkins-build-redirect.tester.js
index 3e5e76aa0cf2d..dd421d4f0b5c5 100644
--- a/services/jenkins/jenkins-build-redirect.tester.js
+++ b/services/jenkins/jenkins-build-redirect.tester.js
@@ -7,25 +7,22 @@ export const t = new ServiceTester({
})
t.create('old jenkins ci prefix + job url in path')
- .get('jenkins-ci/s/https/updates.jenkins-ci.org/job/foo.svg')
- .expectRedirect(
- `/jenkins/build.svg?jobUrl=${encodeURIComponent(
- 'https://updates.jenkins-ci.org/job/foo'
- )}`
- )
+ .get('jenkins-ci/s/https/updates.jenkins-ci.org/job/foo.json')
+ .expectBadge({
+ label: 'jenkins',
+ message: 'https://github.com/badges/shields/pull/11583',
+ })
t.create('old jenkins shorthand prefix + job url in path')
- .get('jenkins/s/https/updates.jenkins-ci.org/job/foo.svg')
- .expectRedirect(
- `/jenkins/build.svg?jobUrl=${encodeURIComponent(
- 'https://updates.jenkins-ci.org/job/foo'
- )}`
- )
+ .get('jenkins/s/https/updates.jenkins-ci.org/job/foo.json')
+ .expectBadge({
+ label: 'jenkins',
+ message: 'https://github.com/badges/shields/pull/11583',
+ })
t.create('new jenkins build prefix + job url in path')
- .get('jenkins/build/https/updates.jenkins-ci.org/job/foo.svg')
- .expectRedirect(
- `/jenkins/build.svg?jobUrl=${encodeURIComponent(
- 'https://updates.jenkins-ci.org/job/foo'
- )}`
- )
+ .get('jenkins/build/https/updates.jenkins-ci.org/job/foo.json')
+ .expectBadge({
+ label: 'jenkins',
+ message: 'https://github.com/badges/shields/pull/11583',
+ })
diff --git a/services/jenkins/jenkins-build.service.js b/services/jenkins/jenkins-build.service.js
index a6cf8c0327067..f3736617d964d 100644
--- a/services/jenkins/jenkins-build.service.js
+++ b/services/jenkins/jenkins-build.service.js
@@ -1,4 +1,5 @@
import Joi from 'joi'
+import { queryParam } from '../index.js'
import { renderBuildStatusBadge } from '../build-status.js'
import JenkinsBase from './jenkins-base.js'
import {
@@ -28,7 +29,7 @@ const colorStatusMap = {
}
const schema = Joi.object({
- color: Joi.allow(...Object.keys(colorStatusMap)).required(),
+ color: Joi.equal(...Object.keys(colorStatusMap)).required(),
}).required()
export default class JenkinsBuild extends JenkinsBase {
@@ -40,16 +41,20 @@ export default class JenkinsBuild extends JenkinsBase {
queryParamSchema,
}
- static examples = [
- {
- title: 'Jenkins',
- namedParams: {},
- queryParams: {
- jobUrl: 'https://wso2.org/jenkins/view/All%20Builds/job/archetypes',
+ static openApi = {
+ '/jenkins/build': {
+ get: {
+ summary: 'Jenkins Build',
+ parameters: [
+ queryParam({
+ name: 'jobUrl',
+ example: 'https://ci.eclipse.org/jgit/job/jgit',
+ required: true,
+ }),
+ ],
},
- staticPreview: renderBuildStatusBadge({ status: 'passing' }),
},
- ]
+ }
static defaultBadgeData = { label: 'build' }
@@ -72,7 +77,7 @@ export default class JenkinsBuild extends JenkinsBase {
const json = await this.fetch({
url: buildUrl({ jobUrl, lastCompletedBuild: false }),
schema,
- qs: buildTreeParamQueryString('color'),
+ searchParams: buildTreeParamQueryString('color'),
})
const { status } = this.transform({ json })
return this.constructor.render({ status })
diff --git a/services/jenkins/jenkins-build.spec.js b/services/jenkins/jenkins-build.spec.js
index a821f272709f3..ad84c3fc912bd 100644
--- a/services/jenkins/jenkins-build.spec.js
+++ b/services/jenkins/jenkins-build.spec.js
@@ -1,10 +1,18 @@
-import { expect } from 'chai'
-import nock from 'nock'
import { test, forCases, given } from 'sazerac'
import { renderBuildStatusBadge } from '../build-status.js'
-import { cleanUpNockAfterEach, defaultContext } from '../test-helpers.js'
+import { testAuth } from '../test-helpers.js'
import JenkinsBuild from './jenkins-build.service.js'
+const authConfigOverride = {
+ public: {
+ services: {
+ jenkins: {
+ authorizedOrigins: ['https://ci.eclipse.org'],
+ },
+ },
+ },
+}
+
describe('JenkinsBuild', function () {
test(JenkinsBuild.prototype.transform, () => {
forCases([
@@ -43,63 +51,27 @@ describe('JenkinsBuild', function () {
color: 'yellow',
})
given({ status: 'passing' }).expect(
- renderBuildStatusBadge({ status: 'passing' })
+ renderBuildStatusBadge({ status: 'passing' }),
)
given({ status: 'failing' }).expect(
- renderBuildStatusBadge({ status: 'failing' })
+ renderBuildStatusBadge({ status: 'failing' }),
)
given({ status: 'building' }).expect(
- renderBuildStatusBadge({ status: 'building' })
+ renderBuildStatusBadge({ status: 'building' }),
)
given({ status: 'not built' }).expect(
- renderBuildStatusBadge({ status: 'not built' })
+ renderBuildStatusBadge({ status: 'not built' }),
)
})
describe('auth', function () {
- cleanUpNockAfterEach()
-
- const user = 'admin'
- const pass = 'password'
- const config = {
- public: {
- services: {
- jenkins: {
- authorizedOrigins: ['https://jenkins.ubuntu.com'],
- },
- },
- },
- private: {
- jenkins_user: user,
- jenkins_pass: pass,
- },
- }
-
it('sends the auth information as configured', async function () {
- const scope = nock('https://jenkins.ubuntu.com')
- .get('/server/job/curtin-vmtest-daily-x/api/json?tree=color')
- // 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.
- .basicAuth({ user, pass })
- .reply(200, { color: 'blue' })
-
- expect(
- await JenkinsBuild.invoke(
- defaultContext,
- config,
- {},
- {
- jobUrl:
- 'https://jenkins.ubuntu.com/server/job/curtin-vmtest-daily-x',
- }
- )
- ).to.deep.equal({
- label: undefined,
- message: 'passing',
- color: 'brightgreen',
- })
-
- scope.done()
+ return testAuth(
+ JenkinsBuild,
+ 'BasicAuth',
+ { color: 'blue' },
+ { configOverride: authConfigOverride },
+ )
})
})
})
diff --git a/services/jenkins/jenkins-build.tester.js b/services/jenkins/jenkins-build.tester.js
index 7ebfb5df861f5..021f573edfe72 100644
--- a/services/jenkins/jenkins-build.tester.js
+++ b/services/jenkins/jenkins-build.tester.js
@@ -1,25 +1,17 @@
-import Joi from 'joi'
import { isBuildStatus } from '../build-status.js'
import { createServiceTester } from '../tester.js'
export const t = await createServiceTester()
-const isJenkinsBuildStatus = Joi.alternatives(
- isBuildStatus,
- Joi.string().allow('unstable')
-)
-
t.create('build job not found')
.get('/build.json?jobUrl=https://ci.eclipse.org/jgit/job/does-not-exist')
.expectBadge({ label: 'build', message: 'instance or job not found' })
t.create('build found (view)')
.get(
- `/build.json?jobUrl=${encodeURIComponent(
- 'https://wso2.org/jenkins/view/All Builds/job/archetypes'
- )}`
+ '/build.json?jobUrl=https://ci.hibernate.org/view/Main/job/hibernate-search/job/main',
)
- .expectBadge({ label: 'build', message: isJenkinsBuildStatus })
+ .expectBadge({ label: 'build', message: isBuildStatus })
t.create('build found (job)')
.get('/build.json?jobUrl=https://ci.eclipse.org/jgit/job/jgit')
- .expectBadge({ label: 'build', message: isJenkinsBuildStatus })
+ .expectBadge({ label: 'build', message: isBuildStatus })
diff --git a/services/jenkins/jenkins-common.spec.js b/services/jenkins/jenkins-common.spec.js
index ae1e2e998359f..f9ead5dc4f32c 100644
--- a/services/jenkins/jenkins-common.spec.js
+++ b/services/jenkins/jenkins-common.spec.js
@@ -9,7 +9,7 @@ describe('jenkins-common', function () {
})
expect(actualResult).to.equal(
- 'https://ci.eclipse.org/jgit/job/jgit/lastCompletedBuild/api/json'
+ 'https://ci.eclipse.org/jgit/job/jgit/lastCompletedBuild/api/json',
)
})
@@ -20,7 +20,7 @@ describe('jenkins-common', function () {
})
expect(actualResult).to.equal(
- 'https://ci.eclipse.org/jgit/job/jgit/lastCompletedBuild/cobertura/api/json'
+ 'https://ci.eclipse.org/jgit/job/jgit/lastCompletedBuild/cobertura/api/json',
)
})
@@ -31,7 +31,7 @@ describe('jenkins-common', function () {
})
expect(actualResult).to.equal(
- 'https://ci.eclipse.org/jgit/job/jgit/api/json'
+ 'https://ci.eclipse.org/jgit/job/jgit/api/json',
)
})
})
@@ -45,7 +45,7 @@ describe('jenkins-common', function () {
})
expect(actualResult).to.equal(
- 'https://jenkins.sqlalchemy.org/job/alembic_coverage'
+ 'https://jenkins.sqlalchemy.org/job/alembic_coverage',
)
})
@@ -57,7 +57,7 @@ describe('jenkins-common', function () {
})
expect(actualResult).to.equal(
- 'https://jenkins.sqlalchemy.org/job/alembic_coverage'
+ 'https://jenkins.sqlalchemy.org/job/alembic_coverage',
)
})
})
diff --git a/services/jenkins/jenkins-coverage-redirector.service.js b/services/jenkins/jenkins-coverage-redirector.service.js
index 05b828f870a40..081fd359354fb 100644
--- a/services/jenkins/jenkins-coverage-redirector.service.js
+++ b/services/jenkins/jenkins-coverage-redirector.service.js
@@ -1,33 +1,33 @@
-import { redirector } from '../index.js'
-import { buildRedirectUrl } from './jenkins-common.js'
+import { deprecatedService } from '../index.js'
const commonProps = {
category: 'coverage',
- transformQueryParams: ({ protocol, host, job }) => ({
- jobUrl: buildRedirectUrl({ protocol, host, job }),
- }),
+ label: 'jenkins',
+ dateAdded: new Date('2025-12-20'),
+ issueUrl: 'https://github.com/badges/shields/pull/11583',
}
export default [
- redirector({
+ deprecatedService({
route: {
base: 'jenkins',
pattern: ':coverageFormat(j|c)/:protocol(http|https)/:host/:job+',
},
- transformPath: ({ coverageFormat }) =>
- `/jenkins/coverage/${coverageFormat === 'j' ? 'jacoco' : 'cobertura'}`,
- dateAdded: new Date('2019-04-20'),
...commonProps,
}),
- redirector({
+ deprecatedService({
route: {
base: 'jenkins/coverage',
pattern:
':coverageFormat(jacoco|cobertura|api)/:protocol(http|https)/:host/:job+',
},
- transformPath: ({ coverageFormat }) =>
- `/jenkins/coverage/${coverageFormat}`,
- dateAdded: new Date('2019-11-29'),
+ ...commonProps,
+ }),
+ deprecatedService({
+ route: {
+ base: 'jenkins/coverage/api',
+ pattern: '',
+ },
...commonProps,
}),
]
diff --git a/services/jenkins/jenkins-coverage-redirector.tester.js b/services/jenkins/jenkins-coverage-redirector.tester.js
index d6475017c59e9..513aa51b337ef 100644
--- a/services/jenkins/jenkins-coverage-redirector.tester.js
+++ b/services/jenkins/jenkins-coverage-redirector.tester.js
@@ -8,48 +8,52 @@ export const t = new ServiceTester({
t.create('old Jacoco prefix + job url in path')
.get(
- '/j/https/wso2.org/jenkins/view/All%20Builds/job/sonar/job/sonar-carbon-dashboards.svg'
- )
- .expectRedirect(
- `/jenkins/coverage/jacoco.svg?jobUrl=${encodeURIComponent(
- 'https://wso2.org/jenkins/view/All Builds/job/sonar/job/sonar-carbon-dashboards'
- )}`
+ '/j/https/wso2.org/jenkins/view/All%20Builds/job/sonar/job/sonar-carbon-dashboards.json',
)
+ .expectBadge({
+ label: 'jenkins',
+ message: 'https://github.com/badges/shields/pull/11583',
+ })
t.create('new Jacoco prefix + job url in path')
.get(
- '/coverage/jacoco/https/wso2.org/jenkins/view/All%20Builds/job/sonar/job/sonar-carbon-dashboards.svg'
- )
- .expectRedirect(
- `/jenkins/coverage/jacoco.svg?jobUrl=${encodeURIComponent(
- 'https://wso2.org/jenkins/view/All Builds/job/sonar/job/sonar-carbon-dashboards'
- )}`
+ '/coverage/jacoco/https/wso2.org/jenkins/view/All%20Builds/job/sonar/job/sonar-carbon-dashboards.json',
)
+ .expectBadge({
+ label: 'jenkins',
+ message: 'https://github.com/badges/shields/pull/11583',
+ })
t.create('old Cobertura prefix + job url in path')
- .get('/c/https/jenkins.sqlalchemy.org/job/alembic_coverage.svg')
- .expectRedirect(
- `/jenkins/coverage/cobertura.svg?jobUrl=${encodeURIComponent(
- 'https://jenkins.sqlalchemy.org/job/alembic_coverage'
- )}`
- )
+ .get('/c/https/jenkins.sqlalchemy.org/job/alembic_coverage.json')
+ .expectBadge({
+ label: 'jenkins',
+ message: 'https://github.com/badges/shields/pull/11583',
+ })
t.create('new Cobertura prefix + job url in path')
.get(
- '/coverage/cobertura/https/jenkins.sqlalchemy.org/job/alembic_coverage.svg'
- )
- .expectRedirect(
- `/jenkins/coverage/cobertura.svg?jobUrl=${encodeURIComponent(
- 'https://jenkins.sqlalchemy.org/job/alembic_coverage'
- )}`
+ '/coverage/cobertura/https/jenkins.sqlalchemy.org/job/alembic_coverage.json',
)
+ .expectBadge({
+ label: 'jenkins',
+ message: 'https://github.com/badges/shields/pull/11583',
+ })
t.create('api prefix + job url in path')
.get(
- '/coverage/api/https/jenkins.library.illinois.edu/job/OpenSourceProjects/job/Speedwagon/job/master.svg'
+ '/coverage/api/https/jenkins.library.illinois.edu/job/OpenSourceProjects/job/Speedwagon/job/master.json',
)
- .expectRedirect(
- `/jenkins/coverage/api.svg?jobUrl=${encodeURIComponent(
- 'https://jenkins.library.illinois.edu/job/OpenSourceProjects/job/Speedwagon/job/master'
- )}`
+ .expectBadge({
+ label: 'jenkins',
+ message: 'https://github.com/badges/shields/pull/11583',
+ })
+
+t.create('old v1 api prefix to new prefix')
+ .get(
+ '/coverage/api.json?jobUrl=http://loneraver.duckdns.org:8082/job/github/job/VisVid/job/master',
)
+ .expectBadge({
+ label: 'jenkins',
+ message: 'https://github.com/badges/shields/pull/11583',
+ })
diff --git a/services/jenkins/jenkins-coverage.service.js b/services/jenkins/jenkins-coverage.service.js
index 6f053650efb42..9256d42014611 100644
--- a/services/jenkins/jenkins-coverage.service.js
+++ b/services/jenkins/jenkins-coverage.service.js
@@ -1,4 +1,5 @@
import Joi from 'joi'
+import { pathParam, queryParam } from '../index.js'
import { coveragePercentage } from '../color-formatters.js'
import JenkinsBase from './jenkins-base.js'
import {
@@ -26,7 +27,7 @@ const formatMap = {
Joi.object({
name: Joi.string().required(),
ratio: Joi.number().min(0).max(100).required(),
- })
+ }),
)
.has(Joi.object({ name: 'Lines' }))
.min(1)
@@ -36,13 +37,13 @@ const formatMap = {
treeQueryParam: 'results[elements[name,ratio]]',
transform: json => {
const lineCoverage = json.results.elements.find(
- element => element.name === 'Lines'
+ element => element.name === 'Lines',
)
return { coverage: lineCoverage.ratio }
},
pluginSpecificPath: 'cobertura',
},
- api: {
+ apiv1: {
schema: Joi.object({
results: Joi.object({
elements: Joi.array()
@@ -50,7 +51,7 @@ const formatMap = {
Joi.object({
name: Joi.string().required(),
ratio: Joi.number().min(0).max(100).required(),
- })
+ }),
)
.has(Joi.object({ name: 'Line' }))
.min(1)
@@ -60,23 +61,41 @@ const formatMap = {
treeQueryParam: 'results[elements[name,ratio]]',
transform: json => {
const lineCoverage = json.results.elements.find(
- element => element.name === 'Line'
+ element => element.name === 'Line',
)
return { coverage: lineCoverage.ratio }
},
pluginSpecificPath: 'coverage/result',
},
+ apiv4: {
+ schema: Joi.object({
+ projectStatistics: Joi.object({
+ line: Joi.string()
+ .pattern(/\d+\.\d+%/)
+ .required(),
+ }).required(),
+ }).required(),
+ treeQueryParam: 'projectStatistics[line]',
+ transform: json => {
+ const lineCoverageStr = json.projectStatistics.line
+ const lineCoverage = lineCoverageStr.substring(
+ 0,
+ lineCoverageStr.length - 1,
+ )
+ return { coverage: Number.parseFloat(lineCoverage) }
+ },
+ pluginSpecificPath: 'coverage',
+ },
}
-const documentation = `
-
- We support coverage metrics from a variety of Jenkins plugins:
-
-
+const description = `
+We support coverage metrics from a variety of Jenkins plugins:
+
+
`
export default class JenkinsCoverage extends JenkinsBase {
@@ -84,24 +103,31 @@ export default class JenkinsCoverage extends JenkinsBase {
static route = {
base: 'jenkins/coverage',
- pattern: ':format(jacoco|cobertura|api)',
+ pattern: ':format(jacoco|cobertura|apiv1|apiv4)',
queryParamSchema,
}
- static examples = [
- {
- title: 'Jenkins Coverage',
- namedParams: {
- format: 'cobertura',
+ static openApi = {
+ '/jenkins/coverage/{format}': {
+ get: {
+ summary: 'Jenkins Coverage',
+ description,
+ parameters: [
+ pathParam({
+ name: 'format',
+ example: 'jacoco',
+ schema: { type: 'string', enum: this.getEnum('format') },
+ }),
+ queryParam({
+ name: 'jobUrl',
+ example:
+ 'https://ci-maven.apache.org/job/Maven/job/maven-box/job/maven-surefire/job/master',
+ required: true,
+ }),
+ ],
},
- queryParams: {
- jobUrl: 'https://jenkins.sqlalchemy.org/job/alembic_coverage',
- },
- keywords: ['jacoco', 'cobertura', 'llvm-cov', 'istanbul'],
- staticPreview: this.render({ coverage: 95 }),
- documentation,
},
- ]
+ }
static defaultBadgeData = { label: 'coverage' }
@@ -118,8 +144,8 @@ export default class JenkinsCoverage extends JenkinsBase {
const json = await this.fetch({
url: buildUrl({ jobUrl, plugin: pluginSpecificPath }),
schema,
- qs: buildTreeParamQueryString(treeQueryParam),
- errorMessages: {
+ searchParams: buildTreeParamQueryString(treeQueryParam),
+ httpErrors: {
404: 'job or coverage not found',
},
})
diff --git a/services/jenkins/jenkins-coverage.spec.js b/services/jenkins/jenkins-coverage.spec.js
new file mode 100644
index 0000000000000..eb8a11ac57931
--- /dev/null
+++ b/services/jenkins/jenkins-coverage.spec.js
@@ -0,0 +1,25 @@
+import { testAuth } from '../test-helpers.js'
+import JenkinsCoverage from './jenkins-coverage.service.js'
+
+const authConfigOverride = {
+ public: {
+ services: {
+ jenkins: {
+ authorizedOrigins: ['https://ci-maven.apache.org'],
+ },
+ },
+ },
+}
+
+describe('JenkinsCoverage', function () {
+ describe('auth', function () {
+ it('sends the auth information as configured', async function () {
+ return testAuth(
+ JenkinsCoverage,
+ 'BasicAuth',
+ { instructionCoverage: { percentage: 93 } },
+ { configOverride: authConfigOverride },
+ )
+ })
+ })
+})
diff --git a/services/jenkins/jenkins-coverage.tester.js b/services/jenkins/jenkins-coverage.tester.js
index fae3f9b4d95d3..1fb29825e506b 100644
--- a/services/jenkins/jenkins-coverage.tester.js
+++ b/services/jenkins/jenkins-coverage.tester.js
@@ -10,35 +10,94 @@ export const t = await createServiceTester()
t.create('jacoco: job found')
.get(
`/jacoco.json?jobUrl=${encodeURIComponent(
- 'https://wso2.org/jenkins/view/All%20Builds/job/archetypes'
- )}`
+ 'https://ci-maven.apache.org/job/Maven/job/maven-box/job/maven-surefire/job/master',
+ )}`,
)
.expectBadge({ label: 'coverage', message: isIntegerPercentage })
t.create('jacoco: job not found')
- .get('/jacoco.json?jobUrl=https://wso2.org/jenkins/job/does-not-exist')
+ .get('/jacoco.json?jobUrl=https://ci-maven.apache.org/job/does-not-exist')
.expectBadge({ label: 'coverage', message: 'job or coverage not found' })
+const coverageCoberturaResponse = {
+ _class: 'io.jenkins.plugins.coverage.targets.CoverageResult',
+ results: {
+ elements: [
+ { name: 'Classes', ratio: 52.0 },
+ { name: 'Lines', ratio: 40.66363 },
+ ],
+ },
+}
+
+t.create('cobertura: job found')
+ .get(
+ '/cobertura.json?jobUrl=https://jenkins.sqlalchemy.org/job/dogpile_coverage',
+ )
+ .intercept(nock =>
+ nock(
+ 'https://jenkins.sqlalchemy.org/job/dogpile_coverage/lastCompletedBuild',
+ )
+ .get('/cobertura/api/json')
+ .query(true)
+ .reply(200, coverageCoberturaResponse),
+ )
+ .expectBadge({ label: 'coverage', message: '41%' })
+
t.create('cobertura: job not found')
.get(
- '/cobertura.json?jobUrl=https://jenkins.sqlalchemy.org/job/does-not-exist'
+ '/cobertura.json?jobUrl=https://jenkins.sqlalchemy.org/job/does-not-exist',
)
.expectBadge({ label: 'coverage', message: 'job or coverage not found' })
-t.create('cobertura: job found')
+const coverageApiV1Response = {
+ _class: 'io.jenkins.plugins.coverage.targets.CoverageResult',
+ results: {
+ elements: [
+ { name: 'Report', ratio: 100.0 },
+ { name: 'Group', ratio: 100.0 },
+ { name: 'Package', ratio: 66.666664 },
+ { name: 'File', ratio: 52.0 },
+ { name: 'Class', ratio: 52.0 },
+ { name: 'Line', ratio: 40.66363 },
+ { name: 'Conditional', ratio: 29.91968 },
+ ],
+ },
+}
+
+t.create('code coverage API v1: job found')
.get(
- '/cobertura.json?jobUrl=https://jenkins.sqlalchemy.org/job/alembic_coverage'
+ '/apiv1.json?jobUrl=http://loneraver.duckdns.org:8082/job/github/job/VisVid/job/master',
+ )
+ .intercept(nock =>
+ nock(
+ 'http://loneraver.duckdns.org:8082/job/github/job/VisVid/job/master/lastCompletedBuild',
+ )
+ .get('/coverage/result/api/json')
+ .query(true)
+ .reply(200, coverageApiV1Response),
)
.expectBadge({ label: 'coverage', message: isIntegerPercentage })
-t.create('code coverage API: job not found')
+t.create('code coverage API v1: job not found')
.get(
- '/api.json?jobUrl=https://jenkins.library.illinois.edu/job/does-not-exist'
+ '/apiv1.json?jobUrl=http://loneraver.duckdns.org:8082/job/does-not-exist',
+ )
+ .intercept(nock =>
+ nock(
+ 'http://loneraver.duckdns.org:8082/job/does-not-exist/lastCompletedBuild',
+ )
+ .get('/coverage/result/api/json')
+ .query(true)
+ .reply(404),
)
.expectBadge({ label: 'coverage', message: 'job or coverage not found' })
-t.create('code coverage API: job found')
+t.create('code coverage API v4+: job found')
.get(
- '/api.json?jobUrl=https://jenkins.library.illinois.edu/job/OpenSourceProjects/job/Speedwagon/job/master'
+ '/apiv4.json?jobUrl=https://jenkins.mm12.xyz/jenkins/job/nmfu/job/master',
)
.expectBadge({ label: 'coverage', message: isIntegerPercentage })
+
+t.create('code coverage API v4+: job not found')
+ .get('/apiv4.json?jobUrl=https://jenkins.mm12.xyz/jenkins/job/does-not-exist')
+ .expectBadge({ label: 'coverage', message: 'job or coverage not found' })
diff --git a/services/jenkins/jenkins-plugin-installs.service.js b/services/jenkins/jenkins-plugin-installs.service.js
index ef24d19e7221c..69624dfcd77d1 100644
--- a/services/jenkins/jenkins-plugin-installs.service.js
+++ b/services/jenkins/jenkins-plugin-installs.service.js
@@ -1,8 +1,7 @@
import Joi from 'joi'
-import { downloadCount as downloadCountColor } from '../color-formatters.js'
-import { metric } from '../text-formatters.js'
+import { renderDownloadsBadge } from '../downloads.js'
import { nonNegativeInteger } from '../validators.js'
-import { BaseJsonService, NotFound } from '../index.js'
+import { BaseJsonService, pathParams } from '../index.js'
const schemaInstallations = Joi.object()
.keys({
@@ -13,32 +12,7 @@ const schemaInstallations = Joi.object()
})
.required()
-const schemaInstallationsPerVersion = Joi.object()
- .keys({
- installationsPerVersion: Joi.object()
- .required()
- .pattern(Joi.string(), nonNegativeInteger)
- .min(1),
- })
- .required()
-
export default class JenkinsPluginInstalls extends BaseJsonService {
- static _getSchema(version) {
- if (version) {
- return schemaInstallationsPerVersion
- } else {
- return schemaInstallations
- }
- }
-
- static _getLabel(version) {
- if (version) {
- return `installs@${version}`
- } else {
- return 'installs'
- }
- }
-
static category = 'downloads'
static route = {
@@ -46,71 +20,48 @@ export default class JenkinsPluginInstalls extends BaseJsonService {
pattern: ':plugin/:version?',
}
- static examples = [
- {
- title: 'Jenkins Plugin installs',
- pattern: ':plugin',
- namedParams: {
- plugin: 'view-job-filters',
+ static openApi = {
+ '/jenkins/plugin/i/{plugin}': {
+ get: {
+ summary: 'Jenkins Plugin installs',
+ parameters: pathParams({
+ name: 'plugin',
+ example: 'view-job-filters',
+ }),
},
- staticPreview: this.render({
- label: this._getLabel(),
- installs: 10247,
- }),
},
- {
- title: 'Jenkins Plugin installs (version)',
- pattern: ':plugin/:version',
- namedParams: {
- plugin: 'view-job-filters',
- version: '1.26',
- },
- staticPreview: this.render({
- label: this._getLabel('1.26'),
- installs: 955,
- }),
- },
- ]
+ }
static defaultBadgeData = { label: 'installs' }
- static render({ label, installs }) {
- return {
- label,
- message: metric(installs),
- color: downloadCountColor(installs),
- }
+ static render({ installs: downloads }) {
+ return renderDownloadsBadge({ downloads })
}
- async fetch({ plugin, version }) {
- const url = `https://stats.jenkins.io/plugin-installation-trend/${plugin}.stats.json`
- const schema = this.constructor._getSchema(version)
+ async fetch({ plugin }) {
return this._requestJson({
- url,
- schema,
- errorMessages: {
+ url: `https://stats.jenkins.io/plugin-installation-trend/${plugin}.stats.json`,
+ schema: schemaInstallations,
+ httpErrors: {
404: 'plugin not found',
},
})
}
- async handle({ plugin, version }) {
- const label = this.constructor._getLabel(version)
- const json = await this.fetch({ plugin, version })
+ static transform({ json }) {
+ const latestDate = Object.keys(json.installations).sort().slice(-1)[0]
+ return { installs: json.installations[latestDate] }
+ }
- let installs
+ async handle({ plugin, version }) {
if (version) {
- installs = json.installationsPerVersion[version]
- if (!installs) {
- throw new NotFound({
- prettyMessage: 'version not found',
- })
+ return {
+ message: 'no longer available per version',
+ color: 'lightgrey',
}
- } else {
- const latestDate = Object.keys(json.installations).sort().slice(-1)[0]
- installs = json.installations[latestDate]
}
-
- return this.constructor.render({ label, installs })
+ const json = await this.fetch({ plugin })
+ const { installs } = this.constructor.transform({ json })
+ return this.constructor.render({ installs })
}
}
diff --git a/services/jenkins/jenkins-plugin-installs.tester.js b/services/jenkins/jenkins-plugin-installs.tester.js
index 5c0f0687b8010..085b7d9bae8c9 100644
--- a/services/jenkins/jenkins-plugin-installs.tester.js
+++ b/services/jenkins/jenkins-plugin-installs.tester.js
@@ -15,24 +15,10 @@ t.create('total installs | not found')
// version installs
-t.create('version installs | valid: numeric version')
+t.create('version installs | no longer available')
.get('/view-job-filters/1.26.json')
.expectBadge({
- label: 'installs@1.26',
- message: isMetric,
+ label: 'installs',
+ message: 'no longer available per version',
+ color: 'lightgrey',
})
-
-t.create('version installs | valid: alphanumeric version')
- .get('/build-failure-analyzer/1.17.2-DRE3.14.json')
- .expectBadge({
- label: 'installs@1.17.2-DRE3.14',
- message: isMetric,
- })
-
-t.create('version installs | not found: non-existent plugin')
- .get('/not-a-plugin/1.26.json')
- .expectBadge({ label: 'installs', message: 'plugin not found' })
-
-t.create('version installs | not found: non-existent version')
- .get('/view-job-filters/1.1-NOT-FOUND.json')
- .expectBadge({ label: 'installs', message: 'version not found' })
diff --git a/services/jenkins/jenkins-plugin-version.service.js b/services/jenkins/jenkins-plugin-version.service.js
index 98486776bf8a9..d40187cd0df10 100644
--- a/services/jenkins/jenkins-plugin-version.service.js
+++ b/services/jenkins/jenkins-plugin-version.service.js
@@ -1,7 +1,6 @@
-import { promisify } from 'util'
-import { regularUpdate } from '../../core/legacy/regular-update.js'
+import { getCachedResource } from '../../core/base-service/resource-cache.js'
import { renderVersionBadge } from '../version.js'
-import { BaseService, NotFound } from '../index.js'
+import { BaseService, NotFound, pathParams } from '../index.js'
export default class JenkinsPluginVersion extends BaseService {
static category = 'version'
@@ -11,19 +10,17 @@ export default class JenkinsPluginVersion extends BaseService {
pattern: ':plugin',
}
- static examples = [
- {
- title: 'Jenkins Plugins',
- namedParams: {
- plugin: 'blueocean',
- },
- staticPreview: {
- label: 'plugin',
- message: 'v1.10.1',
- color: 'blue',
+ static openApi = {
+ '/jenkins/plugin/v/{plugin}': {
+ get: {
+ summary: 'Jenkins Plugin Version',
+ parameters: pathParams({
+ name: 'plugin',
+ example: 'blueocean',
+ }),
},
},
- ]
+ }
static defaultBadgeData = { label: 'plugin' }
@@ -32,9 +29,9 @@ export default class JenkinsPluginVersion extends BaseService {
}
async fetch() {
- return promisify(regularUpdate)({
+ return getCachedResource({
url: 'https://updates.jenkins-ci.org/current/update-center.actual.json',
- intervalMillis: 4 * 3600 * 1000,
+ ttl: 4 * 3600 * 1000, // 4 hours in milliseconds
scraper: json =>
Object.keys(json.plugins).reduce((previous, current) => {
previous[current] = json.plugins[current].version
diff --git a/services/jenkins/jenkins-plugin-version.tester.js b/services/jenkins/jenkins-plugin-version.tester.js
index f9c4d2dfc9460..da1eee8349d79 100644
--- a/services/jenkins/jenkins-plugin-version.tester.js
+++ b/services/jenkins/jenkins-plugin-version.tester.js
@@ -12,7 +12,7 @@ t.create('latest version')
.intercept(nock =>
nock('https://updates.jenkins-ci.org')
.get('/current/update-center.actual.json')
- .reply(200, { plugins: { blueocean: { version: '1.1.6' } } })
+ .reply(200, { plugins: { blueocean: { version: '1.1.6' } } }),
)
.expectBadge({
label: 'plugin',
@@ -24,7 +24,7 @@ t.create('version 0')
.intercept(nock =>
nock('https://updates.jenkins-ci.org')
.get('/current/update-center.actual.json')
- .reply(200, { plugins: { blueocean: { version: '0' } } })
+ .reply(200, { plugins: { blueocean: { version: '0' } } }),
)
.expectBadge({
label: 'plugin',
@@ -36,6 +36,6 @@ t.create('inexistent artifact')
.intercept(nock =>
nock('https://updates.jenkins-ci.org')
.get('/current/update-center.actual.json')
- .reply(200, { plugins: { blueocean: { version: '1.1.6' } } })
+ .reply(200, { plugins: { blueocean: { version: '1.1.6' } } }),
)
.expectBadge({ label: 'plugin', message: 'plugin not found' })
diff --git a/services/jenkins/jenkins-tests-redirector.service.js b/services/jenkins/jenkins-tests-redirector.service.js
index 051b21a81f4ed..3cb30a4cc419d 100644
--- a/services/jenkins/jenkins-tests-redirector.service.js
+++ b/services/jenkins/jenkins-tests-redirector.service.js
@@ -1,29 +1,25 @@
-import { redirector } from '../index.js'
-import { buildRedirectUrl } from './jenkins-common.js'
+import { deprecatedService } from '../index.js'
const commonProps = {
category: 'build',
- transformPath: () => '/jenkins/tests',
- transformQueryParams: ({ protocol, host, job }) => ({
- jobUrl: buildRedirectUrl({ protocol, host, job }),
- }),
+ label: 'jenkins',
+ dateAdded: new Date('2025-12-20'),
+ issueUrl: 'https://github.com/badges/shields/pull/11583',
}
export default [
- redirector({
+ deprecatedService({
route: {
base: 'jenkins/t',
pattern: ':protocol(http|https)/:host/:job+',
},
- dateAdded: new Date('2019-04-20'),
...commonProps,
}),
- redirector({
+ deprecatedService({
route: {
base: 'jenkins/tests',
pattern: ':protocol(http|https)/:host/:job+',
},
- dateAdded: new Date('2019-11-29'),
...commonProps,
}),
]
diff --git a/services/jenkins/jenkins-tests-redirector.tester.js b/services/jenkins/jenkins-tests-redirector.tester.js
index 837c9f4b1394d..7053644d9622d 100644
--- a/services/jenkins/jenkins-tests-redirector.tester.js
+++ b/services/jenkins/jenkins-tests-redirector.tester.js
@@ -8,20 +8,18 @@ export const t = new ServiceTester({
t.create('old tests prefix + job url in path')
.get(
- '/t/https/jenkins.qa.ubuntu.com/view/Trusty/view/Smoke Testing/job/trusty-touch-flo-smoke-daily.svg'
- )
- .expectRedirect(
- `/jenkins/tests.svg?jobUrl=${encodeURIComponent(
- 'https://jenkins.qa.ubuntu.com/view/Trusty/view/Smoke Testing/job/trusty-touch-flo-smoke-daily'
- )}`
+ '/t/https/jenkins.qa.ubuntu.com/view/Trusty/view/Smoke Testing/job/trusty-touch-flo-smoke-daily.json',
)
+ .expectBadge({
+ label: 'jenkins',
+ message: 'https://github.com/badges/shields/pull/11583',
+ })
t.create('new tests prefix + job url in path')
.get(
- '/tests/https/jenkins.qa.ubuntu.com/view/Trusty/view/Smoke Testing/job/trusty-touch-flo-smoke-daily.svg'
- )
- .expectRedirect(
- `/jenkins/tests.svg?jobUrl=${encodeURIComponent(
- 'https://jenkins.qa.ubuntu.com/view/Trusty/view/Smoke Testing/job/trusty-touch-flo-smoke-daily'
- )}`
+ '/tests/https/jenkins.qa.ubuntu.com/view/Trusty/view/Smoke Testing/job/trusty-touch-flo-smoke-daily.json',
)
+ .expectBadge({
+ label: 'jenkins',
+ message: 'https://github.com/badges/shields/pull/11583',
+ })
diff --git a/services/jenkins/jenkins-tests.service.js b/services/jenkins/jenkins-tests.service.js
index 4dcfaac4c1c66..b585bc9a81428 100644
--- a/services/jenkins/jenkins-tests.service.js
+++ b/services/jenkins/jenkins-tests.service.js
@@ -1,11 +1,12 @@
import Joi from 'joi'
import {
- documentation,
+ documentation as description,
testResultQueryParamSchema,
+ testResultOpenApiQueryParams,
renderTestResultBadge,
} from '../test-results.js'
import { optionalNonNegativeInteger } from '../validators.js'
-import { InvalidResponse } from '../index.js'
+import { InvalidResponse, queryParam } from '../index.js'
import JenkinsBase from './jenkins-base.js'
import {
buildTreeParamQueryString,
@@ -28,41 +29,35 @@ const schema = Joi.object({
totalCount: optionalNonNegativeInteger,
failCount: optionalNonNegativeInteger,
skipCount: optionalNonNegativeInteger,
- })
+ }),
)
.required(),
}).required()
export default class JenkinsTests extends JenkinsBase {
- static category = 'build'
-
+ static category = 'test-results'
static route = {
base: 'jenkins',
pattern: 'tests',
queryParamSchema: queryParamSchema.concat(testResultQueryParamSchema),
}
- static examples = [
- {
- title: 'Jenkins tests',
- namedParams: {},
- queryParams: {
- compact_message: null,
- passed_label: 'passed',
- failed_label: 'failed',
- skipped_label: 'skipped',
- jobUrl: 'https://jenkins.sqlalchemy.org/job/alembic_coverage',
+ static openApi = {
+ '/jenkins/tests': {
+ get: {
+ summary: 'Jenkins Tests',
+ description,
+ parameters: [
+ queryParam({
+ name: 'jobUrl',
+ example: 'https://ci.eclipse.org/jgit/job/jgit',
+ required: true,
+ }),
+ ...testResultOpenApiQueryParams,
+ ],
},
- staticPreview: this.render({
- passed: 477,
- failed: 2,
- skipped: 0,
- total: 479,
- isCompact: false,
- }),
- documentation,
},
- ]
+ }
static defaultBadgeData = { label: 'tests' }
@@ -112,12 +107,14 @@ export default class JenkinsTests extends JenkinsBase {
passed_label: passedLabel,
failed_label: failedLabel,
skipped_label: skippedLabel,
- }
+ },
) {
const json = await this.fetch({
url: buildUrl({ jobUrl }),
schema,
- qs: buildTreeParamQueryString('actions[failCount,skipCount,totalCount]'),
+ searchParams: buildTreeParamQueryString(
+ 'actions[failCount,skipCount,totalCount]',
+ ),
})
const { passed, failed, skipped, total } = this.transform({ json })
return this.constructor.render({
diff --git a/services/jenkins/jenkins-tests.spec.js b/services/jenkins/jenkins-tests.spec.js
new file mode 100644
index 0000000000000..802be0519c7b9
--- /dev/null
+++ b/services/jenkins/jenkins-tests.spec.js
@@ -0,0 +1,27 @@
+import { testAuth } from '../test-helpers.js'
+import JenkinsTests from './jenkins-tests.service.js'
+
+const authConfigOverride = {
+ public: {
+ services: {
+ jenkins: {
+ authorizedOrigins: ['https://ci.eclipse.org'],
+ },
+ },
+ },
+}
+
+describe('JenkinsTests', function () {
+ describe('auth', function () {
+ it('sends the auth information as configured', async function () {
+ return testAuth(
+ JenkinsTests,
+ 'BasicAuth',
+ { actions: [{ totalCount: 3, failCount: 2, skipCount: 1 }] },
+ {
+ configOverride: authConfigOverride,
+ },
+ )
+ })
+ })
+})
diff --git a/services/jenkins/jenkins-tests.tester.js b/services/jenkins/jenkins-tests.tester.js
index c2bdcbefa51c2..8c3cd63f374b8 100644
--- a/services/jenkins/jenkins-tests.tester.js
+++ b/services/jenkins/jenkins-tests.tester.js
@@ -13,50 +13,41 @@ export const t = await createServiceTester()
// https://wiki.jenkins.io/pages/viewpage.action?pageId=58001258
t.create('Test status')
- .get('/tests.json?jobUrl=https://jenkins.sqlalchemy.org/job/alembic_coverage')
+ .get('/tests.json?jobUrl=https://ci.eclipse.org/jgit/job/jgit')
.expectBadge({ label: 'tests', message: isDefaultTestTotals })
t.create('Test status with compact message')
- .get(
- '/tests.json?jobUrl=https://jenkins.sqlalchemy.org/job/alembic_coverage',
- {
- qs: { compact_message: null },
- }
- )
+ .get('/tests.json?jobUrl=https://ci.eclipse.org/jgit/job/jgit', {
+ qs: { compact_message: null },
+ })
.expectBadge({ label: 'tests', message: isDefaultCompactTestTotals })
t.create('Test status with custom labels')
- .get(
- '/tests.json?jobUrl=https://jenkins.sqlalchemy.org/job/alembic_coverage',
- {
- qs: {
- passed_label: 'good',
- failed_label: 'bad',
- skipped_label: 'n/a',
- },
- }
- )
+ .get('/tests.json?jobUrl=https://ci.eclipse.org/jgit/job/jgit', {
+ qs: {
+ passed_label: 'good',
+ failed_label: 'bad',
+ skipped_label: 'n/a',
+ },
+ })
.expectBadge({ label: 'tests', message: isCustomTestTotals })
t.create('Test status with compact message and custom labels')
- .get(
- '/tests.json?jobUrl=https://jenkins.sqlalchemy.org/job/alembic_coverage',
- {
- qs: {
- compact_message: null,
- passed_label: '💃',
- failed_label: '🤦♀️',
- skipped_label: '🤷',
- },
- }
- )
+ .get('/tests.json?jobUrl=https://ci.eclipse.org/jgit/job/jgit', {
+ qs: {
+ compact_message: null,
+ passed_label: '💃',
+ failed_label: '🤦♀️',
+ skipped_label: '🤷',
+ },
+ })
.expectBadge({
label: 'tests',
message: isCustomCompactTestTotals,
})
t.create('Test status on job with no tests')
- .get('/tests.json?jobUrl=https://ci.eclipse.org/orbit/job/orbit-recipes')
+ .get('/tests.json?jobUrl=https://ci.eclipse.org/orbit/job/orbit-shell')
.expectBadge({ label: 'tests', message: 'no tests found' })
t.create('Test status on non-existent job')
diff --git a/services/jetbrains/jetbrains-base.js b/services/jetbrains/jetbrains-base.js
index 140a94b2a7559..fe3c497c4797d 100644
--- a/services/jetbrains/jetbrains-base.js
+++ b/services/jetbrains/jetbrains-base.js
@@ -35,7 +35,7 @@ export default class JetbrainsBase extends BaseXmlService {
async fetchIntelliJPluginData({ pluginId, schema }) {
const parserOptions = {
- parseNodeValue: false,
+ parseTagValue: false,
ignoreAttributes: false,
}
return this._requestXml({
@@ -54,7 +54,7 @@ export default class JetbrainsBase extends BaseXmlService {
return super._validate(data, schema)
}
- async _requestJson({ schema, url, options = {}, errorMessages = {} }) {
+ async _requestJson({ schema, url, options = {}, httpErrors = {} }) {
const mergedOptions = {
...{ headers: { Accept: 'application/json' } },
...options,
@@ -62,7 +62,7 @@ export default class JetbrainsBase extends BaseXmlService {
const { buffer } = await this._request({
url,
options: mergedOptions,
- errorMessages,
+ httpErrors,
})
const json = this._parseJson(buffer)
return this.constructor._validateJson(json, schema)
diff --git a/services/jetbrains/jetbrains-downloads.service.js b/services/jetbrains/jetbrains-downloads.service.js
index ab379c5eb7e4d..74f16d25c692e 100644
--- a/services/jetbrains/jetbrains-downloads.service.js
+++ b/services/jetbrains/jetbrains-downloads.service.js
@@ -1,6 +1,6 @@
import Joi from 'joi'
-import { metric } from '../text-formatters.js'
-import { downloadCount as downloadCountColor } from '../color-formatters.js'
+import { pathParams } from '../index.js'
+import { renderDownloadsBadge } from '../downloads.js'
import { nonNegativeInteger } from '../validators.js'
import JetbrainsBase from './jetbrains-base.js'
@@ -12,7 +12,7 @@ const intelliJschema = Joi.object({
.items(
Joi.object({
'@_downloads': nonNegativeInteger,
- })
+ }),
)
.single()
.required(),
@@ -30,21 +30,16 @@ export default class JetbrainsDownloads extends JetbrainsBase {
pattern: ':pluginId',
}
- static examples = [
- {
- title: 'JetBrains plugins',
- namedParams: {
- pluginId: '1347',
+ static openApi = {
+ '/jetbrains/plugin/d/{pluginId}': {
+ get: {
+ summary: 'JetBrains Plugin Downloads',
+ parameters: pathParams({
+ name: 'pluginId',
+ example: '1347',
+ }),
},
- staticPreview: this.render({ downloads: 10200000 }),
},
- ]
-
- static render({ downloads }) {
- return {
- message: `${metric(downloads)}`,
- color: downloadCountColor(downloads),
- }
}
async handle({ pluginId }) {
@@ -62,13 +57,13 @@ export default class JetbrainsDownloads extends JetbrainsBase {
const jetbrainsPluginData = await this._requestJson({
schema: jetbrainsSchema,
url: `https://plugins.jetbrains.com/api/plugins/${this.constructor._cleanPluginId(
- pluginId
+ pluginId,
)}`,
- errorMessages: { 400: 'not found' },
+ httpErrors: { 400: 'not found' },
})
downloads = jetbrainsPluginData.downloads
}
- return this.constructor.render({ downloads })
+ return renderDownloadsBadge({ downloads })
}
}
diff --git a/services/jetbrains/jetbrains-downloads.tester.js b/services/jetbrains/jetbrains-downloads.tester.js
index 06c30636474e2..03090600936dd 100644
--- a/services/jetbrains/jetbrains-downloads.tester.js
+++ b/services/jetbrains/jetbrains-downloads.tester.js
@@ -19,7 +19,7 @@ t.create('downloads (numeric id)')
.intercept(nock =>
nock('https://plugins.jetbrains.com')
.get('/api/plugins/9435')
- .reply(200, { downloads: 2 })
+ .reply(200, { downloads: 2 }),
)
.expectBadge({ label: 'downloads', message: '2' })
@@ -36,11 +36,11 @@ t.create('downloads (string id)')
- `
+ `,
),
{
'Content-Type': 'text/xml;charset=UTF-8',
- }
+ },
)
.expectBadge({ label: 'downloads', message: '2' })
diff --git a/services/jetbrains/jetbrains-rating.service.js b/services/jetbrains/jetbrains-rating.service.js
index 55ae8696273dd..5fe197e66671d 100644
--- a/services/jetbrains/jetbrains-rating.service.js
+++ b/services/jetbrains/jetbrains-rating.service.js
@@ -1,6 +1,7 @@
import Joi from 'joi'
import { starRating } from '../text-formatters.js'
import { colorScale } from '../color-formatters.js'
+import { NotFound, pathParams } from '../index.js'
import JetbrainsBase from './jetbrains-base.js'
const pluginRatingColor = colorScale([2, 3, 4])
@@ -13,7 +14,7 @@ const intelliJschema = Joi.object({
.items(
Joi.object({
rating: Joi.string().required(),
- })
+ }),
)
.single()
.required(),
@@ -22,6 +23,10 @@ const intelliJschema = Joi.object({
}).required()
const jetbrainsSchema = Joi.object({
+ votes: Joi.object()
+ .pattern(Joi.string().required(), Joi.number().required())
+ .required(),
+ meanVotes: Joi.number().min(0).required(),
meanRating: Joi.number().min(0).required(),
}).required()
@@ -33,30 +38,24 @@ export default class JetbrainsRating extends JetbrainsBase {
pattern: ':format(rating|stars)/:pluginId',
}
- static examples = [
- {
- title: 'JetBrains Plugins',
- pattern: 'rating/:pluginId',
- namedParams: {
- pluginId: '11941',
+ static openApi = {
+ '/jetbrains/plugin/r/{format}/{pluginId}': {
+ get: {
+ summary: 'JetBrains Plugin Rating',
+ parameters: pathParams(
+ {
+ name: 'format',
+ example: 'rating',
+ schema: { type: 'string', enum: this.getEnum('format') },
+ },
+ {
+ name: 'pluginId',
+ example: '11941',
+ },
+ ),
},
- staticPreview: this.render({
- rating: '4.5',
- format: 'rating',
- }),
},
- {
- title: 'JetBrains Plugins',
- pattern: 'stars/:pluginId',
- namedParams: {
- pluginId: '11941',
- },
- staticPreview: this.render({
- rating: '4.5',
- format: 'stars',
- }),
- },
- ]
+ }
static defaultBadgeData = { label: 'rating' }
@@ -85,11 +84,27 @@ export default class JetbrainsRating extends JetbrainsBase {
const jetbrainsPluginData = await this._requestJson({
schema: jetbrainsSchema,
url: `https://plugins.jetbrains.com/api/plugins/${this.constructor._cleanPluginId(
- pluginId
+ pluginId,
)}/rating`,
- errorMessages: { 400: 'not found' },
+ httpErrors: { 400: 'not found' },
})
- rating = jetbrainsPluginData.meanRating
+
+ let voteSum = 0
+ let voteCount = 0
+ const votes = jetbrainsPluginData.votes
+ Object.entries(votes).forEach(([rating, votes]) => {
+ voteSum += parseInt(rating) * votes
+ voteCount += votes
+ })
+ const meanRating = jetbrainsPluginData.meanRating
+
+ if (voteCount === 0) {
+ throw new NotFound({ prettyMessage: 'No Plugin Ratings' })
+ }
+
+ // JetBrains Plugin Rating Formula from:
+ // https://plugins.jetbrains.com/docs/marketplace/plugins-rating.html
+ rating = (voteSum + 2 * meanRating) / (voteCount + 2)
}
return this.constructor.render({ rating, format })
diff --git a/services/jetbrains/jetbrains-rating.tester.js b/services/jetbrains/jetbrains-rating.tester.js
index b80d03c5ad824..14cdc2b222c4e 100644
--- a/services/jetbrains/jetbrains-rating.tester.js
+++ b/services/jetbrains/jetbrains-rating.tester.js
@@ -57,9 +57,29 @@ t.create('rating number (numeric id)')
.intercept(nock =>
nock('https://plugins.jetbrains.com')
.get('/api/plugins/11941/rating')
- .reply(200, { meanRating: 4.4848 })
+ .reply(200, {
+ votes: {
+ 4: 1,
+ 5: 4,
+ },
+ meanVotes: 2,
+ meanRating: 4.15669,
+ }),
)
- .expectBadge({ label: 'rating', message: '4.5/5' })
+ .expectBadge({ label: 'rating', message: '4.6/5' })
+
+t.create('rating number for "no vote" plugin (numeric id)')
+ .get('/rating/10998.json')
+ .intercept(nock =>
+ nock('https://plugins.jetbrains.com')
+ .get('/api/plugins/10998/rating')
+ .reply(200, {
+ votes: {},
+ meanVotes: 2,
+ meanRating: 4.15669,
+ }),
+ )
+ .expectBadge({ label: 'rating', message: 'No Plugin Ratings' })
t.create('rating number (string id)')
.get('/rating/com.chriscarini.jetbrains.jetbrains-auto-power-saver.json')
@@ -67,7 +87,7 @@ t.create('rating number (string id)')
nock =>
nock('https://plugins.jetbrains.com')
.get(
- '/plugins/list?pluginId=com.chriscarini.jetbrains.jetbrains-auto-power-saver'
+ '/plugins/list?pluginId=com.chriscarini.jetbrains.jetbrains-auto-power-saver',
)
.reply(
200,
@@ -78,11 +98,11 @@ t.create('rating number (string id)')
4.4848
- `
+ `,
),
{
'Content-Type': 'text/xml;charset=UTF-8',
- }
+ },
)
.expectBadge({ label: 'rating', message: '4.5/5' })
@@ -91,7 +111,14 @@ t.create('rating stars (numeric id)')
.intercept(nock =>
nock('https://plugins.jetbrains.com')
.get('/api/plugins/11941/rating')
- .reply(200, { meanRating: 4.4848 })
+ .reply(200, {
+ votes: {
+ 4: 1,
+ 5: 4,
+ },
+ meanVotes: 2,
+ meanRating: 4.15669,
+ }),
)
.expectBadge({ label: 'rating', message: '★★★★½' })
@@ -101,7 +128,7 @@ t.create('rating stars (string id)')
nock =>
nock('https://plugins.jetbrains.com')
.get(
- '/plugins/list?pluginId=com.chriscarini.jetbrains.jetbrains-auto-power-saver'
+ '/plugins/list?pluginId=com.chriscarini.jetbrains.jetbrains-auto-power-saver',
)
.reply(
200,
@@ -112,10 +139,10 @@ t.create('rating stars (string id)')
4.4848
- `
+ `,
),
{
'Content-Type': 'text/xml;charset=UTF-8',
- }
+ },
)
.expectBadge({ label: 'rating', message: '★★★★½' })
diff --git a/services/jetbrains/jetbrains-version.service.js b/services/jetbrains/jetbrains-version.service.js
index 2b7d8dd3b2b07..8d349167bc5c3 100644
--- a/services/jetbrains/jetbrains-version.service.js
+++ b/services/jetbrains/jetbrains-version.service.js
@@ -1,4 +1,5 @@
import Joi from 'joi'
+import { pathParams } from '../index.js'
import { renderVersionBadge } from '../version.js'
import JetbrainsBase from './jetbrains-base.js'
@@ -10,7 +11,7 @@ const intelliJschema = Joi.object({
.items(
Joi.object({
version: Joi.string().required(),
- })
+ }),
)
.single()
.required(),
@@ -23,7 +24,7 @@ const jetbrainsSchema = Joi.array()
.items(
Joi.object({
version: Joi.string().required(),
- }).required()
+ }).required(),
)
.required()
@@ -35,15 +36,17 @@ export default class JetbrainsVersion extends JetbrainsBase {
pattern: ':pluginId',
}
- static examples = [
- {
- title: 'JetBrains Plugins',
- namedParams: {
- pluginId: '9630',
+ static openApi = {
+ '/jetbrains/plugin/v/{pluginId}': {
+ get: {
+ summary: 'JetBrains Plugin Version',
+ parameters: pathParams({
+ name: 'pluginId',
+ example: '9630',
+ }),
},
- staticPreview: this.render({ version: 'v1.7' }),
},
- ]
+ }
static defaultBadgeData = { label: 'jetbrains plugin' }
@@ -65,9 +68,9 @@ export default class JetbrainsVersion extends JetbrainsBase {
const jetbrainsPluginData = await this._requestJson({
schema: jetbrainsSchema,
url: `https://plugins.jetbrains.com/api/plugins/${this.constructor._cleanPluginId(
- pluginId
+ pluginId,
)}/updates`,
- errorMessages: { 400: 'not found' },
+ httpErrors: { 400: 'not found' },
})
version = jetbrainsPluginData[0].version
}
diff --git a/services/jetbrains/jetbrains-version.tester.js b/services/jetbrains/jetbrains-version.tester.js
index 5a4d477e62cd6..8c2dbf962be4f 100644
--- a/services/jetbrains/jetbrains-version.tester.js
+++ b/services/jetbrains/jetbrains-version.tester.js
@@ -26,7 +26,7 @@ t.create('version (numeric id)')
.intercept(nock =>
nock('https://plugins.jetbrains.com')
.get('/api/plugins/9435/updates')
- .reply(200, [{ version: '1.0' }])
+ .reply(200, [{ version: '1.0' }]),
)
.expectBadge({ label: 'jetbrains plugin', message: 'v1.0' })
@@ -45,11 +45,11 @@ t.create('version (strong id)')
1.0
- `
+ `,
),
{
'Content-Type': 'text/xml;charset=UTF-8',
- }
+ },
)
.expectBadge({ label: 'jetbrains plugin', message: 'v1.0' })
diff --git a/services/jira/jira-issue-redirect.service.js b/services/jira/jira-issue-redirect.service.js
index 8f7f2b9f587ff..ac53bac4efa71 100644
--- a/services/jira/jira-issue-redirect.service.js
+++ b/services/jira/jira-issue-redirect.service.js
@@ -1,16 +1,14 @@
-import { redirector } from '../index.js'
+import { deprecatedService } from '../index.js'
export default [
- redirector({
+ deprecatedService({
category: 'issue-tracking',
+ label: 'jira',
route: {
base: 'jira/issue',
pattern: ':protocol(http|https)/:hostAndPath(.+)/:issueKey',
},
- transformPath: ({ issueKey }) => `/jira/issue/${issueKey}`,
- transformQueryParams: ({ protocol, hostAndPath }) => ({
- baseUrl: `${protocol}://${hostAndPath}`,
- }),
- dateAdded: new Date('2019-09-14'),
+ dateAdded: new Date('2025-12-20'),
+ issueUrl: 'https://github.com/badges/shields/pull/11583',
}),
]
diff --git a/services/jira/jira-issue-redirect.tester.js b/services/jira/jira-issue-redirect.tester.js
index b414839c47ac3..ddee015b67d1e 100644
--- a/services/jira/jira-issue-redirect.tester.js
+++ b/services/jira/jira-issue-redirect.tester.js
@@ -7,9 +7,8 @@ export const t = new ServiceTester({
})
t.create('jira issue')
- .get('/https/issues.apache.org/jira/kafka-2896.svg')
- .expectRedirect(
- `/jira/issue/kafka-2896.svg?baseUrl=${encodeURIComponent(
- 'https://issues.apache.org/jira'
- )}`
- )
+ .get('/https/issues.apache.org/jira/kafka-2896.json')
+ .expectBadge({
+ label: 'jira',
+ message: 'https://github.com/badges/shields/pull/11583',
+ })
diff --git a/services/jira/jira-issue.service.js b/services/jira/jira-issue.service.js
index 35fafa60a3a9b..2cf6f0eb0ee4b 100644
--- a/services/jira/jira-issue.service.js
+++ b/services/jira/jira-issue.service.js
@@ -1,10 +1,10 @@
import Joi from 'joi'
-import { optionalUrl } from '../validators.js'
-import { BaseJsonService } from '../index.js'
+import { url } from '../validators.js'
+import { BaseJsonService, pathParam, queryParam } from '../index.js'
import { authConfig } from './jira-common.js'
const queryParamSchema = Joi.object({
- baseUrl: optionalUrl.required(),
+ baseUrl: url,
}).required()
const schema = Joi.object({
@@ -29,22 +29,24 @@ export default class JiraIssue extends BaseJsonService {
static auth = authConfig
- static examples = [
- {
- title: 'JIRA issue',
- namedParams: {
- issueKey: 'KAFKA-2896',
+ static openApi = {
+ '/jira/issue/{issueKey}': {
+ get: {
+ summary: 'JIRA issue',
+ parameters: [
+ pathParam({
+ name: 'issueKey',
+ example: 'KAFKA-2896',
+ }),
+ queryParam({
+ name: 'baseUrl',
+ example: 'https://issues.apache.org/jira',
+ required: true,
+ }),
+ ],
},
- queryParams: {
- baseUrl: 'https://issues.apache.org/jira',
- },
- staticPreview: this.render({
- issueKey: 'KAFKA-2896',
- statusName: 'Resolved',
- statusColor: 'green',
- }),
},
- ]
+ }
static defaultBadgeData = { color: 'lightgrey', label: 'jira' }
@@ -75,8 +77,8 @@ export default class JiraIssue extends BaseJsonService {
this.authHelper.withBasicAuth({
schema,
url: `${baseUrl}/rest/api/2/issue/${encodeURIComponent(issueKey)}`,
- errorMessages: { 404: 'issue not found' },
- })
+ httpErrors: { 404: 'issue not found' },
+ }),
)
const issueStatus = json.fields.status
diff --git a/services/jira/jira-issue.spec.js b/services/jira/jira-issue.spec.js
index 10ff23efd3762..d70aa6aa1995d 100644
--- a/services/jira/jira-issue.spec.js
+++ b/services/jira/jira-issue.spec.js
@@ -1,35 +1,22 @@
-import { expect } from 'chai'
-import nock from 'nock'
-import { cleanUpNockAfterEach, defaultContext } from '../test-helpers.js'
+import { testAuth } from '../test-helpers.js'
import JiraIssue from './jira-issue.service.js'
-import { user, pass, host, config } from './jira-test-helpers.js'
+import { config } from './jira-test-helpers.js'
describe('JiraIssue', function () {
- cleanUpNockAfterEach()
-
- it('sends the auth information as configured', async function () {
- const scope = nock(`https://${host}`)
- .get(`/rest/api/2/issue/${encodeURIComponent('secure-234')}`)
- // 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.
- .basicAuth({ user, pass })
- .reply(200, { fields: { status: { name: 'in progress' } } })
-
- expect(
- await JiraIssue.invoke(
- defaultContext,
- config,
+ describe('auth', function () {
+ it('sends the auth information as configured', async function () {
+ return testAuth(
+ JiraIssue,
+ 'BasicAuth',
{
- issueKey: 'secure-234',
+ fields: {
+ status: {
+ name: 'in progress',
+ },
+ },
},
- { baseUrl: `https://${host}` }
+ { configOverride: config },
)
- ).to.deep.equal({
- label: 'secure-234',
- message: 'in progress',
- color: 'lightgrey',
})
-
- scope.done()
})
})
diff --git a/services/jira/jira-issue.tester.js b/services/jira/jira-issue.tester.js
index faab0a4ebbcfc..f7f883fb575b8 100644
--- a/services/jira/jira-issue.tester.js
+++ b/services/jira/jira-issue.tester.js
@@ -10,9 +10,9 @@ t.create('known issue')
.expectBadge({ label: 'kafka-2896', message: 'Resolved' })
t.create('no status color')
- .get('/foo-123.json?baseUrl=http://issues.apache.org/jira')
+ .get('/foo-123.json?baseUrl=https://issues.apache.org/jira')
.intercept(nock =>
- nock('http://issues.apache.org/jira/rest/api/2/issue')
+ nock('https://issues.apache.org/jira/rest/api/2/issue')
.get(`/${encodeURIComponent('foo-123')}`)
.reply(200, {
fields: {
@@ -20,7 +20,7 @@ t.create('no status color')
name: 'pending',
},
},
- })
+ }),
)
.expectBadge({
label: 'foo-123',
@@ -42,7 +42,7 @@ t.create('green status color')
},
},
},
- })
+ }),
)
.expectBadge({
label: 'bar-345',
@@ -64,7 +64,7 @@ t.create('medium-gray status color')
},
},
},
- })
+ }),
)
.expectBadge({
label: 'abc-123',
@@ -86,7 +86,7 @@ t.create('yellow status color')
},
},
},
- })
+ }),
)
.expectBadge({
label: 'test-001',
@@ -108,7 +108,7 @@ t.create('brown status color')
},
},
},
- })
+ }),
)
.expectBadge({
label: 'zzz-789',
@@ -130,7 +130,7 @@ t.create('warm-red status color')
},
},
},
- })
+ }),
)
.expectBadge({
label: 'fire-321',
@@ -152,7 +152,7 @@ t.create('blue-gray status color')
},
},
},
- })
+ }),
)
.expectBadge({
label: 'sky-775',
diff --git a/services/jira/jira-sprint-redirect.service.js b/services/jira/jira-sprint-redirect.service.js
index 54dec2a42f509..7faa434c9018d 100644
--- a/services/jira/jira-sprint-redirect.service.js
+++ b/services/jira/jira-sprint-redirect.service.js
@@ -1,16 +1,14 @@
-import { redirector } from '../index.js'
+import { deprecatedService } from '../index.js'
export default [
- redirector({
+ deprecatedService({
category: 'issue-tracking',
+ label: 'jira',
route: {
base: 'jira/sprint',
pattern: ':protocol(http|https)/:hostAndPath(.+)/:sprintId',
},
- transformPath: ({ sprintId }) => `/jira/sprint/${sprintId}`,
- transformQueryParams: ({ protocol, hostAndPath }) => ({
- baseUrl: `${protocol}://${hostAndPath}`,
- }),
- dateAdded: new Date('2019-09-14'),
+ dateAdded: new Date('2025-12-20'),
+ issueUrl: 'https://github.com/badges/shields/pull/11583',
}),
]
diff --git a/services/jira/jira-sprint-redirect.tester.js b/services/jira/jira-sprint-redirect.tester.js
index 86c417106a0e6..eee8c39571b53 100644
--- a/services/jira/jira-sprint-redirect.tester.js
+++ b/services/jira/jira-sprint-redirect.tester.js
@@ -6,10 +6,7 @@ export const t = new ServiceTester({
pathPrefix: '/jira/sprint',
})
-t.create('jira sprint')
- .get('/https/jira.spring.io/94.svg')
- .expectRedirect(
- `/jira/sprint/94.svg?baseUrl=${encodeURIComponent(
- 'https://jira.spring.io'
- )}`
- )
+t.create('jira sprint').get('/https/jira.spring.io/94.json').expectBadge({
+ label: 'jira',
+ message: 'https://github.com/badges/shields/pull/11583',
+})
diff --git a/services/jira/jira-sprint.service.js b/services/jira/jira-sprint.service.js
index 875a85e92cff8..66e31c43b8547 100644
--- a/services/jira/jira-sprint.service.js
+++ b/services/jira/jira-sprint.service.js
@@ -1,10 +1,10 @@
import Joi from 'joi'
-import { optionalUrl } from '../validators.js'
-import { BaseJsonService } from '../index.js'
+import { url } from '../validators.js'
+import { BaseJsonService, pathParam, queryParam } from '../index.js'
import { authConfig } from './jira-common.js'
const queryParamSchema = Joi.object({
- baseUrl: optionalUrl.required(),
+ baseUrl: url,
}).required()
const schema = Joi.object({
@@ -17,17 +17,15 @@ const schema = Joi.object({
name: Joi.string(),
}).allow(null),
}).required(),
- })
+ }),
)
.required(),
}).required()
-const documentation = `
-
- To get the Sprint ID, go to your Backlog view in your project,
- right click on your sprint name and get the value of
- data-sprint-id.
-
+const description = `
+To get the \`Sprint ID\`, go to your Backlog view in your project,
+right click on your sprint name and get the value of
+\`data-sprint-id\`.
`
export default class JiraSprint extends BaseJsonService {
@@ -41,22 +39,25 @@ export default class JiraSprint extends BaseJsonService {
static auth = authConfig
- static examples = [
- {
- title: 'JIRA sprint completion',
- namedParams: {
- sprintId: '94',
- },
- queryParams: {
- baseUrl: 'https://jira.spring.io',
+ static openApi = {
+ '/jira/sprint/{sprintId}': {
+ get: {
+ summary: 'JIRA sprint completion',
+ description,
+ parameters: [
+ pathParam({
+ name: 'sprintId',
+ example: '94',
+ }),
+ queryParam({
+ name: 'baseUrl',
+ example: 'https://issues.apache.org/jira',
+ required: true,
+ }),
+ ],
},
- staticPreview: this.render({
- numCompletedIssues: 27,
- numTotalIssues: 28,
- }),
- documentation,
},
- ]
+ }
static defaultBadgeData = { label: 'jira' }
@@ -86,17 +87,17 @@ export default class JiraSprint extends BaseJsonService {
url: `${baseUrl}/rest/api/2/search`,
schema,
options: {
- qs: {
+ searchParams: {
jql: `sprint=${sprintId} AND type IN (Bug,Improvement,Story,"Technical task")`,
fields: 'resolution',
maxResults: 500,
},
},
- errorMessages: {
+ httpErrors: {
400: 'sprint not found',
404: 'sprint not found',
},
- })
+ }),
)
const numTotalIssues = json.total
diff --git a/services/jira/jira-sprint.spec.js b/services/jira/jira-sprint.spec.js
index 8bcead5bb2e27..7ec69e9a6154a 100644
--- a/services/jira/jira-sprint.spec.js
+++ b/services/jira/jira-sprint.spec.js
@@ -1,49 +1,22 @@
-import { expect } from 'chai'
-import nock from 'nock'
-import { cleanUpNockAfterEach, defaultContext } from '../test-helpers.js'
+import { testAuth } from '../test-helpers.js'
import JiraSprint from './jira-sprint.service.js'
-import {
- user,
- pass,
- host,
- config,
- sprintId,
- sprintQueryString,
-} from './jira-test-helpers.js'
+import { config } from './jira-test-helpers.js'
describe('JiraSprint', function () {
- cleanUpNockAfterEach()
-
- it('sends the auth information as configured', async function () {
- const scope = nock(`https://${host}`)
- .get('/jira/rest/api/2/search')
- .query(sprintQueryString)
- // 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.
- .basicAuth({ user, pass })
- .reply(200, {
- total: 2,
- issues: [
- { fields: { resolution: { name: 'done' } } },
- { fields: { resolution: { name: 'Unresolved' } } },
- ],
- })
-
- expect(
- await JiraSprint.invoke(
- defaultContext,
- config,
+ describe('auth', function () {
+ it('sends the auth information as configured', async function () {
+ return testAuth(
+ JiraSprint,
+ 'BasicAuth',
{
- sprintId,
+ total: 2,
+ issues: [
+ { fields: { resolution: { name: 'done' } } },
+ { fields: { resolution: { name: 'Unresolved' } } },
+ ],
},
- { baseUrl: `https://${host}/jira` }
+ { configOverride: config },
)
- ).to.deep.equal({
- label: 'completion',
- message: '50%',
- color: 'orange',
})
-
- scope.done()
})
})
diff --git a/services/jira/jira-sprint.tester.js b/services/jira/jira-sprint.tester.js
index 997b3aaf55c2f..9744c247e7522 100644
--- a/services/jira/jira-sprint.tester.js
+++ b/services/jira/jira-sprint.tester.js
@@ -4,20 +4,20 @@ import { sprintId, sprintQueryString } from './jira-test-helpers.js'
export const t = await createServiceTester()
t.create('unknown sprint')
- .get('/abc.json?baseUrl=https://jira.spring.io')
+ .get('/abc.json?baseUrl=https://issues.apache.org/jira')
.expectBadge({ label: 'jira', message: 'sprint not found' })
t.create('known sprint')
- .get('/94.json?baseUrl=https://jira.spring.io')
+ .get('/3.json?baseUrl=https://issues.apache.org/jira')
.expectBadge({
label: 'completion',
message: isIntegerPercentage,
})
t.create('100% completion')
- .get(`/${sprintId}.json?baseUrl=http://issues.apache.org/jira`)
+ .get(`/${sprintId}.json?baseUrl=https://issues.apache.org/jira`)
.intercept(nock =>
- nock('http://issues.apache.org/jira/rest/api/2')
+ nock('https://issues.apache.org/jira/rest/api/2')
.get('/search')
.query(sprintQueryString)
.reply(200, {
@@ -38,7 +38,7 @@ t.create('100% completion')
},
},
],
- })
+ }),
)
.expectBadge({
label: 'completion',
@@ -47,9 +47,9 @@ t.create('100% completion')
})
t.create('0% completion')
- .get(`/${sprintId}.json?baseUrl=http://issues.apache.org/jira`)
+ .get(`/${sprintId}.json?baseUrl=https://issues.apache.org/jira`)
.intercept(nock =>
- nock('http://issues.apache.org/jira/rest/api/2')
+ nock('https://issues.apache.org/jira/rest/api/2')
.get('/search')
.query(sprintQueryString)
.reply(200, {
@@ -63,7 +63,7 @@ t.create('0% completion')
},
},
],
- })
+ }),
)
.expectBadge({
label: 'completion',
@@ -72,15 +72,15 @@ t.create('0% completion')
})
t.create('no issues in sprint')
- .get(`/${sprintId}.json?baseUrl=http://issues.apache.org/jira`)
+ .get(`/${sprintId}.json?baseUrl=https://issues.apache.org/jira`)
.intercept(nock =>
- nock('http://issues.apache.org/jira/rest/api/2')
+ nock('https://issues.apache.org/jira/rest/api/2')
.get('/search')
.query(sprintQueryString)
.reply(200, {
total: 0,
issues: [],
- })
+ }),
)
.expectBadge({
label: 'completion',
@@ -110,7 +110,7 @@ t.create('issue with null resolution value')
},
},
],
- })
+ }),
)
.expectBadge({
label: 'completion',
diff --git a/services/jira/jira-test-helpers.js b/services/jira/jira-test-helpers.js
index e188179146bc5..6cdcdfff8919f 100644
--- a/services/jira/jira-test-helpers.js
+++ b/services/jira/jira-test-helpers.js
@@ -5,18 +5,14 @@ const sprintQueryString = {
maxResults: 500,
}
-const user = 'admin'
-const pass = 'password'
-const host = 'myprivatejira.test'
const config = {
public: {
services: {
jira: {
- authorizedOrigins: [`https://${host}`],
+ authorizedOrigins: ['https://issues.apache.org'],
},
},
},
- private: { jira_user: user, jira_pass: pass },
}
-export { sprintId, sprintQueryString, user, pass, host, config }
+export { sprintId, sprintQueryString, config }
diff --git a/services/jitpack/jitpack-version-redirector.service.js b/services/jitpack/jitpack-version-redirector.service.js
index bda5c01485ae0..bbc62145d9c3c 100644
--- a/services/jitpack/jitpack-version-redirector.service.js
+++ b/services/jitpack/jitpack-version-redirector.service.js
@@ -1,14 +1,26 @@
-import { redirector } from '../index.js'
+import { deprecatedService, redirector } from '../index.js'
export default [
+ deprecatedService({
+ category: 'version',
+ label: 'jitpack',
+ name: 'JitpackVersionGitHubRedirect',
+ route: {
+ base: 'jitpack/v',
+ pattern: ':user/:repo',
+ },
+ dateAdded: new Date('2025-12-20'),
+ issueUrl: 'https://github.com/badges/shields/pull/11583',
+ }),
redirector({
category: 'version',
+ name: 'JitpackVersionVcsRedirect',
route: {
base: 'jitpack/v',
- pattern: ':groupId/:artifactId',
+ pattern: ':vcs(github|bitbucket|gitlab|gitee)/:user/:repo',
},
- transformPath: ({ groupId, artifactId }) =>
- `/jitpack/v/github/${groupId}/${artifactId}`,
- dateAdded: new Date('2019-03-31'),
+ transformPath: ({ vcs, user, repo }) =>
+ `/jitpack/version/com.${vcs}.${user}/${repo}`,
+ dateAdded: new Date('2022-08-21'),
}),
]
diff --git a/services/jitpack/jitpack-version-redirector.tester.js b/services/jitpack/jitpack-version-redirector.tester.js
index deb2b820cfe4b..149f4690661bf 100644
--- a/services/jitpack/jitpack-version-redirector.tester.js
+++ b/services/jitpack/jitpack-version-redirector.tester.js
@@ -6,6 +6,13 @@ export const t = new ServiceTester({
pathPrefix: '/jitpack/v',
})
-t.create('jitpack version redirect')
- .get('/jitpack/maven-simple.svg')
- .expectRedirect('/jitpack/v/github/jitpack/maven-simple.svg')
+t.create('jitpack version deprecated (no vcs)')
+ .get('/jitpack/maven-simple.json')
+ .expectBadge({
+ label: 'jitpack',
+ message: 'https://github.com/badges/shields/pull/11583',
+ })
+
+t.create('jitpack version redirect (github)')
+ .get('/github/jitpack/maven-simple.svg')
+ .expectRedirect('/jitpack/version/com.github.jitpack/maven-simple.svg')
diff --git a/services/jitpack/jitpack-version.service.js b/services/jitpack/jitpack-version.service.js
index e739e06ce8a4d..136211e82abbd 100644
--- a/services/jitpack/jitpack-version.service.js
+++ b/services/jitpack/jitpack-version.service.js
@@ -1,6 +1,6 @@
import Joi from 'joi'
import { renderVersionBadge } from '../version.js'
-import { BaseJsonService } from '../index.js'
+import { BaseJsonService, pathParams } from '../index.js'
const schema = Joi.object({
version: Joi.string().required(),
@@ -10,38 +10,45 @@ const schema = Joi.object({
export default class JitPackVersion extends BaseJsonService {
static category = 'version'
+ // Changed endpoint to allow any groupId, custom domains included
+ // See: https://github.com/badges/shields/issues/8312
static route = {
- base: 'jitpack/v',
- pattern: ':vcs(github|bitbucket|gitlab|gitee)/:user/:repo',
+ base: 'jitpack/version',
+ pattern: ':groupId/:artifactId',
}
- static examples = [
- {
- title: 'JitPack',
- namedParams: {
- vcs: 'github',
- user: 'jitpack',
- repo: 'maven-simple',
+ static openApi = {
+ '/jitpack/version/{groupId}/{artifactId}': {
+ get: {
+ summary: 'JitPack',
+ parameters: pathParams(
+ {
+ name: 'groupId',
+ example: 'com.github.jitpack',
+ },
+ {
+ name: 'artifactId',
+ example: 'maven-simple',
+ },
+ ),
},
- staticPreview: renderVersionBadge({ version: 'v1.1' }),
- keywords: ['java', 'maven'],
},
- ]
+ }
static defaultBadgeData = { label: 'jitpack' }
- async fetch({ vcs, user, repo }) {
- const url = `https://jitpack.io/api/builds/com.${vcs}.${user}/${repo}/latest`
+ async fetch({ groupId, artifactId }) {
+ const url = `https://jitpack.io/api/builds/${groupId}/${artifactId}/latestOk`
return this._requestJson({
schema,
url,
- errorMessages: { 401: 'project not found or private' },
+ httpErrors: { 401: 'project not found or private' },
})
}
- async handle({ vcs, user, repo }) {
- const { version } = await this.fetch({ vcs, user, repo })
+ async handle({ groupId, artifactId }) {
+ const { version } = await this.fetch({ groupId, artifactId })
return renderVersionBadge({ version })
}
}
diff --git a/services/jitpack/jitpack-version.tester.js b/services/jitpack/jitpack-version.tester.js
index 5d7322f8e3dfc..3f30f9ce9bf44 100644
--- a/services/jitpack/jitpack-version.tester.js
+++ b/services/jitpack/jitpack-version.tester.js
@@ -6,9 +6,9 @@ export const t = await createServiceTester()
const isAnyV = Joi.string().regex(/^v.+$/)
t.create('version (groupId)')
- .get('/github/erayerdin/kappdirs.json')
+ .get('/com.github.erayerdin/kappdirs.json')
.expectBadge({ label: 'jitpack', message: isAnyV })
t.create('unknown package')
- .get('/github/some-bogus-user/project.json')
+ .get('/com.github.some-bogus-user/project.json')
.expectBadge({ label: 'jitpack', message: 'project not found or private' })
diff --git a/services/jsdelivr/jsdelivr-base.js b/services/jsdelivr/jsdelivr-base.js
index be2b3495fe368..f021b3997af37 100644
--- a/services/jsdelivr/jsdelivr-base.js
+++ b/services/jsdelivr/jsdelivr-base.js
@@ -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'
const schema = Joi.object({
@@ -21,11 +20,8 @@ class BaseJsDelivrService extends BaseJsonService {
label: 'jsdelivr',
}
- static render({ period, hits }) {
- return {
- message: `${metric(hits)}/${periodMap[period]}`,
- color: downloadCount(hits),
- }
+ static render({ period, hits: downloads }) {
+ return renderDownloadsBadge({ downloads, interval: periodMap[period] })
}
}
diff --git a/services/jsdelivr/jsdelivr-hits-github.service.js b/services/jsdelivr/jsdelivr-hits-github.service.js
index 8d760b899266f..0575492487ec5 100644
--- a/services/jsdelivr/jsdelivr-hits-github.service.js
+++ b/services/jsdelivr/jsdelivr-hits-github.service.js
@@ -1,3 +1,4 @@
+import { pathParams } from '../index.js'
import { schema, periodMap, BaseJsDelivrService } from './jsdelivr-base.js'
export default class JsDelivrHitsGitHub extends BaseJsDelivrService {
@@ -6,17 +7,29 @@ export default class JsDelivrHitsGitHub extends BaseJsDelivrService {
pattern: ':period(hd|hw|hm|hy)/:user/:repo',
}
- static examples = [
- {
- title: 'jsDelivr hits (GitHub)',
- namedParams: {
- period: 'hm',
- user: 'jquery',
- repo: 'jquery',
+ static openApi = {
+ '/jsdelivr/gh/{period}/{user}/{repo}': {
+ get: {
+ summary: 'jsDelivr hits (GitHub)',
+ parameters: pathParams(
+ {
+ name: 'period',
+ example: 'hm',
+ schema: { type: 'string', enum: this.getEnum('period') },
+ description: 'Hits per Day, Week, Month or Year',
+ },
+ {
+ name: 'user',
+ example: 'jquery',
+ },
+ {
+ name: 'repo',
+ example: 'jquery',
+ },
+ ),
},
- staticPreview: this.render({ period: 'hm', hits: 9809876 }),
},
- ]
+ }
async fetch({ period, user, repo }) {
return this._requestJson({
diff --git a/services/jsdelivr/jsdelivr-hits-github.tester.js b/services/jsdelivr/jsdelivr-hits-github.tester.js
index 48231e1fd8436..319b3ef99f685 100644
--- a/services/jsdelivr/jsdelivr-hits-github.tester.js
+++ b/services/jsdelivr/jsdelivr-hits-github.tester.js
@@ -8,7 +8,7 @@ export const t = await createServiceTester()
const isDownloadsOverTimePeriod = Joi.alternatives().try(
isMetricOverTimePeriod,
- isZeroOverTimePeriod
+ isZeroOverTimePeriod,
)
t.create('jquery/jquery hits/day')
diff --git a/services/jsdelivr/jsdelivr-hits-npm.service.js b/services/jsdelivr/jsdelivr-hits-npm.service.js
index 79db7f7f557d7..86f5383229c27 100644
--- a/services/jsdelivr/jsdelivr-hits-npm.service.js
+++ b/services/jsdelivr/jsdelivr-hits-npm.service.js
@@ -1,3 +1,4 @@
+import { pathParams } from '../index.js'
import { schema, periodMap, BaseJsDelivrService } from './jsdelivr-base.js'
export default class JsDelivrHitsNPM extends BaseJsDelivrService {
@@ -6,27 +7,46 @@ export default class JsDelivrHitsNPM extends BaseJsDelivrService {
pattern: ':period(hd|hw|hm|hy)/:scope(@[^/]+)?/:packageName',
}
- static examples = [
- {
- title: 'jsDelivr hits (npm)',
- pattern: ':period(hd|hw|hm|hy)/:packageName',
- namedParams: {
- period: 'hm',
- packageName: 'jquery',
+ static openApi = {
+ '/jsdelivr/npm/{period}/{packageName}': {
+ get: {
+ summary: 'jsDelivr hits (npm)',
+ parameters: pathParams(
+ {
+ name: 'period',
+ schema: { type: 'string', enum: this.getEnum('period') },
+ example: 'hm',
+ description: 'Hits per Day, Week, Month or Year',
+ },
+ {
+ name: 'packageName',
+ example: 'fire',
+ },
+ ),
},
- staticPreview: this.render({ period: 'hm', hits: 920101789 }),
},
- {
- title: 'jsDelivr hits (npm scoped)',
- pattern: ':period(hd|hw|hm|hy)/:scope?/:packageName',
- namedParams: {
- period: 'hm',
- scope: '@angular',
- packageName: 'fire',
+ '/jsdelivr/npm/{period}/{scope}/{packageName}': {
+ get: {
+ summary: 'jsDelivr hits (npm scoped)',
+ parameters: pathParams(
+ {
+ name: 'period',
+ schema: { type: 'string', enum: this.getEnum('period') },
+ example: 'hm',
+ description: 'Hits per Day, Week, Month or Year',
+ },
+ {
+ name: 'scope',
+ example: '@angular',
+ },
+ {
+ name: 'packageName',
+ example: 'fire',
+ },
+ ),
},
- staticPreview: this.render({ period: 'hm', hits: 94123 }),
},
- ]
+ }
async fetch({ period, packageName }) {
return this._requestJson({
diff --git a/services/jsdelivr/jsdelivr-hits-npm.tester.js b/services/jsdelivr/jsdelivr-hits-npm.tester.js
index 2c41ed9fe828b..747c08ef464b9 100644
--- a/services/jsdelivr/jsdelivr-hits-npm.tester.js
+++ b/services/jsdelivr/jsdelivr-hits-npm.tester.js
@@ -8,7 +8,7 @@ export const t = await createServiceTester()
const isDownloadsOverTimePeriod = Joi.alternatives().try(
isMetricOverTimePeriod,
- isZeroOverTimePeriod
+ isZeroOverTimePeriod,
)
t.create('jquery hits/day').timeout(10000).get('/hd/ky.json').expectBadge({
diff --git a/services/jsr/jsr-version.service.js b/services/jsr/jsr-version.service.js
new file mode 100644
index 0000000000000..4cb30200289af
--- /dev/null
+++ b/services/jsr/jsr-version.service.js
@@ -0,0 +1,60 @@
+import Joi from 'joi'
+import { renderVersionBadge } from '../version.js'
+import { BaseJsonService, pathParams } from '../index.js'
+
+const schema = Joi.object({
+ latest: Joi.string().required(),
+}).required()
+
+export default class JsrVersion extends BaseJsonService {
+ static category = 'version'
+
+ static route = {
+ base: 'jsr/v',
+ pattern: ':scope(@[^/]+)/:packageName',
+ }
+
+ static openApi = {
+ '/jsr/v/{scope}/{packageName}': {
+ get: {
+ summary: 'JSR Version',
+ description:
+ '[JSR](https://jsr.io/) is a modern package registry for JavaScript and TypeScript.',
+ parameters: pathParams(
+ {
+ name: 'scope',
+ example: '@luca',
+ },
+ {
+ name: 'packageName',
+ example: 'flag',
+ },
+ ),
+ },
+ },
+ }
+
+ static defaultBadgeData = {
+ label: 'jsr',
+ }
+
+ static render({ version }) {
+ return renderVersionBadge({ version })
+ }
+
+ async fetch({ scope, packageName }) {
+ // see https://jsr.io/docs/api#package-version-metadata
+ return this._requestJson({
+ schema,
+ url: `https://jsr.io/${scope}/${packageName}/meta.json`,
+ httpErrors: {
+ 404: 'package not found',
+ },
+ })
+ }
+
+ async handle({ scope, packageName }) {
+ const { latest } = await this.fetch({ scope, packageName })
+ return this.constructor.render({ version: latest })
+ }
+}
diff --git a/services/jsr/jsr-version.tester.js b/services/jsr/jsr-version.tester.js
new file mode 100644
index 0000000000000..877f635c505d4
--- /dev/null
+++ b/services/jsr/jsr-version.tester.js
@@ -0,0 +1,15 @@
+import { isSemver } from '../test-validators.js'
+import { createServiceTester } from '../tester.js'
+export const t = await createServiceTester()
+
+t.create('gets the version of @luca/flag')
+ .get('/@luca/flag.json')
+ .expectBadge({ label: 'jsr', message: isSemver })
+
+t.create('gets the version of @std/assert')
+ .get('/@std/assert.json')
+ .expectBadge({ label: 'jsr', message: isSemver })
+
+t.create('returns an error when getting a non-existent')
+ .get('/@std/this-is-a-non-existent-package-name.json')
+ .expectBadge({ label: 'jsr', message: 'package not found' })
diff --git a/services/keybase/keybase-btc.service.js b/services/keybase/keybase-btc.service.js
index f0a3196135daa..811f00dd8c56e 100644
--- a/services/keybase/keybase-btc.service.js
+++ b/services/keybase/keybase-btc.service.js
@@ -1,4 +1,5 @@
import Joi from 'joi'
+import { pathParams } from '../index.js'
import { nonNegativeInteger } from '../validators.js'
import KeybaseProfile from './keybase-profile.js'
@@ -13,14 +14,14 @@ const bitcoinAddressSchema = Joi.object({
bitcoin: Joi.array().items(
Joi.object({
address: Joi.string().required(),
- }).required()
+ }).required(),
),
})
.required()
.allow(null),
})
.required()
- .allow(null)
+ .allow(null),
)
.min(0)
.max(1),
@@ -32,25 +33,28 @@ export default class KeybaseBTC extends KeybaseProfile {
pattern: ':username',
}
- static examples = [
- {
- title: 'Keybase BTC',
- namedParams: { username: 'skyplabs' },
- staticPreview: this.render({
- address: '12ufRLmbEmgjsdGzhUUFY4pcfiQZyRPV9J',
- }),
- keywords: ['bitcoin'],
+ static openApi = {
+ '/keybase/btc/{username}': {
+ get: {
+ summary: 'Keybase BTC',
+ parameters: pathParams({
+ name: 'username',
+ example: 'skyplabs',
+ }),
+ },
},
- ]
+ }
static defaultBadgeData = {
label: 'btc',
color: 'informational',
+ namedLogo: 'keybase',
}
static render({ address }) {
return {
message: address,
+ style: 'social',
}
}
@@ -58,7 +62,7 @@ export default class KeybaseBTC extends KeybaseProfile {
async handle({ username }) {
const options = {
- qs: {
+ searchParams: {
usernames: username,
fields: 'cryptocurrency_addresses',
},
diff --git a/services/keybase/keybase-pgp.service.js b/services/keybase/keybase-pgp.service.js
index 6e049754ecfc9..c70b9e73b48ca 100644
--- a/services/keybase/keybase-pgp.service.js
+++ b/services/keybase/keybase-pgp.service.js
@@ -1,4 +1,5 @@
import Joi from 'joi'
+import { pathParams } from '../index.js'
import { nonNegativeInteger } from '../validators.js'
import KeybaseProfile from './keybase-profile.js'
@@ -16,7 +17,7 @@ const keyFingerprintSchema = Joi.object({
},
})
.required()
- .allow(null)
+ .allow(null),
)
.min(0)
.max(1),
@@ -28,22 +29,28 @@ export default class KeybasePGP extends KeybaseProfile {
pattern: ':username',
}
- static examples = [
- {
- title: 'Keybase PGP',
- namedParams: { username: 'skyplabs' },
- staticPreview: this.render({ fingerprint: '1863145FD39EE07E' }),
+ static openApi = {
+ '/keybase/pgp/{username}': {
+ get: {
+ summary: 'Keybase PGP',
+ parameters: pathParams({
+ name: 'username',
+ example: 'skyplabs',
+ }),
+ },
},
- ]
+ }
static defaultBadgeData = {
label: 'pgp',
color: 'informational',
+ namedLogo: 'keybase',
}
static render({ fingerprint }) {
return {
message: fingerprint.slice(-16).toUpperCase(),
+ style: 'social',
}
}
@@ -51,7 +58,7 @@ export default class KeybasePGP extends KeybaseProfile {
async handle({ username }) {
const options = {
- qs: {
+ searchParams: {
usernames: username,
fields: 'public_keys',
},
diff --git a/services/keybase/keybase-xlm.service.js b/services/keybase/keybase-xlm.service.js
index d7005ff6f2c29..23a2dbb870e7c 100644
--- a/services/keybase/keybase-xlm.service.js
+++ b/services/keybase/keybase-xlm.service.js
@@ -1,4 +1,5 @@
import Joi from 'joi'
+import { pathParams } from '../index.js'
import { nonNegativeInteger } from '../validators.js'
import KeybaseProfile from './keybase-profile.js'
@@ -18,7 +19,7 @@ const stellarAddressSchema = Joi.object({
}).required(),
})
.required()
- .allow(null)
+ .allow(null),
)
.min(0)
.max(1),
@@ -30,25 +31,28 @@ export default class KeybaseXLM extends KeybaseProfile {
pattern: ':username',
}
- static examples = [
- {
- title: 'Keybase XLM',
- namedParams: { username: 'skyplabs' },
- staticPreview: this.render({
- address: 'GCGH37DYONEBPGAZGCHJEZZF3J2Q3EFYZBQBE6UJL5QKTULCMEA6MXLA',
- }),
- keywords: ['stellar'],
+ static openApi = {
+ '/keybase/xlm/{username}': {
+ get: {
+ summary: 'Keybase XLM',
+ parameters: pathParams({
+ name: 'username',
+ example: 'skyplabs',
+ }),
+ },
},
- ]
+ }
static defaultBadgeData = {
label: 'xlm',
color: 'informational',
+ namedLogo: 'keybase',
}
static render({ address }) {
return {
message: address,
+ style: 'social',
}
}
@@ -56,7 +60,7 @@ export default class KeybaseXLM extends KeybaseProfile {
async handle({ username }) {
const options = {
- qs: {
+ searchParams: {
usernames: username,
fields: 'stellar',
},
diff --git a/services/keybase/keybase-zec.service.js b/services/keybase/keybase-zec.service.js
index 399a61e588b77..758774cb98a3d 100644
--- a/services/keybase/keybase-zec.service.js
+++ b/services/keybase/keybase-zec.service.js
@@ -1,4 +1,5 @@
import Joi from 'joi'
+import { pathParams } from '../index.js'
import { nonNegativeInteger } from '../validators.js'
import KeybaseProfile from './keybase-profile.js'
@@ -13,14 +14,14 @@ const zcachAddressSchema = Joi.object({
zcash: Joi.array().items(
Joi.object({
address: Joi.string().required(),
- }).required()
+ }).required(),
),
})
.required()
.allow(null),
})
.required()
- .allow(null)
+ .allow(null),
)
.min(0)
.max(1),
@@ -32,25 +33,28 @@ export default class KeybaseZEC extends KeybaseProfile {
pattern: ':username',
}
- static examples = [
- {
- title: 'Keybase ZEC',
- namedParams: { username: 'skyplabs' },
- staticPreview: this.render({
- address: 't1RJDxpBcsgqAotqhepkhLFMv2XpMfvnf1y',
- }),
- keywords: ['zcash'],
+ static openApi = {
+ '/keybase/zec/{username}': {
+ get: {
+ summary: 'Keybase ZEC',
+ parameters: pathParams({
+ name: 'username',
+ example: 'skyplabs',
+ }),
+ },
},
- ]
+ }
static defaultBadgeData = {
label: 'zec',
color: 'informational',
+ namedLogo: 'keybase',
}
static render({ address }) {
return {
message: address,
+ style: 'social',
}
}
@@ -58,7 +62,7 @@ export default class KeybaseZEC extends KeybaseProfile {
async handle({ username }) {
const options = {
- qs: {
+ searchParams: {
usernames: username,
fields: 'cryptocurrency_addresses',
},
diff --git a/services/lemmy/lemmy.service.js b/services/lemmy/lemmy.service.js
new file mode 100644
index 0000000000000..dd9d2e2f3b310
--- /dev/null
+++ b/services/lemmy/lemmy.service.js
@@ -0,0 +1,74 @@
+import Joi from 'joi'
+import { metric } from '../text-formatters.js'
+import { BaseJsonService, InvalidParameter, pathParams } from '../index.js'
+
+const lemmyCommunitySchema = Joi.object({
+ community_view: Joi.object({
+ counts: Joi.object({
+ subscribers: Joi.number().required(),
+ }).required(),
+ }).required(),
+}).required()
+
+export default class Lemmy extends BaseJsonService {
+ static category = 'social'
+
+ static route = {
+ base: 'lemmy',
+ pattern: ':community',
+ }
+
+ static openApi = {
+ '/lemmy/{community}': {
+ get: {
+ summary: 'Lemmy',
+ parameters: pathParams({
+ name: 'community',
+ example: 'asklemmy@lemmy.ml',
+ }),
+ },
+ },
+ }
+
+ static defaultBadgeData = { label: 'community', namedLogo: 'lemmy' }
+
+ static render({ community, members }) {
+ return {
+ label: `subscribe to ${community}`,
+ message: metric(members),
+ style: 'social',
+ color: 'brightgreen',
+ }
+ }
+
+ async fetch({ community }) {
+ const splitAlias = community.split('@')
+ // The community will be in the format of `community@server`
+ if (splitAlias.length !== 2) {
+ throw new InvalidParameter({
+ prettyMessage: 'invalid community',
+ })
+ }
+
+ const host = splitAlias[1]
+
+ const data = await this._requestJson({
+ url: `https://${host}/api/v3/community`,
+ schema: lemmyCommunitySchema,
+ options: {
+ searchParams: {
+ name: community,
+ },
+ },
+ httpErrors: {
+ 404: 'community not found',
+ },
+ })
+ return data.community_view.counts.subscribers
+ }
+
+ async handle({ community }) {
+ const members = await this.fetch({ community })
+ return this.constructor.render({ community, members })
+ }
+}
diff --git a/services/lemmy/lemmy.tester.js b/services/lemmy/lemmy.tester.js
new file mode 100644
index 0000000000000..b4b3ad2bfdc2d
--- /dev/null
+++ b/services/lemmy/lemmy.tester.js
@@ -0,0 +1,69 @@
+import Joi from 'joi'
+import { createServiceTester } from '../tester.js'
+export const t = await createServiceTester()
+
+t.create('get community subscribers')
+ .get('/community@DUMMY.dumb.json')
+ .intercept(nock =>
+ nock('https://DUMMY.dumb/')
+ .get('/api/v3/community?name=community%40DUMMY.dumb')
+ .reply(
+ 200,
+ JSON.stringify({
+ community_view: {
+ counts: {
+ subscribers: 42,
+ posts: 0,
+ comments: 0,
+ },
+ },
+ }),
+ ),
+ )
+ .expectBadge({
+ label: 'subscribe to community@DUMMY.dumb',
+ message: '42',
+ color: 'brightgreen',
+ })
+
+t.create('bad server or connection')
+ .get('/community@DUMMY.dumb.json')
+ .networkOff()
+ .expectBadge({
+ label: 'community',
+ message: 'inaccessible',
+ color: 'lightgrey',
+ })
+
+t.create('unknown community')
+ .get('/community@DUMMY.dumb.json')
+ .intercept(nock =>
+ nock('https://DUMMY.dumb/')
+ .get('/api/v3/community?name=community%40DUMMY.dumb')
+ .reply(
+ 404,
+ JSON.stringify({
+ error: 'couldnt_find_community',
+ }),
+ ),
+ )
+ .expectBadge({
+ label: 'community',
+ message: 'community not found',
+ color: 'red',
+ })
+
+t.create('invalid community').get('/ALIASDUMMY.dumb.json').expectBadge({
+ label: 'community',
+ message: 'invalid community',
+ color: 'red',
+})
+
+t.create('test on real lemmy room for API compliance')
+ .get('/asklemmy@lemmy.ml.json')
+ .timeout(10000)
+ .expectBadge({
+ label: 'subscribe to asklemmy@lemmy.ml',
+ message: Joi.string().regex(/^[0-9]+k$/),
+ color: 'brightgreen',
+ })
diff --git a/services/lgtm/lgtm-alerts.service.js b/services/lgtm/lgtm-alerts.service.js
deleted file mode 100644
index 814dce1d6352e..0000000000000
--- a/services/lgtm/lgtm-alerts.service.js
+++ /dev/null
@@ -1,45 +0,0 @@
-import { metric } from '../text-formatters.js'
-import LgtmBaseService from './lgtm-base.js'
-
-export default class LgtmAlerts extends LgtmBaseService {
- static route = {
- base: 'lgtm/alerts',
- pattern: this.pattern,
- }
-
- static examples = [
- {
- title: 'LGTM Alerts',
- namedParams: {
- host: 'github',
- user: 'apache',
- repo: 'cloudstack',
- },
- staticPreview: this.render({ alerts: 2488 }),
- },
- ]
-
- static defaultBadgeData = {
- label: 'lgtm alerts',
- }
-
- static getColor({ alerts }) {
- let color = 'yellow'
- if (alerts === 0) {
- color = 'brightgreen'
- }
- return color
- }
-
- static render({ alerts }) {
- return {
- message: metric(alerts),
- color: this.getColor({ alerts }),
- }
- }
-
- async handle({ host, user, repo }) {
- const { alerts } = await this.fetch({ host, user, repo })
- return this.constructor.render({ alerts })
- }
-}
diff --git a/services/lgtm/lgtm-alerts.tester.js b/services/lgtm/lgtm-alerts.tester.js
deleted file mode 100644
index ed747c5a3d419..0000000000000
--- a/services/lgtm/lgtm-alerts.tester.js
+++ /dev/null
@@ -1,68 +0,0 @@
-import Joi from 'joi'
-import { createServiceTester } from '../tester.js'
-import { data } from './lgtm-test-helpers.js'
-export const t = await createServiceTester()
-
-t.create('alerts: total alerts for a project')
- .get('/github/apache/cloudstack.json')
- .expectBadge({
- label: 'lgtm alerts',
- message: Joi.string().regex(/^[0-9kM.]+$/),
- })
-
-t.create('alerts: missing project')
- .get('/github/some-org/this-project-doesnt-exist.json')
- .expectBadge({
- label: 'lgtm alerts',
- message: 'project not found',
- })
-
-t.create('alerts: no alerts')
- .get('/github/apache/cloudstack.json')
- .intercept(nock =>
- nock('https://lgtm.com')
- .get('/api/v0.1/project/g/apache/cloudstack/details')
- .reply(200, { alerts: 0, languages: data.languages })
- )
- .expectBadge({ label: 'lgtm alerts', message: '0' })
-
-t.create('alerts: single alert')
- .get('/github/apache/cloudstack.json')
- .intercept(nock =>
- nock('https://lgtm.com')
- .get('/api/v0.1/project/g/apache/cloudstack/details')
- .reply(200, { alerts: 1, languages: data.languages })
- )
- .expectBadge({ label: 'lgtm alerts', message: '1' })
-
-t.create('alerts: multiple alerts')
- .get('/github/apache/cloudstack.json')
- .intercept(nock =>
- nock('https://lgtm.com')
- .get('/api/v0.1/project/g/apache/cloudstack/details')
- .reply(200, { alerts: 123, languages: data.languages })
- )
- .expectBadge({ label: 'lgtm alerts', message: '123' })
-
-t.create('alerts: json missing alerts')
- .get('/github/apache/cloudstack.json')
- .intercept(nock =>
- nock('https://lgtm.com')
- .get('/api/v0.1/project/g/apache/cloudstack/details')
- .reply(200, {})
- )
- .expectBadge({ label: 'lgtm alerts', message: 'invalid response data' })
-
-t.create('alerts: total alerts for a project with a github mapped host')
- .get('/github/apache/cloudstack.json')
- .expectBadge({
- label: 'lgtm alerts',
- message: Joi.string().regex(/^[0-9kM.]+$/),
- })
-
-t.create('alerts: total alerts for a project with a bitbucket mapped host')
- .get('/bitbucket/atlassian/confluence-business-blueprints.json')
- .expectBadge({
- label: 'lgtm alerts',
- message: Joi.string().regex(/^[0-9kM.]+$/),
- })
diff --git a/services/lgtm/lgtm-base.js b/services/lgtm/lgtm-base.js
deleted file mode 100644
index ed1e12091092d..0000000000000
--- a/services/lgtm/lgtm-base.js
+++ /dev/null
@@ -1,42 +0,0 @@
-import Joi from 'joi'
-import { BaseJsonService } from '../index.js'
-
-const schema = Joi.object({
- alerts: Joi.number().required(),
-
- languages: Joi.array()
- .items(
- Joi.object({
- lang: Joi.string().required(),
- grade: Joi.string(),
- })
- )
- .required(),
-}).required()
-
-const hostMappings = {
- github: 'g',
- bitbucket: 'b',
- gitlab: 'gl',
-}
-
-export default class LgtmBaseService extends BaseJsonService {
- static category = 'analysis'
-
- static defaultBadgeData = { label: 'lgtm' }
-
- static pattern = `:host(${Object.keys(hostMappings).join('|')})/:user/:repo`
-
- async fetch({ host, user, repo }) {
- const mappedHost = hostMappings[host]
- const url = `https://lgtm.com/api/v0.1/project/${mappedHost}/${user}/${repo}/details`
-
- return this._requestJson({
- schema,
- url,
- errorMessages: {
- 404: 'project not found',
- },
- })
- }
-}
diff --git a/services/lgtm/lgtm-grade.service.js b/services/lgtm/lgtm-grade.service.js
deleted file mode 100644
index 3f196949b428f..0000000000000
--- a/services/lgtm/lgtm-grade.service.js
+++ /dev/null
@@ -1,88 +0,0 @@
-import LgtmBaseService from './lgtm-base.js'
-
-export default class LgtmGrade extends LgtmBaseService {
- static route = {
- base: 'lgtm/grade',
- pattern: `:language/${this.pattern}`,
- }
-
- static examples = [
- {
- title: 'LGTM Grade',
- namedParams: {
- language: 'java',
- host: 'github',
- user: 'apache',
- repo: 'cloudstack',
- },
- staticPreview: this.render({
- language: 'java',
- data: {
- languages: [
- {
- lang: 'java',
- grade: 'C',
- },
- ],
- },
- }),
- },
- ]
-
- static getLabel({ language }) {
- const languageLabel = (() => {
- switch (language) {
- case 'cpp':
- return 'c/c++'
- case 'csharp':
- return 'c#'
- // Javascript analysis on LGTM also includes TypeScript
- case 'javascript':
- return 'js/ts'
- default:
- return language
- }
- })()
- return languageLabel
- }
-
- static getGradeAndColor({ language, data }) {
- let grade = 'no language data'
- let color = 'red'
-
- for (const languageData of data.languages) {
- if (languageData.lang === language && 'grade' in languageData) {
- // Pretty label for the language
- grade = languageData.grade
- // Pick colour based on grade
- if (languageData.grade === 'A+') {
- color = 'brightgreen'
- } else if (languageData.grade === 'A') {
- color = 'green'
- } else if (languageData.grade === 'B') {
- color = 'yellowgreen'
- } else if (languageData.grade === 'C') {
- color = 'yellow'
- } else if (languageData.grade === 'D') {
- color = 'orange'
- }
- }
- }
- return { grade, color }
- }
-
- static render({ language, data }) {
- const { grade, color } = this.getGradeAndColor({ language, data })
-
- return {
- label: `code quality: ${this.getLabel({ language })}`,
- message: grade,
- color,
- }
- }
-
- async handle({ language, host, user, repo }) {
- const data = await this.fetch({ host, user, repo })
- return this.constructor.render({ language, data })
- }
-}
diff --git a/services/lgtm/lgtm-grade.tester.js b/services/lgtm/lgtm-grade.tester.js
deleted file mode 100644
index 8e8c3ba3f8b59..0000000000000
--- a/services/lgtm/lgtm-grade.tester.js
+++ /dev/null
@@ -1,106 +0,0 @@
-import Joi from 'joi'
-import { createServiceTester } from '../tester.js'
-import { data } from './lgtm-test-helpers.js'
-export const t = await createServiceTester()
-
-t.create('grade: missing project')
- .get('/java/github/some-org/this-project-doesnt-exist.json')
- .expectBadge({
- label: 'lgtm',
- message: 'project not found',
- })
-
-t.create('grade: json missing languages')
- .get('/java/github/apache/cloudstack.json')
- .intercept(nock =>
- nock('https://lgtm.com')
- .get('/api/v0.1/project/g/apache/cloudstack/details')
- .reply(200, {})
- )
- .expectBadge({ label: 'lgtm', message: 'invalid response data' })
-
-t.create('grade: grade for a project (java)')
- .get('/java/github/apache/cloudstack.json')
- .expectBadge({
- label: 'code quality: java',
- message: Joi.string().regex(/^(?:A\+)|A|B|C|D|E$/),
- })
-
-t.create('grade: grade for missing language')
- .get('/foo/github/apache/cloudstack.json')
- .expectBadge({
- label: 'code quality: foo',
- message: 'no language data',
- })
-
-t.create('grade: grade for a project with a mapped host')
- .get('/java/github/apache/cloudstack.json')
- .expectBadge({
- label: 'code quality: java',
- message: Joi.string().regex(/^(?:A\+)|A|B|C|D|E$/),
- })
-
-// Test display of languages
-
-t.create('grade: cpp')
- .get('/cpp/github/apache/cloudstack.json')
- .intercept(nock =>
- nock('https://lgtm.com')
- .get('/api/v0.1/project/g/apache/cloudstack/details')
- .reply(200, data)
- )
- .expectBadge({ label: 'code quality: c/c++', message: 'A+' })
-
-t.create('grade: javascript')
- .get('/javascript/github/apache/cloudstack.json')
- .intercept(nock =>
- nock('https://lgtm.com')
- .get('/api/v0.1/project/g/apache/cloudstack/details')
- .reply(200, data)
- )
- .expectBadge({ label: 'code quality: js/ts', message: 'A' })
-
-t.create('grade: java')
- .get('/java/github/apache/cloudstack.json')
- .intercept(nock =>
- nock('https://lgtm.com')
- .get('/api/v0.1/project/g/apache/cloudstack/details')
- .reply(200, data)
- )
- .expectBadge({ label: 'code quality: java', message: 'B' })
-
-t.create('grade: python')
- .get('/python/github/apache/cloudstack.json')
- .intercept(nock =>
- nock('https://lgtm.com')
- .get('/api/v0.1/project/g/apache/cloudstack/details')
- .reply(200, data)
- )
- .expectBadge({ label: 'code quality: python', message: 'C' })
-
-t.create('grade: csharp')
- .get('/csharp/github/apache/cloudstack.json')
- .intercept(nock =>
- nock('https://lgtm.com')
- .get('/api/v0.1/project/g/apache/cloudstack/details')
- .reply(200, data)
- )
- .expectBadge({ label: 'code quality: c#', message: 'D' })
-
-t.create('grade: other')
- .get('/other/github/apache/cloudstack.json')
- .intercept(nock =>
- nock('https://lgtm.com')
- .get('/api/v0.1/project/g/apache/cloudstack/details')
- .reply(200, data)
- )
- .expectBadge({ label: 'code quality: other', message: 'E' })
-
-t.create('grade: foo (no grade for valid language)')
- .get('/foo/github/apache/cloudstack.json')
- .intercept(nock =>
- nock('https://lgtm.com')
- .get('/api/v0.1/project/g/apache/cloudstack/details')
- .reply(200, data)
- )
- .expectBadge({ label: 'code quality: foo', message: 'no language data' })
diff --git a/services/lgtm/lgtm-redirector.service.js b/services/lgtm/lgtm-redirector.service.js
deleted file mode 100644
index bc6bd04806fce..0000000000000
--- a/services/lgtm/lgtm-redirector.service.js
+++ /dev/null
@@ -1,26 +0,0 @@
-import { redirector } from '../index.js'
-
-const commonAttrs = {
- category: 'analysis',
- dateAdded: new Date('2019-04-30'),
-}
-
-export default [
- redirector({
- route: {
- base: 'lgtm/alerts/g',
- pattern: ':user/:repo',
- },
- transformPath: ({ user, repo }) => `/lgtm/alerts/github/${user}/${repo}`,
- ...commonAttrs,
- }),
- redirector({
- route: {
- base: 'lgtm/grade',
- pattern: ':language/g/:user/:repo',
- },
- transformPath: ({ language, user, repo }) =>
- `/lgtm/grade/${language}/github/${user}/${repo}`,
- ...commonAttrs,
- }),
-]
diff --git a/services/lgtm/lgtm-redirector.tester.js b/services/lgtm/lgtm-redirector.tester.js
deleted file mode 100644
index d6d56631813b0..0000000000000
--- a/services/lgtm/lgtm-redirector.tester.js
+++ /dev/null
@@ -1,15 +0,0 @@
-import { ServiceTester } from '../tester.js'
-
-export const t = new ServiceTester({
- id: 'LgtmRedirect',
- title: 'LgtmRedirect',
- pathPrefix: '/lgtm',
-})
-
-t.create('alerts')
- .get('/alerts/g/badges/shields.svg')
- .expectRedirect('/lgtm/alerts/github/badges/shields.svg')
-
-t.create('grade')
- .get('/grade/java/g/apache/cloudstack.svg')
- .expectRedirect('/lgtm/grade/java/github/apache/cloudstack.svg')
diff --git a/services/lgtm/lgtm-test-helpers.js b/services/lgtm/lgtm-test-helpers.js
deleted file mode 100644
index e169d4c6ddd34..0000000000000
--- a/services/lgtm/lgtm-test-helpers.js
+++ /dev/null
@@ -1,14 +0,0 @@
-const data = {
- alerts: 0,
- languages: [
- { lang: 'cpp', grade: 'A+' },
- { lang: 'javascript', grade: 'A' },
- { lang: 'java', grade: 'B' },
- { lang: 'python', grade: 'C' },
- { lang: 'csharp', grade: 'D' },
- { lang: 'other', grade: 'E' },
- { lang: 'foo' },
- ],
-}
-
-export { data }
diff --git a/services/liberapay/liberapay-base.js b/services/liberapay/liberapay-base.js
index a044844150ccd..2725a5c466aea 100644
--- a/services/liberapay/liberapay-base.js
+++ b/services/liberapay/liberapay-base.js
@@ -28,7 +28,7 @@ const schema = Joi.object({
}).required()
const isCurrencyOverTime = Joi.string().regex(
- /^([0-9]*[1-9][0-9]*(\.[0-9]+)?|[0]+\.[0-9]*[1-9][0-9]*)[ A-Za-z]{4}\/week/
+ /^([0-9]*[1-9][0-9]*(\.[0-9]+)?|[0]+\.[0-9]*[1-9][0-9]*)[ A-Za-z]{4}\/week/,
)
function renderCurrencyBadge({ label, amount, currency }) {
@@ -42,10 +42,7 @@ function renderCurrencyBadge({ label, amount, currency }) {
class LiberapayBase extends BaseJsonService {
static category = 'funding'
- static defaultBadgeData = {
- label: 'liberapay',
- namedLogo: 'liberapay',
- }
+ static defaultBadgeData = { label: 'liberapay' }
async fetch({ entity }) {
return this._requestJson({
diff --git a/services/liberapay/liberapay-gives.service.js b/services/liberapay/liberapay-gives.service.js
index 127a3a34e8509..4e6491b26c6cc 100644
--- a/services/liberapay/liberapay-gives.service.js
+++ b/services/liberapay/liberapay-gives.service.js
@@ -1,20 +1,20 @@
-import { InvalidResponse } from '../index.js'
+import { InvalidResponse, pathParams } from '../index.js'
import { renderCurrencyBadge, LiberapayBase } from './liberapay-base.js'
export default class LiberapayGives extends LiberapayBase {
static route = this.buildRoute('gives')
- static examples = [
- {
- title: 'Liberapay giving',
- namedParams: { entity: 'Changaco' },
- staticPreview: renderCurrencyBadge({
- label: 'gives',
- amount: '2.58',
- currency: 'EUR',
- }),
+ static openApi = {
+ '/liberapay/gives/{entity}': {
+ get: {
+ summary: 'Liberapay giving',
+ parameters: pathParams({
+ name: 'entity',
+ example: 'Changaco',
+ }),
+ },
},
- ]
+ }
async handle({ entity }) {
const data = await this.fetch({ entity })
diff --git a/services/liberapay/liberapay-gives.tester.js b/services/liberapay/liberapay-gives.tester.js
index daf77a689f0c3..1231fd632f88f 100644
--- a/services/liberapay/liberapay-gives.tester.js
+++ b/services/liberapay/liberapay-gives.tester.js
@@ -20,7 +20,7 @@ t.create('Giving (missing goal key)')
npatrons: 0,
giving: { amount: '3.71', currency: 'EUR' },
receiving: null,
- })
+ }),
)
.expectBadge({ label: 'gives', message: isCurrencyOverTime })
@@ -32,6 +32,6 @@ t.create('Giving (null)')
giving: null,
receiving: null,
goal: null,
- })
+ }),
)
.expectBadge({ label: 'liberapay', message: 'no public giving stats' })
diff --git a/services/liberapay/liberapay-goal.service.js b/services/liberapay/liberapay-goal.service.js
index 4c7ae71e42767..a1d9ce13aeeb2 100644
--- a/services/liberapay/liberapay-goal.service.js
+++ b/services/liberapay/liberapay-goal.service.js
@@ -1,17 +1,21 @@
import { colorScale } from '../color-formatters.js'
-import { InvalidResponse } from '../index.js'
+import { InvalidResponse, pathParams } from '../index.js'
import { LiberapayBase } from './liberapay-base.js'
export default class LiberapayGoal extends LiberapayBase {
static route = this.buildRoute('goal')
- static examples = [
- {
- title: 'Liberapay goal progress',
- namedParams: { entity: 'Changaco' },
- staticPreview: this.render({ percentAchieved: 33 }),
+ static openApi = {
+ '/liberapay/goal/{entity}': {
+ get: {
+ summary: 'Liberapay goal progress',
+ parameters: pathParams({
+ name: 'entity',
+ example: 'Changaco',
+ }),
+ },
},
- ]
+ }
static render({ percentAchieved }) {
return {
diff --git a/services/liberapay/liberapay-goal.spec.js b/services/liberapay/liberapay-goal.spec.js
index ace4d40c45576..d0f38677514ee 100644
--- a/services/liberapay/liberapay-goal.spec.js
+++ b/services/liberapay/liberapay-goal.spec.js
@@ -15,7 +15,7 @@ describe('LiberapayGoal', function () {
it('throws InvalidResponse on missing goals', function () {
expect(() =>
- LiberapayGoal.prototype.transform({ goal: null, receiving: null })
+ LiberapayGoal.prototype.transform({ goal: null, receiving: null }),
)
.to.throw(InvalidResponse)
.with.property('prettyMessage', 'no public goals')
diff --git a/services/liberapay/liberapay-goal.tester.js b/services/liberapay/liberapay-goal.tester.js
index 518a84b004107..e8ae6433b1dc1 100644
--- a/services/liberapay/liberapay-goal.tester.js
+++ b/services/liberapay/liberapay-goal.tester.js
@@ -14,7 +14,7 @@ t.create('Goal (missing goal key)')
npatrons: 0,
giving: null,
receiving: null,
- })
+ }),
)
.expectBadge({ label: 'liberapay', message: 'no public goals' })
diff --git a/services/liberapay/liberapay-patrons.service.js b/services/liberapay/liberapay-patrons.service.js
index dbcf4d8639637..b21d265306069 100644
--- a/services/liberapay/liberapay-patrons.service.js
+++ b/services/liberapay/liberapay-patrons.service.js
@@ -1,3 +1,4 @@
+import { pathParams } from '../index.js'
import { metric } from '../text-formatters.js'
import { colorScale } from '../color-formatters.js'
import { LiberapayBase } from './liberapay-base.js'
@@ -5,13 +6,17 @@ import { LiberapayBase } from './liberapay-base.js'
export default class LiberapayPatrons extends LiberapayBase {
static route = this.buildRoute('patrons')
- static examples = [
- {
- title: 'Liberapay patrons',
- namedParams: { entity: 'Changaco' },
- staticPreview: this.render({ patrons: 10 }),
+ static openApi = {
+ '/liberapay/patrons/{entity}': {
+ get: {
+ summary: 'Liberapay patrons',
+ parameters: pathParams({
+ name: 'entity',
+ example: 'Changaco',
+ }),
+ },
},
- ]
+ }
static render({ patrons }) {
return {
diff --git a/services/liberapay/liberapay-receives.service.js b/services/liberapay/liberapay-receives.service.js
index 7d828f90bd684..e44572c7f9b1f 100644
--- a/services/liberapay/liberapay-receives.service.js
+++ b/services/liberapay/liberapay-receives.service.js
@@ -1,20 +1,20 @@
-import { InvalidResponse } from '../index.js'
+import { InvalidResponse, pathParams } from '../index.js'
import { renderCurrencyBadge, LiberapayBase } from './liberapay-base.js'
export default class LiberapayReceives extends LiberapayBase {
static route = this.buildRoute('receives')
- static examples = [
- {
- title: 'Liberapay receiving',
- namedParams: { entity: 'Changaco' },
- staticPreview: renderCurrencyBadge({
- label: 'receives',
- amount: '98.32',
- currency: 'EUR',
- }),
+ static openApi = {
+ '/liberapay/receives/{entity}': {
+ get: {
+ summary: 'Liberapay receiving',
+ parameters: pathParams({
+ name: 'entity',
+ example: 'Changaco',
+ }),
+ },
},
- ]
+ }
async handle({ entity }) {
const data = await this.fetch({ entity })
diff --git a/services/liberapay/liberapay-receives.tester.js b/services/liberapay/liberapay-receives.tester.js
index 17988abc7c7a6..765f57e6a1c3b 100644
--- a/services/liberapay/liberapay-receives.tester.js
+++ b/services/liberapay/liberapay-receives.tester.js
@@ -19,6 +19,6 @@ t.create('Receiving (null)')
giving: null,
receiving: null,
goal: null,
- })
+ }),
)
.expectBadge({ label: 'liberapay', message: 'no public receiving stats' })
diff --git a/services/librariesio/librariesio-api-provider.js b/services/librariesio/librariesio-api-provider.js
new file mode 100644
index 0000000000000..523d8b0e6b8c6
--- /dev/null
+++ b/services/librariesio/librariesio-api-provider.js
@@ -0,0 +1,111 @@
+import { ImproperlyConfigured } from '../index.js'
+import log from '../../core/server/log.js'
+import { TokenPool } from '../../core/token-pooling/token-pool.js'
+import { getUserAgent } from '../../core/base-service/got-config.js'
+
+const userAgent = getUserAgent()
+
+// Provides an interface to the Libraries.io API.
+export default class LibrariesIoApiProvider {
+ constructor({ baseUrl, tokens = [], defaultRateLimit = 60 }) {
+ const withPooling = tokens.length > 1
+ Object.assign(this, {
+ baseUrl,
+ withPooling,
+ globalToken: tokens[0],
+ defaultRateLimit,
+ })
+
+ if (this.withPooling) {
+ this.standardTokens = new TokenPool({ batchSize: 10 })
+ tokens.forEach(t => this.standardTokens.add(t, {}, defaultRateLimit))
+ }
+ }
+
+ getRateLimitFromHeaders({ headers, token }) {
+ // The Libraries.io API does not consistently provide the rate limiting headers.
+ // In some cases (e.g. package/version not founds) it won't include any of these headers,
+ // and the `retry-after` header is only provided _after_ the rate limit has been exceeded
+ // and requests are throttled.
+ //
+ // https://github.com/librariesio/libraries.io/issues/2860
+
+ // The standard rate limit is 60/requests/minute, so fallback to that default
+ // if the header isn't present.
+ // https://libraries.io/api#rate-limit
+ const rateLimit = +headers['x-ratelimit-limit'] || this.defaultRateLimit
+
+ // If the remaining header is missing, then we're in the 404 response phase, and simply
+ // subtract one from the `usesRemaining` count on the token, since the 404 responses do count
+ // against the rate limits.
+ const totalUsesRemaining =
+ +headers['x-ratelimit-remaining'] || token.decrementedUsesRemaining
+
+ // The `retry-after` header is only present post-rate limit excess, and contains the value in
+ // seconds the client needs to wait before the limits are reset.
+ // Our token pools internally use UTC-based milliseconds, so we perform the conversion
+ // if the header is present to ensure the token pool has the correct value.
+ // If the header is absent, we just use the current timestamp to
+ // advance the value to _something_
+ const retryAfter = headers['retry-after']
+ const nextReset =
+ ((Date.now() + (retryAfter ? +retryAfter * 1000 : 0)) / 1000) >>> 0
+
+ return {
+ rateLimit,
+ totalUsesRemaining,
+ nextReset,
+ }
+ }
+
+ updateToken({ token, res }) {
+ const { totalUsesRemaining, nextReset } = this.getRateLimitFromHeaders({
+ headers: res.headers,
+ token,
+ })
+ token.update(totalUsesRemaining, nextReset)
+ }
+
+ async fetch(requestFetcher, url, options = {}) {
+ const { baseUrl } = this
+
+ let token
+ let tokenString
+ if (this.withPooling) {
+ try {
+ token = this.standardTokens.next()
+ } catch (e) {
+ log.error(e)
+ throw new ImproperlyConfigured({
+ prettyMessage: 'Unable to select next Libraries.io token from pool',
+ })
+ }
+ tokenString = token.id
+ } else {
+ tokenString = this.globalToken
+ }
+
+ const mergedOptions = {
+ ...options,
+ ...{
+ headers: {
+ 'User-Agent': userAgent,
+ ...options.headers,
+ },
+ searchParams: {
+ api_key: tokenString,
+ ...options.searchParams,
+ },
+ },
+ }
+ 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 })
+ }
+ }
+ return response
+ }
+}
diff --git a/services/librariesio/librariesio-api-provider.spec.js b/services/librariesio/librariesio-api-provider.spec.js
new file mode 100644
index 0000000000000..b2604d1c7f6eb
--- /dev/null
+++ b/services/librariesio/librariesio-api-provider.spec.js
@@ -0,0 +1,134 @@
+import { expect } from 'chai'
+import sinon from 'sinon'
+import { ImproperlyConfigured } from '../index.js'
+import log from '../../core/server/log.js'
+import LibrariesIoApiProvider from './librariesio-api-provider.js'
+
+describe('LibrariesIoApiProvider', function () {
+ const baseUrl = 'https://libraries.io/api'
+ const tokens = ['abc123', 'def456']
+ const rateLimit = 60
+ const remaining = 57
+ const nextReset = 60
+ const mockResponse = {
+ res: {
+ statusCode: 200,
+ headers: {
+ 'x-ratelimit-limit': `${rateLimit}`,
+ 'x-ratelimit-remaining': `${remaining}`,
+ 'retry-after': `${nextReset}`,
+ },
+ },
+ buffer: {},
+ }
+
+ let token, provider, nextTokenStub
+ beforeEach(function () {
+ provider = new LibrariesIoApiProvider({ baseUrl, tokens })
+
+ token = {
+ update: sinon.spy(),
+ invalidate: sinon.spy(),
+ decrementedUsesRemaining: remaining - 1,
+ }
+ nextTokenStub = sinon.stub(provider.standardTokens, 'next').returns(token)
+ })
+
+ afterEach(function () {
+ sinon.restore()
+ })
+
+ context('a core API request', function () {
+ const mockResponse = { res: { headers: {} } }
+ const mockRequest = sinon.stub().resolves(mockResponse)
+ it('should obtain an appropriate token', async function () {
+ await provider.fetch(mockRequest, '/npm/badge-maker')
+ expect(provider.standardTokens.next).to.have.been.calledOnce
+ })
+
+ it('should throw an error when the next token fails', async function () {
+ nextTokenStub.throws(Error)
+ sinon.stub(log, 'error')
+ try {
+ await provider.fetch(mockRequest, '/npm/badge-maker')
+ expect.fail('Expected to throw')
+ } catch (e) {
+ expect(e).to.be.an.instanceof(ImproperlyConfigured)
+ expect(e.prettyMessage).to.equal(
+ 'Unable to select next Libraries.io token from pool',
+ )
+ }
+ })
+ })
+
+ context('a valid API response', function () {
+ const mockRequest = sinon.stub().resolves(mockResponse)
+ const tickTime = 123456789
+
+ beforeEach(function () {
+ const clock = sinon.useFakeTimers()
+ clock.tick(tickTime)
+ })
+
+ it('should return the response', async function () {
+ const res = await provider.fetch(mockRequest, '/npm/badge-maker')
+ expect(Object.is(res, mockResponse)).to.be.true
+ })
+
+ it('should update the token with the expected values when headers are present', async function () {
+ await provider.fetch(mockRequest, '/npm/badge-maker')
+
+ expect(token.update).to.have.been.calledWith(
+ remaining,
+ ((nextReset * 1000 + tickTime) / 1000) >>> 0,
+ )
+ expect(token.invalidate).not.to.have.been.called
+ })
+
+ it('should update the token with the expected values when throttling not applied', async function () {
+ const response = {
+ res: {
+ statusCode: 200,
+ headers: {
+ 'x-ratelimit-limit': rateLimit,
+ 'x-ratelimit-remaining': remaining,
+ },
+ },
+ }
+ const mockRequest = sinon.stub().resolves(response)
+ await provider.fetch(mockRequest, '/npm/badge-maker')
+
+ expect(token.update).to.have.been.calledWith(
+ remaining,
+ (tickTime / 1000) >>> 0,
+ )
+ expect(token.invalidate).not.to.have.been.called
+ })
+
+ it('should update the token with the expected values in 404 case', async function () {
+ const response = {
+ res: { statusCode: 200, headers: {} },
+ }
+ const mockRequest = sinon.stub().resolves(response)
+ await provider.fetch(mockRequest, '/npm/badge-maker')
+
+ expect(token.update).to.have.been.calledWith(
+ remaining - 1,
+ (tickTime / 1000) >>> 0,
+ )
+ expect(token.invalidate).not.to.have.been.called
+ })
+ })
+
+ context('a connection error', function () {
+ const msg = 'connection timeout'
+ const requestError = new Error(msg)
+ const mockRequest = sinon.stub().rejects(requestError)
+
+ it('should throw an exception', async function () {
+ return expect(
+ provider.fetch(mockRequest, '/npm/badge-maker', {}),
+ ).to.be.rejectedWith(Error, 'connection timeout')
+ })
+ })
+})
diff --git a/services/librariesio/librariesio-base.js b/services/librariesio/librariesio-base.js
new file mode 100644
index 0000000000000..8d221c0a35051
--- /dev/null
+++ b/services/librariesio/librariesio-base.js
@@ -0,0 +1,32 @@
+import Joi from 'joi'
+import { anyInteger, nonNegativeInteger } from '../validators.js'
+import { BaseJsonService } from '../index.js'
+
+// API doc: https://libraries.io/api#project
+const projectSchema = Joi.object({
+ platform: Joi.string().required(),
+ dependents_count: nonNegativeInteger,
+ dependent_repos_count: nonNegativeInteger,
+ rank: anyInteger,
+}).required()
+
+export default class LibrariesIoBase extends BaseJsonService {
+ constructor(context, config) {
+ super(context, config)
+ const { requestFetcher, librariesIoApiProvider } = context
+ this._requestFetcher = librariesIoApiProvider.fetch.bind(
+ librariesIoApiProvider,
+ requestFetcher,
+ )
+ }
+
+ async fetchProject({ platform, scope, packageName }) {
+ return this._requestJson({
+ schema: projectSchema,
+ url: `/${encodeURIComponent(platform)}/${
+ scope ? encodeURIComponent(`${scope}/`) : ''
+ }${encodeURIComponent(packageName)}`,
+ httpErrors: { 404: 'package not found' },
+ })
+ }
+}
diff --git a/services/librariesio/librariesio-common.js b/services/librariesio/librariesio-common.js
deleted file mode 100644
index 974060a688809..0000000000000
--- a/services/librariesio/librariesio-common.js
+++ /dev/null
@@ -1,22 +0,0 @@
-import Joi from 'joi'
-import { nonNegativeInteger, anyInteger } from '../validators.js'
-
-// API doc: https://libraries.io/api#project
-const projectSchema = Joi.object({
- platform: Joi.string().required(),
- dependents_count: nonNegativeInteger,
- dependent_repos_count: nonNegativeInteger,
- rank: anyInteger,
-}).required()
-
-async function fetchProject(serviceInstance, { platform, scope, packageName }) {
- return serviceInstance._requestJson({
- schema: projectSchema,
- url: `https://libraries.io/api/${encodeURIComponent(platform)}/${
- scope ? encodeURIComponent(`${scope}/`) : ''
- }${encodeURIComponent(packageName)}`,
- errorMessages: { 404: 'package not found' },
- })
-}
-
-export { fetchProject }
diff --git a/services/librariesio/librariesio-constellation.js b/services/librariesio/librariesio-constellation.js
new file mode 100644
index 0000000000000..0f991f54540ff
--- /dev/null
+++ b/services/librariesio/librariesio-constellation.js
@@ -0,0 +1,13 @@
+import LibrariesIoApiProvider from './librariesio-api-provider.js'
+
+// Convenience class with all the stuff related to the Libraries.io API and its
+// authorization tokens, to simplify server initialization.
+export default class LibrariesIoConstellation {
+ constructor({ private: { librariesio_tokens: tokens } }) {
+ this.apiProvider = new LibrariesIoApiProvider({
+ baseUrl: 'https://libraries.io/api',
+ tokens,
+ defaultRateLimit: 60,
+ })
+ }
+}
diff --git a/services/librariesio/librariesio-dependencies.service.js b/services/librariesio/librariesio-dependencies.service.js
index 05df0e6ae6420..7be34ff51ad23 100644
--- a/services/librariesio/librariesio-dependencies.service.js
+++ b/services/librariesio/librariesio-dependencies.service.js
@@ -1,22 +1,29 @@
import Joi from 'joi'
-import { BaseJsonService } from '../index.js'
+import { nonNegativeInteger } from '../validators.js'
+import { pathParams } from '../index.js'
+import LibrariesIoBase from './librariesio-base.js'
import {
transform,
renderDependenciesBadge,
} from './librariesio-dependencies-helpers.js'
-const schema = Joi.object({
+const projectDependenciesSchema = Joi.object({
dependencies: Joi.array()
.items(
Joi.object({
deprecated: Joi.boolean().allow(null).required(),
outdated: Joi.boolean().allow(null).required(),
- })
+ }),
)
.default([]),
}).required()
-class LibrariesIoProjectDependencies extends BaseJsonService {
+const repoDependenciesSchema = Joi.object({
+ deprecated_count: nonNegativeInteger,
+ outdated_count: nonNegativeInteger,
+}).required()
+
+class LibrariesIoProjectDependencies extends LibrariesIoBase {
static category = 'dependencies'
static route = {
@@ -24,80 +31,47 @@ class LibrariesIoProjectDependencies extends BaseJsonService {
pattern: ':platform/:scope(@[^/]+)?/:packageName/:version?',
}
- static examples = [
- {
- title: 'Libraries.io dependency status for latest release',
- pattern: ':platform/:packageName',
- namedParams: {
- platform: 'hex',
- packageName: 'phoenix',
- },
- staticPreview: renderDependenciesBadge({
- deprecatedCount: 0,
- outdatedCount: 1,
- }),
- },
- {
- title: 'Libraries.io dependency status for specific release',
- pattern: ':platform/:packageName/:version',
- namedParams: {
- platform: 'hex',
- packageName: 'phoenix',
- version: '1.0.3',
- },
- staticPreview: renderDependenciesBadge({
- deprecatedCount: 0,
- outdatedCount: 3,
- }),
- },
- {
- title:
- 'Libraries.io dependency status for latest release, scoped npm package',
- pattern: ':platform/:scope/:packageName',
- namedParams: {
- platform: 'npm',
- scope: '@babel',
- packageName: 'core',
+ static openApi = {
+ '/librariesio/release/{platform}/{packageName}': {
+ get: {
+ summary: 'Libraries.io dependency status for latest release',
+ parameters: pathParams(
+ { name: 'platform', example: 'npm' },
+ { name: 'packageName', example: '@babel/core' },
+ ),
},
- staticPreview: renderDependenciesBadge({
- deprecatedCount: 8,
- outdatedCount: 0,
- }),
},
- {
- title:
- 'Libraries.io dependency status for specific release, scoped npm package',
- pattern: ':platform/:scope/:packageName/:version',
- namedParams: {
- platform: 'npm',
- scope: '@babel',
- packageName: 'core',
- version: '7.0.0',
+ '/librariesio/release/{platform}/{packageName}/{version}': {
+ get: {
+ summary: 'Libraries.io dependency status for specific release',
+ parameters: pathParams(
+ { name: 'platform', example: 'npm' },
+ { name: 'packageName', example: '@babel/core' },
+ { name: 'version', example: '7.0.0' },
+ ),
},
- staticPreview: renderDependenciesBadge({
- deprecatedCount: 12,
- outdatedCount: 0,
- }),
},
- ]
+ }
+
+ static _cacheLength = 900
async handle({ platform, scope, packageName, version = 'latest' }) {
- const url = `https://libraries.io/api/${encodeURIComponent(platform)}/${
+ const url = `/${encodeURIComponent(platform)}/${
scope ? encodeURIComponent(`${scope}/`) : ''
}${encodeURIComponent(packageName)}/${encodeURIComponent(
- version
+ version,
)}/dependencies`
const json = await this._requestJson({
url,
- schema,
- errorMessages: { 404: 'package or version not found' },
+ schema: projectDependenciesSchema,
+ httpErrors: { 404: 'package or version not found' },
})
const { deprecatedCount, outdatedCount } = transform(json)
return renderDependenciesBadge({ deprecatedCount, outdatedCount })
}
}
-class LibrariesIoRepoDependencies extends BaseJsonService {
+class LibrariesIoRepoDependencies extends LibrariesIoBase {
static category = 'dependencies'
static route = {
@@ -105,29 +79,33 @@ class LibrariesIoRepoDependencies extends BaseJsonService {
pattern: ':user/:repo',
}
- static examples = [
- {
- title: 'Libraries.io dependency status for GitHub repo',
- namedParams: {
- user: 'phoenixframework',
- repo: 'phoenix',
+ static openApi = {
+ '/librariesio/github/{user}/{repo}': {
+ get: {
+ summary: 'Libraries.io dependency status for GitHub repo',
+ parameters: pathParams(
+ { name: 'user', example: 'phoenixframework' },
+ { name: 'repo', example: 'phoenix' },
+ ),
},
- staticPreview: renderDependenciesBadge({ outdatedCount: 325 }),
},
- ]
+ }
+
+ static _cacheLength = 900
async handle({ user, repo }) {
- const url = `https://libraries.io/api/github/${encodeURIComponent(
- user
- )}/${encodeURIComponent(repo)}/dependencies`
- const json = await this._requestJson({
- url,
- schema,
- errorMessages: {
- 404: 'repo not found',
- },
- })
- const { deprecatedCount, outdatedCount } = transform(json)
+ const url = `/github/${encodeURIComponent(user)}/${encodeURIComponent(
+ repo,
+ )}/shields_dependencies`
+
+ const { deprecated_count: deprecatedCount, outdated_count: outdatedCount } =
+ await this._requestJson({
+ url,
+ schema: repoDependenciesSchema,
+ httpErrors: {
+ 404: 'repo not found',
+ },
+ })
return renderDependenciesBadge({ deprecatedCount, outdatedCount })
}
}
diff --git a/services/librariesio/librariesio-dependent-repos.service.js b/services/librariesio/librariesio-dependent-repos.service.js
index 0f8217c11bc73..e8b4c1765983b 100644
--- a/services/librariesio/librariesio-dependent-repos.service.js
+++ b/services/librariesio/librariesio-dependent-repos.service.js
@@ -1,9 +1,9 @@
+import { pathParams } from '../index.js'
import { metric } from '../text-formatters.js'
-import { BaseJsonService } from '../index.js'
-import { fetchProject } from './librariesio-common.js'
+import LibrariesIoBase from './librariesio-base.js'
// https://libraries.io/api#project-dependent-repositories
-export default class LibrariesIoDependentRepos extends BaseJsonService {
+export default class LibrariesIoDependentRepos extends LibrariesIoBase {
static category = 'other'
static route = {
@@ -11,27 +11,44 @@ export default class LibrariesIoDependentRepos extends BaseJsonService {
pattern: ':platform/:scope(@[^/]+)?/:packageName',
}
- static examples = [
- {
- title: 'Dependent repos (via libraries.io)',
- pattern: ':platform/:packageName',
- namedParams: {
- platform: 'npm',
- packageName: 'got',
+ static openApi = {
+ '/librariesio/dependent-repos/{platform}/{packageName}': {
+ get: {
+ summary: 'Dependent repos (via libraries.io)',
+ parameters: pathParams(
+ {
+ name: 'platform',
+ example: 'npm',
+ },
+ {
+ name: 'packageName',
+ example: 'got',
+ },
+ ),
},
- staticPreview: this.render({ dependentReposCount: 84000 }),
},
- {
- title: 'Dependent repos (via libraries.io), scoped npm package',
- pattern: ':platform/:scope/:packageName',
- namedParams: {
- platform: 'npm',
- scope: '@babel',
- packageName: 'core',
+ '/librariesio/dependent-repos/{platform}/{scope}/{packageName}': {
+ get: {
+ summary: 'Dependent repos (via libraries.io), scoped npm package',
+ parameters: pathParams(
+ {
+ name: 'platform',
+ example: 'npm',
+ },
+ {
+ name: 'scope',
+ example: '@babel',
+ },
+ {
+ name: 'packageName',
+ example: 'core',
+ },
+ ),
},
- staticPreview: this.render({ dependentReposCount: 50 }),
},
- ]
+ }
+
+ static _cacheLength = 900
static defaultBadgeData = {
label: 'dependent repos',
@@ -45,14 +62,12 @@ export default class LibrariesIoDependentRepos extends BaseJsonService {
}
async handle({ platform, scope, packageName }) {
- const { dependent_repos_count: dependentReposCount } = await fetchProject(
- this,
- {
+ const { dependent_repos_count: dependentReposCount } =
+ await this.fetchProject({
platform,
scope,
packageName,
- }
- )
+ })
return this.constructor.render({ dependentReposCount })
}
}
diff --git a/services/librariesio/librariesio-dependents.service.js b/services/librariesio/librariesio-dependents.service.js
index ceaf4f5b27817..0dfaed871d1f2 100644
--- a/services/librariesio/librariesio-dependents.service.js
+++ b/services/librariesio/librariesio-dependents.service.js
@@ -1,9 +1,9 @@
+import { pathParams } from '../index.js'
import { metric } from '../text-formatters.js'
-import { BaseJsonService } from '../index.js'
-import { fetchProject } from './librariesio-common.js'
+import LibrariesIoBase from './librariesio-base.js'
// https://libraries.io/api#project-dependents
-export default class LibrariesIoDependents extends BaseJsonService {
+export default class LibrariesIoDependents extends LibrariesIoBase {
static category = 'other'
static route = {
@@ -11,27 +11,44 @@ export default class LibrariesIoDependents extends BaseJsonService {
pattern: ':platform/:scope(@[^/]+)?/:packageName',
}
- static examples = [
- {
- title: 'Dependents (via libraries.io)',
- pattern: ':platform/:packageName',
- namedParams: {
- platform: 'npm',
- packageName: 'got',
+ static openApi = {
+ '/librariesio/dependents/{platform}/{packageName}': {
+ get: {
+ summary: 'Dependents (via libraries.io)',
+ parameters: pathParams(
+ {
+ name: 'platform',
+ example: 'npm',
+ },
+ {
+ name: 'packageName',
+ example: 'got',
+ },
+ ),
},
- staticPreview: this.render({ dependentCount: 2000 }),
},
- {
- title: 'Dependents (via libraries.io), scoped npm package',
- pattern: ':platform/:scope/:packageName',
- namedParams: {
- platform: 'npm',
- scope: '@babel',
- packageName: 'core',
+ '/librariesio/dependents/{platform}/{scope}/{packageName}': {
+ get: {
+ summary: 'Dependents (via libraries.io), scoped npm package',
+ parameters: pathParams(
+ {
+ name: 'platform',
+ example: 'npm',
+ },
+ {
+ name: 'scope',
+ example: '@babel',
+ },
+ {
+ name: 'packageName',
+ example: 'core',
+ },
+ ),
},
- staticPreview: this.render({ dependentCount: 94 }),
},
- ]
+ }
+
+ static _cacheLength = 900
static defaultBadgeData = {
label: 'dependents',
@@ -45,7 +62,7 @@ export default class LibrariesIoDependents extends BaseJsonService {
}
async handle({ platform, scope, packageName }) {
- const { dependents_count: dependentCount } = await fetchProject(this, {
+ const { dependents_count: dependentCount } = await this.fetchProject({
platform,
scope,
packageName,
diff --git a/services/librariesio/librariesio-sourcerank.service.js b/services/librariesio/librariesio-sourcerank.service.js
index 351a668eeff7f..dacc98b75e6e1 100644
--- a/services/librariesio/librariesio-sourcerank.service.js
+++ b/services/librariesio/librariesio-sourcerank.service.js
@@ -1,10 +1,10 @@
+import { pathParams } from '../index.js'
import { colorScale } from '../color-formatters.js'
-import { BaseJsonService } from '../index.js'
-import { fetchProject } from './librariesio-common.js'
+import LibrariesIoBase from './librariesio-base.js'
const sourceRankColor = colorScale([10, 15, 20, 25, 30])
-export default class LibrariesIoSourcerank extends BaseJsonService {
+export default class LibrariesIoSourcerank extends LibrariesIoBase {
static category = 'rating'
static route = {
@@ -12,27 +12,42 @@ export default class LibrariesIoSourcerank extends BaseJsonService {
pattern: ':platform/:scope(@[^/]+)?/:packageName',
}
- static examples = [
- {
- title: 'Libraries.io SourceRank',
- pattern: ':platform/:packageName',
- namedParams: {
- platform: 'npm',
- packageName: 'got',
+ static openApi = {
+ '/librariesio/sourcerank/{platform}/{packageName}': {
+ get: {
+ summary: 'Libraries.io SourceRank',
+ parameters: pathParams(
+ {
+ name: 'platform',
+ example: 'npm',
+ },
+ {
+ name: 'packageName',
+ example: 'got',
+ },
+ ),
},
- staticPreview: this.render({ rank: 25 }),
},
- {
- title: 'Libraries.io SourceRank, scoped npm package',
- pattern: ':platform/:scope/:packageName',
- namedParams: {
- platform: 'npm',
- scope: '@babel',
- packageName: 'core',
+ '/librariesio/sourcerank/{platform}/{scope}/{packageName}': {
+ get: {
+ summary: 'Libraries.io SourceRank, scoped npm package',
+ parameters: pathParams(
+ {
+ name: 'platform',
+ example: 'npm',
+ },
+ {
+ name: 'scope',
+ example: '@babel',
+ },
+ {
+ name: 'packageName',
+ example: 'core',
+ },
+ ),
},
- staticPreview: this.render({ rank: 3 }),
},
- ]
+ }
static defaultBadgeData = {
label: 'sourcerank',
@@ -46,7 +61,7 @@ export default class LibrariesIoSourcerank extends BaseJsonService {
}
async handle({ platform, scope, packageName }) {
- const { rank } = await fetchProject(this, {
+ const { rank } = await this.fetchProject({
platform,
scope,
packageName,
diff --git a/services/librariesio/librariesio-sourcerank.spec.js b/services/librariesio/librariesio-sourcerank.spec.js
new file mode 100644
index 0000000000000..6e48f675dce84
--- /dev/null
+++ b/services/librariesio/librariesio-sourcerank.spec.js
@@ -0,0 +1,52 @@
+import { expect } from 'chai'
+import nock from 'nock'
+import { cleanUpNockAfterEach, defaultContext } from '../test-helpers.js'
+import LibrariesIoSourcerank from './librariesio-sourcerank.service.js'
+import LibrariesIoApiProvider from './librariesio-api-provider.js'
+
+describe('LibrariesIoSourcerank', function () {
+ cleanUpNockAfterEach()
+ const fakeApiKey = 'fakeness'
+ const response = {
+ platform: 'npm',
+ dependents_count: 150,
+ dependent_repos_count: 191,
+ rank: 100,
+ }
+ 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(`/npm/badge-maker?api_key=${fakeApiKey}`)
+ .reply(200, response)
+
+ expect(
+ await LibrariesIoSourcerank.invoke(
+ {
+ ...defaultContext,
+ librariesIoApiProvider,
+ },
+ config,
+ {
+ platform: 'npm',
+ packageName: 'badge-maker',
+ },
+ ),
+ ).to.deep.equal({
+ message: 100,
+ color: 'brightgreen',
+ })
+
+ scope.done()
+ })
+})
diff --git a/services/licenses.js b/services/licenses.js
index 69ff20699a460..bef445cc6865e 100644
--- a/services/licenses.js
+++ b/services/licenses.js
@@ -1,3 +1,9 @@
+/**
+ * Common functions and utilities for tasks related to license badges.
+ *
+ * @module
+ */
+
import toArray from '../core/base-service/to-array.js'
const licenseTypes = {
@@ -81,13 +87,18 @@ const licenseTypes = {
},
// public domain licenses do not require 'License and copyright notice' (https://choosealicense.com/appendix/#include-copyright)
'public-domain': {
- spdxLicenseIds: ['CC0-1.0', 'Unlicense', 'WTFPL'],
+ spdxLicenseIds: ['CC0-1.0', 'Unlicense', 'WTFPL', '0BSD'],
aliases: ['CC0'],
color: '7cd958',
priority: '3',
},
}
+/**
+ * Mapping of licenses to their corresponding color and priority.
+ *
+ * @type {object}
+ */
const licenseToColorMap = {}
Object.keys(licenseTypes).forEach(licenseType => {
const { spdxLicenseIds, aliases, color, priority } = licenseTypes[licenseType]
@@ -99,6 +110,12 @@ Object.keys(licenseTypes).forEach(licenseType => {
})
})
+/**
+ * Maps the license to its corresponding color and priority and sorts the list of mapped licenses by priority.
+ *
+ * @param {string | string[]} licenses License or list of licenses
+ * @returns {string} Color corresponding to the license or the list of licenses
+ */
function licenseToColor(licenses) {
if (!Array.isArray(licenses)) {
licenses = [licenses]
@@ -113,6 +130,17 @@ function licenseToColor(licenses) {
return color
}
+/**
+ * Handles rendering concerns of license badges.
+ * Determines the message of the badge by joining the licenses in a comma-separated format.
+ * Sets the badge color to the provided value, if not provided then the color is used from licenseToColorMap.
+ *
+ * @param {object} attrs Refer to individual attributes
+ * @param {string} [attrs.license] License to render, required if badge contains only one license
+ * @param {string[]} [attrs.licenses] List of licenses to render, required if badge contains multiple licenses
+ * @param {string} [attrs.color] If provided then the badge will use this color value
+ * @returns {object} Badge with message and color properties
+ */
function renderLicenseBadge({ license, licenses, color }) {
if (licenses === undefined) {
licenses = toArray(license)
diff --git a/services/licenses.spec.js b/services/licenses.spec.js
index 982b06cccb9ca..a82d10f5be8d7 100644
--- a/services/licenses.spec.js
+++ b/services/licenses.spec.js
@@ -5,7 +5,7 @@ describe('license helpers', function () {
test(licenseToColor, () => {
forCases([given('MIT'), given('BSD')]).expect('green')
forCases([given('MPL-2.0'), given('MPL')]).expect('orange')
- forCases([given('Unlicense'), given('CC0')]).expect('7cd958')
+ forCases([given('Unlicense'), given('CC0'), given('0BSD')]).expect('7cd958')
forCases([given('unknown-license'), given(null)]).expect('lightgrey')
given(['CC0-1.0', 'MPL-2.0']).expect('7cd958')
diff --git a/services/localizely/localizely.service.js b/services/localizely/localizely.service.js
index 1e8ac60608329..b5e62b5fb564d 100644
--- a/services/localizely/localizely.service.js
+++ b/services/localizely/localizely.service.js
@@ -1,32 +1,24 @@
import Joi from 'joi'
-import { BaseJsonService, InvalidResponse } from '../index.js'
+import {
+ BaseJsonService,
+ InvalidResponse,
+ queryParam,
+ pathParam,
+} from '../index.js'
import { coveragePercentage } from '../color-formatters.js'
-const keywords = [
- 'l10n',
- 'i18n',
- 'localization',
- 'internationalization',
- 'translation',
- 'translations',
-]
-
-const documentation = `
-
- The read-only API token from the Localizely account is required to fetch necessary data.
-
-
-
- Note: Do not use the default API token as it grants full read-write permissions to your projects. You will expose your project and allow malicious users to modify the translations at will.
-
- Instead, create a new one with only read permission.
-
-
-
- You can find more details regarding API tokens under My profile page.
-
-
- `
+const description = `
+Localizely is a management system for translation, localization, and internationalization of your projects.
+
+The read-only API token from the Localizely account is required to fetch necessary data.
+
+
+ Note: Do not use the default API token as it grants full read-write permissions to your projects. You will expose your project and allow malicious users to modify the translations at will.
+ Instead, create a new one with only read permission.
+
+
+You can find more details regarding API tokens under My profile page.
+`
const schema = Joi.object({
strings: Joi.number().required(),
@@ -39,7 +31,7 @@ const schema = Joi.object({
strings: Joi.number().required(),
reviewed: Joi.number().required(),
reviewedProgress: Joi.number().required(),
- })
+ }),
)
.required(),
}).required()
@@ -58,40 +50,58 @@ export default class Localizely extends BaseJsonService {
queryParamSchema,
}
- static examples = [
- {
- title: 'Localizely overall progress',
- keywords,
- documentation,
- namedParams: {
- projectId: '5cc34208-0418-40b1-8353-acc70c95f802',
- branch: 'main',
+ static openApi = {
+ '/localizely/progress/{projectId}': {
+ get: {
+ summary: 'Localizely progress',
+ description,
+ parameters: [
+ pathParam({
+ name: 'projectId',
+ example: '5cc34208-0418-40b1-8353-acc70c95f802',
+ }),
+ queryParam({
+ name: 'token',
+ example:
+ '0f4d5e31a44f48dcbab966c52cfb0a67c5f1982186c14b85ab389a031dbc225a',
+ required: true,
+ }),
+ queryParam({
+ name: 'languageCode',
+ example: 'en-US',
+ required: false,
+ }),
+ ],
},
- queryParams: {
- token:
- '0f4d5e31a44f48dcbab966c52cfb0a67c5f1982186c14b85ab389a031dbc225a',
- },
- staticPreview: this.render({ reviewedProgress: 93 }),
},
- {
- title: 'Localizely language progress',
- keywords,
- documentation,
- namedParams: {
- projectId: '5cc34208-0418-40b1-8353-acc70c95f802',
- branch: 'main',
- },
- queryParams: {
- token:
- '0f4d5e31a44f48dcbab966c52cfb0a67c5f1982186c14b85ab389a031dbc225a',
- languageCode: 'en-US',
+ '/localizely/progress/{projectId}/{branch}': {
+ get: {
+ summary: 'Localizely progress (branch)',
+ description,
+ parameters: [
+ pathParam({
+ name: 'projectId',
+ example: '5cc34208-0418-40b1-8353-acc70c95f802',
+ }),
+ pathParam({
+ name: 'branch',
+ example: 'main',
+ }),
+ queryParam({
+ name: 'token',
+ example:
+ '0f4d5e31a44f48dcbab966c52cfb0a67c5f1982186c14b85ab389a031dbc225a',
+ required: true,
+ }),
+ queryParam({
+ name: 'languageCode',
+ example: 'en-US',
+ required: false,
+ }),
+ ],
},
- staticPreview: this.render({
- langName: 'English (US)',
- reviewedProgress: 97,
- }),
},
- ]
+ }
static defaultBadgeData = { label: 'localized' }
@@ -108,10 +118,10 @@ export default class Localizely extends BaseJsonService {
schema,
url: `https://api.localizely.com/v1/projects/${projectId}/status`,
options: {
- qs: { branch },
+ searchParams: { branch },
headers: { 'X-Api-Token': apiToken },
},
- errorMessages: {
+ httpErrors: {
403: 'not authorized for project',
},
})
@@ -136,7 +146,7 @@ export default class Localizely extends BaseJsonService {
const json = await this.fetch({ projectId, branch, apiToken })
const { langName, reviewedProgress } = this.constructor.transform(
json,
- languageCode
+ languageCode,
)
return this.constructor.render({ langName, reviewedProgress })
diff --git a/services/localizely/localizely.tester.js b/services/localizely/localizely.tester.js
index 9f996e287de16..39eb9f0fbf112 100644
--- a/services/localizely/localizely.tester.js
+++ b/services/localizely/localizely.tester.js
@@ -4,19 +4,19 @@ export const t = await createServiceTester()
t.create('Overall progress')
.get(
- '/5cc34208-0418-40b1-8353-acc70c95f802.json?token=0f4d5e31a44f48dcbab966c52cfb0a67c5f1982186c14b85ab389a031dbc225a'
+ '/5cc34208-0418-40b1-8353-acc70c95f802.json?token=0f4d5e31a44f48dcbab966c52cfb0a67c5f1982186c14b85ab389a031dbc225a',
)
.expectBadge({ label: 'localized', message: isIntegerPercentage })
t.create('Overall progress on specific branch')
.get(
- '/5cc34208-0418-40b1-8353-acc70c95f802/Version_1.0.json?token=0f4d5e31a44f48dcbab966c52cfb0a67c5f1982186c14b85ab389a031dbc225a'
+ '/5cc34208-0418-40b1-8353-acc70c95f802/Version_1.0.json?token=0f4d5e31a44f48dcbab966c52cfb0a67c5f1982186c14b85ab389a031dbc225a',
)
.expectBadge({ label: 'localized', message: isIntegerPercentage })
t.create('Overall progress with invalid token')
.get(
- '/1349592f-8d05-4317-9f46-bddc5def11fe/main.json?token=312045388bfb4d2591cfe1d60868ea52b63ac6daa6dc406b9bab682f4d9ab715'
+ '/1349592f-8d05-4317-9f46-bddc5def11fe/main.json?token=312045388bfb4d2591cfe1d60868ea52b63ac6daa6dc406b9bab682f4d9ab715',
)
.intercept(nock =>
nock('https://api.localizely.com', {
@@ -30,25 +30,25 @@ t.create('Overall progress with invalid token')
.reply(403, {
errorCode: 'forbidden',
errorMessage: 'Tried to access unauthorized project',
- })
+ }),
)
.expectBadge({ label: 'localized', message: 'not authorized for project' })
t.create('Language progress')
.get(
- '/5cc34208-0418-40b1-8353-acc70c95f802.json?languageCode=en-US&token=0f4d5e31a44f48dcbab966c52cfb0a67c5f1982186c14b85ab389a031dbc225a'
+ '/5cc34208-0418-40b1-8353-acc70c95f802.json?languageCode=en-US&token=0f4d5e31a44f48dcbab966c52cfb0a67c5f1982186c14b85ab389a031dbc225a',
)
.expectBadge({ label: 'English (US)', message: isIntegerPercentage })
t.create('Language progress on specific branch')
.get(
- '/5cc34208-0418-40b1-8353-acc70c95f802/Version_1.0.json?languageCode=en-US&token=0f4d5e31a44f48dcbab966c52cfb0a67c5f1982186c14b85ab389a031dbc225a'
+ '/5cc34208-0418-40b1-8353-acc70c95f802/Version_1.0.json?languageCode=en-US&token=0f4d5e31a44f48dcbab966c52cfb0a67c5f1982186c14b85ab389a031dbc225a',
)
.expectBadge({ label: 'English (US)', message: isIntegerPercentage })
t.create('Language progress with invalid token')
.get(
- '/1349592f-8d05-4317-9f46-bddc5def11fe/main.json?languageCode=en-US&token=312045388bfb4d2591cfe1d60868ea52b63ac6daa6dc406b9bab682f4d9ab715'
+ '/1349592f-8d05-4317-9f46-bddc5def11fe/main.json?languageCode=en-US&token=312045388bfb4d2591cfe1d60868ea52b63ac6daa6dc406b9bab682f4d9ab715',
)
.intercept(nock =>
nock('https://api.localizely.com', {
@@ -62,13 +62,13 @@ t.create('Language progress with invalid token')
.reply(403, {
errorCode: 'forbidden',
errorMessage: 'Tried to access unauthorized project',
- })
+ }),
)
.expectBadge({ label: 'localized', message: 'not authorized for project' })
t.create('Language progress for unsupported language code')
.get(
- '/1349592f-8d05-4317-9f46-bddc5def11fe/main.json?languageCode=fr&token=312045388bfb4d2591cfe1d60868ea52b63ac6daa6dc406b9bab682f4d9ab715'
+ '/1349592f-8d05-4317-9f46-bddc5def11fe/main.json?languageCode=fr&token=312045388bfb4d2591cfe1d60868ea52b63ac6daa6dc406b9bab682f4d9ab715',
)
.intercept(nock =>
nock('https://api.localizely.com', {
@@ -91,13 +91,13 @@ t.create('Language progress for unsupported language code')
reviewedProgress: 85,
},
],
- })
+ }),
)
.expectBadge({ label: 'localized', message: 'Unsupported language' })
t.create('Language progress for supported language code')
.get(
- '/1349592f-8d05-4317-9f46-bddc5def11fe/main.json?languageCode=en-US&token=312045388bfb4d2591cfe1d60868ea52b63ac6daa6dc406b9bab682f4d9ab715'
+ '/1349592f-8d05-4317-9f46-bddc5def11fe/main.json?languageCode=en-US&token=312045388bfb4d2591cfe1d60868ea52b63ac6daa6dc406b9bab682f4d9ab715',
)
.intercept(nock =>
nock('https://api.localizely.com', {
@@ -127,6 +127,6 @@ t.create('Language progress for supported language code')
reviewedProgress: 60,
},
],
- })
+ }),
)
.expectBadge({ label: 'English (US)', message: '60%' })
diff --git a/services/luarocks/luarocks.service.js b/services/luarocks/luarocks.service.js
index 2ffb68616afad..37b8c361ae4c8 100644
--- a/services/luarocks/luarocks.service.js
+++ b/services/luarocks/luarocks.service.js
@@ -1,13 +1,13 @@
import Joi from 'joi'
-import { addv } from '../text-formatters.js'
-import { BaseJsonService, NotFound } from '../index.js'
+import { BaseJsonService, NotFound, pathParams } from '../index.js'
+import { renderVersionBadge } from '../version.js'
import { latestVersion } from './luarocks-version-helpers.js'
const schema = Joi.object({
repository: Joi.object()
.pattern(
Joi.string(),
- Joi.object().pattern(Joi.string(), Joi.array().strip())
+ Joi.object().pattern(Joi.string(), Joi.array().strip()),
)
.required(),
}).required()
@@ -20,47 +20,35 @@ export default class Luarocks extends BaseJsonService {
pattern: ':user/:moduleName/:version?',
}
- static examples = [
- {
- title: 'LuaRocks',
- namedParams: {
- user: 'mpeterv',
- moduleName: 'luacheck',
+ static openApi = {
+ '/luarocks/v/{user}/{moduleName}': {
+ get: {
+ summary: 'LuaRocks',
+ parameters: pathParams(
+ {
+ name: 'user',
+ example: 'mpeterv',
+ },
+ {
+ name: 'moduleName',
+ example: 'luacheck',
+ },
+ ),
},
- staticPreview: this.render({ version: '0.23.0-1' }),
},
- ]
+ }
static defaultBadgeData = {
label: 'luarocks',
}
- static render({ version }) {
- // The badge colors are following the heuristic rule where `scm < dev <
- // stable` (e.g., `scm-1` < `dev-1` < `0.1.0-1`).
- let color
- switch (version.slice(0, 3).toLowerCase()) {
- case 'dev':
- color = 'yellow'
- break
- case 'scm':
- case 'cvs':
- color = 'orange'
- break
- default:
- color = 'brightgreen'
- }
-
- return { message: addv(version), color }
- }
-
async fetch({ user, moduleName }) {
const { repository } = await this._requestJson({
url: `https://luarocks.org/manifests/${encodeURIComponent(
- user
+ user,
)}/manifest.json`,
schema,
- errorMessages: {
+ httpErrors: {
404: 'user not found',
},
})
@@ -84,6 +72,6 @@ export default class Luarocks extends BaseJsonService {
const versions = Object.keys(moduleInfo)
version = latestVersion(versions)
}
- return this.constructor.render({ version })
+ return renderVersionBadge({ version })
}
}
diff --git a/services/luarocks/luarocks.spec.js b/services/luarocks/luarocks.spec.js
deleted file mode 100644
index 811ab73ccf72a..0000000000000
--- a/services/luarocks/luarocks.spec.js
+++ /dev/null
@@ -1,12 +0,0 @@
-import { test, given } from 'sazerac'
-import Luarocks from './luarocks.service.js'
-
-test(Luarocks.render, () => {
- given({ version: 'dev-1' }).expect({ message: 'dev-1', color: 'yellow' })
- given({ version: 'scm-1' }).expect({ message: 'scm-1', color: 'orange' })
- given({ version: 'cvs-1' }).expect({ message: 'cvs-1', color: 'orange' })
- given({ version: '0.1-1' }).expect({
- message: 'v0.1-1',
- color: 'brightgreen',
- })
-})
diff --git a/services/maintenance/maintenance.service.js b/services/maintenance/maintenance.service.js
index c38bc236318ff..929f9e4ae02d0 100644
--- a/services/maintenance/maintenance.service.js
+++ b/services/maintenance/maintenance.service.js
@@ -1,4 +1,4 @@
-import { BaseService } from '../index.js'
+import { BaseService, pathParams } from '../index.js'
export default class Maintenance extends BaseService {
static category = 'other'
@@ -8,18 +8,23 @@ export default class Maintenance extends BaseService {
pattern: ':maintained/:year(\\d{4})',
}
- static examples = [
- {
- title: 'Maintenance',
- pattern: ':maintained(yes|no)/:year',
- namedParams: {
- maintained: 'yes',
- year: '2019',
+ static openApi = {
+ '/maintenance/{maintained}/{year}': {
+ get: {
+ summary: 'Maintenance',
+ parameters: pathParams(
+ {
+ name: 'maintained',
+ example: 'yes',
+ },
+ {
+ name: 'year',
+ example: '2019',
+ },
+ ),
},
- staticPreview: this.render({ isMaintained: false, targetYear: '2018' }),
- keywords: ['maintained'],
},
- ]
+ }
static defaultBadgeData = {
label: 'maintained',
@@ -34,7 +39,7 @@ export default class Maintenance extends BaseService {
}
return {
- message: `${isStale ? `stale` : 'no!'} (as of ${targetYear})`,
+ message: `${isStale ? 'stale' : 'no!'} (as of ${targetYear})`,
color: isStale ? undefined : 'red',
}
}
diff --git a/services/mastodon/mastodon-follow.service.js b/services/mastodon/mastodon-follow.service.js
index cea9e509948e7..ba601c8ad6246 100644
--- a/services/mastodon/mastodon-follow.service.js
+++ b/services/mastodon/mastodon-follow.service.js
@@ -1,7 +1,7 @@
import Joi from 'joi'
import { metric } from '../text-formatters.js'
-import { optionalUrl, nonNegativeInteger } from '../validators.js'
-import { BaseJsonService, NotFound } from '../index.js'
+import { nonNegativeInteger } from '../validators.js'
+import { BaseJsonService, NotFound, pathParam, queryParam } from '../index.js'
const schema = Joi.object({
username: Joi.string().required(),
@@ -9,13 +9,11 @@ const schema = Joi.object({
})
const queryParamSchema = Joi.object({
- domain: optionalUrl,
+ domain: Joi.string().optional(),
}).required()
-const documentation = `
-To find your user id, you can use this tool .
-Alternatively you can make a request to https://your.mastodon.server/.well-known/webfinger?resource=acct:{user}@{domain}
-Failing that, you can also visit your profile page, where your user ID will be in the header in a tag like this: <link href='https://your.mastodon.server/api/salmon/{your-user-id}' rel='salmon'>
+const description = `
+To find your user id, you can make a request to \`https://your.mastodon.server/api/v1/accounts/lookup?acct=yourusername\`.
`
export default class MastodonFollow extends BaseJsonService {
@@ -27,21 +25,24 @@ export default class MastodonFollow extends BaseJsonService {
queryParamSchema,
}
- static examples = [
- {
- title: 'Mastodon Follow',
- namedParams: {
- id: '26471',
+ static openApi = {
+ '/mastodon/follow/{id}': {
+ get: {
+ summary: 'Mastodon Follow',
+ description,
+ parameters: [
+ pathParam({
+ name: 'id',
+ example: '26471',
+ }),
+ queryParam({
+ name: 'domain',
+ example: 'mastodon.social',
+ }),
+ ],
},
- queryParams: { domain: 'https://mastodon.social' },
- staticPreview: {
- label: 'Follow',
- message: '862',
- style: 'social',
- },
- documentation,
},
- ]
+ }
static defaultBadgeData = {
namedLogo: 'mastodon',
@@ -53,8 +54,8 @@ export default class MastodonFollow extends BaseJsonService {
message: metric(followers),
style: 'social',
link: [
- `${domain}/users/${username}/remote_follow`,
- `${domain}/users/${username}/followers`,
+ `https://${domain}/users/${username}`,
+ `https://${domain}/users/${username}/followers`,
],
}
}
@@ -62,13 +63,14 @@ export default class MastodonFollow extends BaseJsonService {
async fetch({ id, domain }) {
return this._requestJson({
schema,
- url: `${domain}/api/v1/accounts/${id}/`,
+ url: `https://${domain}/api/v1/accounts/${id}/`,
})
}
- async handle({ id }, { domain = 'https://mastodon.social' }) {
+ async handle({ id }, { domain = 'mastodon.social' }) {
if (isNaN(id))
throw new NotFound({ prettyMessage: 'invalid user id format' })
+ domain = domain.replace(/^https?:\/\//, '')
const data = await this.fetch({ id, domain })
return this.constructor.render({
username: data.username,
diff --git a/services/mastodon/mastodon-follow.tester.js b/services/mastodon/mastodon-follow.tester.js
index 1b792bad8c962..c1af88b829867 100644
--- a/services/mastodon/mastodon-follow.tester.js
+++ b/services/mastodon/mastodon-follow.tester.js
@@ -8,7 +8,7 @@ t.create('Followers - default domain')
label: 'follow @wilkie',
message: isMetric,
link: [
- 'https://mastodon.social/users/wilkie/remote_follow',
+ 'https://mastodon.social/users/wilkie',
'https://mastodon.social/users/wilkie/followers',
],
})
@@ -28,12 +28,23 @@ t.create('Followers - default domain - invalid user ID (id not in use)')
})
t.create('Followers - alternate domain')
+ .get('/2214.json?domain=mastodon.xyz')
+ .expectBadge({
+ label: 'follow @PhotonQyv',
+ message: isMetric,
+ link: [
+ 'https://mastodon.xyz/users/PhotonQyv',
+ 'https://mastodon.xyz/users/PhotonQyv/followers',
+ ],
+ })
+
+t.create('Followers - alternate domain legacy')
.get('/2214.json?domain=https%3A%2F%2Fmastodon.xyz')
.expectBadge({
label: 'follow @PhotonQyv',
message: isMetric,
link: [
- 'https://mastodon.xyz/users/PhotonQyv/remote_follow',
+ 'https://mastodon.xyz/users/PhotonQyv',
'https://mastodon.xyz/users/PhotonQyv/followers',
],
})
diff --git a/services/matrix/matrix.service.js b/services/matrix/matrix.service.js
index ca2dca02c5e63..60b22ed730970 100644
--- a/services/matrix/matrix.service.js
+++ b/services/matrix/matrix.service.js
@@ -1,8 +1,19 @@
import Joi from 'joi'
-import { BaseJsonService, InvalidParameter } from '../index.js'
+import {
+ BaseJsonService,
+ InvalidParameter,
+ pathParam,
+ queryParam,
+} from '../index.js'
+import { nonNegativeInteger } from '../validators.js'
+
+const fetchModeEnum = ['guest', 'summary']
const queryParamSchema = Joi.object({
server_fqdn: Joi.string().hostname(),
+ fetchMode: Joi.string()
+ .valid(...fetchModeEnum)
+ .default('guest'),
}).required()
const matrixRegisterSchema = Joi.object({
@@ -22,33 +33,38 @@ const matrixStateSchema = Joi.array()
type: Joi.string().required(),
sender: Joi.string().required(),
state_key: Joi.string().allow('').required(),
- })
+ }),
)
.required()
-const documentation = `
-
- In order for this badge to work, the host of your room must allow guest accounts or dummy accounts to register, and the room must be world readable (chat history visible to anyone).
-
- The following steps will show you how to setup the badge URL using the Element Matrix client.
-
-
- Select the desired room inside the Element client
- Click on the room settings button (gear icon) located near the top right of the client
- Scroll to the very bottom of the settings page and look under the Addresses section
- You should see one or more room addresses (or aliases), which can be easily identified with their starting hash (#) character (ex: #twim:matrix.org)
- If there is no address for this room, add one under Local addresses for this room
- Remove the starting hash character (#)
- The final badge URL should look something like this /matrix/twim:matrix.org.svg
-
-
- Some Matrix homeservers don't hold a server name matching where they live (e.g. if the homeserver example.com that created the room alias #mysuperroom:example.com lives at matrix.example.com).
-
- If that is the case of the homeserver that created the room alias used for generating the badge, you will need to add the server's FQDN (fully qualified domain name) as a query parameter.
-
- The final badge URL should then look something like this /matrix/mysuperroom:example.com.svg?server_fqdn=matrix.example.com.
-
- `
+const matrixSummarySchema = Joi.object({
+ num_joined_members: nonNegativeInteger,
+}).required()
+
+const description = `
+In order for this badge to work, the host of your room must allow guest accounts or dummy accounts to register, and the room must be world readable (chat history visible to anyone).
+
+Alternatively access via the experimental summary endpoint ([MSC3266](https://github.com/matrix-org/matrix-spec-proposals/pull/3266)) can be configured with the query parameter fetchMode for less server load and better performance, if supported by the homeserver
+For the matrix.org homeserver fetchMode is hard-coded to summary.
+
+The following steps will show you how to setup the badge URL using the Element Matrix client.
+
+
+ Select the desired room inside the Element client
+ Click on the room settings button (gear icon) located near the top right of the client
+ Scroll to the very bottom of the settings page and look under the Addresses section
+ You should see one or more room addresses (or aliases), which can be easily identified with their starting hash (#) character (ex: #twim:matrix.org)
+ If there is no address for this room, add one under Local addresses for this room
+ Remove the starting hash character (#)
+ The final badge URL should look something like this /matrix/twim:matrix.org.svg
+
+
+Some Matrix homeservers don't hold a server name matching where they live (e.g. if the homeserver example.com that created the room alias #mysuperroom:example.com lives at matrix.example.com).
+
+If that is the case of the homeserver that created the room alias used for generating the badge, you will need to add the server's FQDN (fully qualified domain name) as a query parameter.
+
+The final badge URL should then look something like this /matrix/mysuperroom:example.com.svg?server_fqdn=matrix.example.com.
+`
export default class Matrix extends BaseJsonService {
static category = 'chat'
@@ -59,23 +75,35 @@ export default class Matrix extends BaseJsonService {
queryParamSchema,
}
- static examples = [
- {
- title: 'Matrix',
- namedParams: { roomAlias: 'twim:matrix.org' },
- staticPreview: this.render({ members: 42 }),
- documentation,
- },
- {
- title: 'Matrix',
- namedParams: { roomAlias: 'twim:matrix.org' },
- queryParams: { server_fqdn: 'matrix.org' },
- staticPreview: this.render({ members: 42 }),
- documentation,
+ static openApi = {
+ '/matrix/{roomAlias}': {
+ get: {
+ summary: 'Matrix',
+ description,
+ parameters: [
+ pathParam({
+ name: 'roomAlias',
+ example: 'twim:matrix.org',
+ }),
+ queryParam({
+ name: 'server_fqdn',
+ example: 'matrix.org',
+ }),
+ queryParam({
+ name: 'fetchMode',
+ example: 'guest',
+ description: `guest configures guest authentication while summary configures usage of the experimental "summary" endpoint ([MSC3266](https://github.com/matrix-org/matrix-spec-proposals/pull/3266)). If not specified, the default fetch mode is guest (except for matrix.org).`,
+ schema: {
+ type: 'string',
+ enum: fetchModeEnum,
+ },
+ }),
+ ],
+ },
},
- ]
+ }
- static _cacheLength = 30
+ static _cacheLength = 14400
static defaultBadgeData = { label: 'chat' }
@@ -106,7 +134,7 @@ export default class Matrix extends BaseJsonService {
schema: matrixRegisterSchema,
options: {
method: 'POST',
- qs: guest
+ searchParams: guest
? {
kind: 'guest',
}
@@ -116,10 +144,9 @@ export default class Matrix extends BaseJsonService {
auth: { type: 'm.login.dummy' },
}),
},
- errorMessages: {
+ httpErrors: {
401: 'auth failed',
403: 'guests not allowed',
- 429: 'rate limited by remote server',
},
})
}
@@ -127,54 +154,53 @@ export default class Matrix extends BaseJsonService {
async lookupRoomAlias({ host, roomAlias, accessToken }) {
return this._requestJson({
url: `https://${host}/_matrix/client/r0/directory/room/${encodeURIComponent(
- `#${roomAlias}`
+ `#${roomAlias}`,
)}`,
schema: matrixAliasLookupSchema,
options: {
- qs: {
+ searchParams: {
access_token: accessToken,
},
},
- errorMessages: {
+ httpErrors: {
401: 'bad auth token',
404: 'room not found',
- 429: 'rate limited by remote server',
},
})
}
- async fetch({ roomAlias, serverFQDN }) {
- let host
- if (serverFQDN === undefined) {
- const splitAlias = roomAlias.split(':')
- // A room alias can either be in the form #localpart:server or
- // #localpart:server:port.
- switch (splitAlias.length) {
- case 2:
- host = splitAlias[1]
- break
- case 3:
- host = `${splitAlias[1]}:${splitAlias[2]}`
- break
- default:
- throw new InvalidParameter({ prettyMessage: 'invalid alias' })
- }
- } else {
- host = serverFQDN
- }
+ async fetchSummary({ host, roomAlias }) {
+ const data = await this._requestJson({
+ url: `https://${host}/_matrix/client/unstable/im.nheko.summary/rooms/%23${encodeURIComponent(
+ roomAlias,
+ )}/summary`,
+ schema: matrixSummarySchema,
+ httpErrors: {
+ 400: 'unknown request',
+ 404: 'room or endpoint not found',
+ },
+ })
+ return data.num_joined_members
+ }
+
+ async fetchGuest({ host, roomAlias }) {
const accessToken = await this.retrieveAccessToken({ host })
- const lookup = await this.lookupRoomAlias({ host, roomAlias, accessToken })
+ const lookup = await this.lookupRoomAlias({
+ host,
+ roomAlias,
+ accessToken,
+ })
const data = await this._requestJson({
url: `https://${host}/_matrix/client/r0/rooms/${encodeURIComponent(
- lookup.room_id
+ lookup.room_id,
)}/state`,
schema: matrixStateSchema,
options: {
- qs: {
+ searchParams: {
access_token: accessToken,
},
},
- errorMessages: {
+ httpErrors: {
400: 'unknown request',
401: 'bad auth token',
403: 'room not world readable or is invalid',
@@ -185,13 +211,41 @@ export default class Matrix extends BaseJsonService {
m =>
m.type === 'm.room.member' &&
m.sender === m.state_key &&
- m.content.membership === 'join'
+ m.content.membership === 'join',
).length
: 0
}
- async handle({ roomAlias }, { server_fqdn: serverFQDN }) {
- const members = await this.fetch({ roomAlias, serverFQDN })
+ async fetch({ roomAlias, serverFQDN, fetchMode }) {
+ let host
+ if (serverFQDN === undefined) {
+ const splitAlias = roomAlias.split(':')
+ // A room alias can either be in the form #localpart:server or
+ // #localpart:server:port.
+ switch (splitAlias.length) {
+ case 2:
+ host = splitAlias[1]
+ break
+ case 3:
+ host = `${splitAlias[1]}:${splitAlias[2]}`
+ break
+ default:
+ throw new InvalidParameter({ prettyMessage: 'invalid alias' })
+ }
+ } else {
+ host = serverFQDN
+ }
+ if (host.toLowerCase() === 'matrix.org' || fetchMode === 'summary') {
+ // summary endpoint (default for matrix.org)
+ return await this.fetchSummary({ host, roomAlias })
+ } else {
+ // guest access
+ return await this.fetchGuest({ host, roomAlias })
+ }
+ }
+
+ async handle({ roomAlias }, { server_fqdn: serverFQDN, fetchMode }) {
+ const members = await this.fetch({ roomAlias, serverFQDN, fetchMode })
return this.constructor.render({ members })
}
}
diff --git a/services/matrix/matrix.tester.js b/services/matrix/matrix.tester.js
index d87857497a2fc..36359d07dfcda 100644
--- a/services/matrix/matrix.tester.js
+++ b/services/matrix/matrix.tester.js
@@ -11,19 +11,19 @@ t.create('get room state as guest')
200,
JSON.stringify({
access_token: 'TOKEN',
- })
+ }),
)
.get(
- '/_matrix/client/r0/directory/room/%23ALIAS%3ADUMMY.dumb?access_token=TOKEN'
+ '/_matrix/client/r0/directory/room/%23ALIAS%3ADUMMY.dumb?access_token=TOKEN',
)
.reply(
200,
JSON.stringify({
room_id: 'ROOM:DUMMY.dumb',
- })
+ }),
)
.get(
- '/_matrix/client/r0/rooms/ROOM%3ADUMMY.dumb/state?access_token=TOKEN'
+ '/_matrix/client/r0/rooms/ROOM%3ADUMMY.dumb/state?access_token=TOKEN',
)
.reply(
200,
@@ -64,8 +64,8 @@ t.create('get room state as guest')
membership: 'fake room',
},
},
- ])
- )
+ ]),
+ ),
)
.expectBadge({
label: 'chat',
@@ -83,26 +83,26 @@ t.create('get room state as member (backup method)')
JSON.stringify({
errcode: 'M_GUEST_ACCESS_FORBIDDEN',
error: 'Guest access not allowed',
- })
+ }),
)
.post('/_matrix/client/r0/register')
.reply(
200,
JSON.stringify({
access_token: 'TOKEN',
- })
+ }),
)
.get(
- '/_matrix/client/r0/directory/room/%23ALIAS%3ADUMMY.dumb?access_token=TOKEN'
+ '/_matrix/client/r0/directory/room/%23ALIAS%3ADUMMY.dumb?access_token=TOKEN',
)
.reply(
200,
JSON.stringify({
room_id: 'ROOM:DUMMY.dumb',
- })
+ }),
)
.get(
- '/_matrix/client/r0/rooms/ROOM%3ADUMMY.dumb/state?access_token=TOKEN'
+ '/_matrix/client/r0/rooms/ROOM%3ADUMMY.dumb/state?access_token=TOKEN',
)
.reply(
200,
@@ -143,8 +143,8 @@ t.create('get room state as member (backup method)')
membership: 'fake room',
},
},
- ])
- )
+ ]),
+ ),
)
.expectBadge({
label: 'chat',
@@ -152,6 +152,26 @@ t.create('get room state as member (backup method)')
color: 'brightgreen',
})
+t.create('get room summary')
+ .get('/ALIAS:DUMMY.dumb.json?fetchMode=summary')
+ .intercept(nock =>
+ nock('https://DUMMY.dumb/')
+ .get(
+ '/_matrix/client/unstable/im.nheko.summary/rooms/%23ALIAS%3ADUMMY.dumb/summary',
+ )
+ .reply(
+ 200,
+ JSON.stringify({
+ num_joined_members: 4,
+ }),
+ ),
+ )
+ .expectBadge({
+ label: 'chat',
+ message: '4 users',
+ color: 'brightgreen',
+ })
+
t.create('bad server or connection')
.get('/ALIAS:DUMMY.dumb.json')
.networkOff()
@@ -170,27 +190,27 @@ t.create('non-world readable room')
200,
JSON.stringify({
access_token: 'TOKEN',
- })
+ }),
)
.get(
- '/_matrix/client/r0/directory/room/%23ALIAS%3ADUMMY.dumb?access_token=TOKEN'
+ '/_matrix/client/r0/directory/room/%23ALIAS%3ADUMMY.dumb?access_token=TOKEN',
)
.reply(
200,
JSON.stringify({
room_id: 'ROOM:DUMMY.dumb',
- })
+ }),
)
.get(
- '/_matrix/client/r0/rooms/ROOM%3ADUMMY.dumb/state?access_token=TOKEN'
+ '/_matrix/client/r0/rooms/ROOM%3ADUMMY.dumb/state?access_token=TOKEN',
)
.reply(
403,
JSON.stringify({
errcode: 'M_GUEST_ACCESS_FORBIDDEN',
error: 'Guest access not allowed',
- })
- )
+ }),
+ ),
)
.expectBadge({
label: 'chat',
@@ -207,18 +227,18 @@ t.create('invalid token')
200,
JSON.stringify({
access_token: 'TOKEN',
- })
+ }),
)
.get(
- '/_matrix/client/r0/directory/room/%23ALIAS%3ADUMMY.dumb?access_token=TOKEN'
+ '/_matrix/client/r0/directory/room/%23ALIAS%3ADUMMY.dumb?access_token=TOKEN',
)
.reply(
401,
JSON.stringify({
errcode: 'M_UNKNOWN_TOKEN',
error: 'Unrecognised access token.',
- })
- )
+ }),
+ ),
)
.expectBadge({
label: 'chat',
@@ -235,27 +255,48 @@ t.create('unknown request')
200,
JSON.stringify({
access_token: 'TOKEN',
- })
+ }),
)
.get(
- '/_matrix/client/r0/directory/room/%23ALIAS%3ADUMMY.dumb?access_token=TOKEN'
+ '/_matrix/client/r0/directory/room/%23ALIAS%3ADUMMY.dumb?access_token=TOKEN',
)
.reply(
200,
JSON.stringify({
room_id: 'ROOM:DUMMY.dumb',
- })
+ }),
)
.get(
- '/_matrix/client/r0/rooms/ROOM%3ADUMMY.dumb/state?access_token=TOKEN'
+ '/_matrix/client/r0/rooms/ROOM%3ADUMMY.dumb/state?access_token=TOKEN',
)
.reply(
400,
JSON.stringify({
errcode: 'M_UNRECOGNIZED',
error: 'Unrecognized request',
- })
+ }),
+ ),
+ )
+ .expectBadge({
+ label: 'chat',
+ message: 'unknown request',
+ color: 'lightgrey',
+ })
+
+t.create('unknown summary request')
+ .get('/ALIAS:DUMMY.dumb.json?fetchMode=summary')
+ .intercept(nock =>
+ nock('https://DUMMY.dumb/')
+ .get(
+ '/_matrix/client/unstable/im.nheko.summary/rooms/%23ALIAS%3ADUMMY.dumb/summary',
)
+ .reply(
+ 400,
+ JSON.stringify({
+ errcode: 'M_UNRECOGNIZED',
+ error: 'Unrecognized request',
+ }),
+ ),
)
.expectBadge({
label: 'chat',
@@ -272,18 +313,18 @@ t.create('unknown alias')
200,
JSON.stringify({
access_token: 'TOKEN',
- })
+ }),
)
.get(
- '/_matrix/client/r0/directory/room/%23ALIAS%3ADUMMY.dumb?access_token=TOKEN'
+ '/_matrix/client/r0/directory/room/%23ALIAS%3ADUMMY.dumb?access_token=TOKEN',
)
.reply(
404,
JSON.stringify({
errcode: 'M_NOT_FOUND',
error: 'Room alias #ALIAS%3ADUMMY.dumb not found.',
- })
- )
+ }),
+ ),
)
.expectBadge({
label: 'chat',
@@ -291,6 +332,27 @@ t.create('unknown alias')
color: 'red',
})
+t.create('unknown summary alias')
+ .get('/ALIAS:DUMMY.dumb.json?fetchMode=summary')
+ .intercept(nock =>
+ nock('https://DUMMY.dumb/')
+ .get(
+ '/_matrix/client/unstable/im.nheko.summary/rooms/%23ALIAS%3ADUMMY.dumb/summary',
+ )
+ .reply(
+ 404,
+ JSON.stringify({
+ errcode: 'M_NOT_FOUND',
+ error: 'Room alias #ALIAS%3ADUMMY.dumb not found.',
+ }),
+ ),
+ )
+ .expectBadge({
+ label: 'chat',
+ message: 'room or endpoint not found',
+ color: 'red',
+ })
+
t.create('invalid alias').get('/ALIASDUMMY.dumb.json').expectBadge({
label: 'chat',
message: 'invalid alias',
@@ -306,19 +368,19 @@ t.create('server uses a custom port')
200,
JSON.stringify({
access_token: 'TOKEN',
- })
+ }),
)
.get(
- '/_matrix/client/r0/directory/room/%23ALIAS%3ADUMMY.dumb%3A5555?access_token=TOKEN'
+ '/_matrix/client/r0/directory/room/%23ALIAS%3ADUMMY.dumb%3A5555?access_token=TOKEN',
)
.reply(
200,
JSON.stringify({
room_id: 'ROOM:DUMMY.dumb:5555',
- })
+ }),
)
.get(
- '/_matrix/client/r0/rooms/ROOM%3ADUMMY.dumb%3A5555/state?access_token=TOKEN'
+ '/_matrix/client/r0/rooms/ROOM%3ADUMMY.dumb%3A5555/state?access_token=TOKEN',
)
.reply(
200,
@@ -359,8 +421,8 @@ t.create('server uses a custom port')
membership: 'fake room',
},
},
- ])
- )
+ ]),
+ ),
)
.expectBadge({
label: 'chat',
@@ -368,6 +430,26 @@ t.create('server uses a custom port')
color: 'brightgreen',
})
+t.create('server uses a custom port for summary')
+ .get('/ALIAS:DUMMY.dumb:5555.json?fetchMode=summary')
+ .intercept(nock =>
+ nock('https://DUMMY.dumb:5555/')
+ .get(
+ '/_matrix/client/unstable/im.nheko.summary/rooms/%23ALIAS%3ADUMMY.dumb%3A5555/summary',
+ )
+ .reply(
+ 200,
+ JSON.stringify({
+ num_joined_members: 4,
+ }),
+ ),
+ )
+ .expectBadge({
+ label: 'chat',
+ message: '4 users',
+ color: 'brightgreen',
+ })
+
t.create('specify the homeserver fqdn')
.get('/ALIAS:DUMMY.dumb.json?server_fqdn=matrix.DUMMY.dumb')
.intercept(nock =>
@@ -377,19 +459,19 @@ t.create('specify the homeserver fqdn')
200,
JSON.stringify({
access_token: 'TOKEN',
- })
+ }),
)
.get(
- '/_matrix/client/r0/directory/room/%23ALIAS%3ADUMMY.dumb?access_token=TOKEN'
+ '/_matrix/client/r0/directory/room/%23ALIAS%3ADUMMY.dumb?access_token=TOKEN',
)
.reply(
200,
JSON.stringify({
room_id: 'ROOM:DUMMY.dumb',
- })
+ }),
)
.get(
- '/_matrix/client/r0/rooms/ROOM%3ADUMMY.dumb/state?access_token=TOKEN'
+ '/_matrix/client/r0/rooms/ROOM%3ADUMMY.dumb/state?access_token=TOKEN',
)
.reply(
200,
@@ -430,8 +512,8 @@ t.create('specify the homeserver fqdn')
membership: 'fake room',
},
},
- ])
- )
+ ]),
+ ),
)
.expectBadge({
label: 'chat',
@@ -439,9 +521,56 @@ t.create('specify the homeserver fqdn')
color: 'brightgreen',
})
-t.create('test on real matrix room for API compliance')
+t.create('specify the homeserver fqdn for summary')
+ .get('/ALIAS:DUMMY.dumb.json?server_fqdn=matrix.DUMMY.dumb&fetchMode=summary')
+ .intercept(nock =>
+ nock('https://matrix.DUMMY.dumb/')
+ .get(
+ '/_matrix/client/unstable/im.nheko.summary/rooms/%23ALIAS%3ADUMMY.dumb/summary',
+ )
+ .reply(
+ 200,
+ JSON.stringify({
+ num_joined_members: 4,
+ }),
+ ),
+ )
+ .expectBadge({
+ label: 'chat',
+ message: '4 users',
+ color: 'brightgreen',
+ })
+
+t.create('test fetchMode=guest is ignored for matrix.org')
+ .get('/ALIAS:DUMMY.dumb.json?server_fqdn=matrix.org&fetchMode=guest')
+ .intercept(nock =>
+ nock('https://matrix.org/')
+ .get(
+ '/_matrix/client/unstable/im.nheko.summary/rooms/%23ALIAS%3ADUMMY.dumb/summary',
+ )
+ .reply(
+ 200,
+ JSON.stringify({
+ num_joined_members: 4,
+ }),
+ ),
+ )
+ .expectBadge({
+ label: 'chat',
+ message: '4 users',
+ color: 'brightgreen',
+ })
+
+t.create('test on real matrix room for guest API compliance')
+ .get('/twim:matrix.org.json?fetchMode=guest')
+ .expectBadge({
+ label: 'chat',
+ message: Joi.string().regex(/^[0-9]+ users$/),
+ color: 'brightgreen',
+ })
+
+t.create('test on real matrix room for summary API compliance')
.get('/twim:matrix.org.json')
- .timeout(10000)
.expectBadge({
label: 'chat',
message: Joi.string().regex(/^[0-9]+ users$/),
diff --git a/services/maven-central/maven-central-base.js b/services/maven-central/maven-central-base.js
new file mode 100644
index 0000000000000..c5bd1489f4bc1
--- /dev/null
+++ b/services/maven-central/maven-central-base.js
@@ -0,0 +1,13 @@
+import { BaseXmlService } from '../index.js'
+
+export default class MavenCentralBase extends BaseXmlService {
+ async fetch({ groupId, artifactId, schema }) {
+ const group = encodeURIComponent(groupId).replace(/\./g, '/')
+ const artifact = encodeURIComponent(artifactId)
+ return this._requestXml({
+ schema,
+ url: `https://repo1.maven.org/maven2/${group}/${artifact}/maven-metadata.xml`,
+ httpErrors: { 404: 'artifact not found' },
+ })
+ }
+}
diff --git a/services/maven-central/maven-central-last-update.service.js b/services/maven-central/maven-central-last-update.service.js
new file mode 100644
index 0000000000000..6632de4fade96
--- /dev/null
+++ b/services/maven-central/maven-central-last-update.service.js
@@ -0,0 +1,51 @@
+import Joi from 'joi'
+import { pathParams } from '../index.js'
+import { parseDate, renderDateBadge } from '../date.js'
+import { nonNegativeInteger } from '../validators.js'
+import MavenCentralBase from './maven-central-base.js'
+
+const updateResponseSchema = Joi.object({
+ metadata: Joi.object({
+ versioning: Joi.object({
+ lastUpdated: nonNegativeInteger,
+ }).required(),
+ }).required(),
+}).required()
+
+export default class MavenCentralLastUpdate extends MavenCentralBase {
+ static category = 'activity'
+
+ static route = {
+ base: 'maven-central/last-update',
+ pattern: ':groupId/:artifactId',
+ }
+
+ static openApi = {
+ '/maven-central/last-update/{groupId}/{artifactId}': {
+ get: {
+ summary: 'Maven Central Last Update',
+ parameters: pathParams(
+ { name: 'groupId', example: 'com.google.guava' },
+ { name: 'artifactId', example: 'guava' },
+ ),
+ },
+ },
+ }
+
+ static defaultBadgeData = { label: 'last updated' }
+
+ async handle({ groupId, artifactId }) {
+ const { metadata } = await this.fetch({
+ groupId,
+ artifactId,
+ schema: updateResponseSchema,
+ })
+
+ const date = parseDate(
+ String(metadata.versioning.lastUpdated),
+ 'YYYYMMDDHHmmss',
+ )
+
+ return renderDateBadge(date)
+ }
+}
diff --git a/services/maven-central/maven-central-last-update.tester.js b/services/maven-central/maven-central-last-update.tester.js
new file mode 100644
index 0000000000000..46fe38b7f22a0
--- /dev/null
+++ b/services/maven-central/maven-central-last-update.tester.js
@@ -0,0 +1,15 @@
+import { isFormattedDate } from '../test-validators.js'
+import { createServiceTester } from '../tester.js'
+export const t = await createServiceTester()
+
+t.create('last update date').get('/com.google.guava/guava.json').expectBadge({
+ label: 'last updated',
+ message: isFormattedDate,
+})
+
+t.create('last update when artifact not found')
+ .get('/com.fail.test/this-does-not-exist.json')
+ .expectBadge({
+ label: 'last updated',
+ message: 'artifact not found',
+ })
diff --git a/services/maven-central/maven-central.service.js b/services/maven-central/maven-central.service.js
index feccfe07d39a1..8c19f741f774a 100644
--- a/services/maven-central/maven-central.service.js
+++ b/services/maven-central/maven-central.service.js
@@ -1,5 +1,5 @@
-import { redirector } from '../index.js'
-import { documentation } from '../maven-metadata/maven-metadata.js'
+import { redirector, pathParam } from '../index.js'
+import { commonParams } from '../maven-metadata/maven-metadata.js'
export default redirector({
category: 'version',
@@ -8,27 +8,19 @@ export default redirector({
base: 'maven-central/v',
pattern: ':groupId/:artifactId/:versionPrefix?',
},
- examples: [
- {
- title: 'Maven Central',
- pattern: ':groupId/:artifactId',
- queryParams: {
- versionSuffix: '-android',
- versionPrefix: '29',
+ openApi: {
+ '/maven-central/v/{groupId}/{artifactId}': {
+ get: {
+ summary: 'Maven Central Version',
+ parameters: [
+ pathParam({ name: 'groupId', example: 'com.google.guava' }),
+ pathParam({ name: 'artifactId', example: 'guava' }),
+ ...commonParams,
+ ],
},
- namedParams: {
- groupId: 'com.google.guava',
- artifactId: 'guava',
- },
- staticPreview: {
- label: 'maven-central',
- message: 'v29.0-android',
- color: 'blue',
- },
- documentation,
},
- ],
- transformPath: () => `/maven-metadata/v`,
+ },
+ transformPath: () => '/maven-metadata/v',
transformQueryParams: ({ groupId, artifactId, versionPrefix }) => {
const group = encodeURIComponent(groupId).replace(/\./g, '/')
const artifact = encodeURIComponent(artifactId)
diff --git a/services/maven-central/maven-central.tester.js b/services/maven-central/maven-central.tester.js
index ef09147dd50bb..78e5a9676abea 100644
--- a/services/maven-central/maven-central.tester.js
+++ b/services/maven-central/maven-central.tester.js
@@ -4,15 +4,15 @@ export const t = await createServiceTester()
t.create('latest version redirection')
.get('/com.github.fabriziocucci/yacl4j.json') // http://repo1.maven.org/maven2/com/github/fabriziocucci/yacl4j/
.expectRedirect(
- `/maven-metadata/v.json?label=maven-central&metadataUrl=${encodeURIComponent(
- 'https://repo1.maven.org/maven2/com/github/fabriziocucci/yacl4j/maven-metadata.xml'
- )}`
+ `/maven-metadata/v.json?metadataUrl=${encodeURIComponent(
+ 'https://repo1.maven.org/maven2/com/github/fabriziocucci/yacl4j/maven-metadata.xml',
+ )}&label=maven-central`,
)
t.create('latest 0.8 version redirection')
.get('/com.github.fabriziocucci/yacl4j/0.8.json') // http://repo1.maven.org/maven2/com/github/fabriziocucci/yacl4j/
.expectRedirect(
- `/maven-metadata/v.json?label=maven-central&metadataUrl=${encodeURIComponent(
- 'https://repo1.maven.org/maven2/com/github/fabriziocucci/yacl4j/maven-metadata.xml'
- )}&versionPrefix=0.8`
+ `/maven-metadata/v.json?metadataUrl=${encodeURIComponent(
+ 'https://repo1.maven.org/maven2/com/github/fabriziocucci/yacl4j/maven-metadata.xml',
+ )}&label=maven-central&versionPrefix=0.8`,
)
diff --git a/services/maven-metadata/maven-metadata-redirect.tester.js b/services/maven-metadata/maven-metadata-redirect.tester.js
index ddd053e8bd129..0f34524979740 100644
--- a/services/maven-metadata/maven-metadata-redirect.tester.js
+++ b/services/maven-metadata/maven-metadata-redirect.tester.js
@@ -3,20 +3,20 @@ export const t = await createServiceTester()
t.create('maven metadata (badge extension)')
.get(
- '/http/central.maven.org/maven2/com/google/code/gson/gson/maven-metadata.xml.json'
+ '/http/central.maven.org/maven2/com/google/code/gson/gson/maven-metadata.xml.json',
)
.expectRedirect(
`/maven-metadata/v.json?metadataUrl=${encodeURIComponent(
- 'http://central.maven.org/maven2/com/google/code/gson/gson/maven-metadata.xml'
- )}`
+ 'http://central.maven.org/maven2/com/google/code/gson/gson/maven-metadata.xml',
+ )}`,
)
t.create('maven metadata (no badge extension)')
.get(
- '/http/central.maven.org/maven2/com/google/code/gson/gson/maven-metadata.xml'
+ '/http/central.maven.org/maven2/com/google/code/gson/gson/maven-metadata.xml',
)
.expectRedirect(
`/maven-metadata/v.svg?metadataUrl=${encodeURIComponent(
- 'http://central.maven.org/maven2/com/google/code/gson/gson/maven-metadata.xml'
- )}`
+ 'http://central.maven.org/maven2/com/google/code/gson/gson/maven-metadata.xml',
+ )}`,
)
diff --git a/services/maven-metadata/maven-metadata.js b/services/maven-metadata/maven-metadata.js
index b7f9865ea29cb..b217704ee760d 100644
--- a/services/maven-metadata/maven-metadata.js
+++ b/services/maven-metadata/maven-metadata.js
@@ -1,11 +1,29 @@
-'use strict'
+import { queryParams } from '../index.js'
-// the file contains common constants for badges uses maven-metadata
+const strategyEnum = ['highestVersion', 'releaseProperty', 'latestProperty']
-export const documentation = `
-
-versionPrefix and versionSuffix allow narrowing down
-the range of versions the badge will take into account,
-but they are completely optional.
-
+const strategyDocs = `The strategy used to determine the version that will be shown
+
+ highestVersion - sort versions using Maven's ComparableVersion semantics and pick the highest (default)
+ releaseProperty - use the "release" metadata property
+ latestProperty - use the "latest" metadata property
+ `
+
+const filterDocs = `
+The filter param can be used to apply a filter to the
+project's versions before selecting the latest from the list.
+Two constructs are available: * is a wildcard matching zero
+or more characters, and if the pattern starts with a !,
+the whole pattern is negated.
`
+const commonParams = queryParams(
+ {
+ name: 'strategy',
+ description: strategyDocs,
+ schema: { type: 'string', enum: strategyEnum },
+ example: 'highestVersion',
+ },
+ { name: 'filter', example: '*beta', description: filterDocs },
+)
+
+export { strategyEnum, commonParams }
diff --git a/services/maven-metadata/maven-metadata.service.js b/services/maven-metadata/maven-metadata.service.js
index ded8f75965bf5..e77f1eb7eab93 100644
--- a/services/maven-metadata/maven-metadata.service.js
+++ b/services/maven-metadata/maven-metadata.service.js
@@ -1,21 +1,43 @@
import Joi from 'joi'
-import { optionalUrl } from '../validators.js'
+import { matcher } from 'matcher'
+import { compare } from 'mvncmp'
+import { url } from '../validators.js'
import { renderVersionBadge } from '../version.js'
-import { BaseXmlService, NotFound } from '../index.js'
-import { documentation } from './maven-metadata.js'
+import {
+ BaseXmlService,
+ InvalidParameter,
+ InvalidResponse,
+ NotFound,
+ queryParams,
+} from '../index.js'
+import { strategyEnum, commonParams } from './maven-metadata.js'
const queryParamSchema = Joi.object({
- metadataUrl: optionalUrl.required(),
+ metadataUrl: url,
+ // versionPrefix and versionSuffix params are undocumented
+ // but supported for legacy compatibility
versionPrefix: Joi.string().optional(),
versionSuffix: Joi.string().optional(),
-}).required()
+ // filter is now the preferred way to do this
+ filter: Joi.string().optional(),
+ strategy: Joi.string()
+ .valid(...strategyEnum)
+ .default('highestVersion')
+ .optional(),
+})
+ // versionPrefix/Suffix are invalid
+ // when combined with filter
+ .oxor('filter', 'versionPrefix')
+ .oxor('filter', 'versionSuffix')
const schema = Joi.object({
metadata: Joi.object({
versioning: Joi.object({
+ latest: Joi.string(),
+ release: Joi.string(),
versions: Joi.object({
- version: Joi.array().items(Joi.string().required()).single().required(),
- }).required(),
+ version: Joi.array().items(Joi.string()).single(),
+ }),
}).required(),
}).required(),
}).required()
@@ -29,20 +51,22 @@ export default class MavenMetadata extends BaseXmlService {
queryParamSchema,
}
- static examples = [
- {
- title: 'Maven metadata URL',
- namedParams: {},
- queryParams: {
- metadataUrl:
- 'https://repo1.maven.org/maven2/com/google/guava/guava/maven-metadata.xml',
- versionPrefix: '29.',
- versionSuffix: '-android',
+ static openApi = {
+ '/maven-metadata/v': {
+ get: {
+ summary: 'Maven metadata URL',
+ parameters: queryParams(
+ {
+ name: 'metadataUrl',
+ example:
+ 'https://repo1.maven.org/maven2/com/google/guava/guava/maven-metadata.xml',
+ required: true,
+ },
+ ...commonParams,
+ ),
},
- staticPreview: renderVersionBadge({ version: '29.0-android' }),
- documentation,
},
- ]
+ }
static defaultBadgeData = {
label: 'maven',
@@ -52,28 +76,75 @@ export default class MavenMetadata extends BaseXmlService {
return this._requestXml({
schema,
url: metadataUrl,
- parserOptions: { parseNodeValue: false },
+ parserOptions: { parseTagValue: false },
})
}
- async handle(_namedParams, { metadataUrl, versionPrefix, versionSuffix }) {
- const data = await this.fetch({ metadataUrl })
- let versions = data.metadata.versioning.versions.version.reverse()
- if (versionPrefix !== undefined) {
- versions = versions.filter(v => v.toString().startsWith(versionPrefix))
+ static applyFilter({ versions, filter }) {
+ if (!filter) {
+ return versions
}
- if (versionSuffix !== undefined) {
- versions = versions.filter(v => v.toString().endsWith(versionSuffix))
+ return matcher(versions, filter)
+ }
+
+ static getLatestVersion({ data, strategy, filter }) {
+ if (strategy === 'latestProperty') {
+ if (data.metadata.versioning.latest === undefined) {
+ throw new InvalidResponse({
+ prettyMessage: "property 'latest' not found",
+ })
+ }
+ return data.metadata.versioning.latest
+ } else if (strategy === 'releaseProperty') {
+ if (data.metadata.versioning.release === undefined) {
+ throw new InvalidResponse({
+ prettyMessage: "property 'release' not found",
+ })
+ }
+ return data.metadata.versioning.release
+ } else if (strategy === 'highestVersion') {
+ if (
+ data.metadata.versioning.versions?.version === undefined ||
+ data.metadata.versioning.versions?.version?.length === 0
+ ) {
+ throw new InvalidResponse({
+ prettyMessage: 'no versions found',
+ })
+ }
+ const versions = this.applyFilter({
+ versions: data.metadata.versioning.versions.version,
+ filter,
+ })
+ if (versions.length === 0) {
+ throw new NotFound({ prettyMessage: 'no matching versions found' })
+ }
+ return versions.sort(compare).reverse()[0]
}
- const version = versions[0]
- // if the filter returned no results, throw a NotFound
+ throw new InvalidParameter({ prettyMessage: 'unknown strategy' })
+ }
+
+ async handle(
+ _namedParams,
+ { metadataUrl, versionPrefix, versionSuffix, strategy, filter },
+ ) {
if (
- (versionPrefix !== undefined || versionSuffix !== undefined) &&
- version === undefined
- )
- throw new NotFound({
- prettyMessage: 'version prefix or suffix not found',
+ (versionPrefix !== undefined ||
+ versionSuffix !== undefined ||
+ filter !== undefined) &&
+ strategy !== 'highestVersion'
+ ) {
+ throw new InvalidParameter({
+ prettyMessage: `filter is not valid with strategy ${strategy}`,
})
- return renderVersionBadge({ version })
+ }
+
+ if (versionPrefix !== undefined || versionSuffix !== undefined) {
+ filter = `${versionPrefix || ''}*${versionSuffix || ''}`
+ }
+
+ const data = await this.fetch({ metadataUrl })
+ return renderVersionBadge({
+ version: this.constructor.getLatestVersion({ data, strategy, filter }),
+ })
}
}
diff --git a/services/maven-metadata/maven-metadata.tester.js b/services/maven-metadata/maven-metadata.tester.js
index 4115bd7cdd136..024de93b16f8a 100644
--- a/services/maven-metadata/maven-metadata.tester.js
+++ b/services/maven-metadata/maven-metadata.tester.js
@@ -1,91 +1,185 @@
-import Joi from 'joi'
import { createServiceTester } from '../tester.js'
import { isVPlusDottedVersionAtLeastOne } from '../test-validators.js'
export const t = await createServiceTester()
+const mockMetaData = `
+
+ mocked-group-id
+ mocked-artifact-id
+
+ 1.31-beta1
+ 1.30
+
+ 1.0
+ 1.31-rc1
+ 1.31-beta1
+ 1.30
+
+ 20190902002617
+
+
+`
+
t.create('valid maven-metadata.xml uri')
.get(
- '/v.json?metadataUrl=https://repo1.maven.org/maven2/com/google/code/gson/gson/maven-metadata.xml'
+ '/v.json?metadataUrl=https://repo1.maven.org/maven2/com/google/code/gson/gson/maven-metadata.xml',
)
.expectBadge({
label: 'maven',
message: isVPlusDottedVersionAtLeastOne,
})
-t.create('with version prefix')
+t.create('release strategy')
.get(
- '/v.json?metadataUrl=https://repo1.maven.org/maven2/com/google/guava/guava/maven-metadata.xml&versionPrefix=27.'
+ '/v.json?metadataUrl=https://repo1.maven.org/maven2/mocked-group-id/mocked-artifact-id/maven-metadata.xml&strategy=releaseProperty',
)
- .expectBadge({
- label: 'maven',
- message: 'v27.1-jre',
- })
+ .intercept(nock =>
+ nock('https://repo1.maven.org/maven2')
+ .get('/mocked-group-id/mocked-artifact-id/maven-metadata.xml')
+ .reply(200, mockMetaData),
+ )
+ .expectBadge({ label: 'maven', message: 'v1.30' })
-t.create('with version suffix')
+t.create('latest strategy')
.get(
- '/v.json?metadataUrl=https://repo1.maven.org/maven2/com/google/guava/guava/maven-metadata.xml&versionSuffix=-android'
+ '/v.json?metadataUrl=https://repo1.maven.org/maven2/mocked-group-id/mocked-artifact-id/maven-metadata.xml&strategy=latestProperty',
)
- .expectBadge({
- label: 'maven',
- message: Joi.string().regex(/-android$/),
- })
+ .intercept(nock =>
+ nock('https://repo1.maven.org/maven2')
+ .get('/mocked-group-id/mocked-artifact-id/maven-metadata.xml')
+ .reply(200, mockMetaData),
+ )
+ .expectBadge({ label: 'maven', message: 'v1.31-beta1' })
-t.create('with version prefix and suffix')
+t.create('comparableVersion strategy')
.get(
- '/v.json?metadataUrl=https://repo1.maven.org/maven2/com/google/guava/guava/maven-metadata.xml&versionPrefix=27.&versionSuffix=-android'
+ '/v.json?metadataUrl=https://repo1.maven.org/maven2/mocked-group-id/mocked-artifact-id/maven-metadata.xml&strategy=highestVersion',
)
- .expectBadge({
- label: 'maven',
- message: 'v27.1-android',
- })
+ .intercept(nock =>
+ nock('https://repo1.maven.org/maven2')
+ .get('/mocked-group-id/mocked-artifact-id/maven-metadata.xml')
+ .reply(200, mockMetaData),
+ )
+ .expectBadge({ label: 'maven', message: 'v1.31-rc1' })
-t.create('version ending with zero')
+t.create('comparableVersion strategy with versionPrefix')
.get(
- '/v.json?metadataUrl=https://repo1.maven.org/maven2/mocked-group-id/mocked-artifact-id/maven-metadata.xml'
+ '/v.json?metadataUrl=https://repo1.maven.org/maven2/mocked-group-id/mocked-artifact-id/maven-metadata.xml&versionPrefix=1.31',
)
.intercept(nock =>
nock('https://repo1.maven.org/maven2')
.get('/mocked-group-id/mocked-artifact-id/maven-metadata.xml')
- .reply(
- 200,
- `
-
- mocked-group-id
- mocked-artifact-id
-
- 1.30
- 1.30
-
- 1.30
-
- 20190902002617
-
-
- `
- )
+ .reply(200, mockMetaData),
)
- .expectBadge({ label: 'maven', message: 'v1.30' })
+ .expectBadge({ label: 'maven', message: 'v1.31-rc1' })
+
+t.create('comparableVersion strategy with versionSuffix')
+ .get(
+ '/v.json?metadataUrl=https://repo1.maven.org/maven2/mocked-group-id/mocked-artifact-id/maven-metadata.xml&versionSuffix=1',
+ )
+ .intercept(nock =>
+ nock('https://repo1.maven.org/maven2')
+ .get('/mocked-group-id/mocked-artifact-id/maven-metadata.xml')
+ .reply(200, mockMetaData),
+ )
+ .expectBadge({ label: 'maven', message: 'v1.31-rc1' })
+
+t.create('comparableVersion strategy with versionPrefix and versionSuffix')
+ .get(
+ '/v.json?metadataUrl=https://repo1.maven.org/maven2/mocked-group-id/mocked-artifact-id/maven-metadata.xml&versionPrefix=1.31&versionSuffix=1',
+ )
+ .intercept(nock =>
+ nock('https://repo1.maven.org/maven2')
+ .get('/mocked-group-id/mocked-artifact-id/maven-metadata.xml')
+ .reply(200, mockMetaData),
+ )
+ .expectBadge({ label: 'maven', message: 'v1.31-rc1' })
+
+t.create('comparableVersion strategy with filter')
+ .get(
+ '/v.json?metadataUrl=https://repo1.maven.org/maven2/mocked-group-id/mocked-artifact-id/maven-metadata.xml&filter=*beta*',
+ )
+ .intercept(nock =>
+ nock('https://repo1.maven.org/maven2')
+ .get('/mocked-group-id/mocked-artifact-id/maven-metadata.xml')
+ .reply(200, mockMetaData),
+ )
+ .expectBadge({ label: 'maven', message: 'v1.31-beta1' })
+
+t.create('no versions matched')
+ .get(
+ '/v.json?metadataUrl=https://repo1.maven.org/maven2/mocked-group-id/mocked-artifact-id/maven-metadata.xml&filter=foobar',
+ )
+ .intercept(nock =>
+ nock('https://repo1.maven.org/maven2')
+ .get('/mocked-group-id/mocked-artifact-id/maven-metadata.xml')
+ .reply(200, mockMetaData),
+ )
+ .expectBadge({ label: 'maven', message: 'no matching versions found' })
t.create('invalid maven-metadata.xml uri')
.get(
- '/v.json?metadataUrl=https://repo1.maven.org/maven2/com/google/code/gson/gson/foobar.xml'
+ '/v.json?metadataUrl=https://repo1.maven.org/maven2/com/google/code/gson/gson/foobar.xml',
)
.expectBadge({ label: 'maven', message: 'not found' })
-t.create('inexistent version prefix')
+t.create('filter with latest strategy')
.get(
- '/v.json?metadataUrl=https://repo1.maven.org/maven2/com/github/fabriziocucci/yacl4j/maven-metadata.xml&versionPrefix=99'
+ '/v.json?metadataUrl=https://repo1.maven.org/maven2/mocked-group-id/mocked-artifact-id/maven-metadata.xml&strategy=latestProperty&filter=*beta*',
)
.expectBadge({
label: 'maven',
- message: 'version prefix or suffix not found',
+ message: 'filter is not valid with strategy latestProperty',
})
-t.create('inexistent version suffix')
+t.create('filter with release strategy')
.get(
- '/v.json?metadataUrl=https://repo1.maven.org/maven2/com/github/fabriziocucci/yacl4j/maven-metadata.xml&versionSuffix=test'
+ '/v.json?metadataUrl=https://repo1.maven.org/maven2/mocked-group-id/mocked-artifact-id/maven-metadata.xml&strategy=releaseProperty&filter=*beta*',
)
.expectBadge({
label: 'maven',
- message: 'version prefix or suffix not found',
+ message: 'filter is not valid with strategy releaseProperty',
})
+
+const emptyMockMetaData = `
+
+ mocked-group-id
+ mocked-artifact-id
+
+ 20190902002617
+
+
+`
+
+t.create('release not found')
+ .get(
+ '/v.json?metadataUrl=https://repo1.maven.org/maven2/mocked-group-id/mocked-artifact-id/maven-metadata.xml&strategy=releaseProperty',
+ )
+ .intercept(nock =>
+ nock('https://repo1.maven.org/maven2')
+ .get('/mocked-group-id/mocked-artifact-id/maven-metadata.xml')
+ .reply(200, emptyMockMetaData),
+ )
+ .expectBadge({ label: 'maven', message: "property 'release' not found" })
+
+t.create('latest not found')
+ .get(
+ '/v.json?metadataUrl=https://repo1.maven.org/maven2/mocked-group-id/mocked-artifact-id/maven-metadata.xml&strategy=latestProperty',
+ )
+ .intercept(nock =>
+ nock('https://repo1.maven.org/maven2')
+ .get('/mocked-group-id/mocked-artifact-id/maven-metadata.xml')
+ .reply(200, emptyMockMetaData),
+ )
+ .expectBadge({ label: 'maven', message: "property 'latest' not found" })
+
+t.create('no versions')
+ .get(
+ '/v.json?metadataUrl=https://repo1.maven.org/maven2/mocked-group-id/mocked-artifact-id/maven-metadata.xml&strategy=highestVersion',
+ )
+ .intercept(nock =>
+ nock('https://repo1.maven.org/maven2')
+ .get('/mocked-group-id/mocked-artifact-id/maven-metadata.xml')
+ .reply(200, emptyMockMetaData),
+ )
+ .expectBadge({ label: 'maven', message: 'no versions found' })
diff --git a/services/mbin/mbin.service.js b/services/mbin/mbin.service.js
new file mode 100644
index 0000000000000..46c9bc7f71afc
--- /dev/null
+++ b/services/mbin/mbin.service.js
@@ -0,0 +1,71 @@
+import Joi from 'joi'
+import { metric } from '../text-formatters.js'
+import { BaseJsonService, InvalidParameter, pathParams } from '../index.js'
+
+const schema = Joi.object({
+ subscriptionsCount: Joi.number().required(),
+}).required()
+
+export default class Mbin extends BaseJsonService {
+ static category = 'social'
+
+ static route = {
+ base: 'mbin',
+ pattern: ':magazine',
+ }
+
+ static openApi = {
+ '/mbin/{magazine}': {
+ get: {
+ summary: 'Mbin',
+ description:
+ 'Mbin is a fork of Kbin, a content aggregator for the Fediverse.',
+ parameters: pathParams({
+ name: 'magazine',
+ description:
+ 'The magazine to query. This is CASE SENSITIVE. Use URL encoding for special characters.',
+ example: 'kbinEarth@kbin.earth',
+ }),
+ },
+ },
+ }
+
+ static defaultBadgeData = { label: 'magazine', namedLogo: 'activitypub' }
+
+ static render({ magazine, members }) {
+ return {
+ label: `subscribe to ${magazine}`,
+ message: metric(members),
+ style: 'social',
+ color: 'brightgreen',
+ }
+ }
+
+ async fetch({ magazine }) {
+ const splitAlias = magazine.split('@')
+ // The magazine will be in the format of 'magazine@server'
+ if (splitAlias.length !== 2) {
+ throw new InvalidParameter({
+ prettyMessage: 'invalid magazine',
+ })
+ }
+
+ const mag = splitAlias[0]
+ const host = splitAlias[1]
+
+ const data = await this._requestJson({
+ url: `https://${host}/api/magazine/name/${mag}`,
+ schema,
+ httpErrors: {
+ 404: 'magazine not found',
+ },
+ })
+
+ return data.subscriptionsCount
+ }
+
+ async handle({ magazine }) {
+ const members = await this.fetch({ magazine })
+ return this.constructor.render({ magazine, members })
+ }
+}
diff --git a/services/mbin/mbin.tester.js b/services/mbin/mbin.tester.js
new file mode 100644
index 0000000000000..86547fee3271c
--- /dev/null
+++ b/services/mbin/mbin.tester.js
@@ -0,0 +1,43 @@
+import { isMetric } from '../test-validators.js'
+import { createServiceTester } from '../tester.js'
+export const t = await createServiceTester()
+
+t.create('get magazine subscribers')
+ .get('/magazine@instance.tld.json')
+ .intercept(nock =>
+ nock('https://instance.tld/')
+ .get('/api/magazine/name/magazine')
+ .reply(
+ 200,
+ JSON.stringify({
+ subscriptionsCount: 42,
+ }),
+ ),
+ )
+ .expectBadge({
+ label: 'subscribe to magazine@instance.tld',
+ message: '42',
+ color: 'brightgreen',
+ })
+
+t.create('unknown community')
+ .get('/01J12N2ETYG3W5B6G8Y11F5EXG@yups.io.json')
+ .expectBadge({
+ label: 'magazine',
+ message: 'magazine not found',
+ color: 'red',
+ })
+
+t.create('invalid magazine').get('/magazine.invalid.json').expectBadge({
+ label: 'magazine',
+ message: 'invalid magazine',
+ color: 'red',
+})
+
+t.create('test on real mbin magazine for API compliance')
+ .get('/kbinEarth@kbin.earth.json')
+ .expectBadge({
+ label: 'subscribe to kbinEarth@kbin.earth',
+ message: isMetric,
+ color: 'brightgreen',
+ })
diff --git a/services/microbadger/microbadger.service.js b/services/microbadger/microbadger.service.js
deleted file mode 100644
index 2d1eff78081e7..0000000000000
--- a/services/microbadger/microbadger.service.js
+++ /dev/null
@@ -1,11 +0,0 @@
-import { deprecatedService } from '../index.js'
-
-export default deprecatedService({
- category: 'build',
- route: {
- base: 'microbadger',
- pattern: ':various+',
- },
- label: 'microbadger',
- dateAdded: new Date('2021-07-03'),
-})
diff --git a/services/microbadger/microbadger.tester.js b/services/microbadger/microbadger.tester.js
deleted file mode 100644
index ac6b7ce145d2b..0000000000000
--- a/services/microbadger/microbadger.tester.js
+++ /dev/null
@@ -1,34 +0,0 @@
-import { ServiceTester } from '../tester.js'
-
-export const t = new ServiceTester({
- id: 'microbadger',
- title: 'Microbadger',
-})
-
-t.create('no longer available (previously image size)')
- .get('/image-size/fedora/apache.json')
- .expectBadge({
- label: 'microbadger',
- message: 'no longer available',
- })
-
-t.create('no longer available (previously image size with tag)')
- .get('/image-size/fedora/apache/latest.json')
- .expectBadge({
- label: 'microbadger',
- message: 'no longer available',
- })
-
-t.create('no longer available (previously layers)')
- .get('/layers/fedora/apache.json')
- .expectBadge({
- label: 'microbadger',
- message: 'no longer available',
- })
-
-t.create('no longer available (previously layers with tag)')
- .get('/layers/fedora/apache/latest.json')
- .expectBadge({
- label: 'microbadger',
- message: 'no longer available',
- })
diff --git a/services/modrinth/modrinth-base.js b/services/modrinth/modrinth-base.js
new file mode 100644
index 0000000000000..1afc68eaa0bd0
--- /dev/null
+++ b/services/modrinth/modrinth-base.js
@@ -0,0 +1,38 @@
+import Joi from 'joi'
+import { BaseJsonService } from '../index.js'
+import { nonNegativeInteger } from '../validators.js'
+
+const projectSchema = Joi.object({
+ downloads: nonNegativeInteger,
+ followers: nonNegativeInteger,
+}).required()
+
+const versionSchema = Joi.array()
+ .items(
+ Joi.object({
+ version_number: Joi.string().required(),
+ game_versions: Joi.array().items(Joi.string()).min(1).required(),
+ }).required(),
+ )
+ .required()
+
+const description =
+ "You can use your project slug, or the project ID. The ID can be found in the 'Technical information' section of your Modrinth page.
"
+
+class BaseModrinthService extends BaseJsonService {
+ async fetchVersions({ projectId }) {
+ return this._requestJson({
+ schema: versionSchema,
+ url: `https://api.modrinth.com/v2/project/${projectId}/version`,
+ })
+ }
+
+ async fetchProject({ projectId }) {
+ return this._requestJson({
+ schema: projectSchema,
+ url: `https://api.modrinth.com/v2/project/${projectId}`,
+ })
+ }
+}
+
+export { BaseModrinthService, description }
diff --git a/services/modrinth/modrinth-downloads.service.js b/services/modrinth/modrinth-downloads.service.js
new file mode 100644
index 0000000000000..ca8e1d8324d1f
--- /dev/null
+++ b/services/modrinth/modrinth-downloads.service.js
@@ -0,0 +1,32 @@
+import { pathParams } from '../index.js'
+import { renderDownloadsBadge } from '../downloads.js'
+import { BaseModrinthService, description } from './modrinth-base.js'
+
+export default class ModrinthDownloads extends BaseModrinthService {
+ static category = 'downloads'
+
+ static route = {
+ base: 'modrinth/dt',
+ pattern: ':projectId',
+ }
+
+ static openApi = {
+ '/modrinth/dt/{projectId}': {
+ get: {
+ summary: 'Modrinth Downloads',
+ description,
+ parameters: pathParams({
+ name: 'projectId',
+ example: 'AANobbMI',
+ }),
+ },
+ },
+ }
+
+ static defaultBadgeData = { label: 'downloads' }
+
+ async handle({ projectId }) {
+ const { downloads } = await this.fetchProject({ projectId })
+ return renderDownloadsBadge({ downloads })
+ }
+}
diff --git a/services/modrinth/modrinth-downloads.tester.js b/services/modrinth/modrinth-downloads.tester.js
new file mode 100644
index 0000000000000..26ac709ab67fd
--- /dev/null
+++ b/services/modrinth/modrinth-downloads.tester.js
@@ -0,0 +1,12 @@
+import { createServiceTester } from '../tester.js'
+import { isMetric } from '../test-validators.js'
+
+export const t = await createServiceTester()
+
+t.create('Downloads')
+ .get('/AANobbMI.json')
+ .expectBadge({ label: 'downloads', message: isMetric })
+
+t.create('Downloads (not found)')
+ .get('/not-existing.json')
+ .expectBadge({ label: 'downloads', message: 'not found', color: 'red' })
diff --git a/services/modrinth/modrinth-followers.service.js b/services/modrinth/modrinth-followers.service.js
new file mode 100644
index 0000000000000..b845768f8f3ab
--- /dev/null
+++ b/services/modrinth/modrinth-followers.service.js
@@ -0,0 +1,40 @@
+import { pathParams } from '../index.js'
+import { metric } from '../text-formatters.js'
+import { BaseModrinthService, description } from './modrinth-base.js'
+
+export default class ModrinthFollowers extends BaseModrinthService {
+ static category = 'social'
+
+ static route = {
+ base: 'modrinth/followers',
+ pattern: ':projectId',
+ }
+
+ static openApi = {
+ '/modrinth/followers/{projectId}': {
+ get: {
+ summary: 'Modrinth Followers',
+ description,
+ parameters: pathParams({
+ name: 'projectId',
+ example: 'AANobbMI',
+ }),
+ },
+ },
+ }
+
+ static defaultBadgeData = { label: 'followers', namedLogo: 'modrinth' }
+
+ static render({ followers }) {
+ return {
+ message: metric(followers),
+ style: 'social',
+ color: 'blue',
+ }
+ }
+
+ async handle({ projectId }) {
+ const { followers } = await this.fetchProject({ projectId })
+ return this.constructor.render({ followers })
+ }
+}
diff --git a/services/modrinth/modrinth-followers.tester.js b/services/modrinth/modrinth-followers.tester.js
new file mode 100644
index 0000000000000..2e39bc464ecf9
--- /dev/null
+++ b/services/modrinth/modrinth-followers.tester.js
@@ -0,0 +1,12 @@
+import { createServiceTester } from '../tester.js'
+import { isMetric } from '../test-validators.js'
+
+export const t = await createServiceTester()
+
+t.create('Followers')
+ .get('/AANobbMI.json')
+ .expectBadge({ label: 'followers', message: isMetric })
+
+t.create('Followers (not found)')
+ .get('/not-existing.json')
+ .expectBadge({ label: 'followers', message: 'not found', color: 'red' })
diff --git a/services/modrinth/modrinth-game-versions.service.js b/services/modrinth/modrinth-game-versions.service.js
new file mode 100644
index 0000000000000..d278a050d077c
--- /dev/null
+++ b/services/modrinth/modrinth-game-versions.service.js
@@ -0,0 +1,45 @@
+import { pathParams } from '../index.js'
+import { BaseModrinthService, description } from './modrinth-base.js'
+
+export default class ModrinthGameVersions extends BaseModrinthService {
+ static category = 'platform-support'
+
+ static route = {
+ base: 'modrinth/game-versions',
+ pattern: ':projectId',
+ }
+
+ static openApi = {
+ '/modrinth/game-versions/{projectId}': {
+ get: {
+ summary: 'Modrinth Game Versions',
+ description,
+ parameters: pathParams({
+ name: 'projectId',
+ example: 'AANobbMI',
+ }),
+ },
+ },
+ }
+
+ static defaultBadgeData = { label: 'game versions' }
+
+ static render({ versions }) {
+ if (versions.length > 5) {
+ return {
+ message: `${versions[0]} | ${versions[1]} | ... | ${versions[versions.length - 2]} | ${versions[versions.length - 1]}`,
+ color: 'blue',
+ }
+ }
+ return {
+ message: versions.join(' | '),
+ color: 'blue',
+ }
+ }
+
+ async handle({ projectId }) {
+ const { 0: latest } = await this.fetchVersions({ projectId })
+ const versions = latest.game_versions
+ return this.constructor.render({ versions })
+ }
+}
diff --git a/services/modrinth/modrinth-game-versions.spec.js b/services/modrinth/modrinth-game-versions.spec.js
new file mode 100644
index 0000000000000..bf2b781578688
--- /dev/null
+++ b/services/modrinth/modrinth-game-versions.spec.js
@@ -0,0 +1,22 @@
+import { test, given } from 'sazerac'
+import ModrinthGameVersions from './modrinth-game-versions.service.js'
+
+describe('render function', function () {
+ it('displays up to five versions', async function () {
+ test(ModrinthGameVersions.render, () => {
+ given({ versions: ['1.1', '1.2', '1.3', '1.4', '1.5'] }).expect({
+ message: '1.1 | 1.2 | 1.3 | 1.4 | 1.5',
+ color: 'blue',
+ })
+ })
+ })
+
+ it('uses ellipsis for six versions or more', async function () {
+ test(ModrinthGameVersions.render, () => {
+ given({ versions: ['1.1', '1.2', '1.3', '1.4', '1.5', '1.6'] }).expect({
+ message: '1.1 | 1.2 | ... | 1.5 | 1.6',
+ color: 'blue',
+ })
+ })
+ })
+})
diff --git a/services/modrinth/modrinth-game-versions.tester.js b/services/modrinth/modrinth-game-versions.tester.js
new file mode 100644
index 0000000000000..4820c8a3b371d
--- /dev/null
+++ b/services/modrinth/modrinth-game-versions.tester.js
@@ -0,0 +1,21 @@
+import Joi from 'joi'
+import { createServiceTester } from '../tester.js'
+import { withRegex } from '../test-validators.js'
+
+export const t = await createServiceTester()
+
+t.create('Game Versions')
+ .get('/AANobbMI.json')
+ .expectBadge({
+ label: 'game versions',
+ message: Joi.alternatives().try(
+ withRegex(/^(\d+\.\d+(\.\d+)?( \| )?)+$/),
+ withRegex(
+ /^\d+\.\d+(\.\d+)? \| \d+\.\d+(\.\d+)? \| \.\.\. \| \d+\.\d+(\.\d+)? \| \d+\.\d+(\.\d+)?$/,
+ ),
+ ),
+ })
+
+t.create('Game Versions (not found)')
+ .get('/not-existing.json')
+ .expectBadge({ label: 'game versions', message: 'not found', color: 'red' })
diff --git a/services/modrinth/modrinth-version.service.js b/services/modrinth/modrinth-version.service.js
new file mode 100644
index 0000000000000..93e351f8b7a9f
--- /dev/null
+++ b/services/modrinth/modrinth-version.service.js
@@ -0,0 +1,33 @@
+import { pathParams } from '../index.js'
+import { renderVersionBadge } from '../version.js'
+import { BaseModrinthService, description } from './modrinth-base.js'
+
+export default class ModrinthVersion extends BaseModrinthService {
+ static category = 'version'
+
+ static route = {
+ base: 'modrinth/v',
+ pattern: ':projectId',
+ }
+
+ static openApi = {
+ '/modrinth/v/{projectId}': {
+ get: {
+ summary: 'Modrinth Version',
+ description,
+ parameters: pathParams({
+ name: 'projectId',
+ example: 'AANobbMI',
+ }),
+ },
+ },
+ }
+
+ static defaultBadgeData = { label: 'version' }
+
+ async handle({ projectId }) {
+ const { 0: latest } = await this.fetchVersions({ projectId })
+ const version = latest.version_number
+ return renderVersionBadge({ version })
+ }
+}
diff --git a/services/modrinth/modrinth-version.tester.js b/services/modrinth/modrinth-version.tester.js
new file mode 100644
index 0000000000000..58366755d0642
--- /dev/null
+++ b/services/modrinth/modrinth-version.tester.js
@@ -0,0 +1,12 @@
+import { createServiceTester } from '../tester.js'
+import { withRegex } from '../test-validators.js'
+
+export const t = await createServiceTester()
+
+t.create('Version')
+ .get('/AANobbMI.json')
+ .expectBadge({ label: 'version', message: withRegex(/.*\d+\.\d+(\.d+)?.*/) })
+
+t.create('Version (not found)')
+ .get('/not-existing.json')
+ .expectBadge({ label: 'version', message: 'not found', color: 'red' })
diff --git a/services/mozilla-observatory/mozilla-observatory.service.js b/services/mozilla-observatory/mozilla-observatory.service.js
index 8f436056c2d0d..a0d47d4d839d1 100644
--- a/services/mozilla-observatory/mozilla-observatory.service.js
+++ b/services/mozilla-observatory/mozilla-observatory.service.js
@@ -1,46 +1,17 @@
import Joi from 'joi'
-import { BaseJsonService } from '../index.js'
+import { BaseJsonService, pathParam } from '../index.js'
const schema = Joi.object({
- state: Joi.string()
- .valid('ABORTED', 'FAILED', 'FINISHED', 'PENDING', 'STARTING', 'RUNNING')
+ grade: Joi.string()
+ .regex(/^[ABCDEF][+-]?$/)
.required(),
- grade: Joi.alternatives()
- .conditional('state', {
- is: 'FINISHED',
- then: Joi.string().regex(/^[ABCDEF][+-]?$/),
- otherwise: Joi.valid(null),
- })
- .required(),
- score: Joi.alternatives()
- .conditional('state', {
- is: 'FINISHED',
- then: Joi.number().integer().min(0).max(200),
- otherwise: Joi.valid(null),
- })
- .required(),
-}).required()
-
-const queryParamSchema = Joi.object({
- publish: Joi.equal(''),
+ score: Joi.number().integer().min(0).max(200).required(),
}).required()
-const documentation = `
-
- The Mozilla HTTP Observatory
- is a set of tools to analyze your website
- and inform you if you are utilizing the many available methods to secure it.
-
-
- By default the scan result is hidden from the public result list.
- You can activate the publication of the scan result
- by setting the publish parameter.
-
-
- The badge returns a cached site result if the site has been scanned anytime in the previous 24 hours.
- If you need to force invalidating the cache,
- you can to do it manually through the Mozilla Observatory Website
-
+const description = `
+The [Mozilla HTTP Observatory](https://developer.mozilla.org/en-US/observatory)
+is a set of security tools to analyze your website
+and inform you if you are utilizing the many available methods to secure it.
`
export default class MozillaObservatory extends BaseJsonService {
@@ -51,36 +22,33 @@ export default class MozillaObservatory extends BaseJsonService {
static route = {
base: 'mozilla-observatory',
pattern: ':format(grade|grade-score)/:host',
- queryParamSchema,
}
- static examples = [
- {
- title: 'Mozilla HTTP Observatory Grade',
- namedParams: { format: 'grade', host: 'github.com' },
- staticPreview: this.render({
- format: 'grade',
- state: 'FINISHED',
- grade: 'A+',
- score: 115,
- }),
- queryParams: { publish: null },
- keywords: ['scanner', 'security'],
- documentation,
+ static openApi = {
+ '/mozilla-observatory/{format}/{host}': {
+ get: {
+ summary: 'Mozilla HTTP Observatory Grade',
+ description,
+ parameters: [
+ pathParam({
+ name: 'format',
+ example: 'grade',
+ schema: { type: 'string', enum: this.getEnum('format') },
+ }),
+ pathParam({
+ name: 'host',
+ example: 'github.com',
+ }),
+ ],
+ },
},
- ]
+ }
static defaultBadgeData = {
label: 'observatory',
}
- static render({ format, state, grade, score }) {
- if (state !== 'FINISHED') {
- return {
- message: state.toLowerCase(),
- color: 'lightgrey',
- }
- }
+ static render({ format, grade, score }) {
const letter = grade[0].toLowerCase()
const colorMap = {
a: 'brightgreen',
@@ -96,20 +64,23 @@ export default class MozillaObservatory extends BaseJsonService {
}
}
- async fetch({ host, publish }) {
+ async fetch({ host }) {
return this._requestJson({
schema,
- url: `https://http-observatory.security.mozilla.org/api/v1/analyze`,
+ url: 'https://observatory-api.mdn.mozilla.net/api/v2/scan',
options: {
method: 'POST',
- qs: { host },
- form: { hidden: !publish },
+ searchParams: { host },
},
})
}
- async handle({ format, host }, { publish }) {
- const { state, grade, score } = await this.fetch({ host, publish })
- return this.constructor.render({ format, state, grade, score })
+ async handle({ format, host }) {
+ const scan = await this.fetch({ host })
+ return this.constructor.render({
+ format,
+ grade: scan.grade,
+ score: scan.score,
+ })
}
}
diff --git a/services/mozilla-observatory/mozilla-observatory.spec.js b/services/mozilla-observatory/mozilla-observatory.spec.js
new file mode 100644
index 0000000000000..a2e73c40bbb9b
--- /dev/null
+++ b/services/mozilla-observatory/mozilla-observatory.spec.js
@@ -0,0 +1,93 @@
+import { test, given } from 'sazerac'
+import MozillaObservatory from './mozilla-observatory.service.js'
+
+describe('MozillaObservatory', function () {
+ test(MozillaObservatory.render, () => {
+ given({ format: 'grade', grade: 'A' }).expect({
+ message: 'A',
+ color: 'brightgreen',
+ })
+ given({ format: 'grade', grade: 'A+' }).expect({
+ message: 'A+',
+ color: 'brightgreen',
+ })
+ given({ format: 'grade', grade: 'A-' }).expect({
+ message: 'A-',
+ color: 'brightgreen',
+ })
+
+ given({ format: 'grade', grade: 'B' }).expect({
+ message: 'B',
+ color: 'green',
+ })
+ given({ format: 'grade', grade: 'B+' }).expect({
+ message: 'B+',
+ color: 'green',
+ })
+ given({ format: 'grade', grade: 'B-' }).expect({
+ message: 'B-',
+ color: 'green',
+ })
+
+ given({ format: 'grade', grade: 'C' }).expect({
+ message: 'C',
+ color: 'yellow',
+ })
+ given({ format: 'grade', grade: 'C+' }).expect({
+ message: 'C+',
+ color: 'yellow',
+ })
+ given({ format: 'grade', grade: 'C-' }).expect({
+ message: 'C-',
+ color: 'yellow',
+ })
+
+ given({ format: 'grade', grade: 'D' }).expect({
+ message: 'D',
+ color: 'orange',
+ })
+ given({ format: 'grade', grade: 'D+' }).expect({
+ message: 'D+',
+ color: 'orange',
+ })
+ given({ format: 'grade', grade: 'D-' }).expect({
+ message: 'D-',
+ color: 'orange',
+ })
+
+ given({ format: 'grade', grade: 'E' }).expect({
+ message: 'E',
+ color: 'orange',
+ })
+ given({ format: 'grade', grade: 'E+' }).expect({
+ message: 'E+',
+ color: 'orange',
+ })
+ given({ format: 'grade', grade: 'E-' }).expect({
+ message: 'E-',
+ color: 'orange',
+ })
+
+ given({ format: 'grade', grade: 'F' }).expect({
+ message: 'F',
+ color: 'red',
+ })
+ given({ format: 'grade', grade: 'F+' }).expect({
+ message: 'F+',
+ color: 'red',
+ })
+ given({ format: 'grade', grade: 'F-' }).expect({
+ message: 'F-',
+ color: 'red',
+ })
+
+ given({
+ format: 'grade-score',
+ grade: 'A',
+ score: '115',
+ }).expect({
+ message: 'A (115/100)',
+ color: 'brightgreen',
+ })
+ })
+})
diff --git a/services/mozilla-observatory/mozilla-observatory.tester.js b/services/mozilla-observatory/mozilla-observatory.tester.js
index b3f73f658d512..92fd1cd103b4e 100644
--- a/services/mozilla-observatory/mozilla-observatory.tester.js
+++ b/services/mozilla-observatory/mozilla-observatory.tester.js
@@ -3,323 +3,17 @@ import { createServiceTester } from '../tester.js'
export const t = await createServiceTester()
const isMessage = Joi.alternatives()
- .try(
- Joi.string().regex(/^[ABCDEF][+-]? \([0-9]{1,3}\/100\)$/),
- Joi.string().allow('pending')
- )
+ .try(Joi.string().regex(/^[ABCDEF][+-]? \([0-9]{1,3}\/100\)$/))
.required()
-t.create('request on observatory.mozilla.org')
- .timeout(10000)
- .get('/grade-score/observatory.mozilla.org.json')
- .expectBadge({
- label: 'observatory',
- message: isMessage,
- })
-
-t.create('request on observatory.mozilla.org with inclusion in public results')
- .timeout(10000)
- .get('/grade-score/observatory.mozilla.org.json?publish')
- .expectBadge({
- label: 'observatory',
- message: isMessage,
- })
-
-t.create('grade without score (mock)')
- .get('/grade/foo.bar.json')
- .intercept(nock =>
- nock('https://http-observatory.security.mozilla.org')
- .post('/api/v1/analyze?host=foo.bar')
- .reply(200, { state: 'FINISHED', grade: 'A', score: 115 })
- )
- .expectBadge({
- label: 'observatory',
- message: 'A',
- color: 'brightgreen',
- })
-
-t.create('grade A with score (mock)')
- .get('/grade-score/foo.bar.json')
- .intercept(nock =>
- nock('https://http-observatory.security.mozilla.org')
- .post('/api/v1/analyze?host=foo.bar')
- .reply(200, { state: 'FINISHED', grade: 'A', score: 115 })
- )
- .expectBadge({
- label: 'observatory',
- message: 'A (115/100)',
- color: 'brightgreen',
- })
-
-t.create('grade A+ with score (mock)')
- .get('/grade-score/foo.bar.json')
- .intercept(nock =>
- nock('https://http-observatory.security.mozilla.org')
- .post('/api/v1/analyze?host=foo.bar')
- .reply(200, { state: 'FINISHED', grade: 'A+', score: 115 })
- )
- .expectBadge({
- label: 'observatory',
- message: 'A+ (115/100)',
- color: 'brightgreen',
- })
-
-t.create('grade A- with score (mock)')
- .get('/grade-score/foo.bar.json')
- .intercept(nock =>
- nock('https://http-observatory.security.mozilla.org')
- .post('/api/v1/analyze?host=foo.bar')
- .reply(200, { state: 'FINISHED', grade: 'A-', score: 115 })
- )
- .expectBadge({
- label: 'observatory',
- message: 'A- (115/100)',
- color: 'brightgreen',
- })
-
-t.create('grade B with score (mock)')
- .get('/grade-score/foo.bar.json')
- .intercept(nock =>
- nock('https://http-observatory.security.mozilla.org')
- .post('/api/v1/analyze?host=foo.bar')
- .reply(200, { state: 'FINISHED', grade: 'B', score: 115 })
- )
- .expectBadge({
- label: 'observatory',
- message: 'B (115/100)',
- color: 'green',
- })
-
-t.create('grade B+ with score (mock)')
- .get('/grade-score/foo.bar.json')
- .intercept(nock =>
- nock('https://http-observatory.security.mozilla.org')
- .post('/api/v1/analyze?host=foo.bar')
- .reply(200, { state: 'FINISHED', grade: 'B+', score: 115 })
- )
- .expectBadge({
- label: 'observatory',
- message: 'B+ (115/100)',
- color: 'green',
- })
-
-t.create('grade B- with score (mock)')
- .get('/grade-score/foo.bar.json')
- .intercept(nock =>
- nock('https://http-observatory.security.mozilla.org')
- .post('/api/v1/analyze?host=foo.bar')
- .reply(200, { state: 'FINISHED', grade: 'B-', score: 115 })
- )
- .expectBadge({
- label: 'observatory',
- message: 'B- (115/100)',
- color: 'green',
- })
-
-t.create('grade C with score (mock)')
- .get('/grade-score/foo.bar.json')
- .intercept(nock =>
- nock('https://http-observatory.security.mozilla.org')
- .post('/api/v1/analyze?host=foo.bar')
- .reply(200, { state: 'FINISHED', grade: 'C', score: 80 })
- )
- .expectBadge({
- label: 'observatory',
- message: 'C (80/100)',
- color: 'yellow',
- })
-
-t.create('grade C+ with score (mock)')
- .get('/grade-score/foo.bar.json')
- .intercept(nock =>
- nock('https://http-observatory.security.mozilla.org')
- .post('/api/v1/analyze?host=foo.bar')
- .reply(200, { state: 'FINISHED', grade: 'C+', score: 80 })
- )
- .expectBadge({
- label: 'observatory',
- message: 'C+ (80/100)',
- color: 'yellow',
- })
-
-t.create('grade C- with score (mock)')
- .get('/grade-score/foo.bar.json')
- .intercept(nock =>
- nock('https://http-observatory.security.mozilla.org')
- .post('/api/v1/analyze?host=foo.bar')
- .reply(200, { state: 'FINISHED', grade: 'C-', score: 80 })
- )
- .expectBadge({
- label: 'observatory',
- message: 'C- (80/100)',
- color: 'yellow',
- })
-
-t.create('grade D with score (mock)')
- .get('/grade-score/foo.bar.json')
- .intercept(nock =>
- nock('https://http-observatory.security.mozilla.org')
- .post('/api/v1/analyze?host=foo.bar')
- .reply(200, { state: 'FINISHED', grade: 'D', score: 15 })
- )
- .expectBadge({
- label: 'observatory',
- message: 'D (15/100)',
- color: 'orange',
- })
-
-t.create('grade D+ with score (mock)')
- .get('/grade-score/foo.bar.json')
- .intercept(nock =>
- nock('https://http-observatory.security.mozilla.org')
- .post('/api/v1/analyze?host=foo.bar')
- .reply(200, { state: 'FINISHED', grade: 'D+', score: 15 })
- )
- .expectBadge({
- label: 'observatory',
- message: 'D+ (15/100)',
- color: 'orange',
- })
-
-t.create('grade D- with score (mock)')
- .get('/grade-score/foo.bar.json')
- .intercept(nock =>
- nock('https://http-observatory.security.mozilla.org')
- .post('/api/v1/analyze?host=foo.bar')
- .reply(200, { state: 'FINISHED', grade: 'D-', score: 15 })
- )
- .expectBadge({
- label: 'observatory',
- message: 'D- (15/100)',
- color: 'orange',
- })
-
-t.create('grade E with score (mock)')
- .get('/grade-score/foo.bar.json')
- .intercept(nock =>
- nock('https://http-observatory.security.mozilla.org')
- .post('/api/v1/analyze?host=foo.bar')
- .reply(200, { state: 'FINISHED', grade: 'E', score: 15 })
- )
- .expectBadge({
- label: 'observatory',
- message: 'E (15/100)',
- color: 'orange',
- })
-
-t.create('grade E+ with score (mock)')
- .get('/grade-score/foo.bar.json')
- .intercept(nock =>
- nock('https://http-observatory.security.mozilla.org')
- .post('/api/v1/analyze?host=foo.bar')
- .reply(200, { state: 'FINISHED', grade: 'E+', score: 15 })
- )
- .expectBadge({
- label: 'observatory',
- message: 'E+ (15/100)',
- color: 'orange',
- })
-
-t.create('grade E- with score (mock)')
- .get('/grade-score/foo.bar.json')
- .intercept(nock =>
- nock('https://http-observatory.security.mozilla.org')
- .post('/api/v1/analyze?host=foo.bar')
- .reply(200, { state: 'FINISHED', grade: 'E-', score: 15 })
- )
- .expectBadge({
- label: 'observatory',
- message: 'E- (15/100)',
- color: 'orange',
- })
-
-t.create('grade F with score (mock)')
- .get('/grade-score/foo.bar.json')
- .intercept(nock =>
- nock('https://http-observatory.security.mozilla.org')
- .post('/api/v1/analyze?host=foo.bar')
- .reply(200, { state: 'FINISHED', grade: 'F', score: 0 })
- )
- .expectBadge({
- label: 'observatory',
- message: 'F (0/100)',
- color: 'red',
- })
-
-t.create('aborted (mock)')
- .get('/grade-score/foo.bar.json')
- .intercept(nock =>
- nock('https://http-observatory.security.mozilla.org')
- .post('/api/v1/analyze?host=foo.bar')
- .reply(200, { state: 'ABORTED', grade: null, score: null })
- )
- .expectBadge({
- label: 'observatory',
- message: 'aborted',
- color: 'lightgrey',
- })
-
-t.create('failed (mock)')
- .get('/grade-score/foo.bar.json')
- .intercept(nock =>
- nock('https://http-observatory.security.mozilla.org')
- .post('/api/v1/analyze?host=foo.bar')
- .reply(200, { state: 'FAILED', grade: null, score: null })
- )
- .expectBadge({
- label: 'observatory',
- message: 'failed',
- color: 'lightgrey',
- })
-
-t.create('pending (mock)')
- .get('/grade-score/foo.bar.json')
- .intercept(nock =>
- nock('https://http-observatory.security.mozilla.org')
- .post('/api/v1/analyze?host=foo.bar')
- .reply(200, { state: 'PENDING', grade: null, score: null })
- )
- .expectBadge({
- label: 'observatory',
- message: 'pending',
- color: 'lightgrey',
- })
-
-t.create('starting (mock)')
- .get('/grade-score/foo.bar.json')
- .intercept(nock =>
- nock('https://http-observatory.security.mozilla.org')
- .post('/api/v1/analyze?host=foo.bar')
- .reply(200, { state: 'STARTING', grade: null, score: null })
- )
- .expectBadge({
- label: 'observatory',
- message: 'starting',
- color: 'lightgrey',
- })
-
-t.create('running (mock)')
- .get('/grade-score/foo.bar.json')
- .intercept(nock =>
- nock('https://http-observatory.security.mozilla.org')
- .post('/api/v1/analyze?host=foo.bar')
- .reply(200, { state: 'RUNNING', grade: null, score: null })
- )
- .expectBadge({
- label: 'observatory',
- message: 'running',
- color: 'lightgrey',
- })
+t.create('valid').get('/grade-score/observatory.mozilla.org.json').expectBadge({
+ label: 'observatory',
+ message: isMessage,
+})
-t.create('invalid response with grade and score but not finished (mock)')
- .get('/grade-score/foo.bar.json')
- .intercept(nock =>
- nock('https://http-observatory.security.mozilla.org')
- .post('/api/v1/analyze?host=foo.bar')
- .reply(200, { state: 'RUNNING', grade: 'A+', score: 135 })
- )
+t.create('invalid')
+ .get('/grade-score/invalidsubdomain.shields.io.json')
.expectBadge({
label: 'observatory',
- message: 'invalid response data',
- color: 'lightgrey',
+ message: 'invalid',
})
diff --git a/services/myget/myget.service.js b/services/myget/myget.service.js
index 0037bdd65d460..f3e8ebfd340a0 100644
--- a/services/myget/myget.service.js
+++ b/services/myget/myget.service.js
@@ -1,3 +1,4 @@
+import { pathParams } from '../index.js'
import { createServiceFamily } from '../nuget/nuget-v3-service-family.js'
const { NugetVersionService: Version, NugetDownloadService: Downloads } =
@@ -8,51 +9,73 @@ const { NugetVersionService: Version, NugetDownloadService: Downloads } =
})
class MyGetVersionService extends Version {
- static examples = [
- {
- title: 'MyGet',
- pattern: 'myget/:feed/v/:packageName',
- namedParams: { feed: 'mongodb', packageName: 'MongoDB.Driver.Core' },
- staticPreview: this.render({ version: '2.6.1' }),
- },
- {
- title: 'MyGet (with prereleases)',
- pattern: 'myget/:feed/vpre/:packageName',
- namedParams: { feed: 'mongodb', packageName: 'MongoDB.Driver.Core' },
- staticPreview: this.render({ version: '2.7.0-beta0001' }),
+ static openApi = {
+ '/myget/{feed}/{variant}/{packageName}': {
+ get: {
+ summary: 'MyGet Version',
+ parameters: pathParams(
+ { name: 'feed', example: 'mongodb' },
+ {
+ name: 'variant',
+ example: 'v',
+ schema: { type: 'variant', enum: ['v', 'vpre'] },
+ description:
+ 'Latest stable version (`v`) or Latest version including prereleases (`vpre`).',
+ },
+ { name: 'packageName', example: 'MongoDB.Driver.Core' },
+ ),
+ },
},
- {
- title: 'MyGet tenant',
- pattern: ':tenant.myget/:feed/v/:packageName',
- namedParams: {
- tenant: 'tizen',
- feed: 'dotnet',
- packageName: 'Tizen.NET',
+ '/{tenant}/{feed}/{variant}/{packageName}': {
+ get: {
+ summary: 'MyGet Version (tenant)',
+ parameters: pathParams(
+ {
+ name: 'tenant',
+ example: 'vs-devcore.myget',
+ description: 'MyGet Tenant in the format `name.myget`',
+ },
+ { name: 'feed', example: 'vs-devcore' },
+ {
+ name: 'variant',
+ example: 'v',
+ schema: { type: 'variant', enum: ['v', 'vpre'] },
+ description:
+ 'Latest stable version (`v`) or Latest version including prereleases (`vpre`).',
+ },
+ { name: 'packageName', example: 'MicroBuild' },
+ ),
},
- staticPreview: this.render({ version: '9.0.0.16564' }),
},
- ]
+ }
}
class MyGetDownloadService extends Downloads {
- static examples = [
- {
- title: 'MyGet',
- pattern: 'myget/:feed/dt/:packageName',
- namedParams: { feed: 'mongodb', packageName: 'MongoDB.Driver.Core' },
- staticPreview: this.render({ downloads: 419 }),
+ static openApi = {
+ '/myget/{feed}/dt/{packageName}': {
+ get: {
+ summary: 'MyGet Downloads',
+ parameters: pathParams(
+ { name: 'feed', example: 'mongodb' },
+ { name: 'packageName', example: 'MongoDB.Driver.Core' },
+ ),
+ },
},
- {
- title: 'MyGet tenant',
- pattern: ':tenant.myget/:feed/dt/:packageName',
- namedParams: {
- tenant: 'cefsharp',
- feed: 'cefsharp',
- packageName: 'CefSharp.Common',
+ '/{tenant}/{feed}/dt/{packageName}': {
+ get: {
+ summary: 'MyGet Downloads (tenant)',
+ parameters: pathParams(
+ {
+ name: 'tenant',
+ example: 'vs-devcore.myget',
+ description: 'MyGet Tenant in the format `name.myget`',
+ },
+ { name: 'feed', example: 'vs-devcore' },
+ { name: 'packageName', example: 'MicroBuild' },
+ ),
},
- staticPreview: this.render({ downloads: 9748 }),
},
- ]
+ }
}
export { MyGetVersionService, MyGetDownloadService }
diff --git a/services/myget/myget.tester.js b/services/myget/myget.tester.js
index 44c5983edf1aa..812f7247cd7d3 100644
--- a/services/myget/myget.tester.js
+++ b/services/myget/myget.tester.js
@@ -3,12 +3,6 @@ import {
isMetric,
isVPlusDottedVersionNClausesWithOptionalSuffix,
} from '../test-validators.js'
-import {
- queryIndex,
- nuGetV3VersionJsonWithDash,
- nuGetV3VersionJsonFirstCharZero,
- nuGetV3VersionJsonFirstCharNotZero,
-} from '../nuget-fixtures.js'
import { invalidJSON } from '../response-fixtures.js'
export const t = new ServiceTester({
@@ -27,7 +21,7 @@ t.create('total downloads (valid)')
})
t.create('total downloads (tenant)')
- .get('/cefsharp.myget/cefsharp/dt/CefSharp.Common.json')
+ .get('/vs-devcore.myget/vs-devcore/dt/MicroBuild.json')
.expectBadge({
label: 'downloads',
message: isMetric,
@@ -37,22 +31,22 @@ t.create('total downloads (not found)')
.get('/myget/mongodb/dt/not-a-real-package.json')
.expectBadge({ label: 'downloads', message: 'package not found' })
-// This tests the erroring behavior in regular-update.
+// This tests the erroring behavior in getCachedResource.
t.create('total downloads (connection error)')
.get('/myget/mongodb/dt/MongoDB.Driver.Core.json')
.networkOff()
.expectBadge({
label: 'downloads',
- message: 'intermediate resource inaccessible',
+ message: 'inaccessible',
})
-// This tests the erroring behavior in regular-update.
+// This tests the erroring behavior in getCachedResource.
t.create('total downloads (unexpected first response)')
.get('/myget/mongodb/dt/MongoDB.Driver.Core.json')
.intercept(nock =>
nock('https://www.myget.org')
.get('/F/mongodb/api/v3/index.json')
- .reply(invalidJSON)
+ .reply(invalidJSON),
)
.expectBadge({
label: 'downloads',
@@ -69,72 +63,12 @@ t.create('version (valid)')
})
t.create('version (tenant)')
- .get('/tizen.myget/dotnet/v/Tizen.NET.json')
+ .get('/vs-devcore.myget/vs-devcore/v/MicroBuild.json')
.expectBadge({
- label: 'dotnet',
+ label: 'vs-devcore',
message: isVPlusDottedVersionNClausesWithOptionalSuffix,
})
-t.create('version (yellow badge)')
- .get('/myget/mongodb/v/MongoDB.Driver.Core.json')
- .intercept(nock =>
- nock('https://www.myget.org')
- .get('/F/mongodb/api/v3/index.json')
- .reply(200, queryIndex)
- )
- .intercept(nock =>
- nock('https://api-v2v3search-0.nuget.org')
- .get(
- '/query?q=packageid%3Amongodb.driver.core&prerelease=true&semVerLevel=2'
- )
- .reply(200, nuGetV3VersionJsonWithDash)
- )
- .expectBadge({
- label: 'mongodb',
- message: 'v1.2-beta',
- color: 'yellow',
- })
-
-t.create('version (orange badge)')
- .get('/myget/mongodb/v/MongoDB.Driver.Core.json')
- .intercept(nock =>
- nock('https://www.myget.org')
- .get('/F/mongodb/api/v3/index.json')
- .reply(200, queryIndex)
- )
- .intercept(nock =>
- nock('https://api-v2v3search-0.nuget.org')
- .get(
- '/query?q=packageid%3Amongodb.driver.core&prerelease=true&semVerLevel=2'
- )
- .reply(200, nuGetV3VersionJsonFirstCharZero)
- )
- .expectBadge({
- label: 'mongodb',
- message: 'v0.35',
- color: 'orange',
- })
-
-t.create('version (blue badge)')
- .get('/myget/mongodb/v/MongoDB.Driver.Core.json')
- .intercept(nock =>
- nock('https://www.myget.org')
- .get('/F/mongodb/api/v3/index.json')
- .reply(200, queryIndex)
- )
- .intercept(nock =>
- nock('https://api-v2v3search-0.nuget.org')
- .get(
- '/query?q=packageid%3Amongodb.driver.core&prerelease=true&semVerLevel=2'
- )
- .reply(200, nuGetV3VersionJsonFirstCharNotZero)
- )
- .expectBadge({
- label: 'mongodb',
- message: 'v1.2.7',
- color: 'blue',
- })
-
t.create('version (not found)')
.get('/myget/foo/v/not-a-real-package.json')
.expectBadge({ label: 'myget', message: 'package not found' })
@@ -148,66 +82,6 @@ t.create('version (pre) (valid)')
message: isVPlusDottedVersionNClausesWithOptionalSuffix,
})
-t.create('version (pre) (yellow badge)')
- .get('/myget/mongodb/vpre/MongoDB.Driver.Core.json')
- .intercept(nock =>
- nock('https://www.myget.org')
- .get('/F/mongodb/api/v3/index.json')
- .reply(200, queryIndex)
- )
- .intercept(nock =>
- nock('https://api-v2v3search-0.nuget.org')
- .get(
- '/query?q=packageid%3Amongodb.driver.core&prerelease=true&semVerLevel=2'
- )
- .reply(200, nuGetV3VersionJsonWithDash)
- )
- .expectBadge({
- label: 'mongodb',
- message: 'v1.2-beta',
- color: 'yellow',
- })
-
-t.create('version (pre) (orange badge)')
- .get('/myget/mongodb/vpre/MongoDB.Driver.Core.json')
- .intercept(nock =>
- nock('https://www.myget.org')
- .get('/F/mongodb/api/v3/index.json')
- .reply(200, queryIndex)
- )
- .intercept(nock =>
- nock('https://api-v2v3search-0.nuget.org')
- .get(
- '/query?q=packageid%3Amongodb.driver.core&prerelease=true&semVerLevel=2'
- )
- .reply(200, nuGetV3VersionJsonFirstCharZero)
- )
- .expectBadge({
- label: 'mongodb',
- message: 'v0.35',
- color: 'orange',
- })
-
-t.create('version (pre) (blue badge)')
- .get('/myget/mongodb/vpre/MongoDB.Driver.Core.json')
- .intercept(nock =>
- nock('https://www.myget.org')
- .get('/F/mongodb/api/v3/index.json')
- .reply(200, queryIndex)
- )
- .intercept(nock =>
- nock('https://api-v2v3search-0.nuget.org')
- .get(
- '/query?q=packageid%3Amongodb.driver.core&prerelease=true&semVerLevel=2'
- )
- .reply(200, nuGetV3VersionJsonFirstCharNotZero)
- )
- .expectBadge({
- label: 'mongodb',
- message: 'v1.2.7',
- color: 'blue',
- })
-
t.create('version (pre) (not found)')
.get('/myget/foo/vpre/not-a-real-package.json')
.expectBadge({ label: 'myget', message: 'package not found' })
diff --git a/services/netlify/netlify.service.js b/services/netlify/netlify.service.js
index 3feefa4165161..dd8ae105b2c82 100644
--- a/services/netlify/netlify.service.js
+++ b/services/netlify/netlify.service.js
@@ -1,5 +1,5 @@
import { renderBuildStatusBadge } from '../build-status.js'
-import { BaseSvgScrapingService } from '../index.js'
+import { BaseSvgScrapingService, pathParams } from '../index.js'
const pendingStatus = 'building'
const notBuiltStatus = 'not built'
@@ -22,17 +22,19 @@ export default class Netlify extends BaseSvgScrapingService {
pattern: ':projectId',
}
- static examples = [
- {
- title: 'Netlify',
- namedParams: {
- projectId: 'e6d5a4e0-dee1-4261-833e-2f47f509c68f',
+ static openApi = {
+ '/netlify/{projectId}': {
+ get: {
+ summary: 'Netlify',
+ description:
+ 'To locate your project id, visit your project settings, scroll to "Status badges" under "General", and copy the ID between "/api/v1/badges/" and "/deploy-status" in the code sample',
+ parameters: pathParams({
+ name: 'projectId',
+ example: 'e6d5a4e0-dee1-4261-833e-2f47f509c68f',
+ }),
},
- documentation:
- 'To locate your project id, visit your project settings, scroll to "Status badges" under "General", and copy the ID between "/api/v1/badges/" and "/deploy-status" in the code sample',
- staticPreview: renderBuildStatusBadge({ status: 'passing' }),
},
- ]
+ }
static defaultBadgeData = {
label: 'netlify',
@@ -47,19 +49,20 @@ export default class Netlify extends BaseSvgScrapingService {
return result
}
- async fetch({ projectId, branch }) {
+ async fetch({ projectId }) {
const url = `https://api.netlify.com/api/v1/badges/${projectId}/deploy-status`
const { buffer } = await this._request({
url,
})
- if (buffer.includes('#0D544F')) return { message: 'passing' }
- if (buffer.includes('#900B31')) return { message: 'failing' }
- if (buffer.includes('#AB6F10')) return { message: 'building' }
+ if (buffer.includes('#0F4A21')) return { message: 'passing' }
+ if (buffer.includes('#800A20')) return { message: 'failing' }
+ if (buffer.includes('#603408')) return { message: 'building' }
+ if (buffer.includes('#181A1C')) return { message: 'canceled' }
return { message: 'unknown' }
}
- async handle({ projectId, branch }) {
- const { message: status } = await this.fetch({ projectId, branch })
+ async handle({ projectId }) {
+ const { message: status } = await this.fetch({ projectId })
return this.constructor.render({ status })
}
}
diff --git a/services/nexus/nexus-redirect.tester.js b/services/nexus/nexus-redirect.tester.js
index 294b0f67143ad..ada3d86dfd967 100644
--- a/services/nexus/nexus-redirect.tester.js
+++ b/services/nexus/nexus-redirect.tester.js
@@ -10,24 +10,24 @@ t.create('Nexus release')
.get('/r/https/oss.sonatype.org/com.google.guava/guava.svg')
.expectRedirect(
`/nexus/r/com.google.guava/guava.svg?server=${encodeURIComponent(
- 'https://oss.sonatype.org'
- )}`
+ 'https://oss.sonatype.org',
+ )}`,
)
t.create('Nexus snapshot')
.get('/s/https/oss.sonatype.org/com.google.guava/guava.svg')
.expectRedirect(
`/nexus/s/com.google.guava/guava.svg?server=${encodeURIComponent(
- 'https://oss.sonatype.org'
- )}`
+ 'https://oss.sonatype.org',
+ )}`,
)
t.create('Nexus repository with query opts')
.get(
- '/fs-public-snapshots/https/repository.jboss.org/nexus/com.progress.fuse/fusehq:p=tar.gz:c=agent-apple-osx.svg'
+ '/fs-public-snapshots/https/repository.jboss.org/nexus/com.progress.fuse/fusehq:p=tar.gz:c=agent-apple-osx.svg',
)
.expectRedirect(
- `/nexus/fs-public-snapshots/com.progress.fuse/fusehq.svg?queryOpt=${encodeURIComponent(
- ':p=tar.gz:c=agent-apple-osx'
- )}&server=${encodeURIComponent('https://repository.jboss.org/nexus')}`
+ `/nexus/fs-public-snapshots/com.progress.fuse/fusehq.svg?server=${encodeURIComponent('https://repository.jboss.org/nexus')}&queryOpt=${encodeURIComponent(
+ ':p=tar.gz:c=agent-apple-osx',
+ )}`,
)
diff --git a/services/nexus/nexus.service.js b/services/nexus/nexus.service.js
index 67451ef0c929e..f0b99e40f72e2 100644
--- a/services/nexus/nexus.service.js
+++ b/services/nexus/nexus.service.js
@@ -1,11 +1,16 @@
import Joi from 'joi'
-import { version as versionColor } from '../color-formatters.js'
-import { addv } from '../text-formatters.js'
+import { renderVersionBadge } from '../version.js'
import {
- optionalUrl,
+ url,
optionalDottedVersionNClausesWithOptionalSuffix,
} from '../validators.js'
-import { BaseJsonService, InvalidResponse, NotFound } from '../index.js'
+import {
+ BaseJsonService,
+ InvalidResponse,
+ NotFound,
+ pathParams,
+ queryParams,
+} from '../index.js'
import { isSnapshotVersion } from './nexus-version.js'
const nexus2SearchApiSchema = Joi.object({
@@ -20,7 +25,7 @@ const nexus2SearchApiSchema = Joi.object({
// the entire history of each published version for the artifact.
// Example artifact that includes such a historical version: https://oss.sonatype.org/service/local/lucene/search?g=com.google.guava&a=guava
version: Joi.string(),
- })
+ }),
)
.required(),
}).required()
@@ -31,7 +36,7 @@ const nexus3SearchApiSchema = Joi.object({
Joi.object({
// This schema is relaxed similarly to nexux2SearchApiSchema
version: Joi.string().required(),
- })
+ }),
)
.required(),
}).required()
@@ -44,13 +49,40 @@ const nexus2ResolveApiSchema = Joi.object({
}).required()
const queryParamSchema = Joi.object({
- server: optionalUrl.required(),
+ server: url,
queryOpt: Joi.string()
.regex(/(:[\w.]+=[^:]*)+/i)
.optional(),
nexusVersion: Joi.equal('2', '3'),
}).required()
+const openApiQueryParams = queryParams(
+ { name: 'server', example: 'https://oss.sonatype.org', required: true },
+ {
+ name: 'nexusVersion',
+ example: '2',
+ schema: { type: 'string', enum: ['2', '3'] },
+ description:
+ 'Specifying `nexusVersion=3` when targeting Nexus 3 servers will speed up the badge rendering.',
+ },
+ {
+ name: 'queryOpt',
+ example: ':c=agent-apple-osx:p=tar.gz',
+ description: `
+Note that you can use query options with any Nexus badge type (Releases, Snapshots, or Repository).
+
+Query options should be provided as key=value pairs separated by a colon.
+
+Possible values:
+
+`,
+ },
+)
+
export default class Nexus extends BaseJsonService {
static category = 'version'
@@ -66,103 +98,51 @@ export default class Nexus extends BaseJsonService {
serviceKey: 'nexus',
}
- static examples = [
- {
- title: 'Sonatype Nexus (Releases)',
- pattern: 'r/:groupId/:artifactId',
- namedParams: {
- groupId: 'org.apache.commons',
- artifactId: 'commons-lang3',
- },
- queryParams: {
- server: 'https://nexus.pentaho.org',
- nexusVersion: '3',
- },
- staticPreview: this.render({
- version: '3.9',
- }),
- documentation: `
-
- Specifying 'nexusVersion=3' when targeting Nexus 3 servers will speed up the badge rendering.
- Note that you can use this query parameter with any Nexus badge type (Releases, Snapshots, or Repository).
-
- `,
- },
- {
- title: 'Sonatype Nexus (Snapshots)',
- pattern: 's/:groupId/:artifactId',
- namedParams: {
- groupId: 'com.google.guava',
- artifactId: 'guava',
- },
- queryParams: {
- server: 'https://oss.sonatype.org',
+ static openApi = {
+ '/nexus/r/{groupId}/{artifactId}': {
+ get: {
+ summary: 'Sonatype Nexus (Releases)',
+ parameters: [
+ ...pathParams(
+ { name: 'groupId', example: 'com.google.guava' },
+ { name: 'artifactId', example: 'guava' },
+ ),
+ ...openApiQueryParams,
+ ],
},
- staticPreview: this.render({
- version: 'v24.0-SNAPSHOT',
- }),
},
- {
- title: 'Sonatype Nexus (Repository)',
- pattern: ':repo/:groupId/:artifactId',
- namedParams: {
- repo: 'developer',
- groupId: 'ai.h2o',
- artifactId: 'h2o-automl',
- },
- queryParams: {
- server: 'https://repository.jboss.org/nexus',
+ '/nexus/s/{groupId}/{artifactId}': {
+ get: {
+ summary: 'Sonatype Nexus (Snapshots)',
+ parameters: [
+ ...pathParams(
+ { name: 'groupId', example: 'com.google.guava' },
+ { name: 'artifactId', example: 'guava' },
+ ),
+ ...openApiQueryParams,
+ ],
},
- staticPreview: this.render({
- version: '3.22.0.2',
- }),
},
- {
- title: 'Sonatype Nexus (Query Options)',
- pattern: ':repo/:groupId/:artifactId',
- namedParams: {
- repo: 'fs-public-snapshots',
- groupId: 'com.progress.fuse',
- artifactId: 'fusehq',
- },
- queryParams: {
- server: 'https://repository.jboss.org/nexus',
- queryOpt: ':c=agent-apple-osx:p=tar.gz',
+ '/nexus/{repo}/{groupId}/{artifactId}': {
+ get: {
+ summary: 'Sonatype Nexus (Repository)',
+ parameters: [
+ ...pathParams(
+ { name: 'repo', example: 'snapshots' },
+ { name: 'groupId', example: 'com.google.guava' },
+ { name: 'artifactId', example: 'guava' },
+ ),
+ ...openApiQueryParams,
+ ],
},
- staticPreview: this.render({
- version: '7.0.1-SNAPSHOT',
- }),
- documentation: `
-
- Note that you can use query options with any Nexus badge type (Releases, Snapshots, or Repository).
-
-
- Query options should be provided as key=value pairs separated by a colon.
-
-
- Possible values:
-
-
- `,
},
- ]
+ }
static defaultBadgeData = {
label: 'nexus',
}
- static render({ version }) {
- return {
- message: addv(version),
- color: versionColor(version),
- }
- }
-
- addQueryParamsToQueryString({ qs, queryOpt }) {
+ addQueryParamsToQueryString({ searchParams, queryOpt }) {
// Users specify query options with 'key=value' pairs, using a
// colon delimiter between pairs ([:k1=v1[:k2=v2[...]]]).
// queryOpt will be a string containing those key/value pairs,
@@ -172,7 +152,7 @@ export default class Nexus extends BaseJsonService {
const paramParts = keyValuePair.split('=')
const paramKey = paramParts[0]
const paramValue = paramParts[1]
- qs[paramKey] = paramValue
+ searchParams[paramKey] = paramValue
})
}
@@ -194,7 +174,7 @@ export default class Nexus extends BaseJsonService {
}
async fetch2({ server, repo, groupId, artifactId, queryOpt }) {
- const qs = {
+ const searchParams = {
g: groupId,
a: artifactId,
}
@@ -209,30 +189,30 @@ export default class Nexus extends BaseJsonService {
} else {
schema = nexus2ResolveApiSchema
url += 'service/local/artifact/maven/resolve'
- qs.r = repo
- qs.v = 'LATEST'
+ searchParams.r = repo
+ searchParams.v = 'LATEST'
}
if (queryOpt) {
- this.addQueryParamsToQueryString({ qs, queryOpt })
+ this.addQueryParamsToQueryString({ searchParams, queryOpt })
}
const json = await this._requestJson(
this.authHelper.withBasicAuth({
schema,
url,
- options: { qs },
- errorMessages: {
+ options: { searchParams },
+ httpErrors: {
404: 'artifact not found',
},
- })
+ }),
)
return { actualNexusVersion: '2', json }
}
async fetch3({ server, repo, groupId, artifactId, queryOpt }) {
- const qs = {
+ const searchParams = {
group: groupId,
name: artifactId,
sort: 'version',
@@ -240,18 +220,18 @@ export default class Nexus extends BaseJsonService {
switch (repo) {
case 's':
- qs.prerelease = 'true'
+ searchParams.prerelease = 'true'
break
case 'r':
- qs.prerelease = 'false'
+ searchParams.prerelease = 'false'
break
default:
- qs.repository = repo
+ searchParams.repository = repo
break
}
if (queryOpt) {
- this.addQueryParamsToQueryString({ qs, queryOpt })
+ this.addQueryParamsToQueryString({ searchParams, queryOpt })
}
const url = `${server}${
@@ -262,11 +242,11 @@ export default class Nexus extends BaseJsonService {
this.authHelper.withBasicAuth({
schema: nexus3SearchApiSchema,
url,
- options: { qs },
- errorMessages: {
+ options: { searchParams },
+ httpErrors: {
404: 'artifact not found',
},
- })
+ }),
)
return { actualNexusVersion: '3', json }
@@ -321,7 +301,7 @@ export default class Nexus extends BaseJsonService {
async handle(
{ repo, groupId, artifactId },
- { server, queryOpt, nexusVersion }
+ { server, queryOpt, nexusVersion },
) {
const { actualNexusVersion, json } = await this.fetch({
repo,
@@ -333,6 +313,6 @@ export default class Nexus extends BaseJsonService {
})
const { version } = this.transform({ repo, json, actualNexusVersion })
- return this.constructor.render({ version })
+ return renderVersionBadge({ version })
}
}
diff --git a/services/nexus/nexus.spec.js b/services/nexus/nexus.spec.js
index 43104b8307da8..e554a9300ea71 100644
--- a/services/nexus/nexus.spec.js
+++ b/services/nexus/nexus.spec.js
@@ -1,6 +1,5 @@
import { expect } from 'chai'
-import nock from 'nock'
-import { cleanUpNockAfterEach, defaultContext } from '../test-helpers.js'
+import { testAuth } from '../test-helpers.js'
import { InvalidResponse, NotFound } from '../index.js'
import Nexus from './nexus.service.js'
@@ -92,7 +91,7 @@ describe('Nexus', function () {
} catch (e) {
expect(e).to.be.an.instanceof(NotFound)
expect(e.prettyMessage).to.equal(
- 'artifact or snapshot version not found'
+ 'artifact or snapshot version not found',
)
}
})
@@ -113,52 +112,27 @@ describe('Nexus', function () {
})
describe('auth', function () {
- cleanUpNockAfterEach()
-
- const user = 'admin'
- const pass = 'password'
const config = {
public: {
services: {
nexus: {
- authorizedOrigins: ['https://repository.jboss.org'],
+ authorizedOrigins: ['https://oss.sonatype.org'],
},
},
},
- private: {
- nexus_user: user,
- nexus_pass: pass,
- },
}
-
it('sends the auth information as configured', async function () {
- const scope = nock('https://repository.jboss.org')
- .get('/nexus/service/local/lucene/search')
- .query({ g: 'jboss', a: 'jboss-client' })
- // 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.
- .basicAuth({ user, pass })
- .reply(200, { data: [{ latestRelease: '2.3.4' }] })
-
- expect(
- await Nexus.invoke(
- defaultContext,
- config,
- {
- repo: 'r',
- groupId: 'jboss',
- artifactId: 'jboss-client',
+ return testAuth(
+ Nexus,
+ 'BasicAuth',
+ {
+ data: {
+ baseVersion: '9.3.95',
+ version: '9.3.95',
},
- {
- server: 'https://repository.jboss.org/nexus',
- }
- )
- ).to.deep.equal({
- message: 'v2.3.4',
- color: 'blue',
- })
-
- scope.done()
+ },
+ { configOverride: config },
+ )
})
})
})
diff --git a/services/nexus/nexus.tester.js b/services/nexus/nexus.tester.js
index f3c7d5c31ec26..c2d351d0fe33e 100644
--- a/services/nexus/nexus.tester.js
+++ b/services/nexus/nexus.tester.js
@@ -13,7 +13,7 @@ t.create('Nexus 2 - search release version valid artifact')
t.create('Nexus 2 - search release version of an nonexistent artifact')
.timeout(15000)
.get(
- '/r/com.google.guava/nonexistent-artifact-id.json?server=https://oss.sonatype.org'
+ '/r/com.google.guava/nonexistent-artifact-id.json?server=https://oss.sonatype.org',
)
.expectBadge({
label: 'nexus',
@@ -22,9 +22,7 @@ t.create('Nexus 2 - search release version of an nonexistent artifact')
t.create('Nexus 2 - search snapshot version valid snapshot artifact')
.timeout(15000)
- .get(
- '/s/org.fusesource.apollo/apollo-karaf-feature.json?server=https://repository.jboss.org/nexus'
- )
+ .get('/s/com.datadoghq/ap-tools.json?server=https://oss.sonatype.org')
.expectBadge({
label: 'nexus',
message: isVersion,
@@ -33,7 +31,7 @@ t.create('Nexus 2 - search snapshot version valid snapshot artifact')
t.create('Nexus 2 - search snapshot version of an nonexistent artifact')
.timeout(15000)
.get(
- '/s/com.google.guava/nonexistent-artifact-id.json?server=https://oss.sonatype.org'
+ '/s/com.google.guava/nonexistent-artifact-id.json?server=https://oss.sonatype.org',
)
.expectBadge({
label: 'nexus',
@@ -42,7 +40,9 @@ t.create('Nexus 2 - search snapshot version of an nonexistent artifact')
})
t.create('Nexus 2 - repository version')
- .get('/public/asm/asm.json?server=http://repo.e-iceblue.com/nexus')
+ .get(
+ '/google-releases/com.google.collections/google-collections.json?server=https://oss.sonatype.org',
+ )
.expectBadge({
label: 'nexus',
message: isVersion,
@@ -51,9 +51,9 @@ t.create('Nexus 2 - repository version')
t.create('Nexus 2 - repository version with query')
.timeout(15000)
.get(
- `/fs-public-snapshots/com.progress.fuse/fusehq.json?server=https://repository.jboss.org/nexus&queryOpt=${encodeURIComponent(
- ':p=tar.gz:c=agent-apple-osx'
- )}`
+ `/google-releases/com.google.collections/google-collections.json?server=https://oss.sonatype.org&queryOpt=${encodeURIComponent(
+ ':p=jar:v=0.9',
+ )}`,
)
.expectBadge({
label: 'nexus',
@@ -63,7 +63,7 @@ t.create('Nexus 2 - repository version with query')
t.create('Nexus 2 - repository version of an nonexistent artifact')
.timeout(15000)
.get(
- '/developer/jboss/nonexistent-artifact-id.json?server=https://repository.jboss.org/nexus'
+ '/google-releases/com.google.collections/nonexistent-artifact-id.json?server=https://oss.sonatype.org',
)
.expectBadge({
label: 'nexus',
@@ -71,14 +71,12 @@ t.create('Nexus 2 - repository version of an nonexistent artifact')
})
t.create('Nexus 2 - snapshot version with + in version')
- .get(
- '/s/com.progress.fuse/fusehq.json?server=https://repository.jboss.org/nexus'
- )
+ .get('/s/com.progress.fuse/fusehq.json?server=https://oss.sonatype.org')
.intercept(nock =>
- nock('https://repository.jboss.org/nexus')
+ nock('https://oss.sonatype.org')
.get('/service/local/lucene/search')
.query({ g: 'com.progress.fuse', a: 'fusehq' })
- .reply(200, { data: [{ version: '7.0.1+19-8844c122-SNAPSHOT' }] })
+ .reply(200, { data: [{ version: '7.0.1+19-8844c122-SNAPSHOT' }] }),
)
.expectBadge({
label: 'nexus',
@@ -88,13 +86,13 @@ t.create('Nexus 2 - snapshot version with + in version')
t.create('Nexus 2 - snapshot version with + and hex hash in version')
.get(
- '/s/com.typesafe.akka/akka-stream-kafka_2.13.json?server=https://repository.jboss.org/nexus'
+ '/s/com.typesafe.akka/akka-stream-kafka_2.13.json?server=https://oss.sonatype.org',
)
.intercept(nock =>
- nock('https://repository.jboss.org/nexus')
+ nock('https://oss.sonatype.org')
.get('/service/local/lucene/search')
.query({ g: 'com.typesafe.akka', a: 'akka-stream-kafka_2.13' })
- .reply(200, { data: [{ version: '2.1.0-M1+58-f25047fc-SNAPSHOT' }] })
+ .reply(200, { data: [{ version: '2.1.0-M1+58-f25047fc-SNAPSHOT' }] }),
)
.expectBadge({
label: 'nexus',
@@ -103,14 +101,12 @@ t.create('Nexus 2 - snapshot version with + and hex hash in version')
})
t.create('Nexus 2 - search snapshot version not in latestSnapshot')
- .get(
- '/s/com.progress.fuse/fusehq.json?server=https://repository.jboss.org/nexus'
- )
+ .get('/s/com.progress.fuse/fusehq.json?server=https://oss.sonatype.org')
.intercept(nock =>
- nock('https://repository.jboss.org/nexus')
+ nock('https://oss.sonatype.org')
.get('/service/local/lucene/search')
.query({ g: 'com.progress.fuse', a: 'fusehq' })
- .reply(200, { data: [{ version: '7.0.1-SNAPSHOT' }] })
+ .reply(200, { data: [{ version: '7.0.1-SNAPSHOT' }] }),
)
.expectBadge({
label: 'nexus',
@@ -119,14 +115,12 @@ t.create('Nexus 2 - search snapshot version not in latestSnapshot')
})
t.create('Nexus 2 - search snapshot no snapshot versions')
- .get(
- '/s/com.progress.fuse/fusehq.json?server=https://repository.jboss.org/nexus'
- )
+ .get('/s/com.progress.fuse/fusehq.json?server=https://oss.sonatype.org')
.intercept(nock =>
- nock('https://repository.jboss.org/nexus')
+ nock('https://oss.sonatype.org')
.get('/service/local/lucene/search')
.query({ g: 'com.progress.fuse', a: 'fusehq' })
- .reply(200, { data: [{ version: '1.2.3' }] })
+ .reply(200, { data: [{ version: '1.2.3' }] }),
)
.expectBadge({
label: 'nexus',
@@ -135,12 +129,12 @@ t.create('Nexus 2 - search snapshot no snapshot versions')
})
t.create('Nexus 2 - search release version')
- .get('/r/jboss/jboss-client.json?server=https://repository.jboss.org/nexus')
+ .get('/r/jboss/jboss-client.json?server=https://oss.sonatype.org')
.intercept(nock =>
- nock('https://repository.jboss.org/nexus')
+ nock('https://oss.sonatype.org')
.get('/service/local/lucene/search')
.query({ g: 'jboss', a: 'jboss-client' })
- .reply(200, { data: [{ latestRelease: '1.0.0' }] })
+ .reply(200, { data: [{ latestRelease: '1.0.0' }] }),
)
.expectBadge({
label: 'nexus',
@@ -149,11 +143,9 @@ t.create('Nexus 2 - search release version')
})
t.create('Nexus 2 - repository release version')
- .get(
- '/developer/ai.h2o/h2o-automl.json?server=https://repository.jboss.org/nexus'
- )
+ .get('/developer/ai.h2o/h2o-automl.json?server=https://oss.sonatype.org')
.intercept(nock =>
- nock('https://repository.jboss.org/nexus')
+ nock('https://oss.sonatype.org')
.get('/service/local/artifact/maven/resolve')
.query({
g: 'ai.h2o',
@@ -166,7 +158,7 @@ t.create('Nexus 2 - repository release version')
baseVersion: '1.2.3',
version: '1.0.0',
},
- })
+ }),
)
.expectBadge({
label: 'nexus',
@@ -175,11 +167,9 @@ t.create('Nexus 2 - repository release version')
})
t.create('Nexus 2 - repository release version')
- .get(
- '/developer/ai.h2o/h2o-automl.json?server=https://repository.jboss.org/nexus'
- )
+ .get('/developer/ai.h2o/h2o-automl.json?server=https://oss.sonatype.org')
.intercept(nock =>
- nock('https://repository.jboss.org/nexus')
+ nock('https://oss.sonatype.org')
.get('/service/local/artifact/maven/resolve')
.query({
g: 'ai.h2o',
@@ -191,7 +181,7 @@ t.create('Nexus 2 - repository release version')
data: {
version: '1.0.0',
},
- })
+ }),
)
.expectBadge({
label: 'nexus',
@@ -201,10 +191,10 @@ t.create('Nexus 2 - repository release version')
t.create('Nexus 2 - user query params')
.get(
- '/fs-public-snapshots/com.progress.fuse/fusehq.json?queryOpt=:c=agent-apple-osx:p=tar.gz&server=https://repository.jboss.org/nexus'
+ '/fs-public-snapshots/com.progress.fuse/fusehq.json?queryOpt=:c=agent-apple-osx:p=tar.gz&server=https://oss.sonatype.org',
)
.intercept(nock =>
- nock('https://repository.jboss.org/nexus')
+ nock('https://oss.sonatype.org')
.get('/service/local/artifact/maven/resolve')
.query({
g: 'com.progress.fuse',
@@ -218,7 +208,7 @@ t.create('Nexus 2 - user query params')
data: {
version: '3.2.1',
},
- })
+ }),
)
.expectBadge({
label: 'nexus',
@@ -228,7 +218,7 @@ t.create('Nexus 2 - user query params')
t.create('Nexus 3 - search release version valid artifact')
.get(
- '/r/org.apache.commons/commons-lang3.json?server=https://nexus.pentaho.org&nexusVersion=3'
+ '/r/me.neznamy/tab-api.json?server=https://repo.tomkeuper.com&nexusVersion=3',
)
.expectBadge({
label: 'nexus',
@@ -236,12 +226,10 @@ t.create('Nexus 3 - search release version valid artifact')
})
t.create(
- 'Nexus 3 - search release version valid artifact without explicit nexusVersion parameter'
+ 'Nexus 3 - search release version valid artifact without explicit nexusVersion parameter',
)
.timeout(15000)
- .get(
- '/r/org.apache.commons/commons-lang3.json?server=https://nexus.pentaho.org'
- )
+ .get('/r/me.neznamy/tab-api.json?server=https://repo.tomkeuper.com')
.expectBadge({
label: 'nexus',
message: isVersion,
@@ -249,7 +237,7 @@ t.create(
t.create('Nexus 3 - search release version of an nonexistent artifact')
.get(
- '/r/org.apache.commons/nonexistent-artifact-id.json?server=https://nexus.pentaho.org&nexusVersion=3'
+ '/r/me.neznamy/nonexistent-artifact-id.json?server=https://repo.tomkeuper.com&nexusVersion=3',
)
.expectBadge({
label: 'nexus',
@@ -258,7 +246,7 @@ t.create('Nexus 3 - search release version of an nonexistent artifact')
t.create('Nexus 3 - search snapshot version valid snapshot artifact')
.get(
- '/s/org.pentaho/pentaho-registry.json?server=https://nexus.pentaho.org&nexusVersion=3'
+ '/s/net.voxelpi.event/event.json?server=https://repo.voxelpi.net&nexusVersion=3',
)
.expectBadge({
label: 'nexus',
@@ -267,7 +255,7 @@ t.create('Nexus 3 - search snapshot version valid snapshot artifact')
t.create('Nexus 3 - search snapshot version for artifact without snapshots')
.get(
- '/s/javax.inject/javax.inject.json?server=https://nexus.pentaho.org&nexusVersion=3'
+ '/s/com.tomkeuper/spigot.json?server=https://repo.tomkeuper.com&nexusVersion=3',
)
.expectBadge({
label: 'nexus',
@@ -277,7 +265,7 @@ t.create('Nexus 3 - search snapshot version for artifact without snapshots')
t.create('Nexus 3 - repository version')
.get(
- '/proxy-public-3rd-party-release/com.h2database/h2.json?server=https://nexus.pentaho.org&nexusVersion=3'
+ '/bedwars-releases/me.neznamy/tab-api.json?server=https://repo.tomkeuper.com&nexusVersion=3',
)
.expectBadge({
label: 'nexus',
@@ -285,11 +273,11 @@ t.create('Nexus 3 - repository version')
})
t.create(
- 'Nexus 3 - repository version valid artifact without explicit nexusVersion parameter'
+ 'Nexus 3 - repository version valid artifact without explicit nexusVersion parameter',
)
.timeout(15000)
.get(
- '/proxy-public-3rd-party-release/com.h2database/h2.json?server=https://nexus.pentaho.org'
+ '/bedwars-releases/me.neznamy/tab-api.json?server=https://repo.tomkeuper.com&nexusVersion=3',
)
.expectBadge({
label: 'nexus',
@@ -298,9 +286,9 @@ t.create(
t.create('Nexus 3 - repository version with query')
.get(
- `/proxy-public-3rd-party-release/org.junit.jupiter/junit-jupiter.json?server=https://nexus.pentaho.org&nexusVersion=3&queryOpt=${encodeURIComponent(
- ':maven.extension=jar:direction=asc'
- )}`
+ `/bedwars-releases/me.neznamy/tab-api.json?server=https://repo.tomkeuper.com&nexusVersion=3&queryOpt=${encodeURIComponent(
+ ':maven.extension=jar:direction=asc',
+ )}`,
)
.expectBadge({
label: 'nexus',
@@ -310,11 +298,11 @@ t.create('Nexus 3 - repository version with query')
t.create('Nexus 3 - search release version without snapshots')
.get(
// Limit the version from above, so that any later artifacts don't break this test.
- `/r/org.pentaho.adaptive/daemon.json?server=https://nexus.pentaho.org&nexusVersion=3&queryOpt=${encodeURIComponent(
- ':maven.baseVersion=<8.1.0.1'
- )}`
+ `/r/me.neznamy/tab-api.json?server=https://repo.tomkeuper.com&nexusVersion=3&queryOpt=${encodeURIComponent(
+ ':maven.baseVersion=<4.0.0.0',
+ )}`,
)
.expectBadge({
label: 'nexus',
- message: 'v8.1.0.0-365',
+ message: 'v4.0.0',
})
diff --git a/services/node/node-base.js b/services/node/node-base.js
index 61f049595eec9..7daf26f32de41 100644
--- a/services/node/node-base.js
+++ b/services/node/node-base.js
@@ -1,88 +1,8 @@
import NPMBase from '../npm/npm-base.js'
-const keywords = ['npm']
-
export default class NodeVersionBase extends NPMBase {
static category = 'platform-support'
- static get examples() {
- const type = this.type
- const documentation = `
-
- ${this.documentation}
- The node version support is retrieved from the engines.node section in package.json.
-
-`
- const prefix = `node-${type}`
- return [
- {
- title: `${prefix}`,
- pattern: ':packageName',
- namedParams: { packageName: 'passport' },
- staticPreview: this.renderStaticPreview({
- nodeVersionRange: '>= 6.0.0',
- }),
- keywords,
- documentation,
- },
- {
- title: `${prefix} (scoped)`,
- pattern: '@:scope/:packageName',
- namedParams: { scope: 'stdlib', packageName: 'stdlib' },
- staticPreview: this.renderStaticPreview({
- nodeVersionRange: '>= 6.0.0',
- }),
- keywords,
- documentation,
- },
- {
- title: `${prefix} (tag)`,
- pattern: ':packageName/:tag',
- namedParams: { packageName: 'passport', tag: 'latest' },
- staticPreview: this.renderStaticPreview({
- nodeVersionRange: '>= 6.0.0',
- tag: 'latest',
- }),
- keywords,
- documentation,
- },
- {
- title: `${prefix} (scoped with tag)`,
- pattern: '@:scope/:packageName/:tag',
- namedParams: { scope: 'stdlib', packageName: 'stdlib', tag: 'latest' },
- staticPreview: this.renderStaticPreview({
- nodeVersionRange: '>= 6.0.0',
- tag: 'latest',
- }),
- keywords,
- documentation,
- },
- {
- title: `${prefix} (scoped with tag, custom registry)`,
- pattern: '@:scope/:packageName/:tag',
- namedParams: { scope: 'stdlib', packageName: 'stdlib', tag: 'latest' },
- queryParams: { registry_uri: 'https://registry.npmjs.com' },
- staticPreview: this.renderStaticPreview({
- nodeVersionRange: '>= 6.0.0',
- tag: 'latest',
- }),
- keywords,
- documentation,
- },
- ]
- }
-
- static renderStaticPreview({ tag, nodeVersionRange }) {
- // Since this badge has an async `render()` function, but `get examples()` has to
- // be synchronous, this method exists. It should return the same value as the
- // real `render()`. There's a unit test to check that.
- return {
- label: tag ? `${this.defaultBadgeData.label}@${tag}` : undefined,
- message: nodeVersionRange,
- color: 'brightgreen',
- }
- }
-
static async render({ tag, nodeVersionRange }) {
// Atypically, the `render()` function of this badge is `async` because it needs to pull
// data from the server.
diff --git a/services/node/node-current.service.js b/services/node/node-current.service.js
index 4494641fc1f75..9cb8b09e38c10 100644
--- a/services/node/node-current.service.js
+++ b/services/node/node-current.service.js
@@ -1,9 +1,55 @@
+import { pathParam, queryParam } from '../index.js'
+import { packageNameDescription } from '../npm/npm-base.js'
import NodeVersionBase from './node-base.js'
import { versionColorForRangeCurrent } from './node-version-color.js'
+const description = `This badge indicates whether the package supports the latest release of node.
+The node version support is retrieved from the engines.node section in package.json.
`
+
export default class NodeCurrentVersion extends NodeVersionBase {
static route = this.buildRoute('node/v', { withTag: true })
+ static openApi = {
+ '/node/v/{packageName}': {
+ get: {
+ summary: 'Node Current',
+ description,
+ parameters: [
+ pathParam({
+ name: 'packageName',
+ example: 'passport',
+ description: packageNameDescription,
+ }),
+ queryParam({
+ name: 'registry_uri',
+ example: 'https://registry.npmjs.com',
+ }),
+ ],
+ },
+ },
+ '/node/v/{packageName}/{tag}': {
+ get: {
+ summary: 'Node Current (with tag)',
+ description,
+ parameters: [
+ pathParam({
+ name: 'packageName',
+ example: 'passport',
+ description: packageNameDescription,
+ }),
+ pathParam({
+ name: 'tag',
+ example: 'latest',
+ }),
+ queryParam({
+ name: 'registry_uri',
+ example: 'https://registry.npmjs.com',
+ }),
+ ],
+ },
+ },
+ }
+
static defaultBadgeData = {
label: 'node',
}
@@ -11,6 +57,4 @@ export default class NodeCurrentVersion extends NodeVersionBase {
static type = 'current'
static colorResolver = versionColorForRangeCurrent
-
- static documentation = `This badge indicates whether the package supports the latest release of node`
}
diff --git a/services/node/node-current.spec.js b/services/node/node-current.spec.js
deleted file mode 100644
index 11cff97eefaa8..0000000000000
--- a/services/node/node-current.spec.js
+++ /dev/null
@@ -1,21 +0,0 @@
-import { test, given } from 'sazerac'
-import NodeVersion from './node-current.service.js'
-
-describe('node static renderStaticPreview', function () {
- it('should have parity with render()', async function () {
- const nodeVersionRange = '>= 6.0.0'
-
- const expectedNoTag = await NodeVersion.renderStaticPreview({
- nodeVersionRange,
- })
- const expectedLatestTag = await NodeVersion.renderStaticPreview({
- nodeVersionRange,
- tag: 'latest',
- })
-
- test(NodeVersion.renderStaticPreview.bind(NodeVersion), () => {
- given({ nodeVersionRange }).expect(expectedNoTag)
- given({ nodeVersionRange, tag: 'latest' }).expect(expectedLatestTag)
- })
- })
-})
diff --git a/services/node/node-current.tester.js b/services/node/node-current.tester.js
index 7d50fa272df0a..e82d24743679d 100644
--- a/services/node/node-current.tester.js
+++ b/services/node/node-current.tester.js
@@ -22,10 +22,10 @@ t.create('engines satisfies current node version')
mockPackageData({
packageName: 'passport',
engines: '>=0.4.0',
- })
+ }),
)
.intercept(mockCurrentSha(13))
- .expectBadge({ label: 'node', message: `>=0.4.0`, color: `brightgreen` })
+ .expectBadge({ label: 'node', message: '>=0.4.0', color: 'brightgreen' })
t.create('engines does not satisfy current node version')
.get('/passport.json')
@@ -33,10 +33,10 @@ t.create('engines does not satisfy current node version')
mockPackageData({
packageName: 'passport',
engines: '12',
- })
+ }),
)
.intercept(mockCurrentSha(13))
- .expectBadge({ label: 'node', message: `12`, color: `yellow` })
+ .expectBadge({ label: 'node', message: '12', color: 'yellow' })
t.create('gets the node version of @stdlib/stdlib')
.get('/@stdlib/stdlib.json')
@@ -54,10 +54,10 @@ t.create('engines satisfies current node version - scoped')
scope: '@stdlib',
tag: '',
registry: '',
- })
+ }),
)
.intercept(mockCurrentSha(13))
- .expectBadge({ label: 'node', message: `>=0.4.0`, color: `brightgreen` })
+ .expectBadge({ label: 'node', message: '>=0.4.0', color: 'brightgreen' })
t.create('engines does not satisfy current node version - scoped')
.get('/@stdlib/stdlib.json')
@@ -68,10 +68,10 @@ t.create('engines does not satisfy current node version - scoped')
scope: '@stdlib',
tag: '',
registry: '',
- })
+ }),
)
.intercept(mockCurrentSha(13))
- .expectBadge({ label: 'node', message: `12`, color: `yellow` })
+ .expectBadge({ label: 'node', message: '12', color: 'yellow' })
t.create("gets the tagged release's node version version of ionic")
.get('/ionic/testing.json')
@@ -87,13 +87,13 @@ t.create('engines satisfies current node version - tagged')
packageName: 'ionic',
engines: '>=0.4.0',
tag: 'testing',
- })
+ }),
)
.intercept(mockCurrentSha(13))
.expectBadge({
label: 'node@testing',
- message: `>=0.4.0`,
- color: `brightgreen`,
+ message: '>=0.4.0',
+ color: 'brightgreen',
})
t.create('engines does not satisfy current node version - tagged')
@@ -103,10 +103,10 @@ t.create('engines does not satisfy current node version - tagged')
packageName: 'ionic',
engines: '12',
tag: 'testing',
- })
+ }),
)
.intercept(mockCurrentSha(13))
- .expectBadge({ label: 'node@testing', message: `12`, color: `yellow` })
+ .expectBadge({ label: 'node@testing', message: '12', color: 'yellow' })
t.create("gets the tagged release's node version of @cycle/core")
.get('/@cycle/core/canary.json')
@@ -123,13 +123,13 @@ t.create('engines satisfies current node version - scoped and tagged')
engines: '>=0.4.0',
scope: '@cycle',
tag: 'canary',
- })
+ }),
)
.intercept(mockCurrentSha(13))
.expectBadge({
label: 'node@canary',
- message: `>=0.4.0`,
- color: `brightgreen`,
+ message: '>=0.4.0',
+ color: 'brightgreen',
})
t.create('engines does not satisfy current node version - scoped and tagged')
@@ -140,10 +140,10 @@ t.create('engines does not satisfy current node version - scoped and tagged')
engines: '12',
scope: '@cycle',
tag: 'canary',
- })
+ }),
)
.intercept(mockCurrentSha(13))
- .expectBadge({ label: 'node@canary', message: `12`, color: `yellow` })
+ .expectBadge({ label: 'node@canary', message: '12', color: 'yellow' })
t.create('gets the node version of passport from a custom registry')
.get('/passport.json?registry_uri=https://registry.npmjs.com')
diff --git a/services/node/node-lts.service.js b/services/node/node-lts.service.js
index 934e2ff71912d..a095411d83431 100644
--- a/services/node/node-lts.service.js
+++ b/services/node/node-lts.service.js
@@ -1,9 +1,55 @@
+import { pathParam, queryParam } from '../index.js'
+import { packageNameDescription } from '../npm/npm-base.js'
import NodeVersionBase from './node-base.js'
import { versionColorForRangeLts } from './node-version-color.js'
+const description = `This badge indicates whether the package supports all LTS node versions.
+The node version support is retrieved from the engines.node section in package.json.
`
+
export default class NodeLtsVersion extends NodeVersionBase {
static route = this.buildRoute('node/v-lts', { withTag: true })
+ static openApi = {
+ '/node/v-lts/{packageName}': {
+ get: {
+ summary: 'Node LTS',
+ description,
+ parameters: [
+ pathParam({
+ name: 'packageName',
+ example: 'passport',
+ description: packageNameDescription,
+ }),
+ queryParam({
+ name: 'registry_uri',
+ example: 'https://registry.npmjs.com',
+ }),
+ ],
+ },
+ },
+ '/node/v-lts/{packageName}/{tag}': {
+ get: {
+ summary: 'Node LTS (with tag)',
+ description,
+ parameters: [
+ pathParam({
+ name: 'packageName',
+ example: 'passport',
+ description: packageNameDescription,
+ }),
+ pathParam({
+ name: 'tag',
+ example: 'latest',
+ }),
+ queryParam({
+ name: 'registry_uri',
+ example: 'https://registry.npmjs.com',
+ }),
+ ],
+ },
+ },
+ }
+
static defaultBadgeData = {
label: 'node-lts',
}
@@ -11,6 +57,4 @@ export default class NodeLtsVersion extends NodeVersionBase {
static type = 'lts'
static colorResolver = versionColorForRangeLts
-
- static documentation = `This badge indicates whether the package supports all LTS node versions`
}
diff --git a/services/node/node-lts.spec.js b/services/node/node-lts.spec.js
deleted file mode 100644
index cc3711731065d..0000000000000
--- a/services/node/node-lts.spec.js
+++ /dev/null
@@ -1,21 +0,0 @@
-import { test, given } from 'sazerac'
-import NodeVersion from './node-lts.service.js'
-
-describe('node-lts renderStaticPreview', function () {
- it('should have parity with render()', async function () {
- const nodeVersionRange = '>= 6.0.0'
-
- const expectedNoTag = await NodeVersion.renderStaticPreview({
- nodeVersionRange,
- })
- const expectedLatestTag = await NodeVersion.renderStaticPreview({
- nodeVersionRange,
- tag: 'latest',
- })
-
- test(NodeVersion.renderStaticPreview.bind(NodeVersion), () => {
- given({ nodeVersionRange }).expect(expectedNoTag)
- given({ nodeVersionRange, tag: 'latest' }).expect(expectedLatestTag)
- })
- })
-})
diff --git a/services/node/node-lts.tester.js b/services/node/node-lts.tester.js
index 016604c21df79..0592b1b5aebc4 100644
--- a/services/node/node-lts.tester.js
+++ b/services/node/node-lts.tester.js
@@ -27,10 +27,10 @@ t.create('engines satisfies all lts node versions')
mockPackageData({
packageName: 'passport',
engines: '10 - 12',
- })
+ }),
)
.intercept(mockVersionsSha())
- .expectBadge({ label: 'node-lts', message: `10 - 12`, color: `brightgreen` })
+ .expectBadge({ label: 'node-lts', message: '10 - 12', color: 'brightgreen' })
t.create('engines does not satisfy all lts node versions')
.get('/passport.json')
@@ -39,10 +39,10 @@ t.create('engines does not satisfy all lts node versions')
mockPackageData({
packageName: 'passport',
engines: '8',
- })
+ }),
)
.intercept(mockVersionsSha())
- .expectBadge({ label: 'node-lts', message: `8`, color: `orange` })
+ .expectBadge({ label: 'node-lts', message: '8', color: 'orange' })
t.create('engines satisfies some lts node versions')
.get('/passport.json')
@@ -51,10 +51,10 @@ t.create('engines satisfies some lts node versions')
mockPackageData({
packageName: 'passport',
engines: '10',
- })
+ }),
)
.intercept(mockVersionsSha())
- .expectBadge({ label: 'node-lts', message: `10`, color: `yellow` })
+ .expectBadge({ label: 'node-lts', message: '10', color: 'yellow' })
t.create('gets the node version of @stdlib/stdlib')
.get('/@stdlib/stdlib.json')
@@ -71,10 +71,10 @@ t.create('engines satisfies all lts node versions - scoped')
packageName: 'stdlib',
engines: '10 - 12',
scope: '@stdlib',
- })
+ }),
)
.intercept(mockVersionsSha())
- .expectBadge({ label: 'node-lts', message: `10 - 12`, color: `brightgreen` })
+ .expectBadge({ label: 'node-lts', message: '10 - 12', color: 'brightgreen' })
t.create('engines does not satisfy all lts node versions - scoped')
.get('/@stdlib/stdlib.json')
@@ -84,10 +84,10 @@ t.create('engines does not satisfy all lts node versions - scoped')
packageName: 'stdlib',
engines: '8',
scope: '@stdlib',
- })
+ }),
)
.intercept(mockVersionsSha())
- .expectBadge({ label: 'node-lts', message: `8`, color: `orange` })
+ .expectBadge({ label: 'node-lts', message: '8', color: 'orange' })
t.create('engines satisfies some lts node versions - scoped')
.get('/@stdlib/stdlib.json')
@@ -97,10 +97,10 @@ t.create('engines satisfies some lts node versions - scoped')
packageName: 'stdlib',
engines: '10',
scope: '@stdlib',
- })
+ }),
)
.intercept(mockVersionsSha())
- .expectBadge({ label: 'node-lts', message: `10`, color: `yellow` })
+ .expectBadge({ label: 'node-lts', message: '10', color: 'yellow' })
t.create("gets the tagged release's node version version of ionic")
.get('/ionic/testing.json')
@@ -117,13 +117,13 @@ t.create('engines satisfies all lts node versions - tagged')
packageName: 'ionic',
engines: '10 - 12',
tag: 'testing',
- })
+ }),
)
.intercept(mockVersionsSha())
.expectBadge({
label: 'node-lts@testing',
- message: `10 - 12`,
- color: `brightgreen`,
+ message: '10 - 12',
+ color: 'brightgreen',
})
t.create('engines does not satisfy all lts node versions - tagged')
@@ -134,10 +134,10 @@ t.create('engines does not satisfy all lts node versions - tagged')
packageName: 'ionic',
engines: '8',
tag: 'testing',
- })
+ }),
)
.intercept(mockVersionsSha())
- .expectBadge({ label: 'node-lts@testing', message: `8`, color: `orange` })
+ .expectBadge({ label: 'node-lts@testing', message: '8', color: 'orange' })
t.create('engines satisfies some lts node versions - tagged')
.get('/ionic/testing.json')
@@ -147,10 +147,10 @@ t.create('engines satisfies some lts node versions - tagged')
packageName: 'ionic',
engines: '10',
tag: 'testing',
- })
+ }),
)
.intercept(mockVersionsSha())
- .expectBadge({ label: 'node-lts@testing', message: `10`, color: `yellow` })
+ .expectBadge({ label: 'node-lts@testing', message: '10', color: 'yellow' })
t.create("gets the tagged release's node version of @cycle/core")
.get('/@cycle/core/canary.json')
@@ -168,13 +168,13 @@ t.create('engines satisfies all lts node versions - scoped and tagged')
engines: '10 - 12',
scope: '@cycle',
tag: 'canary',
- })
+ }),
)
.intercept(mockVersionsSha())
.expectBadge({
label: 'node-lts@canary',
- message: `10 - 12`,
- color: `brightgreen`,
+ message: '10 - 12',
+ color: 'brightgreen',
})
t.create('engines does not satisfy all lts node versions - scoped and tagged')
@@ -186,10 +186,10 @@ t.create('engines does not satisfy all lts node versions - scoped and tagged')
engines: '8',
scope: '@cycle',
tag: 'canary',
- })
+ }),
)
.intercept(mockVersionsSha())
- .expectBadge({ label: 'node-lts@canary', message: `8`, color: `orange` })
+ .expectBadge({ label: 'node-lts@canary', message: '8', color: 'orange' })
t.create('engines satisfies some lts node versions - scoped and tagged')
.get('/@cycle/core/canary.json')
@@ -200,10 +200,10 @@ t.create('engines satisfies some lts node versions - scoped and tagged')
engines: '10',
scope: '@cycle',
tag: 'canary',
- })
+ }),
)
.intercept(mockVersionsSha())
- .expectBadge({ label: 'node-lts@canary', message: `10`, color: `yellow` })
+ .expectBadge({ label: 'node-lts@canary', message: '10', color: 'yellow' })
t.create('gets the node version of passport from a custom registry')
.get('/passport.json?registry_uri=https://registry.npmjs.com')
diff --git a/services/node/node-version-color.js b/services/node/node-version-color.js
index 44e36984a25ec..78f50f2fa3d1e 100644
--- a/services/node/node-version-color.js
+++ b/services/node/node-version-color.js
@@ -1,18 +1,16 @@
-import { promisify } from 'util'
-import moment from 'moment'
+import dayjs from 'dayjs'
import semver from 'semver'
-import { regularUpdate } from '../../core/legacy/regular-update.js'
+import { getCachedResource } from '../../core/base-service/resource-cache.js'
const dateFormat = 'YYYY-MM-DD'
-function getVersion(version) {
- let semver = ``
+async function getVersion(version) {
+ let semver = ''
if (version) {
semver = `-${version}.x`
}
- return promisify(regularUpdate)({
+ return getCachedResource({
url: `https://nodejs.org/dist/latest${semver}/SHASUMS256.txt`,
- intervalMillis: 24 * 3600 * 1000,
json: false,
scraper: shasums => {
// tarball index start, tarball index end
@@ -25,7 +23,7 @@ function getVersion(version) {
}
function ltsVersionsScraper(versions) {
- const currentDate = moment().format(dateFormat)
+ const currentDate = dayjs().format(dateFormat)
return Object.keys(versions).filter(function (version) {
const data = versions[version]
return data.lts && data.lts < currentDate && data.end > currentDate
@@ -37,10 +35,8 @@ async function getCurrentVersion() {
}
async function getLtsVersions() {
- const versions = await promisify(regularUpdate)({
+ const versions = await getCachedResource({
url: 'https://raw.githubusercontent.com/nodejs/Release/master/schedule.json',
- intervalMillis: 24 * 3600 * 1000,
- json: true,
scraper: ltsVersionsScraper,
})
return Promise.all(versions.map(getVersion))
diff --git a/services/node/testUtils/test-utils.js b/services/node/testUtils/test-utils.js
index 2a7535ce04cf4..d6ee3a6a3e673 100644
--- a/services/node/testUtils/test-utils.js
+++ b/services/node/testUtils/test-utils.js
@@ -1,7 +1,7 @@
import fs from 'fs'
import path from 'path'
import { fileURLToPath } from 'url'
-import moment from 'moment'
+import dayjs from 'dayjs'
const dateFormat = 'YYYY-MM-DD'
@@ -9,16 +9,16 @@ const templates = {
packageJsonVersionsTemplate: fs.readFileSync(
path.join(
path.dirname(fileURLToPath(import.meta.url)),
- `packageJsonVersionsTemplate.json`
+ 'packageJsonVersionsTemplate.json',
),
- 'utf-8'
+ 'utf-8',
),
packageJsonTemplate: fs.readFileSync(
path.join(
path.dirname(fileURLToPath(import.meta.url)),
- `packageJsonTemplate.json`
+ 'packageJsonTemplate.json',
),
- 'utf-8'
+ 'utf-8',
),
}
@@ -51,7 +51,7 @@ const mockPackageData =
const mockCurrentSha = latestVersion => nock => {
const latestSha = `node-v${latestVersion}.12.0-aix-ppc64.tar.gz`
return nock('https://nodejs.org/dist/')
- .get(`/latest/SHASUMS256.txt`)
+ .get('/latest/SHASUMS256.txt')
.reply(200, latestSha)
}
@@ -67,7 +67,7 @@ const mockVersionsSha = () => nock => {
}
const mockReleaseSchedule = () => nock => {
- const currentDate = moment()
+ const currentDate = dayjs()
const schedule = {
'v0.10': {
start: '2013-03-11',
@@ -146,7 +146,7 @@ const mockReleaseSchedule = () => nock => {
},
}
return nock('https://raw.githubusercontent.com/')
- .get(`/nodejs/Release/master/schedule.json`)
+ .get('/nodejs/Release/master/schedule.json')
.reply(200, schedule)
}
diff --git a/services/nodeping/nodeping-status.service.js b/services/nodeping/nodeping-status.service.js
index 32d145b995fed..cf90f7dd6ead9 100644
--- a/services/nodeping/nodeping-status.service.js
+++ b/services/nodeping/nodeping-status.service.js
@@ -1,10 +1,10 @@
import Joi from 'joi'
import {
queryParamSchema,
- exampleQueryParams,
+ queryParams,
renderWebsiteStatus,
} from '../website-status.js'
-import { BaseJsonService } from '../index.js'
+import { BaseJsonService, pathParams } from '../index.js'
const schema = Joi.array()
.items(Joi.object().keys({ su: Joi.boolean() }))
@@ -24,14 +24,17 @@ export default class NodePingStatus extends BaseJsonService {
queryParamSchema,
}
- static examples = [
- {
- title: 'NodePing status',
- namedParams: { checkUuid: exampleCheckUuid },
- queryParams: exampleQueryParams,
- staticPreview: renderWebsiteStatus({ isUp: true }),
+ static openApi = {
+ '/nodeping/status/{checkUuid}': {
+ get: {
+ summary: 'NodePing status',
+ parameters: pathParams({
+ name: 'checkUuid',
+ example: exampleCheckUuid,
+ }).concat(queryParams),
+ },
},
- ]
+ }
static defaultBadgeData = { label: 'status' }
@@ -40,7 +43,7 @@ export default class NodePingStatus extends BaseJsonService {
schema,
url: `https://nodeping.com/reports/results/${checkUuid}/1`,
options: {
- qs: { format: 'json' },
+ searchParams: { format: 'json' },
headers: {
'cache-control': 'no-cache',
},
@@ -56,7 +59,7 @@ export default class NodePingStatus extends BaseJsonService {
down_message: downMessage,
up_color: upColor,
down_color: downColor,
- }
+ },
) {
const { isUp } = await this.fetch({ checkUuid })
return renderWebsiteStatus({
diff --git a/services/nodeping/nodeping-status.tester.js b/services/nodeping/nodeping-status.tester.js
index 6361cb99ce305..9cc586322bd66 100644
--- a/services/nodeping/nodeping-status.tester.js
+++ b/services/nodeping/nodeping-status.tester.js
@@ -11,9 +11,9 @@ t.create('NodePing status - up')
.intercept(nock =>
nock('https://nodeping.com')
.get(
- '/reports/results/jkiwn052-ntpp-4lbb-8d45-ihew6d9ucoei/1?format=json'
+ '/reports/results/jkiwn052-ntpp-4lbb-8d45-ihew6d9ucoei/1?format=json',
)
- .reply(200, [{ su: true }])
+ .reply(200, [{ su: true }]),
)
.expectBadge({ label: 'status', message: 'up' })
@@ -22,34 +22,34 @@ t.create('NodePing status - down')
.intercept(nock =>
nock('https://nodeping.com')
.get(
- '/reports/results/jkiwn052-ntpp-4lbb-8d45-ihew6d9ucoei/1?format=json'
+ '/reports/results/jkiwn052-ntpp-4lbb-8d45-ihew6d9ucoei/1?format=json',
)
- .reply(200, [{ su: false }])
+ .reply(200, [{ su: false }]),
)
.expectBadge({ label: 'status', message: 'down' })
t.create('NodePing status - custom up color/message')
.get(
- '/jkiwn052-ntpp-4lbb-8d45-ihew6d9ucoei.json?up_color=blue&up_message=happy'
+ '/jkiwn052-ntpp-4lbb-8d45-ihew6d9ucoei.json?up_color=blue&up_message=happy',
)
.intercept(nock =>
nock('https://nodeping.com')
.get(
- '/reports/results/jkiwn052-ntpp-4lbb-8d45-ihew6d9ucoei/1?format=json'
+ '/reports/results/jkiwn052-ntpp-4lbb-8d45-ihew6d9ucoei/1?format=json',
)
- .reply(200, [{ su: true }])
+ .reply(200, [{ su: true }]),
)
.expectBadge({ label: 'status', message: 'happy', color: 'blue' })
t.create('NodePing status - custom down color/message')
.get(
- '/jkiwn052-ntpp-4lbb-8d45-ihew6d9ucoei.json?down_color=yellow&down_message=sad'
+ '/jkiwn052-ntpp-4lbb-8d45-ihew6d9ucoei.json?down_color=yellow&down_message=sad',
)
.intercept(nock =>
nock('https://nodeping.com')
.get(
- '/reports/results/jkiwn052-ntpp-4lbb-8d45-ihew6d9ucoei/1?format=json'
+ '/reports/results/jkiwn052-ntpp-4lbb-8d45-ihew6d9ucoei/1?format=json',
)
- .reply(200, [{ su: false }])
+ .reply(200, [{ su: false }]),
)
.expectBadge({ label: 'status', message: 'sad', color: 'yellow' })
diff --git a/services/nodeping/nodeping-uptime.service.js b/services/nodeping/nodeping-uptime.service.js
index 827ac7b6a826d..ad7f1aefb1e91 100644
--- a/services/nodeping/nodeping-uptime.service.js
+++ b/services/nodeping/nodeping-uptime.service.js
@@ -1,6 +1,6 @@
import Joi from 'joi'
import { colorScale } from '../color-formatters.js'
-import { BaseJsonService } from '../index.js'
+import { BaseJsonService, pathParams } from '../index.js'
const colorFormatter = colorScale([99, 99.5, 100])
@@ -26,13 +26,17 @@ export default class NodePingUptime extends BaseJsonService {
static route = { base: 'nodeping/uptime', pattern: ':checkUuid' }
- static examples = [
- {
- title: 'NodePing uptime',
- namedParams: { checkUuid: sampleCheckUuid },
- staticPreview: this.render({ uptime: 99.999 }),
+ static openApi = {
+ '/nodeping/uptime/{checkUuid}': {
+ get: {
+ summary: 'NodePing uptime',
+ parameters: pathParams({
+ name: 'checkUuid',
+ example: sampleCheckUuid,
+ }),
+ },
},
- ]
+ }
static defaultBadgeData = { label: 'uptime' }
@@ -52,7 +56,7 @@ export default class NodePingUptime extends BaseJsonService {
async fetch({ checkUuid }) {
const thirtyDaysAgo = new Date(
- new Date().getTime() - 30 * 24 * 60 * 60 * 1000
+ new Date().getTime() - 30 * 24 * 60 * 60 * 1000,
)
.toISOString()
.slice(0, 10)
@@ -61,7 +65,11 @@ export default class NodePingUptime extends BaseJsonService {
schema,
url: `https://nodeping.com/reports/uptime/${checkUuid}`,
options: {
- qs: { format: 'json', interval: 'days', start: thirtyDaysAgo },
+ searchParams: {
+ format: 'json',
+ interval: 'days',
+ start: thirtyDaysAgo,
+ },
headers: {
'cache-control': 'no-cache',
},
diff --git a/services/nodeping/nodeping-uptime.tester.js b/services/nodeping/nodeping-uptime.tester.js
index 5e09a52eca4ac..fc3367336e249 100644
--- a/services/nodeping/nodeping-uptime.tester.js
+++ b/services/nodeping/nodeping-uptime.tester.js
@@ -12,12 +12,12 @@ t.create('NodePing uptime - 100%')
nock('https://nodeping.com')
.get(
`/reports/uptime/jkiwn052-ntpp-4lbb-8d45-ihew6d9ucoei?format=json&interval=days&start=${new Date(
- new Date().getTime() - 30 * 24 * 60 * 60 * 1000
+ new Date().getTime() - 30 * 24 * 60 * 60 * 1000,
)
.toISOString()
- .slice(0, 10)}`
+ .slice(0, 10)}`,
)
- .reply(200, [{ uptime: 100 }])
+ .reply(200, [{ uptime: 100 }]),
)
.expectBadge({ label: 'uptime', message: '100%', color: 'brightgreen' })
@@ -27,12 +27,12 @@ t.create('NodePing uptime - 99.999%')
nock('https://nodeping.com')
.get(
`/reports/uptime/jkiwn052-ntpp-4lbb-8d45-ihew6d9ucoei?format=json&interval=days&start=${new Date(
- new Date().getTime() - 30 * 24 * 60 * 60 * 1000
+ new Date().getTime() - 30 * 24 * 60 * 60 * 1000,
)
.toISOString()
- .slice(0, 10)}`
+ .slice(0, 10)}`,
)
- .reply(200, [{ uptime: 99.999 }])
+ .reply(200, [{ uptime: 99.999 }]),
)
.expectBadge({ label: 'uptime', message: '99.999%', color: 'green' })
@@ -42,12 +42,12 @@ t.create('NodePing uptime - 99.001%')
nock('https://nodeping.com')
.get(
`/reports/uptime/jkiwn052-ntpp-4lbb-8d45-ihew6d9ucoei?format=json&interval=days&start=${new Date(
- new Date().getTime() - 30 * 24 * 60 * 60 * 1000
+ new Date().getTime() - 30 * 24 * 60 * 60 * 1000,
)
.toISOString()
- .slice(0, 10)}`
+ .slice(0, 10)}`,
)
- .reply(200, [{ uptime: 99.001 }])
+ .reply(200, [{ uptime: 99.001 }]),
)
.expectBadge({ label: 'uptime', message: '99.001%', color: 'yellow' })
@@ -57,11 +57,11 @@ t.create('NodePing uptime - 90.001%')
nock('https://nodeping.com')
.get(
`/reports/uptime/jkiwn052-ntpp-4lbb-8d45-ihew6d9ucoei?format=json&interval=days&start=${new Date(
- new Date().getTime() - 30 * 24 * 60 * 60 * 1000
+ new Date().getTime() - 30 * 24 * 60 * 60 * 1000,
)
.toISOString()
- .slice(0, 10)}`
+ .slice(0, 10)}`,
)
- .reply(200, [{ uptime: 90.001 }])
+ .reply(200, [{ uptime: 90.001 }]),
)
.expectBadge({ label: 'uptime', message: '90.001%', color: 'red' })
diff --git a/services/nostr-band/nostr-band-followers.service.js b/services/nostr-band/nostr-band-followers.service.js
new file mode 100644
index 0000000000000..96bd23a0d5721
--- /dev/null
+++ b/services/nostr-band/nostr-band-followers.service.js
@@ -0,0 +1,11 @@
+import { deprecatedService } from '../index.js'
+
+export default deprecatedService({
+ category: 'social',
+ route: {
+ base: 'nostrband/followers',
+ pattern: ':npub',
+ },
+ label: 'nostrband',
+ dateAdded: new Date('2025-11-22'),
+})
diff --git a/services/nostr-band/nostr-band-followers.tester.js b/services/nostr-band/nostr-band-followers.tester.js
new file mode 100644
index 0000000000000..471cc1fba3538
--- /dev/null
+++ b/services/nostr-band/nostr-band-followers.tester.js
@@ -0,0 +1,15 @@
+import { ServiceTester } from '../tester.js'
+
+export const t = new ServiceTester({
+ id: 'nostrband',
+ title: 'Nostr.band',
+})
+
+t.create('followers')
+ .get(
+ '/followers/npub18c556t7n8xa3df2q82rwxejfglw5przds7sqvefylzjh8tjne28qld0we7.json',
+ )
+ .expectBadge({
+ label: 'nostrband',
+ message: 'no longer available',
+ })
diff --git a/services/npm-stat/npm-stat-downloads.service.js b/services/npm-stat/npm-stat-downloads.service.js
new file mode 100644
index 0000000000000..d06a28ef3d4e6
--- /dev/null
+++ b/services/npm-stat/npm-stat-downloads.service.js
@@ -0,0 +1,82 @@
+import Joi from 'joi'
+import dayjs from 'dayjs'
+import { nonNegativeInteger } from '../validators.js'
+import { BaseJsonService, pathParams } from '../index.js'
+import { renderDownloadsBadge } from '../downloads.js'
+
+const schema = Joi.object()
+ .pattern(Joi.string(), Joi.object().pattern(Joi.string(), nonNegativeInteger))
+ .required()
+
+const intervalMap = {
+ dw: { interval: 'week' },
+ dm: { interval: 'month' },
+ dy: { interval: 'year' },
+}
+
+export default class NpmStatDownloads extends BaseJsonService {
+ static category = 'downloads'
+
+ static route = {
+ base: 'npm-stat',
+ pattern: ':interval(dw|dm|dy)/:author',
+ }
+
+ static openApi = {
+ '/npm-stat/{interval}/{author}': {
+ get: {
+ summary: 'NPM Downloads by package author',
+ description:
+ 'The total number of downloads of npm packages published by the specified author from [npm-stat](https://npm-stat.com).',
+ parameters: pathParams(
+ {
+ name: 'interval',
+ example: 'dw',
+ schema: { type: 'string', enum: this.getEnum('interval') },
+ description: 'Downloads per Week, Month or Year',
+ },
+ {
+ name: 'author',
+ example: 'dukeluo',
+ },
+ ),
+ },
+ },
+ }
+
+ static _cacheLength = 21600
+
+ static defaultBadgeData = { label: 'downloads' }
+
+ static getTotalDownloads(data) {
+ const add = (x, y) => x + y
+ const sum = nums => nums.reduce(add, 0)
+
+ return Object.values(data).reduce(
+ (count, packageDownloads) => count + sum(Object.values(packageDownloads)),
+ 0,
+ )
+ }
+
+ static render({ interval, downloads }) {
+ return renderDownloadsBadge({
+ downloads,
+ interval: intervalMap[interval].interval,
+ colorOverride: downloads > 0 ? 'brightgreen' : 'red',
+ })
+ }
+
+ async handle({ interval, author }) {
+ const unit = intervalMap[interval].interval
+ const today = dayjs()
+ const until = today.format('YYYY-MM-DD')
+ const from = today.subtract(1, unit).format('YYYY-MM-DD')
+ const data = await this._requestJson({
+ url: `https://npm-stat.com/api/download-counts?author=${author}&from=${from}&until=${until}`,
+ schema,
+ })
+ const downloads = this.constructor.getTotalDownloads(data)
+
+ return this.constructor.render({ interval, downloads })
+ }
+}
diff --git a/services/npm-stat/npm-stat-downloads.spec.js b/services/npm-stat/npm-stat-downloads.spec.js
new file mode 100644
index 0000000000000..1932b9b650a7a
--- /dev/null
+++ b/services/npm-stat/npm-stat-downloads.spec.js
@@ -0,0 +1,25 @@
+import { test, given } from 'sazerac'
+import NpmStatDownloads from './npm-stat-downloads.service.js'
+
+describe('NpmStatDownloads helpers', function () {
+ test(NpmStatDownloads.getTotalDownloads, () => {
+ given({
+ 'hexo-theme-candelas': {
+ '2022-12-01': 1,
+ '2022-12-02': 2,
+ '2022-12-03': 3,
+ },
+ '@dukeluo/fanjs': {
+ '2022-12-01': 10,
+ '2022-12-02': 20,
+ '2022-12-03': 30,
+ },
+ 'eslint-plugin-check-file': {
+ '2022-12-01': 100,
+ '2022-12-02': 200,
+ '2022-12-03': 300,
+ },
+ }).expect(666)
+ given({}).expect(0)
+ })
+})
diff --git a/services/npm-stat/npm-stat-downloads.tester.js b/services/npm-stat/npm-stat-downloads.tester.js
new file mode 100644
index 0000000000000..830a4208f204c
--- /dev/null
+++ b/services/npm-stat/npm-stat-downloads.tester.js
@@ -0,0 +1,35 @@
+import { isMetricOverTimePeriod } from '../test-validators.js'
+import { createServiceTester } from '../tester.js'
+export const t = await createServiceTester()
+
+t.create('weekly downloads of npm author dukeluo')
+ .get('/dw/dukeluo.json')
+ .expectBadge({
+ label: 'downloads',
+ message: isMetricOverTimePeriod,
+ color: 'brightgreen',
+ })
+
+t.create('monthly downloads of npm author dukeluo')
+ .get('/dm/dukeluo.json')
+ .expectBadge({
+ label: 'downloads',
+ message: isMetricOverTimePeriod,
+ color: 'brightgreen',
+ })
+
+t.create('yearly downloads of npm author dukeluo')
+ .get('/dy/dukeluo.json')
+ .expectBadge({
+ label: 'downloads',
+ message: isMetricOverTimePeriod,
+ color: 'brightgreen',
+ })
+
+t.create('downloads of unknown npm package author')
+ .get('/dy/npm-api-does-not-have-this-package-author.json')
+ .expectBadge({
+ label: 'downloads',
+ message: '0/year',
+ color: 'red',
+ })
diff --git a/services/npm/npm-base.js b/services/npm/npm-base.js
index 7f8ed6cbc2ca3..033e3b0709a0b 100644
--- a/services/npm/npm-base.js
+++ b/services/npm/npm-base.js
@@ -15,13 +15,13 @@ const packageDataSchema = Joi.object({
Joi.string(),
deprecatedLicenseObjectSchema,
Joi.array().items(
- Joi.alternatives(Joi.string(), deprecatedLicenseObjectSchema)
- )
+ Joi.alternatives(Joi.string(), deprecatedLicenseObjectSchema),
+ ),
),
maintainers: Joi.array()
// We don't need the keys here, just the length.
.items(Joi.object({}))
- .required(),
+ .default([]),
types: Joi.string(),
// `typings` is an alias for `types` and often used
// https://www.typescriptlang.org/docs/handbook/declaration-files/publishing.html#including-declarations-in-your-npm-package
@@ -34,6 +34,9 @@ export const queryParamSchema = Joi.object({
registry_uri: optionalUrl,
}).required()
+export const packageNameDescription =
+ 'This may be the name of an unscoped package like `package-name` or a [scoped package](https://docs.npmjs.com/about-scopes) like `@author/package-name`'
+
// Abstract class for NPM badges which display data about the latest version
// of a package.
export default class NpmBase extends BaseJsonService {
@@ -60,7 +63,7 @@ export default class NpmBase extends BaseJsonService {
static unpackParams(
{ scope, packageName, tag },
- { registry_uri: registryUrl = 'https://registry.npmjs.org' }
+ { registry_uri: registryUrl = 'https://registry.npmjs.org' },
) {
return {
scope,
@@ -78,8 +81,11 @@ export default class NpmBase extends BaseJsonService {
}
async _requestJson(data) {
- return super._requestJson(
- this.authHelper.withBearerAuthHeader({
+ let payload
+ if (data?.options?.headers?.Accept) {
+ payload = data
+ } else {
+ payload = {
...data,
options: {
headers: {
@@ -88,8 +94,9 @@ export default class NpmBase extends BaseJsonService {
Accept: '*/*',
},
},
- })
- )
+ }
+ }
+ return super._requestJson(this.authHelper.withBearerAuthHeader(payload))
}
async fetchPackageData({ registryUrl, scope, packageName, tag }) {
@@ -117,7 +124,7 @@ export default class NpmBase extends BaseJsonService {
// We don't validate here because we need to pluck the desired subkey first.
schema: Joi.any(),
url,
- errorMessages: { 404: 'package not found' },
+ httpErrors: { 404: 'package not found' },
})
let packageData
@@ -140,4 +147,37 @@ export default class NpmBase extends BaseJsonService {
return this.constructor._validate(packageData, packageDataSchema)
}
+
+ async fetch({
+ registryUrl,
+ scope,
+ packageName,
+ schema,
+ abbreviated = false,
+ }) {
+ registryUrl = registryUrl || this.constructor.defaultRegistryUrl
+ let url
+
+ if (scope === undefined) {
+ url = `${registryUrl}/${packageName}`
+ } else {
+ const scoped = this.constructor.encodeScopedPackage({
+ scope,
+ packageName,
+ })
+ url = `${registryUrl}/${scoped}`
+ }
+
+ // https://github.com/npm/registry/blob/main/docs/responses/package-metadata.md
+ const options = abbreviated
+ ? { headers: { Accept: 'application/vnd.npm.install-v1+json' } }
+ : {}
+
+ return this._requestJson({
+ url,
+ schema,
+ options,
+ httpErrors: { 404: 'package not found' },
+ })
+ }
}
diff --git a/services/npm/npm-base.spec.js b/services/npm/npm-base.spec.js
index 1d68e226d027c..b8a6bec47f007 100644
--- a/services/npm/npm-base.spec.js
+++ b/services/npm/npm-base.spec.js
@@ -31,10 +31,10 @@ describe('npm', function () {
}
expect(
- await NpmVersion.invoke(defaultContext, config, { packageName: 'npm' })
+ await NpmVersion.invoke(defaultContext, config, { packageName: 'npm' }),
).to.deep.equal({
color: 'orange',
- label: undefined,
+ label: 'npm',
message: 'v0.1.0',
})
diff --git a/services/npm/npm-collaborators.service.js b/services/npm/npm-collaborators.service.js
index 3c277b58c6f0f..dc386da9a68c4 100644
--- a/services/npm/npm-collaborators.service.js
+++ b/services/npm/npm-collaborators.service.js
@@ -1,30 +1,30 @@
+import { pathParam, queryParam } from '../index.js'
import { renderContributorBadge } from '../contributor-count.js'
-import NpmBase from './npm-base.js'
-
-const keywords = ['node']
+import NpmBase, { packageNameDescription } from './npm-base.js'
export default class NpmCollaborators extends NpmBase {
static category = 'activity'
static route = this.buildRoute('npm/collaborators', { withTag: false })
- static examples = [
- {
- title: 'npm collaborators',
- pattern: ':packageName',
- namedParams: { packageName: 'prettier' },
- staticPreview: this.render({ collaborators: 6 }),
- keywords,
- },
- {
- title: 'npm collaborators',
- pattern: ':packageName',
- namedParams: { packageName: 'prettier' },
- queryParams: { registry_uri: 'https://registry.npmjs.com' },
- staticPreview: this.render({ collaborators: 6 }),
- keywords,
+ static openApi = {
+ '/npm/collaborators/{packageName}': {
+ get: {
+ summary: 'NPM Collaborators',
+ parameters: [
+ pathParam({
+ name: 'packageName',
+ example: 'prettier',
+ description: packageNameDescription,
+ }),
+ queryParam({
+ name: 'registry_uri',
+ example: 'https://registry.npmjs.com',
+ }),
+ ],
+ },
},
- ]
+ }
static defaultBadgeData = {
label: 'npm collaborators',
@@ -37,7 +37,7 @@ export default class NpmCollaborators extends NpmBase {
async handle(namedParams, queryParams) {
const { scope, packageName, registryUrl } = this.constructor.unpackParams(
namedParams,
- queryParams
+ queryParams,
)
const { maintainers } = await this.fetchPackageData({
scope,
diff --git a/services/npm/npm-collaborators.tester.js b/services/npm/npm-collaborators.tester.js
index 90488d5e85366..c2f41b7e6bd42 100644
--- a/services/npm/npm-collaborators.tester.js
+++ b/services/npm/npm-collaborators.tester.js
@@ -16,3 +16,12 @@ t.create('contributor count for unknown package')
label: 'npm collaborators',
message: 'package not found',
})
+
+t.create('contributor count for package package without a maintainers property')
+ .get('/package-without-maintainers.json')
+ .intercept(nock =>
+ nock('https://registry.npmjs.org')
+ .get('/package-without-maintainers/latest')
+ .reply(200, {}),
+ )
+ .expectBadge({ label: 'npm collaborators', message: '0' })
diff --git a/services/npm/npm-dependency-version.service.js b/services/npm/npm-dependency-version.service.js
index 916a5d7e0a0a9..ffe25442b6e34 100644
--- a/services/npm/npm-dependency-version.service.js
+++ b/services/npm/npm-dependency-version.service.js
@@ -1,8 +1,9 @@
+import { pathParam, queryParam } from '../index.js'
import { getDependencyVersion } from '../package-json-helpers.js'
-import NpmBase from './npm-base.js'
-
-const { queryParamSchema } = NpmBase
-const keywords = ['node']
+import NpmBase, {
+ queryParamSchema,
+ packageNameDescription,
+} from './npm-base.js'
export default class NpmDependencyVersion extends NpmBase {
static category = 'platform-support'
@@ -14,89 +15,55 @@ export default class NpmDependencyVersion extends NpmBase {
queryParamSchema,
}
- static examples = [
- {
- title: 'npm peer dependency version',
- pattern: ':packageName/peer/:dependency',
- namedParams: {
- packageName: 'react-boxplot',
- dependency: 'prop-types',
- },
- staticPreview: this.render({
- dependency: 'prop-types',
- range: '^15.5.4',
- }),
- keywords,
- },
- {
- title: 'npm peer dependency version (scoped)',
- pattern: ':scope?/:packageName/peer/:dependencyScope?/:dependency',
- namedParams: {
- scope: '@swellaby',
- packageName: 'eslint-config',
- dependency: 'eslint',
- },
- staticPreview: this.render({
- dependency: 'eslint',
- range: '^3.0.0',
- }),
- keywords,
- },
- {
- title: 'npm dev dependency version',
- pattern: ':packageName/dev/:dependency',
- namedParams: {
- packageName: 'react-boxplot',
- dependency: 'eslint-config-standard',
+ static openApi = {
+ '/npm/dependency-version/{packageName}/{dependency}': {
+ get: {
+ summary: 'NPM (prod) Dependency Version',
+ parameters: [
+ pathParam({
+ name: 'packageName',
+ example: 'react-boxplot',
+ description: packageNameDescription,
+ }),
+ pathParam({
+ name: 'dependency',
+ example: 'simple-statistics',
+ description: packageNameDescription,
+ }),
+ queryParam({
+ name: 'registry_uri',
+ example: 'https://registry.npmjs.com',
+ }),
+ ],
},
- staticPreview: this.render({
- dependency: 'eslint-config-standard',
- range: '^12.0.0',
- }),
- keywords,
},
- {
- title: 'npm dev dependency version (scoped)',
- pattern: ':scope?/:packageName/dev/:dependencyScope?/:dependency',
- namedParams: {
- packageName: 'mocha',
- dependencyScope: '@mocha',
- dependency: 'contributors',
+ '/npm/dependency-version/{packageName}/{kind}/{dependency}': {
+ get: {
+ summary: 'NPM dev or peer Dependency Version',
+ parameters: [
+ pathParam({
+ name: 'packageName',
+ example: 'react-boxplot',
+ description: packageNameDescription,
+ }),
+ pathParam({
+ name: 'kind',
+ example: 'dev',
+ schema: { type: 'string', enum: this.getEnum('kind') },
+ }),
+ pathParam({
+ name: 'dependency',
+ example: 'prop-types',
+ description: packageNameDescription,
+ }),
+ queryParam({
+ name: 'registry_uri',
+ example: 'https://registry.npmjs.com',
+ }),
+ ],
},
- staticPreview: this.render({
- dependency: '@mocha/contributors',
- range: '^1.0.3',
- }),
- keywords,
},
- {
- title: 'npm (prod) dependency version',
- pattern: ':packageName/:dependency',
- namedParams: {
- packageName: 'react-boxplot',
- dependency: 'simple-statistics',
- },
- staticPreview: this.render({
- dependency: 'simple-statistics',
- range: '^6.1.1',
- }),
- keywords,
- },
- {
- title: 'npm (prod) dependency version (scoped)',
- pattern: ':scope?/:packageName/:dependencyScope?/:dependency',
- namedParams: {
- packageName: 'got',
- dependencyScope: '@sindresorhus',
- dependency: 'is',
- },
- staticPreview: this.render({
- dependency: '@sindresorhus/is',
- range: '^0.15.0',
- }),
- keywords,
- },
- ]
+ }
static defaultBadgeData = {
label: 'dependency',
@@ -113,7 +80,7 @@ export default class NpmDependencyVersion extends NpmBase {
async handle(namedParams, queryParams) {
const { scope, packageName, registryUrl } = this.constructor.unpackParams(
namedParams,
- queryParams
+ queryParams,
)
const { kind, dependency, dependencyScope } = namedParams
const wantedDependency = `${
@@ -127,7 +94,7 @@ export default class NpmDependencyVersion extends NpmBase {
registryUrl,
})
- const { range } = getDependencyVersion({
+ const range = getDependencyVersion({
kind,
wantedDependency,
dependencies,
diff --git a/services/npm/npm-downloads-redirect.service.js b/services/npm/npm-downloads-redirect.service.js
new file mode 100644
index 0000000000000..9227d8a6dd9aa
--- /dev/null
+++ b/services/npm/npm-downloads-redirect.service.js
@@ -0,0 +1,11 @@
+import { redirector } from '../index.js'
+
+export default redirector({
+ category: 'downloads',
+ route: {
+ base: 'npm/dt',
+ pattern: ':packageName+',
+ },
+ transformPath: ({ packageName }) => `/npm/d18m/${packageName}`,
+ dateAdded: new Date('2024-03-19'),
+})
diff --git a/services/npm/npm-downloads.service.js b/services/npm/npm-downloads.service.js
index ca01fae33f147..67a42c8711483 100644
--- a/services/npm/npm-downloads.service.js
+++ b/services/npm/npm-downloads.service.js
@@ -1,7 +1,8 @@
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 { BaseJsonService, pathParams } from '../index.js'
+import { packageNameDescription } from './npm-base.js'
// https://github.com/npm/registry/blob/master/docs/download-counts.md#output
const pointResponseSchema = Joi.object({
@@ -13,21 +14,21 @@ const intervalMap = {
query: 'point/last-week',
schema: pointResponseSchema,
transform: json => json.downloads,
- messageSuffix: '/week',
+ interval: 'week',
},
dm: {
query: 'point/last-month',
schema: pointResponseSchema,
transform: json => json.downloads,
- messageSuffix: '/month',
+ interval: 'month',
},
dy: {
query: 'point/last-year',
schema: pointResponseSchema,
transform: json => json.downloads,
- messageSuffix: '/year',
+ interval: 'year',
},
- dt: {
+ d18m: {
query: 'range/1000-01-01:3000-01-01',
// https://github.com/npm/registry/blob/master/docs/download-counts.md#output-1
schema: Joi.object({
@@ -37,7 +38,6 @@ const intervalMap = {
json.downloads
.map(item => item.downloads)
.reduce((accum, current) => accum + current),
- messageSuffix: '',
},
}
@@ -48,28 +48,42 @@ export default class NpmDownloads extends BaseJsonService {
static route = {
base: 'npm',
- pattern: ':interval(dw|dm|dy|dt)/:scope(@.+)?/:packageName',
+ pattern: ':interval(dw|dm|dy|d18m)/:scope(@.+)?/:packageName',
}
- static examples = [
- {
- title: 'npm',
- namedParams: { interval: 'dw', packageName: 'localeval' },
- staticPreview: this.render({ interval: 'dw', downloadCount: 30000 }),
- keywords: ['node'],
+ static openApi = {
+ '/npm/{interval}/{packageName}': {
+ get: {
+ summary: 'NPM Downloads',
+ parameters: pathParams(
+ {
+ name: 'interval',
+ example: 'dw',
+ description:
+ 'Downloads in the last Week, Month, Year, or 18 Months',
+ schema: { type: 'string', enum: this.getEnum('interval') },
+ },
+ {
+ name: 'packageName',
+ example: 'localeval',
+ description: packageNameDescription,
+ },
+ ),
+ },
},
- ]
+ }
+
+ static _cacheLength = 7200
// For testing.
static _intervalMap = intervalMap
- static render({ interval, downloadCount }) {
- const { messageSuffix } = intervalMap[interval]
-
- return {
- message: `${metric(downloadCount)}${messageSuffix}`,
- color: downloadCount > 0 ? 'brightgreen' : 'red',
- }
+ static render({ interval, downloadCount: downloads }) {
+ return renderDownloadsBadge({
+ downloads,
+ interval: intervalMap[interval].interval,
+ colorOverride: downloads > 0 ? 'brightgreen' : 'red',
+ })
}
async handle({ interval, scope, packageName }) {
@@ -79,7 +93,7 @@ export default class NpmDownloads extends BaseJsonService {
const json = await this._requestJson({
schema,
url: `https://api.npmjs.org/downloads/${query}/${slug}`,
- errorMessages: { 404: 'package not found or too new' },
+ httpErrors: { 404: 'package not found or too new' },
})
const downloadCount = transform(json)
diff --git a/services/npm/npm-downloads.spec.js b/services/npm/npm-downloads.spec.js
index edb9854226272..6529dd57efadf 100644
--- a/services/npm/npm-downloads.spec.js
+++ b/services/npm/npm-downloads.spec.js
@@ -2,7 +2,7 @@ import { test, given } from 'sazerac'
import NpmDownloads from './npm-downloads.service.js'
describe('NpmDownloads', function () {
- test(NpmDownloads._intervalMap.dt.transform, () => {
+ test(NpmDownloads._intervalMap.d18m.transform, () => {
given({
downloads: [
{ downloads: 2, day: '2018-01-01' },
@@ -13,11 +13,12 @@ describe('NpmDownloads', function () {
test(NpmDownloads.render, () => {
given({
- interval: 'dt',
+ interval: 'd18m',
downloadCount: 0,
}).expect({
- message: '0',
color: 'red',
+ message: '0',
+ label: undefined,
})
})
})
diff --git a/services/npm/npm-downloads.tester.js b/services/npm/npm-downloads.tester.js
index 4ff67bad94be1..7c02c940e9e82 100644
--- a/services/npm/npm-downloads.tester.js
+++ b/services/npm/npm-downloads.tester.js
@@ -12,20 +12,30 @@ t.create('weekly downloads of @cycle/core')
.get('/dw/@cycle/core.json')
.expectBadge({ label: 'downloads', message: isMetricOverTimePeriod })
-t.create('total downloads of left-pad').get('/dt/left-pad.json').expectBadge({
- label: 'downloads',
- message: isMetric,
- color: 'brightgreen',
-})
+t.create('downloads in last 18 months of left-pad')
+ .get('/d18m/left-pad.json')
+ .expectBadge({
+ label: 'downloads',
+ message: isMetric,
+ color: 'brightgreen',
+ })
-t.create('total downloads of @cycle/core')
- .get('/dt/@cycle/core.json')
+t.create('downloads in last 18 months of @cycle/core')
+ .get('/d18m/@cycle/core.json')
.expectBadge({ label: 'downloads', message: isMetric })
t.create('downloads of unknown package')
- .get('/dt/npm-api-does-not-have-this-package.json')
+ .get('/dy/npm-api-does-not-have-this-package.json')
.expectBadge({
label: 'downloads',
message: 'package not found or too new',
color: 'red',
})
+
+t.create('Total downloads redirect: unscoped package')
+ .get('/dt/left-pad.svg')
+ .expectRedirect('/npm/d18m/left-pad.svg')
+
+t.create('Total downloads redirect: scoped package')
+ .get('/dt/@cycle/core.svg')
+ .expectRedirect('/npm/d18m/@cycle/core.svg')
diff --git a/services/npm/npm-last-update.service.js b/services/npm/npm-last-update.service.js
new file mode 100644
index 0000000000000..b8d254f5ef7a1
--- /dev/null
+++ b/services/npm/npm-last-update.service.js
@@ -0,0 +1,119 @@
+import Joi from 'joi'
+import { NotFound, pathParam, queryParam } from '../index.js'
+import { renderDateBadge } from '../date.js'
+import NpmBase, {
+ packageNameDescription,
+ queryParamSchema,
+} from './npm-base.js'
+
+const fullSchema = Joi.object({
+ time: Joi.object()
+ .pattern(Joi.string().required(), Joi.string().required())
+ .required(),
+ 'dist-tags': Joi.object()
+ .pattern(Joi.string().required(), Joi.string().required())
+ .required(),
+}).required()
+
+const abbreviatedSchema = Joi.object({
+ modified: Joi.string().required(),
+}).required()
+
+export class NpmLastUpdateWithTag extends NpmBase {
+ static category = 'activity'
+
+ static route = {
+ base: 'npm/last-update',
+ pattern: ':scope(@[^/]+)?/:packageName/:tag',
+ queryParamSchema,
+ }
+
+ static openApi = {
+ '/npm/last-update/{packageName}/{tag}': {
+ get: {
+ summary: 'NPM Last Update (with dist tag)',
+ parameters: [
+ pathParam({
+ name: 'packageName',
+ example: 'verdaccio',
+ packageNameDescription,
+ }),
+ pathParam({
+ name: 'tag',
+ example: 'next-8',
+ }),
+ queryParam({
+ name: 'registry_uri',
+ example: 'https://registry.npmjs.com',
+ }),
+ ],
+ },
+ },
+ }
+
+ static defaultBadgeData = { label: 'last updated' }
+
+ async handle(namedParams, queryParams) {
+ const { scope, packageName, tag, registryUrl } =
+ this.constructor.unpackParams(namedParams, queryParams)
+
+ const packageData = await this.fetch({
+ registryUrl,
+ scope,
+ packageName,
+ schema: fullSchema,
+ })
+
+ const tagVersion = packageData['dist-tags'][tag]
+
+ if (!tagVersion) {
+ throw new NotFound({ prettyMessage: 'tag not found' })
+ }
+
+ return renderDateBadge(packageData.time[tagVersion])
+ }
+}
+
+export class NpmLastUpdate extends NpmBase {
+ static category = 'activity'
+
+ static route = this.buildRoute('npm/last-update', { withTag: false })
+
+ static openApi = {
+ '/npm/last-update/{packageName}': {
+ get: {
+ summary: 'NPM Last Update',
+ parameters: [
+ pathParam({
+ name: 'packageName',
+ example: 'verdaccio',
+ packageNameDescription,
+ }),
+ queryParam({
+ name: 'registry_uri',
+ example: 'https://registry.npmjs.com',
+ }),
+ ],
+ },
+ },
+ }
+
+ static defaultBadgeData = { label: 'last updated' }
+
+ async handle(namedParams, queryParams) {
+ const { scope, packageName, registryUrl } = this.constructor.unpackParams(
+ namedParams,
+ queryParams,
+ )
+
+ const packageData = await this.fetch({
+ registryUrl,
+ scope,
+ packageName,
+ schema: abbreviatedSchema,
+ abbreviated: true,
+ })
+
+ return renderDateBadge(packageData.modified)
+ }
+}
diff --git a/services/npm/npm-last-update.tester.js b/services/npm/npm-last-update.tester.js
new file mode 100644
index 0000000000000..7fcead5b5f534
--- /dev/null
+++ b/services/npm/npm-last-update.tester.js
@@ -0,0 +1,81 @@
+import { isFormattedDate } from '../test-validators.js'
+import { createServiceTester } from '../tester.js'
+
+export const t = await createServiceTester()
+
+t.create('last updated date, no tag, valid package')
+ .get('/verdaccio.json')
+ .expectBadge({
+ label: 'last updated',
+ message: isFormattedDate,
+ })
+
+t.create('last updated date, no tag, invalid package')
+ .get('/not-a-package.json')
+ .expectBadge({
+ label: 'last updated',
+ message: 'package not found',
+ })
+
+t.create('last updated date, no tag, custom repository, valid package')
+ .get('/verdaccio.json?registry_uri=https://registry.npmjs.com')
+ .expectBadge({
+ label: 'last updated',
+ message: isFormattedDate,
+ })
+
+t.create('last updated date, no tag, valid package with scope')
+ .get('/@npm/types.json')
+ .expectBadge({
+ label: 'last updated',
+ message: isFormattedDate,
+ })
+
+t.create('last updated date, no tag, invalid package with scope')
+ .get('/@not-a-scoped-package/not-a-valid-package.json')
+ .expectBadge({
+ label: 'last updated',
+ message: 'package not found',
+ })
+
+t.create('last updated date, with tag, valid package')
+ .get('/verdaccio/latest.json')
+ .expectBadge({
+ label: 'last updated',
+ message: isFormattedDate,
+ })
+
+t.create('last updated date, with tag, invalid package')
+ .get('/not-a-package/doesnt-matter.json')
+ .expectBadge({
+ label: 'last updated',
+ message: 'package not found',
+ })
+
+t.create('last updated date, with tag, invalid tag')
+ .get('/verdaccio/not-a-valid-tag.json')
+ .expectBadge({
+ label: 'last updated',
+ message: 'tag not found',
+ })
+
+t.create('last updated date, with tag, custom repository, valid package')
+ .get('/verdaccio/latest.json?registry_uri=https://registry.npmjs.com')
+ .expectBadge({
+ label: 'last updated',
+ message: isFormattedDate,
+ })
+
+t.create('last updated date, with tag, valid package with scope')
+ .get('/@npm/types/latest.json')
+ .expectBadge({
+ label: 'last updated',
+ message: isFormattedDate,
+ })
+
+t.create('last updated date, with tag, invalid package with scope')
+ .get('/@not-a-scoped-package/not-a-valid-package/doesnt-matter.json')
+ .expectBadge({
+ label: 'last updated',
+ message: 'package not found',
+ })
diff --git a/services/npm/npm-license.service.js b/services/npm/npm-license.service.js
index ed354e28b8e03..5dc08c9f0a0e9 100644
--- a/services/npm/npm-license.service.js
+++ b/services/npm/npm-license.service.js
@@ -1,29 +1,31 @@
+import { pathParam, queryParam } from '../index.js'
import { renderLicenseBadge } from '../licenses.js'
import toArray from '../../core/base-service/to-array.js'
-import NpmBase from './npm-base.js'
+import NpmBase, { packageNameDescription } from './npm-base.js'
export default class NpmLicense extends NpmBase {
static category = 'license'
static route = this.buildRoute('npm/l', { withTag: false })
- static examples = [
- {
- title: 'NPM',
- pattern: ':packageName',
- namedParams: { packageName: 'express' },
- staticPreview: this.render({ licenses: ['MIT'] }),
- keywords: ['node'],
+ static openApi = {
+ '/npm/l/{packageName}': {
+ get: {
+ summary: 'NPM License',
+ parameters: [
+ pathParam({
+ name: 'packageName',
+ example: 'express',
+ description: packageNameDescription,
+ }),
+ queryParam({
+ name: 'registry_uri',
+ example: 'https://registry.npmjs.com',
+ }),
+ ],
+ },
},
- {
- title: 'NPM',
- pattern: ':packageName',
- namedParams: { packageName: 'express' },
- queryParams: { registry_uri: 'https://registry.npmjs.com' },
- staticPreview: this.render({ licenses: ['MIT'] }),
- keywords: ['node'],
- },
- ]
+ }
static render({ licenses }) {
return renderLicenseBadge({ licenses })
@@ -32,7 +34,7 @@ export default class NpmLicense extends NpmBase {
async handle(namedParams, queryParams) {
const { scope, packageName, registryUrl } = this.constructor.unpackParams(
namedParams,
- queryParams
+ queryParams,
)
const { license } = await this.fetchPackageData({
scope,
@@ -40,7 +42,7 @@ export default class NpmLicense extends NpmBase {
registryUrl,
})
const licenses = toArray(license).map(license =>
- typeof license === 'string' ? license : license.type
+ typeof license === 'string' ? license : license.type,
)
return this.constructor.render({ licenses })
}
diff --git a/services/npm/npm-license.tester.js b/services/npm/npm-license.tester.js
index a06aef4774ae1..076a02830e35f 100644
--- a/services/npm/npm-license.tester.js
+++ b/services/npm/npm-license.tester.js
@@ -26,7 +26,7 @@ t.create('permissive license for scoped package')
.expectBadge({ label: 'license', message: 'MIT', color: 'green' })
t.create(
- 'permissive and copyleft licenses (SPDX license expression syntax version 2.0)'
+ 'permissive and copyleft licenses (SPDX license expression syntax version 2.0)',
)
.get('/rho-cc-promise.json')
.expectBadge({
@@ -43,7 +43,7 @@ t.create('license for package without a license property')
.reply(200, {
label: 'package-without-license',
maintainers: [],
- })
+ }),
)
.expectBadge({ label: 'license', message: 'missing', color: 'red' })
@@ -59,7 +59,7 @@ t.create('license for package with a license object')
url: 'https://www.opensource.org/licenses/mit-license.php',
},
maintainers: [],
- })
+ }),
)
.expectBadge({ label: 'license', message: 'MIT', color: 'green' })
@@ -72,7 +72,7 @@ t.create('license for package with a license array')
label: 'package-license-object',
license: ['MPL-2.0', 'MIT'],
maintainers: [],
- })
+ }),
)
.expectBadge({
label: 'license',
@@ -99,7 +99,7 @@ t.create('when json is malformed for scoped package')
latest: '1.2.3',
},
versions: null,
- })
+ }),
)
.expectBadge({
label: 'license',
diff --git a/services/npm/npm-type-definitions.service.js b/services/npm/npm-type-definitions.service.js
index 9120b24593cae..01bf009654c77 100644
--- a/services/npm/npm-type-definitions.service.js
+++ b/services/npm/npm-type-definitions.service.js
@@ -1,4 +1,5 @@
-import NpmBase from './npm-base.js'
+import { pathParam, queryParam } from '../index.js'
+import NpmBase, { packageNameDescription } from './npm-base.js'
// For this badge to correctly detect type definitions, either the relevant
// dependencies must be declared, or the `types` key must be set in
@@ -8,17 +9,24 @@ export default class NpmTypeDefinitions extends NpmBase {
static route = this.buildRoute('npm/types', { withTag: false })
- static examples = [
- {
- title: 'npm type definitions',
- pattern: ':packageName',
- namedParams: { packageName: 'chalk' },
- staticPreview: this.render({
- supportedLanguages: ['TypeScript', 'Flow'],
- }),
- keywords: ['node', 'typescript', 'flow'],
+ static openApi = {
+ '/npm/types/{packageName}': {
+ get: {
+ summary: 'NPM Type Definitions',
+ parameters: [
+ pathParam({
+ name: 'packageName',
+ example: 'chalk',
+ description: packageNameDescription,
+ }),
+ queryParam({
+ name: 'registry_uri',
+ example: 'https://registry.npmjs.com',
+ }),
+ ],
+ },
},
- ]
+ }
static defaultBadgeData = {
label: 'types',
@@ -60,7 +68,7 @@ export default class NpmTypeDefinitions extends NpmBase {
async handle(namedParams, queryParams) {
const { scope, packageName, registryUrl } = this.constructor.unpackParams(
namedParams,
- queryParams
+ queryParams,
)
const json = await this.fetchPackageData({
scope,
diff --git a/services/npm/npm-type-definitions.tester.js b/services/npm/npm-type-definitions.tester.js
index 60cadb61fac11..0029bdb36d9aa 100644
--- a/services/npm/npm-type-definitions.tester.js
+++ b/services/npm/npm-type-definitions.tester.js
@@ -3,7 +3,7 @@ import { createServiceTester } from '../tester.js'
export const t = await createServiceTester()
const isTypeDefinition = Joi.string().regex(
- /^((Flow|TypeScript)|(Flow \| TypeScript))$/
+ /^((Flow|TypeScript)|(Flow \| TypeScript))$/,
)
t.create('types (from dev dependencies)')
@@ -14,11 +14,11 @@ t.create('types (from files)')
.get('/form-data-entries.json')
.intercept(nock =>
nock('https://registry.npmjs.org')
- .get(`/form-data-entries/latest`)
+ .get('/form-data-entries/latest')
.reply(200, {
maintainers: [],
files: ['index.js', 'index.d.ts'],
- })
+ }),
)
.expectBadge({ label: 'types', message: isTypeDefinition })
diff --git a/services/npm/npm-unpacked-size.service.js b/services/npm/npm-unpacked-size.service.js
new file mode 100644
index 0000000000000..e187234db9220
--- /dev/null
+++ b/services/npm/npm-unpacked-size.service.js
@@ -0,0 +1,94 @@
+import Joi from 'joi'
+import { pathParam, queryParam } from '../index.js'
+import { renderSizeBadge } from '../size.js'
+import { optionalNonNegativeInteger } from '../validators.js'
+import NpmBase, {
+ packageNameDescription,
+ queryParamSchema,
+} from './npm-base.js'
+
+const schema = Joi.object({
+ dist: Joi.object({
+ unpackedSize: optionalNonNegativeInteger,
+ }).required(),
+}).required()
+
+export default class NpmUnpackedSize extends NpmBase {
+ static category = 'size'
+
+ static route = {
+ base: 'npm/unpacked-size',
+ pattern: ':scope(@[^/]+)?/:packageName/:version*',
+ queryParamSchema,
+ }
+
+ static openApi = {
+ '/npm/unpacked-size/{packageName}': {
+ get: {
+ summary: 'NPM Unpacked Size',
+ parameters: [
+ pathParam({
+ name: 'packageName',
+ example: 'npm',
+ description: packageNameDescription,
+ }),
+ queryParam({
+ name: 'registry_uri',
+ example: 'https://registry.npmjs.com',
+ }),
+ ],
+ },
+ },
+ '/npm/unpacked-size/{packageName}/{version}': {
+ get: {
+ summary: 'NPM Unpacked Size (with version)',
+ parameters: [
+ pathParam({
+ name: 'packageName',
+ example: 'npm',
+ description: packageNameDescription,
+ }),
+ pathParam({
+ name: 'version',
+ example: '4.18.2',
+ }),
+ queryParam({
+ name: 'registry_uri',
+ example: 'https://registry.npmjs.com',
+ }),
+ ],
+ },
+ },
+ }
+
+ static defaultBadgeData = { label: 'unpacked size' }
+
+ async fetch({ registryUrl, packageName, version }) {
+ return this._requestJson({
+ schema,
+ url: `${registryUrl}/${packageName}/${version}`,
+ })
+ }
+
+ async handle(
+ { scope, packageName, version },
+ { registry_uri: registryUrl = 'https://registry.npmjs.org' },
+ ) {
+ const packageNameWithScope = scope ? `${scope}/${packageName}` : packageName
+ const { dist } = await this.fetch({
+ registryUrl,
+ packageName: packageNameWithScope,
+ version: version ?? 'latest',
+ })
+ const { unpackedSize } = dist
+
+ if (unpackedSize) {
+ return renderSizeBadge(unpackedSize, 'metric', 'unpacked size')
+ }
+ return {
+ label: 'unpacked size',
+ message: 'unknown',
+ color: 'lightgray',
+ }
+ }
+}
diff --git a/services/npm/npm-unpacked-size.tester.js b/services/npm/npm-unpacked-size.tester.js
new file mode 100644
index 0000000000000..436310d1aaba2
--- /dev/null
+++ b/services/npm/npm-unpacked-size.tester.js
@@ -0,0 +1,28 @@
+import { isMetricFileSize } from '../test-validators.js'
+import { createServiceTester } from '../tester.js'
+
+export const t = await createServiceTester()
+
+t.create('Latest unpacked size')
+ .get('/firereact.json')
+ .expectBadge({ label: 'unpacked size', message: isMetricFileSize })
+
+t.create('Nonexistent unpacked size with version')
+ .get('/express/4.16.0.json')
+ .expectBadge({ label: 'unpacked size', message: 'unknown' })
+
+t.create('Unpacked size with version')
+ .get('/firereact/0.7.0.json')
+ .expectBadge({ label: 'unpacked size', message: '147.2 kB' })
+
+t.create('Unpacked size for scoped package')
+ .get('/@testing-library/react.json')
+ .expectBadge({ label: 'unpacked size', message: isMetricFileSize })
+
+t.create('Unpacked size for scoped package with version')
+ .get('/@testing-library/react/14.2.1.json')
+ .expectBadge({ label: 'unpacked size', message: '5.4 MB' })
+
+t.create('Nonexistent unpacked size for scoped package with version')
+ .get('/@cycle/rx-run/7.2.0.json')
+ .expectBadge({ label: 'unpacked size', message: 'unknown' })
diff --git a/services/npm/npm-version.service.js b/services/npm/npm-version.service.js
index 95abd88a6c825..4843d46cb7a24 100644
--- a/services/npm/npm-version.service.js
+++ b/services/npm/npm-version.service.js
@@ -1,9 +1,7 @@
import Joi from 'joi'
import { renderVersionBadge } from '../version.js'
-import { NotFound } from '../index.js'
-import NpmBase from './npm-base.js'
-
-const keywords = ['node']
+import { NotFound, pathParam, queryParam } from '../index.js'
+import NpmBase, { packageNameDescription } from './npm-base.js'
// Joi.string should be a semver.
const schema = Joi.object()
@@ -16,44 +14,44 @@ export default class NpmVersion extends NpmBase {
static route = this.buildRoute('npm/v', { withTag: true })
- static examples = [
- {
- title: 'npm',
- pattern: ':packageName',
- namedParams: { packageName: 'npm' },
- staticPreview: this.render({ version: '6.3.0' }),
- keywords,
- },
- {
- title: 'npm (scoped)',
- pattern: ':scope/:packageName',
- namedParams: { scope: '@cycle', packageName: 'core' },
- staticPreview: this.render({ version: '7.0.0' }),
- keywords,
- },
- {
- title: 'npm (tag)',
- pattern: ':packageName/:tag',
- namedParams: { packageName: 'npm', tag: 'next' },
- staticPreview: this.render({ tag: 'latest', version: '6.3.0' }),
- keywords,
+ static openApi = {
+ '/npm/v/{packageName}': {
+ get: {
+ summary: 'NPM Version',
+ parameters: [
+ pathParam({
+ name: 'packageName',
+ example: 'npm',
+ description: packageNameDescription,
+ }),
+ queryParam({
+ name: 'registry_uri',
+ example: 'https://registry.npmjs.com',
+ }),
+ ],
+ },
},
- {
- title: 'npm (custom registry)',
- pattern: ':packageName/:tag',
- namedParams: { packageName: 'npm', tag: 'next' },
- queryParams: { registry_uri: 'https://registry.npmjs.com' },
- staticPreview: this.render({ tag: 'latest', version: '7.0.0' }),
- keywords,
+ '/npm/v/{packageName}/{tag}': {
+ get: {
+ summary: 'NPM Version (with dist tag)',
+ parameters: [
+ pathParam({
+ name: 'packageName',
+ example: 'npm',
+ description: packageNameDescription,
+ }),
+ pathParam({
+ name: 'tag',
+ example: 'next-8',
+ }),
+ queryParam({
+ name: 'registry_uri',
+ example: 'https://registry.npmjs.com',
+ }),
+ ],
+ },
},
- {
- title: 'npm (scoped with tag)',
- pattern: ':scope/:packageName/:tag',
- namedParams: { scope: '@cycle', packageName: 'core', tag: 'canary' },
- staticPreview: this.render({ tag: 'latest', version: '6.3.0' }),
- keywords,
- },
- ]
+ }
static defaultBadgeData = {
label: 'npm',
@@ -80,7 +78,7 @@ export default class NpmVersion extends NpmBase {
const packageData = await this._requestJson({
schema,
url: `${registryUrl}/-/package/${slug}/dist-tags`,
- errorMessages: { 404: 'package not found' },
+ httpErrors: { 404: 'package not found' },
})
if (tag && !(tag in packageData)) {
diff --git a/services/npm/npm-version.tester.js b/services/npm/npm-version.tester.js
index a7f3d06a2e5ef..0a2683ff982fe 100644
--- a/services/npm/npm-version.tester.js
+++ b/services/npm/npm-version.tester.js
@@ -15,14 +15,14 @@ t.create('gets the package version of @cycle/core')
.expectBadge({ label: 'npm', message: isSemver })
t.create('gets a tagged package version of npm')
- .get('/npm/next.json')
- .expectBadge({ label: 'npm@next', message: isSemver })
+ .get('/npm/next-8.json')
+ .expectBadge({ label: 'npm@next-8', message: isSemver })
t.create('gets the correct tagged package version of npm')
.intercept(nock =>
nock('https://registry.npmjs.org')
.get('/-/package/npm/dist-tags')
- .reply(200, { latest: '1.2.3', next: '4.5.6' })
+ .reply(200, { latest: '1.2.3', next: '4.5.6' }),
)
.get('/npm/next.json')
.expectBadge({ label: 'npm@next', message: 'v4.5.6' })
@@ -39,7 +39,7 @@ t.create('gets the tagged package version with a "/" in the tag name')
.intercept(nock =>
nock('https://registry.npmjs.org')
.get('/-/package/npm/dist-tags')
- .reply(200, { 'release/1.0': '1.0.3', latest: '2.0.1' })
+ .reply(200, { 'release/1.0': '1.0.3', latest: '2.0.1' }),
)
.get('/npm/release/1.0.json')
.expectBadge({ label: 'npm@release/1.0', message: 'v1.0.3' })
@@ -49,7 +49,7 @@ t.create('gets the tagged package version of @cycle/core')
.expectBadge({ label: 'npm@canary', message: isSemver })
t.create(
- 'gets the tagged package version of @cycle/core from a custom registry'
+ 'gets the tagged package version of @cycle/core from a custom registry',
)
.get('/@cycle/core/canary.json?registry_uri=https://registry.npmjs.com')
.expectBadge({ label: 'npm@canary', message: isSemver })
@@ -62,7 +62,7 @@ t.create("Response doesn't include a 'latest' key")
.intercept(nock =>
nock('https://registry.npmjs.org')
.get('/-/package/npm/dist-tags')
- .reply(200, { next: 'v4.5.6' })
+ .reply(200, { next: 'v4.5.6' }),
)
.get('/npm.json')
.expectBadge({
diff --git a/services/npms-io/npms-io-score.service.js b/services/npms-io/npms-io-score.service.js
index 23f54ac95eb51..52f09c33f0696 100644
--- a/services/npms-io/npms-io-score.service.js
+++ b/services/npms-io/npms-io-score.service.js
@@ -1,5 +1,5 @@
import Joi from 'joi'
-import { BaseJsonService } from '../index.js'
+import { BaseJsonService, pathParams } from '../index.js'
import { coveragePercentage } from '../color-formatters.js'
// https://api-docs.npms.io/#api-Package-GetPackageInfo
@@ -15,7 +15,8 @@ const responseSchema = Joi.object({
}),
}).required()
-const keywords = ['node', 'npm score']
+const description =
+ '[npms.io](https://npms.io) holds statistics for javascript packages.'
export default class NpmsIOScore extends BaseJsonService {
static category = 'analysis'
@@ -23,36 +24,49 @@ export default class NpmsIOScore extends BaseJsonService {
static route = {
base: 'npms-io',
pattern:
- ':type(final|maintenance|popularity|quality)-score/:scope(@.+)?/:packageName',
+ ':type(final-score|maintenance-score|popularity-score|quality-score)/:scope(@.+)?/:packageName',
}
- static examples = [
- {
- title: 'npms.io (final)',
- namedParams: { type: 'final', packageName: 'egg' },
- staticPreview: this.render({ score: 0.9711 }),
- keywords,
+ static openApi = {
+ '/npms-io/{type}/{packageName}': {
+ get: {
+ summary: 'npms.io',
+ description,
+ parameters: pathParams(
+ {
+ name: 'type',
+ schema: { type: 'string', enum: this.getEnum('type') },
+ example: 'maintenance-score',
+ },
+ {
+ name: 'packageName',
+ example: 'command',
+ },
+ ),
+ },
},
- {
- title: 'npms.io (popularity)',
- pattern: ':type/:scope/:packageName',
- namedParams: { type: 'popularity', scope: '@vue', packageName: 'cli' },
- staticPreview: this.render({ type: 'popularity', score: 0.89 }),
- keywords,
+ '/npms-io/{type}/{scope}/{packageName}': {
+ get: {
+ summary: 'npms.io (scoped package)',
+ description,
+ parameters: pathParams(
+ {
+ name: 'type',
+ schema: { type: 'string', enum: this.getEnum('type') },
+ example: 'maintenance-score',
+ },
+ {
+ name: 'scope',
+ example: '@vue',
+ },
+ {
+ name: 'packageName',
+ example: 'cli',
+ },
+ ),
+ },
},
- {
- title: 'npms.io (quality)',
- namedParams: { type: 'quality', packageName: 'egg' },
- staticPreview: this.render({ type: 'quality', score: 0.98 }),
- keywords,
- },
- {
- title: 'npms.io (maintenance)',
- namedParams: { type: 'maintenance', packageName: 'command' },
- staticPreview: this.render({ type: 'maintenance', score: 0.222 }),
- keywords,
- },
- ]
+ }
static defaultBadgeData = {
label: 'score',
@@ -73,11 +87,13 @@ export default class NpmsIOScore extends BaseJsonService {
const json = await this._requestJson({
schema: responseSchema,
url,
- errorMessages: { 404: 'package not found or too new' },
+ httpErrors: { 404: 'package not found or too new' },
})
- const score = type === 'final' ? json.score.final : json.score.detail[type]
+ const scoreType = type.slice(0, -6)
+ const score =
+ scoreType === 'final' ? json.score.final : json.score.detail[scoreType]
- return this.constructor.render({ type, score })
+ return this.constructor.render({ type: scoreType, score })
}
}
diff --git a/services/npms-io/npms-io-score.tester.js b/services/npms-io/npms-io-score.tester.js
index 855efceefad30..b7ee9b3a577d8 100644
--- a/services/npms-io/npms-io-score.tester.js
+++ b/services/npms-io/npms-io-score.tester.js
@@ -13,7 +13,7 @@ t.create('should show color')
nock.enableNetConnect()
return nock('https://api.npms.io', { allowUnmocked: true })
- .get(`/v2/package/mock-for-package-score`)
+ .get('/v2/package/mock-for-package-score')
.reply(200, {
score: {
final: 0.89,
diff --git a/services/nuget-fixtures.js b/services/nuget-fixtures.js
deleted file mode 100644
index 919cdd3b0bcf4..0000000000000
--- a/services/nuget-fixtures.js
+++ /dev/null
@@ -1,57 +0,0 @@
-const queryIndex = JSON.stringify({
- resources: [
- {
- '@id': 'https://api-v2v3search-0.nuget.org/query',
- '@type': 'SearchQueryService',
- },
- ],
-})
-
-const nuGetV3VersionJsonWithDash = JSON.stringify({
- data: [
- {
- totalDownloads: 0,
- versions: [{ version: '1.2-beta' }],
- },
- ],
-})
-const nuGetV3VersionJsonFirstCharZero = JSON.stringify({
- data: [
- {
- totalDownloads: 0,
- versions: [{ version: '0.35' }],
- },
- ],
-})
-const nuGetV3VersionJsonFirstCharNotZero = JSON.stringify({
- data: [
- {
- totalDownloads: 0,
- versions: [{ version: '1.2.7' }],
- },
- ],
-})
-
-const nuGetV3VersionJsonBuildMetadataWithDash = JSON.stringify({
- data: [
- {
- totalDownloads: 0,
- versions: [
- {
- version: '1.16.0+388',
- },
- {
- version: '1.17.0+1b81349-429',
- },
- ],
- },
- ],
-})
-
-export {
- queryIndex,
- nuGetV3VersionJsonWithDash,
- nuGetV3VersionJsonFirstCharZero,
- nuGetV3VersionJsonFirstCharNotZero,
- nuGetV3VersionJsonBuildMetadataWithDash,
-}
diff --git a/services/nuget/nuget-helpers.js b/services/nuget/nuget-helpers.js
index d39fbe4348869..d24f59d310241 100644
--- a/services/nuget/nuget-helpers.js
+++ b/services/nuget/nuget-helpers.js
@@ -1,25 +1,7 @@
-import { promisify } from 'util'
import semver from 'semver'
-import { metric, addv } from '../text-formatters.js'
+import { metric } from '../text-formatters.js'
import { downloadCount as downloadCountColor } from '../color-formatters.js'
-import { regularUpdate } from '../../core/legacy/regular-update.js'
-
-function renderVersionBadge({ version, feed }) {
- let color
- if (version.includes('-')) {
- color = 'yellow'
- } else if (version.startsWith('0')) {
- color = 'orange'
- } else {
- color = 'blue'
- }
-
- return {
- message: addv(version),
- color,
- label: feed,
- }
-}
+import { getCachedResource } from '../../core/base-service/resource-cache.js'
function renderDownloadBadge({ downloads }) {
return {
@@ -52,7 +34,7 @@ function randomElementFrom(items) {
*/
async function searchServiceUrl(baseUrl, serviceType = 'SearchQueryService') {
// Should we really be caching all these NuGet feeds in memory?
- const searchQueryServices = await promisify(regularUpdate)({
+ const searchQueryServices = await getCachedResource({
url: `${baseUrl}/index.json`,
// The endpoint changes once per year (ie, a period of n = 1 year).
// We minimize the users' waiting time for information.
@@ -62,8 +44,7 @@ async function searchServiceUrl(baseUrl, serviceType = 'SearchQueryService') {
// right endpoint.
// So the waiting time within n years is n*l/x + x years, for which a
// derivation yields an optimum at x = sqrt(n*l), roughly 42 minutes.
- intervalMillis: 42 * 60 * 1000,
- json: true,
+ ttl: 42 * 60 * 1000,
scraper: json =>
json.resources.filter(resource => resource['@type'] === serviceType),
})
@@ -102,7 +83,6 @@ function selectVersion(versions, includePrereleases) {
}
export {
- renderVersionBadge,
renderDownloadBadge,
odataToObject,
searchServiceUrl,
diff --git a/services/nuget/nuget-helpers.spec.js b/services/nuget/nuget-helpers.spec.js
index a5e316490d461..9cc8ea5b4af09 100644
--- a/services/nuget/nuget-helpers.spec.js
+++ b/services/nuget/nuget-helpers.spec.js
@@ -1,30 +1,11 @@
import { test, given } from 'sazerac'
import {
- renderVersionBadge,
odataToObject,
stripBuildMetadata,
selectVersion,
} from './nuget-helpers.js'
describe('NuGet helpers', function () {
- test(renderVersionBadge, () => {
- given({ version: '1.2-beta' }).expect({
- label: undefined,
- message: 'v1.2-beta',
- color: 'yellow',
- })
- given({ version: '0.35' }).expect({
- label: undefined,
- message: 'v0.35',
- color: 'orange',
- })
- given({ version: '1.2.7' }).expect({
- label: undefined,
- message: 'v1.2.7',
- color: 'blue',
- })
- })
-
test(odataToObject, () => {
given({ 'm:properties': { 'd:Version': '1.2.3' } }).expect({
Version: '1.2.3',
diff --git a/services/nuget/nuget-v2-service-family.js b/services/nuget/nuget-v2-service-family.js
index b950a1ed2b6ea..22c84c393b766 100644
--- a/services/nuget/nuget-v2-service-family.js
+++ b/services/nuget/nuget-v2-service-family.js
@@ -1,41 +1,26 @@
import Joi from 'joi'
+import qs from 'qs'
import { nonNegativeInteger } from '../validators.js'
import {
- BaseJsonService,
BaseXmlService,
NotFound,
- redirector,
+ deprecatedService,
+ pathParams,
+ pathParam,
+ queryParam,
} from '../index.js'
-import {
- renderVersionBadge,
- renderDownloadBadge,
- odataToObject,
-} from './nuget-helpers.js'
+import { renderVersionBadge } from '../version.js'
+import { renderDownloadBadge, odataToObject } from './nuget-helpers.js'
function createFilter({ packageName, includePrereleases }) {
const releaseTypeFilter = includePrereleases
? 'IsAbsoluteLatestVersion eq true'
: 'IsLatestVersion eq true'
- return `Id eq '${packageName}' and ${releaseTypeFilter}`
+ return `tolower(Id) eq '${packageName.toLowerCase()}' and ${releaseTypeFilter}`
}
const versionSchema = Joi.alternatives(Joi.string(), Joi.number())
-const jsonSchema = Joi.object({
- d: Joi.object({
- results: Joi.array()
- .items(
- Joi.object({
- Version: versionSchema,
- NormalizedVersion: Joi.string(),
- DownloadCount: nonNegativeInteger,
- })
- )
- .max(1)
- .default([]),
- }).required(),
-}).required()
-
const xmlSchema = Joi.object({
feed: Joi.object({
entry: Joi.object({
@@ -55,38 +40,29 @@ const queryParamSchema = Joi.object({
async function fetch(
serviceInstance,
- { odataFormat, baseUrl, packageName, includePrereleases = false }
+ { baseUrl, packageName, includePrereleases = false },
) {
const url = `${baseUrl}/Packages()`
- const qs = { $filter: createFilter({ packageName, includePrereleases }) }
-
- let packageData
- if (odataFormat === 'xml') {
- const data = await serviceInstance._requestXml({
- schema: xmlSchema,
- url,
- options: { qs },
- })
- packageData = odataToObject(data.feed.entry)
- } else if (odataFormat === 'json') {
- const data = await serviceInstance._requestJson({
- schema: jsonSchema,
- url,
- options: {
- headers: { Accept: 'application/atom+json,application/json' },
- qs,
- },
- })
- packageData = data.d.results[0]
- } else {
- throw Error(`Unsupported Atom OData format: ${odataFormat}`)
- }
+ const searchParams = qs.stringify(
+ {
+ $filter: createFilter({ packageName, includePrereleases }),
+ },
+ { encode: false },
+ )
+
+ const data = await serviceInstance._requestXml({
+ schema: xmlSchema,
+ url: `${url}?${searchParams}`,
+ options: {
+ headers: { Accept: 'application/atom+xml,application/xml' },
+ },
+ })
+ const packageData = odataToObject(data.feed.entry)
if (packageData) {
return packageData
} else if (!includePrereleases) {
return fetch(serviceInstance, {
- odataFormat,
baseUrl,
packageName,
includePrereleases: true,
@@ -110,22 +86,9 @@ function createServiceFamily({
defaultLabel,
serviceBaseUrl,
apiBaseUrl,
- odataFormat,
examplePackageName,
- exampleVersion,
- examplePrereleaseVersion,
- exampleDownloadCount,
}) {
- let Base
- if (odataFormat === 'xml') {
- Base = BaseXmlService
- } else if (odataFormat === 'json') {
- Base = BaseJsonService
- } else {
- throw Error(`Unsupported Atom OData format: ${odataFormat}`)
- }
-
- class NugetVersionService extends Base {
+ class NugetVersionService extends BaseXmlService {
static name = `${name}Version`
static category = 'version'
@@ -136,58 +99,54 @@ function createServiceFamily({
queryParamSchema,
}
- static get examples() {
- if (!title) return []
-
- return [
- {
- title: `${title} Version`,
- namedParams: { packageName: examplePackageName },
- staticPreview: this.render({ version: exampleVersion }),
- },
- {
- title: `${title} Version (including pre-releases)`,
- namedParams: { packageName: examplePackageName },
- queryParams: { include_prereleases: null },
- staticPreview: this.render({ version: examplePrereleaseVersion }),
+ static get openApi() {
+ if (!title) return {}
+
+ const key = `/${serviceBaseUrl}/v/{packageName}`
+ const route = {}
+ route[key] = {
+ get: {
+ summary: `${title} Version`,
+ parameters: [
+ pathParam({ name: 'packageName', example: examplePackageName }),
+ queryParam({
+ name: 'include_prereleases',
+ schema: { type: 'boolean' },
+ example: null,
+ }),
+ ],
},
- ]
+ }
+ return route
}
static defaultBadgeData = {
label: defaultLabel,
}
- static render(props) {
- return renderVersionBadge(props)
- }
-
async handle({ packageName }, queryParams) {
const packageData = await fetch(this, {
- odataFormat,
baseUrl: apiBaseUrl,
packageName,
includePrereleases: queryParams.include_prereleases !== undefined,
})
const version = packageData.NormalizedVersion || `${packageData.Version}`
- return this.constructor.render({ version })
+ return renderVersionBadge({ version })
}
}
- const NugetVersionRedirector = redirector({
+ const NugetVersionRedirector = deprecatedService({
category: 'version',
+ label: defaultLabel,
route: {
base: `${serviceBaseUrl}/vpre`,
pattern: ':packageName',
},
- transformPath: ({ packageName }) => `/${serviceBaseUrl}/v/${packageName}`,
- transformQueryParams: params => ({
- include_prereleases: null,
- }),
- dateAdded: new Date('2019-12-15'),
+ dateAdded: new Date('2025-12-20'),
+ issueUrl: 'https://github.com/badges/shields/pull/11583',
})
- class NugetDownloadService extends Base {
+ class NugetDownloadService extends BaseXmlService {
static name = `${name}Downloads`
static category = 'downloads'
@@ -197,16 +156,21 @@ function createServiceFamily({
pattern: 'dt/:packageName',
}
- static get examples() {
- if (!title) return []
-
- return [
- {
- title,
- namedParams: { packageName: examplePackageName },
- staticPreview: this.render({ downloads: exampleDownloadCount }),
+ static get openApi() {
+ if (!title) return {}
+
+ const key = `/${serviceBaseUrl}/dt/{packageName}`
+ const route = {}
+ route[key] = {
+ get: {
+ summary: `${title} Downloads`,
+ parameters: pathParams({
+ name: 'packageName',
+ example: examplePackageName,
+ }),
},
- ]
+ }
+ return route
}
static render(props) {
@@ -215,7 +179,6 @@ function createServiceFamily({
async handle({ packageName }) {
const packageData = await fetch(this, {
- odataFormat,
baseUrl: apiBaseUrl,
packageName,
})
diff --git a/services/nuget/nuget-v3-service-family.js b/services/nuget/nuget-v3-service-family.js
index 3a06cd297e84d..1cc9f6dd92fc3 100644
--- a/services/nuget/nuget-v3-service-family.js
+++ b/services/nuget/nuget-v3-service-family.js
@@ -1,8 +1,8 @@
import Joi from 'joi'
import RouteBuilder from '../route-builder.js'
import { BaseJsonService, NotFound } from '../index.js'
+import { renderVersionBadge } from '../version.js'
import {
- renderVersionBadge,
renderDownloadBadge,
searchServiceUrl,
stripBuildMetadata,
@@ -56,12 +56,12 @@ const schema = Joi.object({
.items(
Joi.object({
version: Joi.string().required(),
- })
+ }),
)
.default([]),
totalDownloads: Joi.number().integer(),
totaldownloads: Joi.number().integer(),
- })
+ }),
)
.max(1)
.default([]),
@@ -72,13 +72,13 @@ const schema = Joi.object({
*/
async function fetch(
serviceInstance,
- { baseUrl, packageName, includePrereleases = false }
+ { baseUrl, packageName, includePrereleases = false },
) {
return serviceInstance._requestJson({
schema,
url: await searchServiceUrl(baseUrl, 'SearchQueryService'),
options: {
- qs: {
+ searchParams: {
q: `packageid:${encodeURIComponent(packageName.toLowerCase())}`,
// Include prerelease versions.
prerelease: 'true',
@@ -121,16 +121,12 @@ function createServiceFamily({
.push('(.+?)', 'packageName')
.toObject()
- static examples = []
+ static openApi = {}
static defaultBadgeData = {
label: defaultLabel,
}
- static render(props) {
- return renderVersionBadge(props)
- }
-
/*
* Extract version information from the raw package info.
*/
@@ -138,7 +134,7 @@ function createServiceFamily({
if (json.data.length === 1 && json.data[0].versions.length > 0) {
const { versions: packageVersions } = json.data[0]
const versions = packageVersions.map(item =>
- stripBuildMetadata(item.version)
+ stripBuildMetadata(item.version),
)
return selectVersion(versions, includePrereleases)
} else {
@@ -158,7 +154,7 @@ function createServiceFamily({
})
const json = await fetch(this, { baseUrl, packageName })
const version = this.transform({ json, includePrereleases })
- return this.constructor.render({ version, feed })
+ return renderVersionBadge({ version, defaultLabel: feed })
}
}
@@ -170,7 +166,7 @@ function createServiceFamily({
.push('(.+?)', 'packageName')
.toObject()
- static examples = []
+ static openApi = {}
static render(props) {
return renderDownloadBadge(props)
diff --git a/services/nuget/nuget-v3-service-family.spec.js b/services/nuget/nuget-v3-service-family.spec.js
index a6e49f6b6cb46..f2f0bcd0ffde9 100644
--- a/services/nuget/nuget-v3-service-family.spec.js
+++ b/services/nuget/nuget-v3-service-family.spec.js
@@ -31,7 +31,7 @@ const tooMuchDataJson = { data: [{}, {}] }
describe('Nuget Version service', function () {
test(NugetVersionService.prototype.transform, () => {
given({ json: versionJson(['1.0.0']), includePrereleases: false }).expect(
- '1.0.0'
+ '1.0.0',
)
given({
json: versionJson(['1.0.0', '1.0.1']),
@@ -56,22 +56,22 @@ describe('Nuget Version service', function () {
}).expect('1.0.1-beta1')
given({ json: versionJson([]), includePrereleases: false }).expectError(
- 'Not Found: package not found'
+ 'Not Found: package not found',
)
given({ json: versionJson([]), includePrereleases: true }).expectError(
- 'Not Found: package not found'
+ 'Not Found: package not found',
)
given({ json: noDataJson, includePrereleases: false }).expectError(
- 'Not Found: package not found'
+ 'Not Found: package not found',
)
given({ json: noDataJson, includePrereleases: true }).expectError(
- 'Not Found: package not found'
+ 'Not Found: package not found',
)
given({ json: tooMuchDataJson, includePrereleases: false }).expectError(
- 'Not Found: package not found'
+ 'Not Found: package not found',
)
given({ json: tooMuchDataJson, includePrereleases: true }).expectError(
- 'Not Found: package not found'
+ 'Not Found: package not found',
)
})
})
diff --git a/services/nuget/nuget.service.js b/services/nuget/nuget.service.js
index a05bbc3079075..58316dec21373 100644
--- a/services/nuget/nuget.service.js
+++ b/services/nuget/nuget.service.js
@@ -1,3 +1,4 @@
+import { pathParams } from '../index.js'
import { createServiceFamily } from './nuget-v3-service-family.js'
const { NugetVersionService: Version, NugetDownloadService: Downloads } =
@@ -10,31 +11,37 @@ const { NugetVersionService: Version, NugetDownloadService: Downloads } =
})
class NugetVersionService extends Version {
- static examples = [
- {
- title: 'Nuget',
- pattern: 'v/:packageName',
- namedParams: { packageName: 'Microsoft.AspNet.Mvc' },
- staticPreview: this.render({ version: '5.2.4' }),
+ static openApi = {
+ '/nuget/{variant}/{packageName}': {
+ get: {
+ summary: 'NuGet Version',
+ parameters: pathParams(
+ {
+ name: 'variant',
+ example: 'v',
+ schema: { type: 'variant', enum: ['v', 'vpre'] },
+ description:
+ 'Latest stable version (`v`) or Latest version including prereleases (`vpre`).',
+ },
+ { name: 'packageName', example: 'Microsoft.AspNet.Mvc' },
+ ),
+ },
},
- {
- title: 'Nuget (with prereleases)',
- pattern: 'vpre/:packageName',
- namedParams: { packageName: 'Microsoft.AspNet.Mvc' },
- staticPreview: this.render({ version: '5.2.5-preview1' }),
- },
- ]
+ }
}
class NugetDownloadService extends Downloads {
- static examples = [
- {
- title: 'Nuget',
- pattern: 'dt/:packageName',
- namedParams: { packageName: 'Microsoft.AspNet.Mvc' },
- staticPreview: this.render({ downloads: 49e6 }),
+ static openApi = {
+ '/nuget/dt/{packageName}': {
+ get: {
+ summary: 'NuGet Downloads',
+ parameters: pathParams({
+ name: 'packageName',
+ example: 'Microsoft.AspNet.Mvc',
+ }),
+ },
},
- ]
+ }
}
export { NugetVersionService, NugetDownloadService }
diff --git a/services/nuget/nuget.tester.js b/services/nuget/nuget.tester.js
index 65878d6a11edd..6d98c65c58588 100644
--- a/services/nuget/nuget.tester.js
+++ b/services/nuget/nuget.tester.js
@@ -4,17 +4,35 @@ import {
isVPlusDottedVersionNClauses,
isVPlusDottedVersionNClausesWithOptionalSuffix,
} from '../test-validators.js'
-import {
- queryIndex,
- nuGetV3VersionJsonWithDash,
- nuGetV3VersionJsonFirstCharZero,
- nuGetV3VersionJsonFirstCharNotZero,
- nuGetV3VersionJsonBuildMetadataWithDash,
-} from '../nuget-fixtures.js'
import { invalidJSON } from '../response-fixtures.js'
export const t = new ServiceTester({ id: 'nuget', title: 'NuGet' })
+const queryIndex = JSON.stringify({
+ resources: [
+ {
+ '@id': 'https://api-v2v3search-0.nuget.org/query',
+ '@type': 'SearchQueryService',
+ },
+ ],
+})
+
+const nuGetV3VersionJsonBuildMetadataWithDash = JSON.stringify({
+ data: [
+ {
+ totalDownloads: 0,
+ versions: [
+ {
+ version: '1.16.0+388',
+ },
+ {
+ version: '1.17.0+1b81349-429',
+ },
+ ],
+ },
+ ],
+})
+
// downloads
t.create('total downloads (valid)')
@@ -31,14 +49,14 @@ t.create('total downloads (not found)')
t.create('total downloads (unexpected second response)')
.get('/dt/Microsoft.AspNetCore.Mvc.json')
.intercept(nock =>
- nock('https://api.nuget.org').get('/v3/index.json').reply(200, queryIndex)
+ nock('https://api.nuget.org').get('/v3/index.json').reply(200, queryIndex),
)
.intercept(nock =>
nock('https://api-v2v3search-0.nuget.org')
.get(
- '/query?q=packageid%3Amicrosoft.aspnetcore.mvc&prerelease=true&semVerLevel=2'
+ '/query?q=packageid%3Amicrosoft.aspnetcore.mvc&prerelease=true&semVerLevel=2',
)
- .reply(invalidJSON)
+ .reply(invalidJSON),
)
.expectBadge({ label: 'downloads', message: 'unparseable json response' })
@@ -51,70 +69,34 @@ t.create('version (valid)')
message: isVPlusDottedVersionNClauses,
})
-t.create('version (yellow badge)')
- .get('/v/Microsoft.AspNetCore.Mvc.json')
- .intercept(nock =>
- nock('https://api.nuget.org').get('/v3/index.json').reply(200, queryIndex)
- )
- .intercept(nock =>
- nock('https://api-v2v3search-0.nuget.org')
- .get(
- '/query?q=packageid%3Amicrosoft.aspnetcore.mvc&prerelease=true&semVerLevel=2'
- )
- .reply(200, nuGetV3VersionJsonWithDash)
- )
- .expectBadge({
- label: 'nuget',
- message: 'v1.2-beta',
- color: 'yellow',
- })
-
-t.create('version (orange badge)')
- .get('/v/Microsoft.AspNetCore.Mvc.json')
- .intercept(nock =>
- nock('https://api.nuget.org').get('/v3/index.json').reply(200, queryIndex)
- )
- .intercept(nock =>
- nock('https://api-v2v3search-0.nuget.org')
- .get(
- '/query?q=packageid%3Amicrosoft.aspnetcore.mvc&prerelease=true&semVerLevel=2'
- )
- .reply(200, nuGetV3VersionJsonFirstCharZero)
- )
- .expectBadge({
- label: 'nuget',
- message: 'v0.35',
- color: 'orange',
- })
+t.create('version (not found)')
+ .get('/v/not-a-real-package.json')
+ .expectBadge({ label: 'nuget', message: 'package not found' })
-t.create('version (blue badge)')
+t.create('version (unexpected second response)')
.get('/v/Microsoft.AspNetCore.Mvc.json')
.intercept(nock =>
- nock('https://api.nuget.org').get('/v3/index.json').reply(200, queryIndex)
+ nock('https://api.nuget.org').get('/v3/index.json').reply(200, queryIndex),
)
.intercept(nock =>
nock('https://api-v2v3search-0.nuget.org')
.get(
- '/query?q=packageid%3Amicrosoft.aspnetcore.mvc&prerelease=true&semVerLevel=2'
+ '/query?q=packageid%3Amicrosoft.aspnetcore.mvc&prerelease=true&semVerLevel=2',
)
- .reply(200, nuGetV3VersionJsonFirstCharNotZero)
+ .reply(invalidJSON),
)
- .expectBadge({
- label: 'nuget',
- message: 'v1.2.7',
- color: 'blue',
- })
+ .expectBadge({ label: 'nuget', message: 'unparseable json response' })
// https://github.com/badges/shields/issues/4219
t.create('version (build metadata with -)')
.get('/v/MongoFramework.json')
.intercept(nock =>
- nock('https://api.nuget.org').get('/v3/index.json').reply(200, queryIndex)
+ nock('https://api.nuget.org').get('/v3/index.json').reply(200, queryIndex),
)
.intercept(nock =>
nock('https://api-v2v3search-0.nuget.org')
.get('/query?q=packageid%3Amongoframework&prerelease=true&semVerLevel=2')
- .reply(200, nuGetV3VersionJsonBuildMetadataWithDash)
+ .reply(200, nuGetV3VersionJsonBuildMetadataWithDash),
)
.expectBadge({
label: 'nuget',
@@ -122,24 +104,6 @@ t.create('version (build metadata with -)')
color: 'blue',
})
-t.create('version (not found)')
- .get('/v/not-a-real-package.json')
- .expectBadge({ label: 'nuget', message: 'package not found' })
-
-t.create('version (unexpected second response)')
- .get('/v/Microsoft.AspNetCore.Mvc.json')
- .intercept(nock =>
- nock('https://api.nuget.org').get('/v3/index.json').reply(200, queryIndex)
- )
- .intercept(nock =>
- nock('https://api-v2v3search-0.nuget.org')
- .get(
- '/query?q=packageid%3Amicrosoft.aspnetcore.mvc&prerelease=true&semVerLevel=2'
- )
- .reply(invalidJSON)
- )
- .expectBadge({ label: 'nuget', message: 'unparseable json response' })
-
// version (pre)
t.create('version (pre) (valid)')
@@ -149,74 +113,6 @@ t.create('version (pre) (valid)')
message: isVPlusDottedVersionNClausesWithOptionalSuffix,
})
-t.create('version (pre) (yellow badge)')
- .get('/vpre/Microsoft.AspNetCore.Mvc.json')
- .intercept(nock =>
- nock('https://api.nuget.org').get('/v3/index.json').reply(200, queryIndex)
- )
- .intercept(nock =>
- nock('https://api-v2v3search-0.nuget.org')
- .get(
- '/query?q=packageid%3Amicrosoft.aspnetcore.mvc&prerelease=true&semVerLevel=2'
- )
- .reply(200, nuGetV3VersionJsonWithDash)
- )
- .expectBadge({
- label: 'nuget',
- message: 'v1.2-beta',
- color: 'yellow',
- })
-
-t.create('version (pre) (orange badge)')
- .get('/vpre/Microsoft.AspNetCore.Mvc.json')
- .intercept(nock =>
- nock('https://api.nuget.org').get('/v3/index.json').reply(200, queryIndex)
- )
- .intercept(nock =>
- nock('https://api-v2v3search-0.nuget.org')
- .get(
- '/query?q=packageid%3Amicrosoft.aspnetcore.mvc&prerelease=true&semVerLevel=2'
- )
- .reply(200, nuGetV3VersionJsonFirstCharZero)
- )
- .expectBadge({
- label: 'nuget',
- message: 'v0.35',
- color: 'orange',
- })
-
-t.create('version (pre) (blue badge)')
- .get('/vpre/Microsoft.AspNetCore.Mvc.json')
- .intercept(nock =>
- nock('https://api.nuget.org').get('/v3/index.json').reply(200, queryIndex)
- )
- .intercept(nock =>
- nock('https://api-v2v3search-0.nuget.org')
- .get(
- '/query?q=packageid%3Amicrosoft.aspnetcore.mvc&prerelease=true&semVerLevel=2'
- )
- .reply(200, nuGetV3VersionJsonFirstCharNotZero)
- )
- .expectBadge({
- label: 'nuget',
- message: 'v1.2.7',
- color: 'blue',
- })
-
t.create('version (pre) (not found)')
.get('/vpre/not-a-real-package.json')
.expectBadge({ label: 'nuget', message: 'package not found' })
-
-t.create('version (pre) (unexpected second response)')
- .get('/vpre/Microsoft.AspNetCore.Mvc.json')
- .intercept(nock =>
- nock('https://api.nuget.org').get('/v3/index.json').reply(200, queryIndex)
- )
- .intercept(nock =>
- nock('https://api-v2v3search-0.nuget.org')
- .get(
- '/query?q=packageid%3Amicrosoft.aspnetcore.mvc&prerelease=true&semVerLevel=2'
- )
- .reply(invalidJSON)
- )
- .expectBadge({ label: 'nuget', message: 'unparseable json response' })
diff --git a/services/nycrc/nycrc.service.js b/services/nycrc/nycrc.service.js
index 1e4405e1b64e8..fc461c2769560 100644
--- a/services/nycrc/nycrc.service.js
+++ b/services/nycrc/nycrc.service.js
@@ -2,7 +2,13 @@ import Joi from 'joi'
import { coveragePercentage } from '../color-formatters.js'
import { ConditionalGithubAuthV3Service } from '../github/github-auth-service.js'
import { fetchJsonFromRepo } from '../github/github-common-fetch.js'
-import { InvalidParameter, InvalidResponse, NotFound } from '../index.js'
+import {
+ InvalidParameter,
+ InvalidResponse,
+ NotFound,
+ pathParam,
+ queryParam,
+} from '../index.js'
const nycrcSchema = Joi.object({
branches: Joi.number().min(0).max(100),
@@ -23,11 +29,11 @@ const pkgJSONSchema = Joi.object({
}).optional(),
}).required()
-const documentation = `
- Create a code coverage badge, based on thresholds stored in a
- .nycrc config file
- on GitHub.
-
`
+const description = `
+Create a code coverage badge, based on thresholds stored in a
+[.nycrc config file](https://github.com/istanbuljs/nyc#common-configuration-options)
+on GitHub.
+`
const validThresholds = ['branches', 'lines', 'functions']
@@ -44,21 +50,28 @@ export default class Nycrc extends ConditionalGithubAuthV3Service {
.default('.nycrc'),
// Allow the default threshold detection logic to be overridden, .e.g.,
// favoring lines over branches:
- preferredThreshold: Joi.string()
- .optional()
- .allow(...validThresholds),
+ preferredThreshold: Joi.string(),
}).required(),
}
- static examples = [
- {
- title: 'nycrc config on GitHub',
- namedParams: { user: 'yargs', repo: 'yargs' },
- queryParams: { config: '.nycrc', preferredThreshold: 'lines' },
- staticPreview: this.render({ coverage: 92 }),
- documentation,
+ static openApi = {
+ '/nycrc/{user}/{repo}': {
+ get: {
+ summary: 'nycrc config on GitHub',
+ description,
+ parameters: [
+ pathParam({ name: 'user', example: 'yargs' }),
+ pathParam({ name: 'repo', example: 'yargs' }),
+ queryParam({ name: 'config', example: '.nycrc' }),
+ queryParam({
+ name: 'preferredThreshold',
+ example: 'lines',
+ schema: { type: 'string', enum: validThresholds },
+ }),
+ ],
+ },
},
- ]
+ }
static defaultBadgeData = { label: 'min coverage' }
@@ -74,7 +87,8 @@ export default class Nycrc extends ConditionalGithubAuthV3Service {
if (preferredThreshold) {
if (!validThresholds.includes(preferredThreshold)) {
throw new InvalidParameter({
- prettyMessage: `threshold must be "branches", "lines", or "functions"`,
+ prettyMessage:
+ 'threshold must be "branches", "lines", or "functions"',
})
}
if (!config[preferredThreshold]) {
@@ -122,7 +136,7 @@ export default class Nycrc extends ConditionalGithubAuthV3Service {
branch: 'HEAD',
filename: config,
}),
- preferredThreshold
+ preferredThreshold,
)
}
return this.constructor.render({ coverage })
diff --git a/services/nycrc/nycrc.tester.js b/services/nycrc/nycrc.tester.js
index 66894780a8df9..f9e4398d69118 100644
--- a/services/nycrc/nycrc.tester.js
+++ b/services/nycrc/nycrc.tester.js
@@ -30,10 +30,10 @@ t.create('.nycrc in monorepo')
content: Buffer.from(
JSON.stringify({
lines: 99,
- })
+ }),
).toString('base64'),
encoding: 'base64',
- })
+ }),
)
.expectBadge({ label: 'min coverage', message: isIntegerPercentage })
@@ -46,10 +46,10 @@ t.create('.nycrc with no thresholds')
content: Buffer.from(
JSON.stringify({
reporter: 'foo',
- })
+ }),
).toString('base64'),
encoding: 'base64',
- })
+ }),
)
.expectBadge({
label: 'min coverage',
@@ -67,10 +67,10 @@ t.create('package.json with nyc stanza')
nyc: {
lines: 99,
},
- })
+ }),
).toString('base64'),
encoding: 'base64',
- })
+ }),
)
.expectBadge({ label: 'min coverage', message: isIntegerPercentage })
@@ -83,10 +83,10 @@ t.create('package.json with nyc stanza, but no thresholds')
content: Buffer.from(
JSON.stringify({
nyc: {},
- })
+ }),
).toString('base64'),
encoding: 'base64',
- })
+ }),
)
.expectBadge({
label: 'min coverage',
diff --git a/services/obs/obs-build-status.js b/services/obs/obs-build-status.js
index 0be8d3fd2553f..c26da4279cbb9 100644
--- a/services/obs/obs-build-status.js
+++ b/services/obs/obs-build-status.js
@@ -16,10 +16,10 @@ const localStatuses = {
const isBuildStatus = Joi.alternatives().try(
gIsBuildStatus,
- Joi.equal(...Object.keys(localStatuses))
+ Joi.equal(...Object.keys(localStatuses)),
)
-function renderBuildStatusBadge({ repository, status }) {
+function renderBuildStatusBadge({ status }) {
const color = localStatuses[status]
if (color) {
return {
diff --git a/services/obs/obs.service.js b/services/obs/obs.service.js
index d6c6304eeeb17..d22d63d25016e 100644
--- a/services/obs/obs.service.js
+++ b/services/obs/obs.service.js
@@ -1,5 +1,5 @@
import Joi from 'joi'
-import { BaseXmlService } from '../index.js'
+import { BaseXmlService, pathParam, queryParam } from '../index.js'
import { optionalUrl } from '../validators.js'
import { isBuildStatus, renderBuildStatusBadge } from './obs-build-status.js'
@@ -26,28 +26,27 @@ export default class ObsService extends BaseXmlService {
isRequired: true,
}
- static examples = [
- {
- title: 'OBS package build status',
- namedParams: {
- project: 'openSUSE:Tools',
- packageName: 'osc',
- repository: 'Debian_11',
- arch: 'x86_64',
+ static openApi = {
+ '/obs/{project}/{packageName}/{repository}/{arch}': {
+ get: {
+ summary: 'OBS package build status',
+ description:
+ '[Open Build Service](https://openbuildservice.org/) (OBS) is a generic system to build and distribute binary packages',
+ parameters: [
+ pathParam({ name: 'project', example: 'openSUSE:Tools' }),
+ pathParam({ name: 'packageName', example: 'osc' }),
+ pathParam({ name: 'repository', example: 'Debian_11' }),
+ pathParam({ name: 'arch', example: 'x86_64' }),
+ queryParam({ name: 'instance', example: 'https://api.opensuse.org' }),
+ ],
},
- queryParams: { instance: 'https://api.opensuse.org' },
- staticPreview: this.render({
- repository: 'Debian_11',
- status: 'succeeded',
- }),
- keywords: ['open build service'],
},
- ]
+ }
static defaultBadgeData = { label: 'build' }
- static render({ repository, status }) {
- return renderBuildStatusBadge({ repository, status })
+ static render({ status }) {
+ return renderBuildStatusBadge({ status })
}
async fetch({ instance, project, packageName, repository, arch }) {
@@ -58,13 +57,13 @@ export default class ObsService extends BaseXmlService {
parserOptions: {
ignoreAttributes: false,
},
- })
+ }),
)
}
async handle(
{ project, packageName, repository, arch },
- { instance = 'https://api.opensuse.org' }
+ { instance = 'https://api.opensuse.org' },
) {
const resp = await this.fetch({
instance,
@@ -74,7 +73,6 @@ export default class ObsService extends BaseXmlService {
arch,
})
return this.constructor.render({
- repository,
status: resp.status['@_code'],
})
}
diff --git a/services/obs/obs.spec.js b/services/obs/obs.spec.js
new file mode 100644
index 0000000000000..fa19d9868b2f9
--- /dev/null
+++ b/services/obs/obs.spec.js
@@ -0,0 +1,16 @@
+import { testAuth } from '../test-helpers.js'
+import ObsService from './obs.service.js'
+
+describe('ObsService', function () {
+ describe('auth', function () {
+ it('sends the auth information as configured', async function () {
+ return testAuth(
+ ObsService,
+ 'BasicAuth',
+ `
+ `,
+ { contentType: 'application/xml' },
+ )
+ })
+ })
+})
diff --git a/services/offset-earth/offset-earth-carbon-redirect.service.js b/services/offset-earth/offset-earth-carbon-redirect.service.js
index ccbabfab208fc..b4b3feaab7be1 100644
--- a/services/offset-earth/offset-earth-carbon-redirect.service.js
+++ b/services/offset-earth/offset-earth-carbon-redirect.service.js
@@ -1,15 +1,16 @@
-import { redirector } from '../index.js'
+import { deprecatedService } from '../index.js'
export default [
// https://github.com/badges/shields/issues/5433
- redirector({
+ deprecatedService({
name: 'OffsetEarthCarbonRedirect',
category: 'other',
+ label: 'offset-earth',
route: {
base: 'offset-earth/carbon',
pattern: ':username',
},
- transformPath: ({ username }) => `/ecologi/carbon/${username}`,
- dateAdded: new Date('2020-08-16'),
+ dateAdded: new Date('2025-12-20'),
+ issueUrl: 'https://github.com/badges/shields/pull/11583',
}),
]
diff --git a/services/offset-earth/offset-earth-carbon-redirect.tester.js b/services/offset-earth/offset-earth-carbon-redirect.tester.js
index 66e171d95215b..90cb551ce8102 100644
--- a/services/offset-earth/offset-earth-carbon-redirect.tester.js
+++ b/services/offset-earth/offset-earth-carbon-redirect.tester.js
@@ -6,6 +6,7 @@ export const t = new ServiceTester({
pathPrefix: '/offset-earth',
})
-t.create('Offset Earth carbon alias')
- .get('/carbon/ecologi.svg')
- .expectRedirect('/ecologi/carbon/ecologi.svg')
+t.create('Offset Earth carbon alias').get('/carbon/ecologi.json').expectBadge({
+ label: 'offset-earth',
+ message: 'https://github.com/badges/shields/pull/11583',
+})
diff --git a/services/offset-earth/offset-earth-trees-redirect.service.js b/services/offset-earth/offset-earth-trees-redirect.service.js
index bdc3b16c64d71..8e3c7f411c0d0 100644
--- a/services/offset-earth/offset-earth-trees-redirect.service.js
+++ b/services/offset-earth/offset-earth-trees-redirect.service.js
@@ -1,15 +1,16 @@
-import { redirector } from '../index.js'
+import { deprecatedService } from '../index.js'
export default [
// https://github.com/badges/shields/issues/5433
- redirector({
+ deprecatedService({
name: 'OffsetEarthTreesRedirect',
category: 'other',
+ label: 'offset-earth',
route: {
base: 'offset-earth/trees',
pattern: ':username',
},
- transformPath: ({ username }) => `/ecologi/trees/${username}`,
- dateAdded: new Date('2020-08-16'),
+ dateAdded: new Date('2025-12-20'),
+ issueUrl: 'https://github.com/badges/shields/pull/11583',
}),
]
diff --git a/services/offset-earth/offset-earth-trees-redirect.tester.js b/services/offset-earth/offset-earth-trees-redirect.tester.js
index eea1a2098e2a4..8b4c8d24b4168 100644
--- a/services/offset-earth/offset-earth-trees-redirect.tester.js
+++ b/services/offset-earth/offset-earth-trees-redirect.tester.js
@@ -6,6 +6,7 @@ export const t = new ServiceTester({
pathPrefix: '/offset-earth',
})
-t.create('Offset Earth trees alias')
- .get('/trees/ecologi.svg')
- .expectRedirect('/ecologi/trees/ecologi.svg')
+t.create('Offset Earth trees alias').get('/trees/ecologi.json').expectBadge({
+ label: 'offset-earth',
+ message: 'https://github.com/badges/shields/pull/11583',
+})
diff --git a/services/open-vsx/open-vsx-base.js b/services/open-vsx/open-vsx-base.js
index d17a1e66a8cf7..9a6aced1e9bb3 100644
--- a/services/open-vsx/open-vsx-base.js
+++ b/services/open-vsx/open-vsx-base.js
@@ -16,13 +16,6 @@ const extensionQuerySchema = Joi.object({
}).required()
export default class OpenVSXBase extends BaseJsonService {
- static keywords = [
- 'ovsx',
- 'open-vsx',
- 'ovsx-marketplace',
- 'open-vsx-marketplace',
- ]
-
static defaultBadgeData = {
label: 'open vsx',
color: 'blue',
@@ -31,13 +24,18 @@ export default class OpenVSXBase extends BaseJsonService {
async fetch({ namespace, extension, version }) {
return this._requestJson({
schema: extensionQuerySchema,
- url: `https://open-vsx.org/api/${namespace}/${extension}/${
- version || ''
+ url: `https://open-vsx.org/api/${namespace}/${extension}${
+ version ? `/${version}` : ''
}`,
- errorMessages: {
+ httpErrors: {
400: 'invalid extension id',
404: 'extension not found',
},
})
}
}
+
+const description =
+ '[Open VSX](https://open-vsx.org/) (OVSX) is a registry of extensions for VS Code compatible editors.'
+
+export { OpenVSXBase, description }
diff --git a/services/open-vsx/open-vsx-downloads.service.js b/services/open-vsx/open-vsx-downloads.service.js
index b1d86ec4dccad..2a7f9a1d6385e 100644
--- a/services/open-vsx/open-vsx-downloads.service.js
+++ b/services/open-vsx/open-vsx-downloads.service.js
@@ -1,6 +1,6 @@
-import { metric } from '../text-formatters.js'
-import { downloadCount } from '../color-formatters.js'
-import OpenVSXBase from './open-vsx-base.js'
+import { pathParams } from '../index.js'
+import { renderDownloadsBadge } from '../downloads.js'
+import { OpenVSXBase, description } from './open-vsx-base.js'
export default class OpenVSXDownloads extends OpenVSXBase {
static category = 'downloads'
@@ -10,49 +10,56 @@ export default class OpenVSXDownloads extends OpenVSXBase {
pattern: ':interval(dt)/:namespace/:extension/:version*',
}
- static examples = [
- {
- title: 'Open VSX Downloads',
- pattern: 'dt/:namespace/:extension',
- namedParams: {
- namespace: 'redhat',
- extension: 'java',
+ static openApi = {
+ '/open-vsx/dt/{namespace}/{extension}': {
+ get: {
+ summary: 'Open VSX Downloads',
+ description,
+ parameters: pathParams(
+ {
+ name: 'namespace',
+ example: 'redhat',
+ },
+ {
+ name: 'extension',
+ example: 'java',
+ },
+ ),
},
- staticPreview: this.render({ downloads: 29000 }),
- keywords: this.keywords,
},
- {
- title: 'Open VSX Downloads (version)',
- pattern: 'dt/:namespace/:extension/:version',
- namedParams: {
- namespace: 'redhat',
- extension: 'java',
- version: '0.69.0',
+ '/open-vsx/dt/{namespace}/{extension}/{version}': {
+ get: {
+ summary: 'Open VSX Downloads (version)',
+ description,
+ parameters: pathParams(
+ {
+ name: 'namespace',
+ example: 'redhat',
+ },
+ {
+ name: 'extension',
+ example: 'java',
+ },
+ {
+ name: 'version',
+ example: '0.69.0',
+ },
+ ),
},
- staticPreview: this.render({ version: '0.69.0', downloads: 29000 }),
- keywords: this.keywords,
},
- ]
+ }
static defaultBadgeData = { label: 'downloads' }
- static render({ version, downloads }) {
- return {
- label: version ? `downloads@${version}` : 'downloads',
- message: metric(downloads),
- color: downloadCount(downloads),
- }
- }
-
async handle({ namespace, extension, version }) {
- const { version: tag, downloadCount } = await this.fetch({
+ const { version: tag, downloadCount: downloads } = await this.fetch({
namespace,
extension,
version,
})
- return this.constructor.render({
+ return renderDownloadsBadge({
+ downloads,
version: version ? tag : undefined,
- downloads: downloadCount,
})
}
}
diff --git a/services/open-vsx/open-vsx-rating.service.js b/services/open-vsx/open-vsx-rating.service.js
index ae5702c0f10a0..6abeafc0ee784 100644
--- a/services/open-vsx/open-vsx-rating.service.js
+++ b/services/open-vsx/open-vsx-rating.service.js
@@ -1,6 +1,7 @@
+import { pathParams } from '../index.js'
import { starRating } from '../text-formatters.js'
import { floorCount } from '../color-formatters.js'
-import OpenVSXBase from './open-vsx-base.js'
+import { OpenVSXBase, description } from './open-vsx-base.js'
export default class OpenVSXRating extends OpenVSXBase {
static category = 'rating'
@@ -10,36 +11,29 @@ export default class OpenVSXRating extends OpenVSXBase {
pattern: ':format(rating|stars)/:namespace/:extension',
}
- static examples = [
- {
- title: 'Open VSX Rating',
- pattern: 'rating/:namespace/:extension',
- namedParams: {
- namespace: 'redhat',
- extension: 'java',
+ static openApi = {
+ '/open-vsx/{format}/{namespace}/{extension}': {
+ get: {
+ summary: 'Open VSX Rating',
+ description,
+ parameters: pathParams(
+ {
+ name: 'format',
+ example: 'rating',
+ schema: { type: 'string', enum: this.getEnum('format') },
+ },
+ {
+ name: 'namespace',
+ example: 'redhat',
+ },
+ {
+ name: 'extension',
+ example: 'java',
+ },
+ ),
},
- staticPreview: this.render({
- format: 'rating',
- averageRating: 5,
- ratingCount: 2,
- }),
- keywords: this.keywords,
},
- {
- title: 'Open VSX Rating (Stars)',
- pattern: 'stars/:namespace/:extension',
- namedParams: {
- namespace: 'redhat',
- extension: 'java',
- },
- staticPreview: this.render({
- format: 'stars',
- averageRating: 5,
- ratingCount: 2,
- }),
- keywords: this.keywords,
- },
- ]
+ }
static defaultBadgeData = {
label: 'rating',
diff --git a/services/open-vsx/open-vsx-release-date.service.js b/services/open-vsx/open-vsx-release-date.service.js
index c201306c8eb6a..511c33de1aeb5 100644
--- a/services/open-vsx/open-vsx-release-date.service.js
+++ b/services/open-vsx/open-vsx-release-date.service.js
@@ -1,6 +1,6 @@
-import { age } from '../color-formatters.js'
-import { formatDate } from '../text-formatters.js'
-import OpenVSXBase from './open-vsx-base.js'
+import { pathParams } from '../index.js'
+import { renderDateBadge } from '../date.js'
+import { OpenVSXBase, description } from './open-vsx-base.js'
export default class OpenVSXReleaseDate extends OpenVSXBase {
static category = 'activity'
@@ -10,33 +10,29 @@ export default class OpenVSXReleaseDate extends OpenVSXBase {
pattern: 'release-date/:namespace/:extension',
}
- static examples = [
- {
- title: 'Open VSX Release Date',
- namedParams: {
- namespace: 'redhat',
- extension: 'java',
+ static openApi = {
+ '/open-vsx/release-date/{namespace}/{extension}': {
+ get: {
+ summary: 'Open VSX Release Date',
+ description,
+ parameters: pathParams(
+ {
+ name: 'namespace',
+ example: 'redhat',
+ },
+ {
+ name: 'extension',
+ example: 'java',
+ },
+ ),
},
- staticPreview: this.render({
- releaseDate: '2020-10-15T13:40:16.986723Z',
- }),
- keywords: this.keywords,
},
- ]
+ }
static defaultBadgeData = { label: 'release date' }
- static render({ releaseDate }) {
- return {
- message: formatDate(releaseDate),
- color: age(releaseDate),
- }
- }
-
async handle({ namespace, extension }) {
const { timestamp } = await this.fetch({ namespace, extension })
- return this.constructor.render({
- releaseDate: timestamp,
- })
+ return renderDateBadge(timestamp)
}
}
diff --git a/services/open-vsx/open-vsx-version.service.js b/services/open-vsx/open-vsx-version.service.js
index 45f342a94c4e0..1385e93887d2a 100644
--- a/services/open-vsx/open-vsx-version.service.js
+++ b/services/open-vsx/open-vsx-version.service.js
@@ -1,5 +1,6 @@
+import { pathParams } from '../index.js'
import { renderVersionBadge } from '../version.js'
-import OpenVSXBase from './open-vsx-base.js'
+import { OpenVSXBase, description } from './open-vsx-base.js'
export default class OpenVSXVersion extends OpenVSXBase {
static category = 'version'
@@ -9,14 +10,24 @@ export default class OpenVSXVersion extends OpenVSXBase {
pattern: 'v/:namespace/:extension',
}
- static examples = [
- {
- title: 'Open VSX Version',
- namedParams: { namespace: 'redhat', extension: 'java' },
- staticPreview: this.render({ version: '0.69.0' }),
- keywords: this.keywords,
+ static openApi = {
+ '/open-vsx/v/{namespace}/{extension}': {
+ get: {
+ summary: 'Open VSX Version',
+ description,
+ parameters: pathParams(
+ {
+ name: 'namespace',
+ example: 'redhat',
+ },
+ {
+ name: 'extension',
+ example: 'java',
+ },
+ ),
+ },
},
- ]
+ }
static render({ version }) {
return renderVersionBadge({ version })
diff --git a/services/opencollective/opencollective-all.service.js b/services/opencollective/opencollective-all.service.js
index dac3229f97791..174ef58fd71be 100644
--- a/services/opencollective/opencollective-all.service.js
+++ b/services/opencollective/opencollective-all.service.js
@@ -1,23 +1,33 @@
+import { pathParams } from '../index.js'
import OpencollectiveBase from './opencollective-base.js'
export default class OpencollectiveAll extends OpencollectiveBase {
static route = this.buildRoute('all')
- static examples = [
- {
- title: 'Open Collective backers and sponsors',
- namedParams: { collective: 'shields' },
- staticPreview: this.render(35),
- keywords: ['opencollective'],
+ static openApi = {
+ '/opencollective/all/{collective}': {
+ get: {
+ summary: 'Open Collective backers and sponsors',
+ parameters: pathParams({
+ name: 'collective',
+ example: 'shields',
+ }),
+ },
},
- ]
+ }
+
+ static _cacheLength = 3600
static defaultBadgeData = {
label: 'backers and sponsors',
}
async handle({ collective }) {
- const { backersCount } = await this.fetchCollectiveInfo(collective)
+ const data = await this.fetchCollectiveInfo({
+ collective,
+ accountType: [],
+ })
+ const backersCount = this.getCount(data)
return this.constructor.render(backersCount)
}
}
diff --git a/services/opencollective/opencollective-all.tester.js b/services/opencollective/opencollective-all.tester.js
index d827313c509ad..61f2ef3e2cabb 100644
--- a/services/opencollective/opencollective-all.tester.js
+++ b/services/opencollective/opencollective-all.tester.js
@@ -2,25 +2,6 @@ import { nonNegativeInteger } from '../validators.js'
import { createServiceTester } from '../tester.js'
export const t = await createServiceTester()
-t.create('renders correctly')
- .get('/shields.json')
- .intercept(nock =>
- nock('https://opencollective.com/').get('/shields.json').reply(200, {
- slug: 'shields',
- currency: 'USD',
- image:
- 'https://opencollective-production.s3-us-west-1.amazonaws.com/44dcbb90-1ee9-11e8-a4c3-7bb1885c0b6e.png',
- balance: 105494,
- yearlyIncome: 157371,
- backersCount: 35,
- contributorsCount: 276,
- })
- )
- .expectBadge({
- label: 'backers and sponsors',
- message: '35',
- color: 'brightgreen',
- })
t.create('gets amount of backers and sponsors')
.get('/shields.json')
.expectBadge({
@@ -28,23 +9,10 @@ t.create('gets amount of backers and sponsors')
message: nonNegativeInteger,
})
-t.create('renders not found correctly')
- .get('/nonexistent-collective.json')
- .intercept(nock =>
- nock('https://opencollective.com/')
- .get('/nonexistent-collective.json')
- .reply(404, 'Not found')
- )
- .expectBadge({
- label: 'backers and sponsors',
- message: 'collective not found',
- color: 'red',
- })
-
t.create('handles not found correctly')
.get('/nonexistent-collective.json')
.expectBadge({
label: 'backers and sponsors',
- message: 'collective not found',
- color: 'red',
+ message: 'No collective found with slug nonexistent-collective',
+ color: 'lightgrey',
})
diff --git a/services/opencollective/opencollective-backers.service.js b/services/opencollective/opencollective-backers.service.js
index 3f2b7da4d7541..0565e097bb385 100644
--- a/services/opencollective/opencollective-backers.service.js
+++ b/services/opencollective/opencollective-backers.service.js
@@ -1,26 +1,34 @@
+import { pathParams } from '../index.js'
import OpencollectiveBase from './opencollective-base.js'
export default class OpencollectiveBackers extends OpencollectiveBase {
static route = this.buildRoute('backers')
- static examples = [
- {
- title: 'Open Collective backers',
- namedParams: { collective: 'shields' },
- staticPreview: this.render(25),
- keywords: ['opencollective'],
+ static openApi = {
+ '/opencollective/backers/{collective}': {
+ get: {
+ summary: 'Open Collective backers',
+ parameters: pathParams({
+ name: 'collective',
+ example: 'shields',
+ }),
+ },
},
- ]
+ }
+
+ static _cacheLength = 3600
static defaultBadgeData = {
label: 'backers',
}
async handle({ collective }) {
- const { backersCount } = await this.fetchCollectiveBackersCount(
+ const data = await this.fetchCollectiveInfo({
collective,
- { userType: 'users' }
- )
+ accountType: ['INDIVIDUAL'],
+ })
+ const backersCount = this.getCount(data)
+
return this.constructor.render(backersCount)
}
}
diff --git a/services/opencollective/opencollective-backers.tester.js b/services/opencollective/opencollective-backers.tester.js
index 7163f9a5da4ce..29855ae57d07c 100644
--- a/services/opencollective/opencollective-backers.tester.js
+++ b/services/opencollective/opencollective-backers.tester.js
@@ -2,80 +2,6 @@ import { nonNegativeInteger } from '../validators.js'
import { createServiceTester } from '../tester.js'
export const t = await createServiceTester()
-t.create('renders correctly')
- .get('/shields.json')
- .intercept(nock =>
- nock('https://opencollective.com/')
- .get('/shields/members/users.json')
- .reply(200, [
- { MemberId: 8685, type: 'USER', role: 'ADMIN' },
- { MemberId: 8686, type: 'USER', role: 'ADMIN' },
- { MemberId: 8682, type: 'USER', role: 'ADMIN' },
- { MemberId: 10305, type: 'USER', role: 'BACKER', tier: 'backer' },
- { MemberId: 10396, type: 'USER', role: 'BACKER', tier: 'backer' },
- { MemberId: 10733, type: 'USER', role: 'BACKER' },
- { MemberId: 8684, type: 'USER', role: 'ADMIN' },
- { MemberId: 10741, type: 'USER', role: 'BACKER' },
- {
- MemberId: 10756,
- type: 'USER',
- role: 'BACKER',
- tier: 'monthly backer',
- },
- { MemberId: 11578, type: 'USER', role: 'CONTRIBUTOR' },
- { MemberId: 13459, type: 'USER', role: 'CONTRIBUTOR' },
- {
- MemberId: 13507,
- type: 'USER',
- role: 'BACKER',
- tier: 'monthly backer',
- },
- { MemberId: 13512, type: 'USER', role: 'BACKER' },
- { MemberId: 13513, type: 'USER', role: 'FUNDRAISER' },
- { MemberId: 13984, type: 'USER', role: 'BACKER', tier: 'backer' },
- { MemberId: 14916, type: 'USER', role: 'BACKER' },
- {
- MemberId: 16326,
- type: 'USER',
- role: 'BACKER',
- tier: 'monthly backer',
- },
- { MemberId: 18252, type: 'USER', role: 'BACKER', tier: 'backer' },
- { MemberId: 17631, type: 'USER', role: 'BACKER', tier: 'backer' },
- {
- MemberId: 16420,
- type: 'USER',
- role: 'BACKER',
- tier: 'monthly backer',
- },
- { MemberId: 17186, type: 'USER', role: 'BACKER', tier: 'backer' },
- { MemberId: 18791, type: 'USER', role: 'BACKER', tier: 'backer' },
- {
- MemberId: 19279,
- type: 'USER',
- role: 'BACKER',
- tier: 'monthly backer',
- },
- { MemberId: 19863, type: 'USER', role: 'BACKER', tier: 'backer' },
- { MemberId: 21451, type: 'USER', role: 'BACKER', tier: 'backer' },
- { MemberId: 22718, type: 'USER', role: 'BACKER' },
- { MemberId: 23561, type: 'USER', role: 'BACKER', tier: 'backer' },
- { MemberId: 25092, type: 'USER', role: 'CONTRIBUTOR' },
- { MemberId: 24473, type: 'USER', role: 'BACKER', tier: 'backer' },
- { MemberId: 25439, type: 'USER', role: 'BACKER', tier: 'backer' },
- { MemberId: 24483, type: 'USER', role: 'BACKER', tier: 'backer' },
- { MemberId: 25090, type: 'USER', role: 'CONTRIBUTOR' },
- { MemberId: 26404, type: 'USER', role: 'BACKER', tier: 'backer' },
- { MemberId: 27026, type: 'USER', role: 'BACKER', tier: 'backer' },
- { MemberId: 27132, type: 'USER', role: 'CONTRIBUTOR' },
- ])
- )
- .expectBadge({
- label: 'backers',
- message: '25',
- color: 'brightgreen',
- })
-
t.create('gets amount of backers').get('/shields.json').expectBadge({
label: 'backers',
message: nonNegativeInteger,
@@ -85,6 +11,6 @@ t.create('handles not found correctly')
.get('/nonexistent-collective.json')
.expectBadge({
label: 'backers',
- message: 'collective not found',
- color: 'red',
+ message: 'No collective found with slug nonexistent-collective',
+ color: 'lightgrey',
})
diff --git a/services/opencollective/opencollective-base.js b/services/opencollective/opencollective-base.js
index ec92f434e2808..97e42da30d58a 100644
--- a/services/opencollective/opencollective-base.js
+++ b/services/opencollective/opencollective-base.js
@@ -1,27 +1,38 @@
+import gql from 'graphql-tag'
import Joi from 'joi'
+import { BaseGraphqlService } from '../index.js'
import { nonNegativeInteger } from '../validators.js'
-import { BaseJsonService } from '../index.js'
+import { metric } from '../text-formatters.js'
-// https://developer.opencollective.com/#/api/collectives?id=get-info
-const collectiveDetailsSchema = Joi.object().keys({
- slug: Joi.string().required(),
- backersCount: nonNegativeInteger,
-})
+const schema = Joi.object({
+ data: Joi.object({
+ account: Joi.object({
+ name: Joi.string(),
+ slug: Joi.string(),
+ members: Joi.object({
+ totalCount: nonNegativeInteger,
+ nodes: Joi.array().items(
+ Joi.object({
+ tier: Joi.object({
+ legacyId: Joi.number(),
+ name: Joi.string(),
+ }).allow(null),
+ }),
+ ),
+ }).required(),
+ }).required(),
+ }).required(),
+}).required()
-// https://developer.opencollective.com/#/api/collectives?id=get-members
-function buildMembersArraySchema({ userType, tierRequired }) {
- const keys = {
- MemberId: Joi.number().required(),
- type: userType || Joi.string().required(),
- role: Joi.string().required(),
- }
- if (tierRequired) keys.tier = Joi.string().required()
- return Joi.array().items(Joi.object().keys(keys))
-}
-
-export default class OpencollectiveBase extends BaseJsonService {
+export default class OpencollectiveBase extends BaseGraphqlService {
static category = 'funding'
+ static auth = {
+ passKey: 'opencollective_token',
+ authorizedOrigins: ['https://api.opencollective.com'],
+ isRequired: false,
+ }
+
static buildRoute(base, withTierId) {
return {
base: `opencollective${base ? `/${base}` : ''}`,
@@ -30,53 +41,58 @@ export default class OpencollectiveBase extends BaseJsonService {
}
static render(backersCount, label) {
- const badge = {
- message: backersCount,
+ return {
+ label,
+ message: metric(backersCount),
color: backersCount > 0 ? 'brightgreen' : 'lightgrey',
}
- if (label) badge.label = label
- return badge
}
- async fetchCollectiveInfo(collective) {
- return this._requestJson({
- schema: collectiveDetailsSchema,
- // https://developer.opencollective.com/#/api/collectives?id=get-info
- url: `https://opencollective.com/${collective}.json`,
- errorMessages: {
- 404: 'collective not found',
- },
- })
+ async fetchCollectiveInfo({ collective, accountType }) {
+ return this._requestGraphql(
+ this.authHelper.withQueryStringAuth(
+ { passKey: 'personalToken' },
+ {
+ schema,
+ url: 'https://api.opencollective.com/graphql/v2',
+ query: gql`
+ query account($slug: String, $accountType: [AccountType]) {
+ account(slug: $slug) {
+ name
+ slug
+ members(accountType: $accountType, role: BACKER) {
+ totalCount
+ nodes {
+ tier {
+ legacyId
+ name
+ }
+ }
+ }
+ }
+ }
+ `,
+ variables: {
+ slug: collective,
+ accountType,
+ },
+ options: {
+ headers: { 'content-type': 'application/json' },
+ },
+ },
+ ),
+ )
}
- async fetchCollectiveBackersCount(collective, { userType, tierId }) {
- const schema = buildMembersArraySchema({
- userType:
- userType === 'users'
- ? 'USER'
- : userType === 'organizations'
- ? 'ORGANIZATION'
- : undefined,
- tierRequired: tierId,
- })
- const members = await this._requestJson({
- schema,
- // https://developer.opencollective.com/#/api/collectives?id=get-members
- // https://developer.opencollective.com/#/api/collectives?id=get-members-per-tier
- url: `https://opencollective.com/${collective}/members/${
- userType || 'all'
- }.json${tierId ? `?TierId=${tierId}` : ''}`,
- errorMessages: {
- 404: 'collective not found',
+ getCount(data) {
+ const {
+ data: {
+ account: {
+ members: { totalCount },
+ },
},
- })
+ } = data
- const result = {
- backersCount: members.filter(member => member.role === 'BACKER').length,
- }
- // Find the title of the tier
- if (tierId && members.length > 0)
- result.tier = members.map(member => member.tier)[0]
- return result
+ return totalCount
}
}
diff --git a/services/opencollective/opencollective-base.spec.js b/services/opencollective/opencollective-base.spec.js
new file mode 100644
index 0000000000000..87a6646f2cd31
--- /dev/null
+++ b/services/opencollective/opencollective-base.spec.js
@@ -0,0 +1,37 @@
+import { expect } from 'chai'
+import nock from 'nock'
+import { cleanUpNockAfterEach, defaultContext } from '../test-helpers.js'
+import OpencollectiveBase from './opencollective-base.js'
+
+class DummyOpencollectiveService extends OpencollectiveBase {
+ static route = this.buildRoute('dummy')
+
+ async handle({ collective }) {
+ const data = await this.fetchCollectiveInfo({
+ collective,
+ accountType: [],
+ })
+ return this.constructor.render(this.getCount(data))
+ }
+}
+
+describe('OpencollectiveBase', function () {
+ describe('auth', function () {
+ cleanUpNockAfterEach()
+
+ const config = { private: { opencollective_token: 'fake-token' } }
+
+ it('sends the auth information as configured', async function () {
+ const scope = nock('https://api.opencollective.com')
+ .post('/graphql/v2')
+ .query({ personalToken: 'fake-token' })
+ .reply(200, { data: { account: { members: { totalCount: 1 } } } })
+
+ expect(
+ await DummyOpencollectiveService.invoke(defaultContext, config, {}),
+ ).to.deep.equal({ color: 'brightgreen', label: undefined, message: '1' })
+
+ scope.done()
+ })
+ })
+})
diff --git a/services/opencollective/opencollective-by-tier.service.js b/services/opencollective/opencollective-by-tier.service.js
index 98acc383f509d..7f265b28c5542 100644
--- a/services/opencollective/opencollective-by-tier.service.js
+++ b/services/opencollective/opencollective-by-tier.service.js
@@ -1,20 +1,111 @@
-import OpencollectiveBase from './opencollective-base.js'
+import Joi from 'joi'
+import { nonNegativeInteger } from '../validators.js'
+import { BaseJsonService, pathParams } from '../index.js'
+import { metric } from '../text-formatters.js'
-const documentation = `How to get the tierId
+const description = `How to get the tierId
According to open collectives documentation , you can find the tierId by looking at the URL after clicking on a Tier Card on the collective page. (e.g. tierId for https://opencollective.com/shields/order/2988 is 2988)
`
-export default class OpencollectiveByTier extends OpencollectiveBase {
+// https://developer.opencollective.com/#/api/collectives?id=get-info
+const collectiveDetailsSchema = Joi.object().keys({
+ slug: Joi.string().required(),
+ backersCount: nonNegativeInteger,
+})
+
+// https://developer.opencollective.com/#/api/collectives?id=get-members
+function buildMembersArraySchema({ userType, tierRequired }) {
+ const keys = {
+ MemberId: Joi.number().required(),
+ type: userType || Joi.string().required(),
+ role: Joi.string().required(),
+ }
+ if (tierRequired) keys.tier = Joi.string().required()
+ return Joi.array().items(Joi.object().keys(keys))
+}
+
+class OpencollectiveBaseJson extends BaseJsonService {
+ static category = 'funding'
+
+ static buildRoute(base, withTierId) {
+ return {
+ base: `opencollective${base ? `/${base}` : ''}`,
+ pattern: `:collective${withTierId ? '/:tierId' : ''}`,
+ }
+ }
+
+ static render(backersCount, label) {
+ return {
+ label,
+ message: metric(backersCount),
+ color: backersCount > 0 ? 'brightgreen' : 'lightgrey',
+ }
+ }
+
+ async fetchCollectiveInfo(collective) {
+ return this._requestJson({
+ schema: collectiveDetailsSchema,
+ // https://developer.opencollective.com/#/api/collectives?id=get-info
+ url: `https://opencollective.com/${collective}.json`,
+ httpErrors: {
+ 404: 'collective not found',
+ },
+ })
+ }
+
+ async fetchCollectiveBackersCount(collective, { userType, tierId }) {
+ const schema = buildMembersArraySchema({
+ userType:
+ userType === 'users'
+ ? 'USER'
+ : userType === 'organizations'
+ ? 'ORGANIZATION'
+ : undefined,
+ tierRequired: tierId,
+ })
+ const members = await this._requestJson({
+ schema,
+ // https://developer.opencollective.com/#/api/collectives?id=get-members
+ // https://developer.opencollective.com/#/api/collectives?id=get-members-per-tier
+ url: `https://opencollective.com/${collective}/members/${
+ userType || 'all'
+ }.json${tierId ? `?TierId=${tierId}` : ''}`,
+ httpErrors: {
+ 404: 'collective not found',
+ },
+ })
+
+ const result = {
+ backersCount: members.filter(member => member.role === 'BACKER').length,
+ }
+ // Find the title of the tier
+ if (tierId && members.length > 0)
+ result.tier = members.map(member => member.tier)[0]
+ return result
+ }
+}
+
+// TODO: 1. pagination is needed. 2. use new graphql api instead of legacy rest api
+export default class OpencollectiveByTier extends OpencollectiveBaseJson {
static route = this.buildRoute('tier', true)
- static examples = [
- {
- title: 'Open Collective members by tier',
- namedParams: { collective: 'shields', tierId: '2988' },
- staticPreview: this.render(8, 'monthly backers'),
- keywords: ['opencollective'],
- documentation,
+ static openApi = {
+ '/opencollective/tier/{collective}/{tierId}': {
+ get: {
+ summary: 'Open Collective members by tier',
+ description,
+ parameters: pathParams(
+ {
+ name: 'collective',
+ example: 'shields',
+ },
+ {
+ name: 'tierId',
+ example: '2988',
+ },
+ ),
+ },
},
- ]
+ }
static defaultBadgeData = {
label: 'open collective',
diff --git a/services/opencollective/opencollective-by-tier.tester.js b/services/opencollective/opencollective-by-tier.tester.js
index c2179fef409a2..0b5bb68c5427d 100644
--- a/services/opencollective/opencollective-by-tier.tester.js
+++ b/services/opencollective/opencollective-by-tier.tester.js
@@ -56,7 +56,7 @@ t.create('renders correctly')
role: 'BACKER',
tier: 'monthly backer',
},
- ])
+ ]),
)
.expectBadge({
label: 'monthly backers',
@@ -70,7 +70,7 @@ t.create('shows 0 when given a non existent tier')
.intercept(nock =>
nock('https://opencollective.com/')
.get('/shields/members/all.json?TierId=1234567890')
- .reply(200, [])
+ .reply(200, []),
)
.expectBadge({
label: 'new tier',
diff --git a/services/opencollective/opencollective-sponsors.service.js b/services/opencollective/opencollective-sponsors.service.js
index 0f41df6f03657..192d5f4bef420 100644
--- a/services/opencollective/opencollective-sponsors.service.js
+++ b/services/opencollective/opencollective-sponsors.service.js
@@ -1,26 +1,33 @@
+import { pathParams } from '../index.js'
import OpencollectiveBase from './opencollective-base.js'
export default class OpencollectiveSponsors extends OpencollectiveBase {
static route = this.buildRoute('sponsors')
- static examples = [
- {
- title: 'Open Collective sponsors',
- namedParams: { collective: 'shields' },
- staticPreview: this.render(10),
- keywords: ['opencollective'],
+ static openApi = {
+ '/opencollective/sponsors/{collective}': {
+ get: {
+ summary: 'Open Collective sponsors',
+ parameters: pathParams({
+ name: 'collective',
+ example: 'shields',
+ }),
+ },
},
- ]
+ }
+
+ static _cacheLength = 3600
static defaultBadgeData = {
label: 'sponsors',
}
async handle({ collective }) {
- const { backersCount } = await this.fetchCollectiveBackersCount(
+ const data = await this.fetchCollectiveInfo({
collective,
- { userType: 'organizations' }
- )
+ accountType: ['ORGANIZATION'],
+ })
+ const backersCount = this.getCount(data)
return this.constructor.render(backersCount)
}
}
diff --git a/services/opencollective/opencollective-sponsors.tester.js b/services/opencollective/opencollective-sponsors.tester.js
index 300af584c42ce..d563744a49ae3 100644
--- a/services/opencollective/opencollective-sponsors.tester.js
+++ b/services/opencollective/opencollective-sponsors.tester.js
@@ -2,80 +2,16 @@ import { nonNegativeInteger } from '../validators.js'
import { createServiceTester } from '../tester.js'
export const t = await createServiceTester()
-t.create('renders correctly')
- .get('/shields.json')
- .intercept(nock =>
- nock('https://opencollective.com/')
- .get('/shields/members/organizations.json')
- .reply(200, [
- { MemberId: 8683, type: 'ORGANIZATION', role: 'HOST' },
- {
- MemberId: 13484,
- type: 'ORGANIZATION',
- role: 'BACKER',
- tier: 'backer',
- },
- { MemberId: 13508, type: 'ORGANIZATION', role: 'FUNDRAISER' },
- { MemberId: 15987, type: 'ORGANIZATION', role: 'BACKER' },
- {
- MemberId: 16561,
- type: 'ORGANIZATION',
- role: 'BACKER',
- tier: 'sponsor',
- },
- {
- MemberId: 16469,
- type: 'ORGANIZATION',
- role: 'BACKER',
- tier: 'sponsor',
- },
- {
- MemberId: 18162,
- type: 'ORGANIZATION',
- role: 'BACKER',
- tier: 'sponsor',
- },
- {
- MemberId: 21023,
- type: 'ORGANIZATION',
- role: 'BACKER',
- tier: 'sponsor',
- },
- {
- MemberId: 21482,
- type: 'ORGANIZATION',
- role: 'BACKER',
- tier: 'monthly backer',
- },
- {
- MemberId: 26367,
- type: 'ORGANIZATION',
- role: 'BACKER',
- tier: 'monthly backer',
- },
- { MemberId: 27531, type: 'ORGANIZATION', role: 'BACKER' },
- {
- MemberId: 29443,
- type: 'ORGANIZATION',
- role: 'BACKER',
- tier: 'monthly backer',
- },
- ])
- )
- .expectBadge({
- label: 'sponsors',
- message: '10',
- color: 'brightgreen',
- })
t.create('gets amount of sponsors').get('/shields.json').expectBadge({
label: 'sponsors',
message: nonNegativeInteger,
+ color: 'brightgreen',
})
t.create('handles not found correctly')
.get('/nonexistent-collective.json')
.expectBadge({
label: 'sponsors',
- message: 'collective not found',
- color: 'red',
+ message: 'No collective found with slug nonexistent-collective',
+ color: 'lightgrey',
})
diff --git a/services/opm/opm-version.service.js b/services/opm/opm-version.service.js
index 6d9f41c43880d..03d66950b66c0 100644
--- a/services/opm/opm-version.service.js
+++ b/services/opm/opm-version.service.js
@@ -1,5 +1,5 @@
import { renderVersionBadge } from '../version.js'
-import { BaseService, NotFound, InvalidResponse } from '../index.js'
+import { BaseService, NotFound, InvalidResponse, pathParams } from '../index.js'
export default class OpmVersion extends BaseService {
static category = 'version'
@@ -9,16 +9,23 @@ export default class OpmVersion extends BaseService {
pattern: ':user/:moduleName',
}
- static examples = [
- {
- title: 'OPM',
- namedParams: {
- user: 'openresty',
- moduleName: 'lua-resty-lrucache',
+ static openApi = {
+ '/opm/v/{user}/{moduleName}': {
+ get: {
+ summary: 'OPM Version',
+ parameters: pathParams(
+ {
+ name: 'user',
+ example: 'openresty',
+ },
+ {
+ name: 'moduleName',
+ example: 'lua-resty-lrucache',
+ },
+ ),
},
- staticPreview: renderVersionBadge({ version: 'v0.08' }),
},
- ]
+ }
static defaultBadgeData = {
label: 'opm',
@@ -26,21 +33,21 @@ export default class OpmVersion extends BaseService {
async fetch({ user, moduleName }) {
const { res } = await this._request({
- url: `https://opm.openresty.org/api/pkg/fetch`,
+ url: 'https://opm.openresty.org/api/pkg/fetch',
options: {
method: 'HEAD',
- qs: {
+ searchParams: {
account: user,
name: moduleName,
},
},
- errorMessages: {
+ httpErrors: {
404: 'module not found',
},
})
// TODO: set followRedirect to false and intercept 302 redirects
- const location = res.request.redirects[0]
+ const location = res.redirectUrls[0].toString()
if (!location) {
throw new NotFound({ prettyMessage: 'module not found' })
}
diff --git a/services/ore/ore-base.js b/services/ore/ore-base.js
index 670d92af48e51..a2388c992124d 100644
--- a/services/ore/ore-base.js
+++ b/services/ore/ore-base.js
@@ -32,11 +32,10 @@ const resourceSchema = Joi.object({
}).required(),
}).required()
-const documentation = `
+const description = `
+Ore is a Minecraft package repository.
Your Plugin ID is the name of your plugin in lowercase, without any spaces or dashes.
-Example: https://ore.spongepowered.org/Erigitic/Total-Economy - Here the Plugin ID is totaleconomy.`
-
-const keywords = ['sponge', 'spongemc', 'spongepowered']
+Example: https://ore.spongepowered.org/Erigitic/Total-Economy - Here the Plugin ID is totaleconomy.
`
class BaseOreService extends BaseJsonService {
async _refreshSessionToken() {
@@ -83,4 +82,4 @@ class BaseOreService extends BaseJsonService {
BaseOreService.sessionToken = null
-export { keywords, documentation, BaseOreService }
+export { description, BaseOreService }
diff --git a/services/ore/ore-category.service.js b/services/ore/ore-category.service.js
index ab0e067ce0a6e..1fabb6c5457e3 100644
--- a/services/ore/ore-category.service.js
+++ b/services/ore/ore-category.service.js
@@ -1,4 +1,5 @@
-import { BaseOreService, documentation, keywords } from './ore-base.js'
+import { pathParams } from '../index.js'
+import { BaseOreService, description } from './ore-base.js'
export default class OreCategory extends BaseOreService {
static category = 'other'
@@ -8,17 +9,18 @@ export default class OreCategory extends BaseOreService {
pattern: ':pluginId',
}
- static examples = [
- {
- title: 'Ore Category',
- namedParams: {
- pluginId: 'nucleus',
+ static openApi = {
+ '/ore/category/{pluginId}': {
+ get: {
+ summary: 'Ore Category',
+ description,
+ parameters: pathParams({
+ name: 'pluginId',
+ example: 'nucleus',
+ }),
},
- staticPreview: this.render({ category: 'misc' }),
- documentation,
- keywords,
},
- ]
+ }
static defaultBadgeData = {
label: 'category',
diff --git a/services/ore/ore-downloads.service.js b/services/ore/ore-downloads.service.js
index dd44194804d32..15f28b4409455 100644
--- a/services/ore/ore-downloads.service.js
+++ b/services/ore/ore-downloads.service.js
@@ -1,6 +1,6 @@
-import { metric } from '../text-formatters.js'
-import { downloadCount } from '../color-formatters.js'
-import { BaseOreService, documentation, keywords } from './ore-base.js'
+import { pathParams } from '../index.js'
+import { renderDownloadsBadge } from '../downloads.js'
+import { BaseOreService, description } from './ore-base.js'
export default class OreDownloads extends BaseOreService {
static category = 'downloads'
@@ -10,33 +10,23 @@ export default class OreDownloads extends BaseOreService {
pattern: ':pluginId',
}
- static examples = [
- {
- title: 'Ore Downloads',
- namedParams: {
- pluginId: 'nucleus',
+ static openApi = {
+ '/ore/dt/{pluginId}': {
+ get: {
+ summary: 'Ore Downloads',
+ description,
+ parameters: pathParams({
+ name: 'pluginId',
+ example: 'nucleus',
+ }),
},
- staticPreview: this.render({ downloads: 560891 }),
- documentation,
- keywords,
},
- ]
-
- static defaultBadgeData = {
- label: 'downloads',
}
- static render({ downloads }) {
- return {
- message: metric(downloads),
- color: downloadCount(downloads),
- }
- }
+ static defaultBadgeData = { label: 'downloads' }
async handle({ pluginId }) {
- const {
- stats: { downloads },
- } = await this.fetch({ pluginId })
- return this.constructor.render({ downloads })
+ const { stats } = await this.fetch({ pluginId })
+ return renderDownloadsBadge({ downloads: stats.downloads })
}
}
diff --git a/services/ore/ore-license.service.js b/services/ore/ore-license.service.js
index 362c226aa95d8..07f870e646f2f 100644
--- a/services/ore/ore-license.service.js
+++ b/services/ore/ore-license.service.js
@@ -1,5 +1,6 @@
+import { pathParams } from '../index.js'
import { renderLicenseBadge } from '../licenses.js'
-import { BaseOreService, documentation, keywords } from './ore-base.js'
+import { BaseOreService, description } from './ore-base.js'
export default class OreLicense extends BaseOreService {
static category = 'license'
@@ -9,17 +10,18 @@ export default class OreLicense extends BaseOreService {
pattern: ':pluginId',
}
- static examples = [
- {
- title: 'Ore License',
- namedParams: {
- pluginId: 'nucleus',
+ static openApi = {
+ '/ore/l/{pluginId}': {
+ get: {
+ summary: 'Ore License',
+ description,
+ parameters: pathParams({
+ name: 'pluginId',
+ example: 'nucleus',
+ }),
},
- staticPreview: this.render({ license: 'MIT' }),
- documentation,
- keywords,
},
- ]
+ }
static defaultBadgeData = {
label: 'license',
diff --git a/services/ore/ore-sponge-versions.service.js b/services/ore/ore-sponge-versions.service.js
index e7689bdc1842b..0a6c891904ce4 100644
--- a/services/ore/ore-sponge-versions.service.js
+++ b/services/ore/ore-sponge-versions.service.js
@@ -1,4 +1,5 @@
-import { BaseOreService, documentation, keywords } from './ore-base.js'
+import { pathParams } from '../index.js'
+import { BaseOreService, description } from './ore-base.js'
export default class OreSpongeVersions extends BaseOreService {
static category = 'platform-support'
@@ -8,17 +9,18 @@ export default class OreSpongeVersions extends BaseOreService {
pattern: ':pluginId',
}
- static examples = [
- {
- title: 'Compatible versions (plugins on Ore)',
- namedParams: {
- pluginId: 'nucleus',
+ static openApi = {
+ '/ore/sponge-versions/{pluginId}': {
+ get: {
+ summary: 'Compatible versions (plugins on Ore)',
+ description,
+ parameters: pathParams({
+ name: 'pluginId',
+ example: 'nucleus',
+ }),
},
- staticPreview: this.render({ versions: ['7.3', '6.0'] }),
- documentation,
- keywords,
},
- ]
+ }
static defaultBadgeData = {
label: 'sponge',
@@ -32,9 +34,9 @@ export default class OreSpongeVersions extends BaseOreService {
}
transform({ data }) {
- const { promoted_versions } = data
+ const { promoted_versions: promotedVersions } = data
return {
- versions: promoted_versions
+ versions: promotedVersions
.reduce((acc, { tags }) => acc.concat(tags), [])
.filter(({ name }) => name.toLowerCase() === 'sponge')
.map(({ display_data: displayData }) => displayData)
diff --git a/services/ore/ore-sponge-versions.spec.js b/services/ore/ore-sponge-versions.spec.js
index b6c7accd798ce..9489c95b2aa5b 100644
--- a/services/ore/ore-sponge-versions.spec.js
+++ b/services/ore/ore-sponge-versions.spec.js
@@ -32,7 +32,7 @@ describe('OreSpongeVersions', function () {
],
},
}),
- ]
+ ],
).expect({ versions: ['1.23', '4.56'] })
})
diff --git a/services/ore/ore-stars.service.js b/services/ore/ore-stars.service.js
index bdc226423b409..276643dd983e7 100644
--- a/services/ore/ore-stars.service.js
+++ b/services/ore/ore-stars.service.js
@@ -1,5 +1,6 @@
+import { pathParams } from '../index.js'
import { metric } from '../text-formatters.js'
-import { BaseOreService, documentation, keywords } from './ore-base.js'
+import { BaseOreService, description } from './ore-base.js'
export default class OreStars extends BaseOreService {
static category = 'rating'
@@ -9,17 +10,18 @@ export default class OreStars extends BaseOreService {
pattern: ':pluginId',
}
- static examples = [
- {
- title: 'Ore Stars',
- namedParams: {
- pluginId: 'nucleus',
+ static openApi = {
+ '/ore/stars/{pluginId}': {
+ get: {
+ summary: 'Ore Stars',
+ description,
+ parameters: pathParams({
+ name: 'pluginId',
+ example: 'nucleus',
+ }),
},
- staticPreview: this.render({ stars: 1000 }),
- documentation,
- keywords,
},
- ]
+ }
static defaultBadgeData = {
label: 'stars',
diff --git a/services/ore/ore-version.service.js b/services/ore/ore-version.service.js
index cb7fa47cc4d2f..2f1f72b417a00 100644
--- a/services/ore/ore-version.service.js
+++ b/services/ore/ore-version.service.js
@@ -1,5 +1,6 @@
+import { pathParams } from '../index.js'
import { renderVersionBadge } from '../version.js'
-import { BaseOreService, documentation, keywords } from './ore-base.js'
+import { BaseOreService, description } from './ore-base.js'
export default class OreVersion extends BaseOreService {
static category = 'version'
@@ -9,17 +10,18 @@ export default class OreVersion extends BaseOreService {
pattern: ':pluginId',
}
- static examples = [
- {
- title: 'Ore Version',
- namedParams: {
- pluginId: 'nucleus',
+ static openApi = {
+ '/ore/v/{pluginId}': {
+ get: {
+ summary: 'Ore Version',
+ description,
+ parameters: pathParams({
+ name: 'pluginId',
+ example: 'nucleus',
+ }),
},
- staticPreview: renderVersionBadge({ version: '2.2.3' }),
- documentation,
- keywords,
},
- ]
+ }
static defaultBadgeData = {
label: 'version',
@@ -33,12 +35,10 @@ export default class OreVersion extends BaseOreService {
}
transform({ data }) {
- const { promoted_versions } = data
+ const { promoted_versions: promotedVersions } = data
return {
version:
- promoted_versions.length === 0
- ? undefined
- : promoted_versions[0].version,
+ promotedVersions.length === 0 ? undefined : promotedVersions[0].version,
}
}
diff --git a/services/ore/ore-version.tester.js b/services/ore/ore-version.tester.js
index 5f3b3ba491021..4583564e873ac 100644
--- a/services/ore/ore-version.tester.js
+++ b/services/ore/ore-version.tester.js
@@ -1,10 +1,10 @@
-import { isVPlusDottedVersionAtLeastOne } from '../test-validators.js'
+import { isVPlusDottedVersionNClausesWithOptionalSuffix } from '../test-validators.js'
import { createServiceTester } from '../tester.js'
export const t = await createServiceTester()
t.create('Nucleus (pluginId nucleus)').get('/nucleus.json').expectBadge({
label: 'version',
- message: isVPlusDottedVersionAtLeastOne,
+ message: isVPlusDottedVersionNClausesWithOptionalSuffix,
})
t.create('Invalid Plugin (pluginId 1)').get('/1.json').expectBadge({
diff --git a/services/ossf-scorecard/ossf-scorecard.service.js b/services/ossf-scorecard/ossf-scorecard.service.js
new file mode 100644
index 0000000000000..1ee57a04017b9
--- /dev/null
+++ b/services/ossf-scorecard/ossf-scorecard.service.js
@@ -0,0 +1,65 @@
+import Joi from 'joi'
+import { BaseJsonService, pathParams } from '../index.js'
+import { colorScale } from '../color-formatters.js'
+
+const schema = Joi.object({
+ score: Joi.number().min(0).required(),
+}).required()
+
+const ossfScorecardColorScale = colorScale(
+ [2, 5, 8, 10],
+ ['red', 'yellow', 'yellowgreen', 'green', 'brightgreen'],
+)
+
+export default class OSSFScorecard extends BaseJsonService {
+ static category = 'analysis'
+
+ static route = { base: 'ossf-scorecard', pattern: ':host/:orgName/:repoName' }
+
+ static openApi = {
+ '/ossf-scorecard/{host}/{orgName}/{repoName}': {
+ get: {
+ summary: 'OSSF-Scorecard Score',
+ parameters: pathParams(
+ {
+ name: 'host',
+ example: 'github.com',
+ },
+ {
+ name: 'orgName',
+ example: 'rohankh532',
+ },
+ {
+ name: 'repoName',
+ example: 'org-workflow-add',
+ },
+ ),
+ },
+ },
+ }
+
+ static defaultBadgeData = { label: 'score' }
+
+ static render({ score }) {
+ return {
+ message: score,
+ color: ossfScorecardColorScale(score),
+ }
+ }
+
+ async fetch({ host, orgName, repoName }) {
+ return this._requestJson({
+ schema,
+ url: `https://api.securityscorecards.dev/projects/${host}/${orgName}/${repoName}`,
+ httpErrors: {
+ 404: 'invalid repo path',
+ },
+ })
+ }
+
+ async handle({ host, orgName, repoName }) {
+ const { score } = await this.fetch({ host, orgName, repoName })
+
+ return this.constructor.render({ score })
+ }
+}
diff --git a/services/ossf-scorecard/ossf-scorecard.tester.js b/services/ossf-scorecard/ossf-scorecard.tester.js
new file mode 100644
index 0000000000000..ba107664283ec
--- /dev/null
+++ b/services/ossf-scorecard/ossf-scorecard.tester.js
@@ -0,0 +1,19 @@
+import Joi from 'joi'
+import { createServiceTester } from '../tester.js'
+export const t = await createServiceTester()
+
+t.create('score valid')
+ .get('/github.com/rohankh532/org-workflow-add.json')
+ .expectBadge({
+ label: 'score',
+ message: Joi.number().min(0),
+ color: Joi.equal('red', 'yellow', 'yellowgreen', 'green', 'brightgreen'),
+ })
+
+t.create('score ivalid')
+ .get('/github.com/invalid-user/invalid-repo.json')
+ .expectBadge({
+ label: 'score',
+ message: 'invalid repo path',
+ color: 'red',
+ })
diff --git a/services/osslifecycle/osslifecycle-redirector.service.js b/services/osslifecycle/osslifecycle-redirector.service.js
new file mode 100644
index 0000000000000..abdefb0a91536
--- /dev/null
+++ b/services/osslifecycle/osslifecycle-redirector.service.js
@@ -0,0 +1,20 @@
+import { redirector } from '../index.js'
+
+const commonProps = {
+ category: 'other',
+ dateAdded: new Date('2024-09-02'),
+}
+
+export default [
+ redirector({
+ route: {
+ base: 'osslifecycle',
+ pattern: ':user/:repo/:branch*',
+ },
+ transformPath: () => '/osslifecycle',
+ transformQueryParams: ({ user, repo, branch }) => ({
+ file_url: `https://raw.githubusercontent.com/${user}/${repo}/${branch || 'HEAD'}/OSSMETADATA`,
+ }),
+ ...commonProps,
+ }),
+]
diff --git a/services/osslifecycle/osslifecycle-redirector.tester.js b/services/osslifecycle/osslifecycle-redirector.tester.js
new file mode 100644
index 0000000000000..e098229b436d5
--- /dev/null
+++ b/services/osslifecycle/osslifecycle-redirector.tester.js
@@ -0,0 +1,26 @@
+import queryString from 'querystring'
+import { ServiceTester } from '../tester.js'
+
+export const t = new ServiceTester({
+ id: 'osslifecycleRedirect',
+ title: 'OSSLifecycleRedirect',
+ pathPrefix: '/osslifecycle',
+})
+
+t.create('oss lifecycle redirect')
+ .get('/netflix/osstracker.svg')
+ .expectRedirect(
+ `/osslifecycle.svg?${queryString.stringify({
+ file_url:
+ 'https://raw.githubusercontent.com/netflix/osstracker/HEAD/OSSMETADATA',
+ })}`,
+ )
+
+t.create('oss lifecycle redirect (branch)')
+ .get('/netflix/osstracker/documentation.svg')
+ .expectRedirect(
+ `/osslifecycle.svg?${queryString.stringify({
+ file_url:
+ 'https://raw.githubusercontent.com/netflix/osstracker/documentation/OSSMETADATA',
+ })}`,
+ )
diff --git a/services/osslifecycle/osslifecycle.service.js b/services/osslifecycle/osslifecycle.service.js
index 114e0e09d75df..ffb547916df60 100644
--- a/services/osslifecycle/osslifecycle.service.js
+++ b/services/osslifecycle/osslifecycle.service.js
@@ -1,46 +1,46 @@
-import { BaseService, InvalidResponse } from '../index.js'
+import Joi from 'joi'
+import { url } from '../validators.js'
+import { BaseService, InvalidResponse, queryParam } from '../index.js'
-const documentation = `
-
- OSS Lifecycle is an initiative started by Netflix to classify open-source projects into lifecycles
- and clearly identify which projects are active and which ones are retired. To enable this badge,
- simply create an OSSMETADATA tagging file at the root of your GitHub repository containing a
- single line similar to the following: osslifecycle=active. Other suggested values are
- osslifecycle=maintenance and osslifecycle=archived. A working example
- can be viewed on the OSS Tracker repository .
-
+const description = `
+OSS Lifecycle is an initiative started by Netflix to classify open-source projects into lifecycles
+and clearly identify which projects are active and which ones are retired. To enable this badge,
+simply create an OSSMETADATA tagging file at the root of your repository containing a
+single line similar to the following: \`osslifecycle=active\`. Other suggested values are
+\`osslifecycle=maintenance\` and \`osslifecycle=archived\`. A working example
+can be viewed on the [OSS Tracker repository](https://github.com/Netflix/osstracker).
`
+const queryParamSchema = Joi.object({
+ file_url: url,
+}).required()
+
export default class OssTracker extends BaseService {
static category = 'other'
static route = {
- base: 'osslifecycle',
- pattern: ':user/:repo/:branch*',
+ base: '',
+ pattern: 'osslifecycle',
+ queryParamSchema,
}
- static examples = [
- {
- title: 'OSS Lifecycle',
- pattern: ':user/:repo',
- namedParams: { user: 'Teevity', repo: 'ice' },
- staticPreview: this.render({ status: 'active' }),
- keywords: ['Netflix'],
- documentation,
- },
- {
- title: 'OSS Lifecycle (branch)',
- pattern: ':user/:repo/:branch',
- namedParams: {
- user: 'Netflix',
- repo: 'osstracker',
- branch: 'documentation',
+ static openApi = {
+ '/osslifecycle': {
+ get: {
+ summary: 'OSS Lifecycle',
+ description,
+ parameters: [
+ queryParam({
+ name: 'file_url',
+ example:
+ 'https://raw.githubusercontent.com/Netflix/aws-autoscaling/master/OSSMETADATA',
+ required: true,
+ description: 'URL for the `OSSMETADATA` file',
+ }),
+ ],
},
- staticPreview: this.render({ status: 'active' }),
- keywords: ['Netflix'],
- documentation,
},
- ]
+ }
static defaultBadgeData = { label: 'oss lifecycle' }
@@ -73,17 +73,15 @@ export default class OssTracker extends BaseService {
}
}
- async fetch({ user, repo, branch }) {
+ async fetch({ fileUrl }) {
return this._request({
- url: `https://raw.githubusercontent.com/${user}/${repo}/${branch}/OSSMETADATA`,
+ url: fileUrl,
})
}
- async handle({ user, repo, branch }) {
+ async handle(pathParams, { file_url: fileUrl = '' }) {
const { buffer } = await this.fetch({
- user,
- repo,
- branch: branch || 'HEAD',
+ fileUrl,
})
try {
const status = buffer.match(/osslifecycle=([a-z]+)/im)[1]
diff --git a/services/osslifecycle/osslifecycle.tester.js b/services/osslifecycle/osslifecycle.tester.js
index 9003d2e778d73..2599d936bdbf8 100644
--- a/services/osslifecycle/osslifecycle.tester.js
+++ b/services/osslifecycle/osslifecycle.tester.js
@@ -5,14 +5,20 @@ export const t = new ServiceTester({
title: 'OSS Lifecycle',
})
-t.create('osslifecycle active status').get('/netflix/sureal.json').expectBadge({
- label: 'oss lifecycle',
- message: 'active',
- color: 'brightgreen',
-})
+t.create('osslifecycle active status')
+ .get(
+ '.json?file_url=https://raw.githubusercontent.com/Netflix/sureal/HEAD/OSSMETADATA',
+ )
+ .expectBadge({
+ label: 'oss lifecycle',
+ message: 'active',
+ color: 'brightgreen',
+ })
t.create('osslifecycle maintenance status')
- .get('/Teevity/ice.json')
+ .get(
+ '.json?file_url=https://raw.githubusercontent.com/Teevity/ice/HEAD/OSSMETADATA',
+ )
.expectBadge({
label: 'oss lifecycle',
message: 'maintenance',
@@ -20,43 +26,48 @@ t.create('osslifecycle maintenance status')
})
t.create('osslifecycle archived status')
- .get('/Netflix/rx-aws-java-sdk.json')
+ .get(
+ '.json?file_url=https://raw.githubusercontent.com/Netflix/rx-aws-java-sdk/HEAD/OSSMETADATA',
+ )
.expectBadge({
label: 'oss lifecycle',
message: 'archived',
color: 'red',
})
-t.create('osslifecycle other status').get('/Netflix/metacat.json').expectBadge({
- label: 'oss lifecycle',
- message: 'private',
- color: 'lightgrey',
-})
-
-t.create('osslifecycle status (branch)')
- .get('/Netflix/osstracker/documentation.json')
+t.create('osslifecycle other status')
+ .get(
+ '.json?file_url=https://raw.githubusercontent.com/Netflix/metacat/HEAD/OSSMETADATA',
+ )
.expectBadge({
label: 'oss lifecycle',
- message: 'active',
+ message: 'private',
+ color: 'lightgrey',
})
t.create('oss metadata in unexpected format')
- .get('/some-user/some-project.json')
+ .get(
+ '.json?file_url=https://raw.githubusercontent.com/some-user/some-project/HEAD/OSSMETADATA',
+ )
.intercept(
nock =>
nock('https://raw.githubusercontent.com')
.get('/some-user/some-project/HEAD/OSSMETADATA')
- .reply(200, `wrongkey=active`),
+ .reply(200, 'wrongkey=active'),
{
'Content-Type': 'text/plain;charset=UTF-8',
- }
+ },
)
.expectBadge({
label: 'oss lifecycle',
message: 'metadata in unexpected format',
})
-t.create('oss metadata not found').get('/PyvesB/empty-repo.json').expectBadge({
- label: 'oss lifecycle',
- message: 'not found',
-})
+t.create('oss metadata not found')
+ .get(
+ '.json?file_url=https://raw.githubusercontent.com/PyvesB/empty-repo/HEAD/OSSMETADATA',
+ )
+ .expectBadge({
+ label: 'oss lifecycle',
+ message: 'not found',
+ })
diff --git a/services/package-json-helpers.js b/services/package-json-helpers.js
index f8cca7cda261d..1c913e1362e56 100644
--- a/services/package-json-helpers.js
+++ b/services/package-json-helpers.js
@@ -1,14 +1,31 @@
+/**
+ * Common functions and utilities for tasks related to package.json
+ *
+ * @module
+ */
+
import Joi from 'joi'
import { InvalidParameter } from './index.js'
+/**
+ * Joi schema for validating dependency map.
+ *
+ * @type {Joi}
+ */
const isDependencyMap = Joi.object()
.pattern(
/./,
// This accepts a semver range, a URL, and many other possible values.
- Joi.string().min(1).required()
+ Joi.string().min(1).required(),
)
.default({})
+/**
+ * Joi schema for validating package json object.
+ * Checks if the object has all the dependency types and the dependency types are valid.
+ *
+ * @type {Joi}
+ */
const isPackageJsonWithDependencies = Joi.object({
dependencies: isDependencyMap,
devDependencies: isDependencyMap,
@@ -16,6 +33,20 @@ const isPackageJsonWithDependencies = Joi.object({
optionalDependencies: isDependencyMap,
}).required()
+/**
+ * Determines the dependency version based on the dependency type.
+ *
+ * @param {object} attrs - Refer to individual attributes
+ * @param {string} attrs.kind - Wanted dependency type, defaults to prod
+ * @param {string} attrs.wantedDependency - Name of the wanted dependency
+ * @param {object} attrs.dependencies - Map of dependencies
+ * @param {object} attrs.devDependencies - Map of dev dependencies
+ * @param {object} attrs.peerDependencies - Map of peer dependencies
+ * @param {object} attrs.optionalDependencies - Map of optional dependencies
+ * @throws {string} - Error message if unknown dependency type provided
+ * @throws {InvalidParameter} - Error if wanted dependency is not present
+ * @returns {string} Semver range of the wanted dependency (eg. ~2.1.6 or >=3.0.0 or <4.0.0)
+ */
function getDependencyVersion({
kind = 'prod',
wantedDependency,
@@ -41,7 +72,7 @@ function getDependencyVersion({
})
}
- return { range }
+ return range
}
export { isDependencyMap, isPackageJsonWithDependencies, getDependencyVersion }
diff --git a/services/package-json-helpers.spec.js b/services/package-json-helpers.spec.js
index 4cdd60741b4a4..6180eff24c989 100644
--- a/services/package-json-helpers.spec.js
+++ b/services/package-json-helpers.spec.js
@@ -1,16 +1,15 @@
import { test, given } from 'sazerac'
import { getDependencyVersion } from './package-json-helpers.js'
-describe('Contributor count helpers', function () {
+describe('Package json helpers', function () {
test(getDependencyVersion, () => {
given({
wantedDependency: 'left-pad',
dependencies: { 'left-pad': '~1.2.3' },
devDependencies: {},
peerDependencies: {},
- }).expect({
- range: '~1.2.3',
- })
+ }).expect('~1.2.3')
+
given({
kind: 'dev',
wantedDependency: 'left-pad',
@@ -18,24 +17,23 @@ describe('Contributor count helpers', function () {
devDependencies: {},
peerDependencies: {},
}).expectError('Invalid Parameter')
+
given({
kind: 'dev',
wantedDependency: 'left-pad',
dependencies: {},
devDependencies: { 'left-pad': '~1.2.3' },
peerDependencies: {},
- }).expect({
- range: '~1.2.3',
- })
+ }).expect('~1.2.3')
+
given({
kind: 'peer',
wantedDependency: 'left-pad',
dependencies: {},
devDependencies: {},
peerDependencies: { 'left-pad': '~1.2.3' },
- }).expect({
- range: '~1.2.3',
- })
+ }).expect('~1.2.3')
+
given({
kind: 'notreal',
wantedDependency: 'left-pad',
diff --git a/services/packagecontrol/packagecontrol.service.js b/services/packagecontrol/packagecontrol.service.js
index 8189054d8b7fa..442d266dbf34d 100644
--- a/services/packagecontrol/packagecontrol.service.js
+++ b/services/packagecontrol/packagecontrol.service.js
@@ -1,10 +1,7 @@
import Joi from 'joi'
-import { metric } from '../text-formatters.js'
-import { downloadCount } from '../color-formatters.js'
+import { renderDownloadsBadge } from '../downloads.js'
import { nonNegativeInteger } from '../validators.js'
-import { BaseJsonService } from '../index.js'
-
-const keywords = ['sublime', 'sublimetext', 'packagecontrol']
+import { BaseJsonService, pathParams } from '../index.js'
const schema = Joi.object({
installs: Joi.object({
@@ -14,104 +11,98 @@ const schema = Joi.object({
.items(
Joi.object({
totals: Joi.array().items(nonNegativeInteger).required(),
- }).required()
+ }).required(),
)
.required(),
}).required(),
}).required(),
})
-function DownloadsForInterval(interval) {
- const { base, messageSuffix, transform, name } = {
- day: {
- base: 'packagecontrol/dd',
- messageSuffix: '/day',
- transform: resp => {
- const platforms = resp.installs.daily.data
- let downloads = 0
- platforms.forEach(platform => {
- // use the downloads from yesterday
- downloads += platform.totals[1]
- })
- return downloads
- },
- name: 'PackageControlDownloadsDay',
+const intervalMap = {
+ dd: {
+ label: 'day',
+ transform: resp => {
+ const platforms = resp.installs.daily.data
+ let downloads = 0
+ platforms.forEach(platform => {
+ // use the downloads from yesterday
+ downloads += platform.totals[1]
+ })
+ return downloads
},
- week: {
- base: 'packagecontrol/dw',
- messageSuffix: '/week',
- transform: resp => {
- const platforms = resp.installs.daily.data
- let downloads = 0
- platforms.forEach(platform => {
- // total for the first 7 days
- for (let i = 0; i < 7; i++) {
- downloads += platform.totals[i]
- }
- })
- return downloads
- },
- name: 'PackageControlDownloadsWeek',
+ },
+ dw: {
+ label: 'week',
+ transform: resp => {
+ const platforms = resp.installs.daily.data
+ let downloads = 0
+ platforms.forEach(platform => {
+ // total for the first 7 days
+ for (let i = 0; i < 7; i++) {
+ downloads += platform.totals[i]
+ }
+ })
+ return downloads
},
- month: {
- base: 'packagecontrol/dm',
- messageSuffix: '/month',
- transform: resp => {
- const platforms = resp.installs.daily.data
- let downloads = 0
- platforms.forEach(platform => {
- // total for the first 30 days
- for (let i = 0; i < 30; i++) {
- downloads += platform.totals[i]
- }
- })
- return downloads
- },
- name: 'PackageControlDownloadsMonth',
+ },
+ dm: {
+ label: 'month',
+ transform: resp => {
+ const platforms = resp.installs.daily.data
+ let downloads = 0
+ platforms.forEach(platform => {
+ // total for the first 30 days
+ for (let i = 0; i < 30; i++) {
+ downloads += platform.totals[i]
+ }
+ })
+ return downloads
},
- total: {
- base: 'packagecontrol/dt',
- messageSuffix: '',
- transform: resp => resp.installs.total,
- name: 'PackageControlDownloadsTotal',
- },
- }[interval]
-
- return class PackageControlDownloads extends BaseJsonService {
- static name = name
+ },
+ dt: {
+ transform: resp => resp.installs.total,
+ },
+}
- static category = 'downloads'
+export default class PackageControlDownloads extends BaseJsonService {
+ static category = 'downloads'
- static route = { base, pattern: ':packageName' }
+ static route = {
+ base: 'packagecontrol',
+ pattern: ':interval(dd|dw|dm|dt)/:packageName',
+ }
- static examples = [
- {
- title: 'Package Control',
- namedParams: { packageName: 'GitGutter' },
- staticPreview: this.render({ downloads: 12000 }),
- keywords,
+ static openApi = {
+ '/packagecontrol/{interval}/{packageName}': {
+ get: {
+ summary: 'Package Control Downloads',
+ description:
+ 'Package Control is a package registry for Sublime Text packages',
+ parameters: pathParams(
+ {
+ name: 'interval',
+ example: 'dt',
+ schema: { type: 'string', enum: this.getEnum('interval') },
+ description: 'Daily, Weekly, Monthly, or Total downloads',
+ },
+ { name: 'packageName', example: 'GitGutter' },
+ ),
},
- ]
-
- static defaultBadgeData = { label: 'downloads' }
+ },
+ }
- static render({ downloads }) {
- return {
- message: `${metric(downloads)}${messageSuffix}`,
- color: downloadCount(downloads),
- }
- }
+ static defaultBadgeData = { label: 'downloads' }
- async fetch({ packageName }) {
- const url = `https://packagecontrol.io/packages/${packageName}.json`
- return this._requestJson({ schema, url })
- }
+ async fetch({ packageName }) {
+ const url = `https://packagecontrol.io/packages/${packageName}.json`
+ return this._requestJson({ schema, url })
+ }
- async handle({ packageName }) {
- const data = await this.fetch({ packageName })
- return this.constructor.render({ downloads: transform(data) })
- }
+ async handle({ interval, packageName }) {
+ const data = await this.fetch({ packageName })
+ return renderDownloadsBadge({
+ downloads: intervalMap[interval].transform(data),
+ interval: intervalMap[interval].label,
+ })
}
}
-
-export default ['day', 'week', 'month', 'total'].map(DownloadsForInterval)
diff --git a/services/packagist/packagist-base.js b/services/packagist/packagist-base.js
index 083de17bdf102..e3e18fb5519e5 100644
--- a/services/packagist/packagist-base.js
+++ b/services/packagist/packagist-base.js
@@ -1,29 +1,26 @@
import Joi from 'joi'
-import { BaseJsonService } from '../index.js'
-
-const packageSchema = Joi.object()
- .pattern(
- /^/,
- Joi.object({
- 'default-branch': Joi.bool(),
- version: Joi.string(),
- require: Joi.object({
- php: Joi.string(),
- }),
- }).required()
- )
- .required()
+import { BaseJsonService, NotFound } from '../index.js'
+import { isStable, latest } from '../php-version.js'
+
+const packageSchema = Joi.array().items(
+ Joi.object({
+ version: Joi.string().required(),
+ require: Joi.alternatives(
+ Joi.object().pattern(Joi.string(), Joi.string()).required(),
+ Joi.string().valid('__unset'),
+ ),
+ }),
+)
const allVersionsSchema = Joi.object({
packages: Joi.object().pattern(/^/, packageSchema).required(),
}).required()
-const keywords = ['PHP']
class BasePackagistService extends BaseJsonService {
/**
* Default fetch method.
*
- * This method utilize composer metadata API which
+ * This method utilizes composer metadata API which
* "... is the preferred way to access the data as it is always up to date,
* and dumped to static files so it is very efficient on our end." (comment from official documentation).
* For more information please refer to https://packagist.org/apidoc#get-package-data.
@@ -35,8 +32,37 @@ class BasePackagistService extends BaseJsonService {
* @param {string} attrs.server URL for the packagist registry server (Optional)
* @returns {object} Parsed response
*/
- async fetch({ user, repo, schema, server = 'https://packagist.org' }) {
- const url = `${server}/p/${user.toLowerCase()}/${repo.toLowerCase()}.json`
+ async fetch({ user, repo, schema, server = 'https://repo.packagist.org' }) {
+ const url = `${server}/p2/${user.toLowerCase()}/${repo.toLowerCase()}.json`
+
+ return this._requestJson({
+ schema,
+ url,
+ })
+ }
+
+ /**
+ * Fetch dev releases method.
+ *
+ * This method utilizes composer metadata API which
+ * "... is the preferred way to access the data as it is always up to date,
+ * and dumped to static files so it is very efficient on our end." (comment from official documentation).
+ * For more information please refer to https://packagist.org/apidoc#get-package-data.
+ *
+ * @param {object} attrs Refer to individual attrs
+ * @param {string} attrs.user package user
+ * @param {string} attrs.repo package repository
+ * @param {Joi} attrs.schema Joi schema to validate the response transformed to JSON
+ * @param {string} attrs.server URL for the packagist registry server (Optional)
+ * @returns {object} Parsed response
+ */
+ async fetchDev({
+ user,
+ repo,
+ schema,
+ server = 'https://repo.packagist.org',
+ }) {
+ const url = `${server}/p2/${user.toLowerCase()}/${repo.toLowerCase()}~dev.json`
return this._requestJson({
schema,
@@ -73,36 +99,94 @@ class BasePackagistService extends BaseJsonService {
})
}
- getDefaultBranch(json, user, repo) {
- const packageName = this.getPackageName(user, repo)
- return Object.values(json.packages[packageName]).find(
- b => b['default-branch'] === true
- )
- }
-
getPackageName(user, repo) {
return `${user.toLowerCase()}/${repo.toLowerCase()}`
}
+
+ /**
+ * Extract the array of minified versions of the given packageName,
+ * expand them back to their original format then return.
+ *
+ * @param {object} json The response of Packagist v2 API.
+ * @param {string} packageName The package name.
+ *
+ * @returns {object[]} An array of version metadata object.
+ *
+ * @see https://github.com/composer/metadata-minifier/blob/c549d23829536f0d0e984aaabbf02af91f443207/src/MetadataMinifier.php#L16-L46
+ */
+ static expandPackageVersions(json, packageName) {
+ const versions = json.packages[packageName]
+ const expanded = []
+ let expandedVersion = null
+
+ for (const i in versions) {
+ const versionData = versions[i]
+ if (!expandedVersion) {
+ expandedVersion = { ...versionData }
+ expanded.push(expandedVersion)
+ continue
+ }
+
+ expandedVersion = { ...expandedVersion, ...versionData }
+ for (const key in expandedVersion) {
+ if (expandedVersion[key] === '__unset') {
+ delete expandedVersion[key]
+ }
+ }
+ expanded.push(expandedVersion)
+ }
+
+ return expanded
+ }
+
+ /**
+ * Find the object representation of the latest release.
+ *
+ * @param {object[]} versions An array of object representing a version.
+ * @param {boolean} includePrereleases Includes pre-release semver for the search.
+ *
+ * @returns {object} The object of the latest version.
+ * @throws {NotFound} Thrown if there is no item from the version array.
+ */
+ findLatestRelease(versions, includePrereleases = false) {
+ // Find the latest version string, if not found, throw NotFound.
+ const versionStrings = versions
+ .filter(
+ version =>
+ typeof version.version === 'string' ||
+ version.version instanceof String,
+ )
+ .map(version => version.version)
+ if (versionStrings.length < 1) {
+ throw new NotFound({ prettyMessage: 'no released version found' })
+ }
+
+ let release = latest(versionStrings)
+ if (!includePrereleases) {
+ release = latest(versionStrings.filter(isStable)) || release
+ }
+ return versions.filter(version => version.version === release)[0]
+ }
}
+const description = `
+Packagist is a registry for PHP packages which can be installed with Composer.
+`
+
const customServerDocumentationFragment = `
-
- Note that only network-accessible packagist.org and other self-hosted Packagist instances are supported.
-
- `
+Note that only network-accessible packagist.org and other self-hosted Packagist instances are supported.
+`
const cacheDocumentationFragment = `
-
- Displayed data may be slightly outdated.
- Due to performance reasons, data fetched from packagist JSON API is cached for twelve hours on packagist infrastructure.
- For more information please refer to official packagist documentation .
-
- `
+Displayed data may be slightly outdated.
+Due to performance reasons, data fetched from packagist JSON API is cached for twelve hours on packagist infrastructure.
+For more information please refer to official packagist documentation .
+`
export {
allVersionsSchema,
- keywords,
BasePackagistService,
+ description,
customServerDocumentationFragment,
cacheDocumentationFragment,
}
diff --git a/services/packagist/packagist-base.spec.js b/services/packagist/packagist-base.spec.js
new file mode 100644
index 0000000000000..ba97113327af2
--- /dev/null
+++ b/services/packagist/packagist-base.spec.js
@@ -0,0 +1,77 @@
+import { strict as assert } from 'assert'
+import { describe, it } from 'mocha'
+import { BasePackagistService } from './packagist-base.js'
+
+// @reference: https://github.com/composer/metadata-minifier/blob/c549d23829536f0d0e984aaabbf02af91f443207/tests/MetadataMinifierTest.php#L36-L40
+const minifiedSample = [
+ {
+ name: 'foo/bar',
+ version: '2.0.0',
+ version_normalized: '2.0.0.0',
+ type: 'library',
+ scripts: {
+ foo: 'bar',
+ },
+ license: ['MIT'],
+ },
+ {
+ version: '1.2.0',
+ version_normalized: '1.2.0.0',
+ license: ['GPL'],
+ homepage: 'https://example.org',
+ scripts: '__unset',
+ },
+ {
+ version: '1.0.0',
+ version_normalized: '1.0.0.0',
+ homepage: '__unset',
+ },
+]
+
+const expandedSample = [
+ {
+ name: 'foo/bar',
+ version: '2.0.0',
+ version_normalized: '2.0.0.0',
+ type: 'library',
+ scripts: {
+ foo: 'bar',
+ },
+ license: ['MIT'],
+ },
+ {
+ name: 'foo/bar',
+ version: '1.2.0',
+ version_normalized: '1.2.0.0',
+ type: 'library',
+ license: ['GPL'],
+ homepage: 'https://example.org',
+ },
+ {
+ name: 'foo/bar',
+ version: '1.0.0',
+ version_normalized: '1.0.0.0',
+ type: 'library',
+ license: ['GPL'],
+ },
+]
+
+describe('BasePackagistService', () => {
+ describe('expandPackageVersions', () => {
+ const expanded = BasePackagistService.expandPackageVersions(
+ {
+ packages: {
+ 'foobar/foobar': minifiedSample,
+ },
+ },
+ 'foobar/foobar',
+ )
+ it('should expand the minified package array to match the expanded sample', () => {
+ assert.deepStrictEqual(
+ expanded,
+ expandedSample,
+ 'The expanded array should match the sample',
+ )
+ })
+ })
+})
diff --git a/services/packagist/packagist-dependency-version.service.js b/services/packagist/packagist-dependency-version.service.js
new file mode 100644
index 0000000000000..7862aad4ee722
--- /dev/null
+++ b/services/packagist/packagist-dependency-version.service.js
@@ -0,0 +1,177 @@
+import Joi from 'joi'
+import { optionalUrl } from '../validators.js'
+import { NotFound, pathParam, queryParam } from '../index.js'
+import {
+ allVersionsSchema,
+ BasePackagistService,
+ customServerDocumentationFragment,
+ description,
+} from './packagist-base.js'
+
+const queryParamSchema = Joi.object({
+ server: optionalUrl,
+ version: Joi.string(),
+}).required()
+
+export default class PackagistDependencyVersion extends BasePackagistService {
+ static category = 'platform-support'
+
+ static route = {
+ base: 'packagist/dependency-v',
+ pattern: ':user/:repo/:dependency+',
+ queryParamSchema,
+ }
+
+ static openApi = {
+ '/packagist/dependency-v/{user}/{repo}/{dependency}': {
+ get: {
+ summary: 'Packagist Dependency Version',
+ description,
+ parameters: [
+ pathParam({
+ name: 'user',
+ example: 'guzzlehttp',
+ }),
+ pathParam({
+ name: 'repo',
+ example: 'guzzle',
+ }),
+ pathParam({
+ name: 'dependency',
+ example: 'php',
+ description:
+ '`dependency` can be a PHP package like `twig/twig` or a platform/extension like `php` or `ext-xml`',
+ }),
+ queryParam({
+ name: 'version',
+ example: 'v2.8.0',
+ }),
+ queryParam({
+ name: 'server',
+ description: customServerDocumentationFragment,
+ example: 'https://packagist.org',
+ }),
+ ],
+ },
+ },
+ }
+
+ static defaultBadgeData = {
+ label: 'dependency version',
+ color: 'blue',
+ }
+
+ static render({ dependency, dependencyVersion }) {
+ return {
+ label: dependency,
+ message: dependencyVersion,
+ }
+ }
+
+ async getDependencyVersion({
+ json,
+ user,
+ repo,
+ dependency,
+ version = '',
+ server,
+ }) {
+ let packageVersion
+ const versions = BasePackagistService.expandPackageVersions(
+ json,
+ this.getPackageName(user, repo),
+ )
+
+ if (version === '') {
+ packageVersion = this.findLatestRelease(versions)
+ } else {
+ try {
+ packageVersion = await this.findSpecifiedVersion(
+ versions,
+ user,
+ repo,
+ version,
+ server,
+ )
+ } catch (e) {
+ packageVersion = null
+ }
+ }
+
+ if (!packageVersion) {
+ throw new NotFound({ prettyMessage: 'invalid version' })
+ }
+
+ if (!packageVersion.require) {
+ throw new NotFound({ prettyMessage: 'version requirement not found' })
+ }
+
+ // All dependencies' names in the 'require' section from the response should be lowercase,
+ // so that we can compare lowercase name of the dependency given via url by the user.
+ Object.keys(packageVersion.require).forEach(dependency => {
+ packageVersion.require[dependency.toLowerCase()] =
+ packageVersion.require[dependency]
+ })
+
+ const depLowerCase = dependency.toLowerCase()
+
+ if (!packageVersion.require[depLowerCase]) {
+ throw new NotFound({ prettyMessage: 'version requirement not found' })
+ }
+
+ return { dependencyVersion: packageVersion.require[depLowerCase] }
+ }
+
+ async handle({ user, repo, dependency }, { server, version = '' }) {
+ const allData = await this.fetch({
+ user,
+ repo,
+ schema: allVersionsSchema,
+ server,
+ })
+
+ const { dependencyVersion } = await this.getDependencyVersion({
+ json: allData,
+ user,
+ repo,
+ dependency,
+ version,
+ server,
+ })
+
+ return this.constructor.render({
+ dependency,
+ dependencyVersion,
+ })
+ }
+
+ findVersionIndex(json, version) {
+ return json.findIndex(v => v.version === version)
+ }
+
+ async findSpecifiedVersion(json, user, repo, version, server) {
+ let release
+
+ if ((release = json[this.findVersionIndex(json, version)])) {
+ return release
+ } else {
+ try {
+ const allData = await this.fetchDev({
+ user,
+ repo,
+ schema: allVersionsSchema,
+ server,
+ })
+
+ const versions = BasePackagistService.expandPackageVersions(
+ allData,
+ this.getPackageName(user, repo),
+ )
+
+ return versions[this.findVersionIndex(versions, version)]
+ } catch (e) {
+ return release
+ }
+ }
+ }
+}
diff --git a/services/packagist/packagist-dependency-version.spec.js b/services/packagist/packagist-dependency-version.spec.js
new file mode 100644
index 0000000000000..1f1cde2eafccf
--- /dev/null
+++ b/services/packagist/packagist-dependency-version.spec.js
@@ -0,0 +1,92 @@
+import { expect, use } from 'chai'
+import chaiAsPromised from 'chai-as-promised'
+import PackagistDependencyVersion from './packagist-dependency-version.service.js'
+use(chaiAsPromised)
+
+describe('PackagistDependencyVersion', function () {
+ const fullPackagistJson = {
+ packages: {
+ 'frodo/the-one-package': [
+ {
+ version: 'v3.0.0',
+ require: { php: '^7.4 || 8', 'twig/twig': '~1.28|~2.0' },
+ },
+ {
+ version: 'v2.5.0',
+ require: '__unset',
+ },
+ {
+ version: 'v2.4.0',
+ },
+ {
+ version: 'v2.0.0',
+ require: { php: '^7.2', 'twig/twig': '~1.20|~1.30' },
+ },
+ {
+ version: 'v1.0.0',
+ require: { php: '^5.6 || ^7', 'twig/twig': '~1.10|~1.0' },
+ },
+ ],
+ },
+ }
+
+ it('should throw NotFound when package version is missing in the response', async function () {
+ await expect(
+ PackagistDependencyVersion.prototype.getDependencyVersion({
+ json: fullPackagistJson,
+ user: 'frodo',
+ repo: 'the-one-package',
+ version: 'v4.0.0',
+ }),
+ ).to.be.rejectedWith('invalid version')
+ })
+
+ it('should throw NotFound when `require` section is missing in the response', async function () {
+ await expect(
+ PackagistDependencyVersion.prototype.getDependencyVersion({
+ json: fullPackagistJson,
+ user: 'frodo',
+ repo: 'the-one-package',
+ version: 'v2.4.0',
+ }),
+ ).to.be.rejectedWith('version requirement not found')
+ })
+
+ it('should throw NotFound when `require` section in the response has the value of __unset (thank you, Packagist API :p)', async function () {
+ await expect(
+ PackagistDependencyVersion.prototype.getDependencyVersion({
+ json: fullPackagistJson,
+ user: 'frodo',
+ repo: 'the-one-package',
+ version: 'v2.5.0',
+ }),
+ ).to.be.rejectedWith('version requirement not found')
+ })
+
+ it('should return dependency version for the default release', async function () {
+ expect(
+ await PackagistDependencyVersion.prototype.getDependencyVersion({
+ json: fullPackagistJson,
+ user: 'frodo',
+ repo: 'the-one-package',
+ dependency: 'twig/twig',
+ }),
+ )
+ .to.have.property('dependencyVersion')
+ .that.equals('~1.28|~2.0')
+ })
+
+ it('should return dependency version for the specified release', async function () {
+ expect(
+ await PackagistDependencyVersion.prototype.getDependencyVersion({
+ json: fullPackagistJson,
+ user: 'frodo',
+ repo: 'the-one-package',
+ version: 'v2.0.0',
+ dependency: 'twig/twig',
+ }),
+ )
+ .to.have.property('dependencyVersion')
+ .that.equals('~1.20|~1.30')
+ })
+})
diff --git a/services/packagist/packagist-dependency-version.tester.js b/services/packagist/packagist-dependency-version.tester.js
new file mode 100644
index 0000000000000..3509ae484b5fa
--- /dev/null
+++ b/services/packagist/packagist-dependency-version.tester.js
@@ -0,0 +1,61 @@
+import { isComposerVersion } from '../test-validators.js'
+import { createServiceTester } from '../tester.js'
+export const t = await createServiceTester()
+
+t.create('gets the package version')
+ .get('/symfony/symfony/twig/twig.json')
+ .expectBadge({ label: 'twig/twig', message: isComposerVersion })
+
+t.create('incorrect dependency name')
+ .get('/symfony/symfony/twig/twiiiiiiig.json')
+ .expectBadge({
+ label: 'dependency version',
+ message: 'version requirement not found',
+ })
+
+t.create('missing vendor of dependency')
+ .get('/symfony/symfony/twig.json')
+ .expectBadge({
+ label: 'dependency version',
+ message: 'version requirement not found',
+ })
+
+t.create('gets the package version + specified symfony version')
+ .get('/symfony/symfony/twig/twig.json?version=v3.2.8')
+ .expectBadge({ label: 'twig/twig', message: isComposerVersion })
+
+t.create('gets the package version + valid custom server')
+ .get('/symfony/symfony/twig/twig.json?server=https://packagist.org')
+ .expectBadge({ label: 'twig/twig', message: isComposerVersion })
+
+t.create('invalid custom server')
+ .get('/symfony/symfony/twig/twig.json?server=https://packagisttttttt.org')
+ .expectBadge({
+ label: 'dependency version',
+ message: 'inaccessible',
+ })
+
+t.create('incorrect symfony version')
+ .get('/symfony/symfony/twig/twig.json?version=v3.2.80000')
+ .expectBadge({
+ label: 'dependency version',
+ message: 'invalid version',
+ })
+
+t.create('gets the package version - dependency does not need the vendor')
+ .get('/symfony/symfony/ext-xml.json')
+ .expectBadge({ label: 'ext-xml', message: isComposerVersion })
+
+t.create('package with no requirements')
+ .get('/bpampuch/pdfmake/twig/twig.json')
+ .expectBadge({
+ label: 'dependency version',
+ message: 'version requirement not found',
+ })
+
+t.create('package with no twig/twig version requirement')
+ .get('/raulfraile/ladybug-theme-modern/twig/twig.json')
+ .expectBadge({
+ label: 'dependency version',
+ message: 'version requirement not found',
+ })
diff --git a/services/packagist/packagist-downloads.service.js b/services/packagist/packagist-downloads.service.js
index 5d8f133b026ec..4285d40c704e7 100644
--- a/services/packagist/packagist-downloads.service.js
+++ b/services/packagist/packagist-downloads.service.js
@@ -1,26 +1,25 @@
import Joi from 'joi'
-import { metric } from '../text-formatters.js'
-import { downloadCount } from '../color-formatters.js'
+import { renderDownloadsBadge } from '../downloads.js'
import { optionalUrl } from '../validators.js'
+import { pathParam, queryParam } from '../index.js'
import {
- keywords,
BasePackagistService,
customServerDocumentationFragment,
cacheDocumentationFragment,
+ description,
} from './packagist-base.js'
const periodMap = {
dm: {
field: 'monthly',
- suffix: '/month',
+ interval: 'month',
},
dd: {
field: 'daily',
- suffix: '/day',
+ interval: 'day',
},
dt: {
field: 'total',
- suffix: '',
},
}
@@ -43,61 +42,54 @@ export default class PackagistDownloads extends BasePackagistService {
static route = {
base: 'packagist',
- pattern: ':interval(dm|dd|dt)/:user/:repo',
+ pattern: ':interval(dd|dm|dt)/:user/:repo',
queryParamSchema,
}
- static examples = [
- {
- title: 'Packagist Downloads',
- namedParams: {
- interval: 'dm',
- user: 'doctrine',
- repo: 'orm',
+ static openApi = {
+ '/packagist/{interval}/{user}/{repo}': {
+ get: {
+ summary: 'Packagist Downloads',
+ description: description + cacheDocumentationFragment,
+ parameters: [
+ pathParam({
+ name: 'interval',
+ example: 'dm',
+ schema: { type: 'string', enum: this.getEnum('interval') },
+ description: 'Daily, Monthly, or Total downloads',
+ }),
+ pathParam({
+ name: 'user',
+ example: 'guzzlehttp',
+ }),
+ pathParam({
+ name: 'repo',
+ example: 'guzzle',
+ }),
+ queryParam({
+ name: 'server',
+ description: customServerDocumentationFragment,
+ example: 'https://packagist.org',
+ }),
+ ],
},
- staticPreview: this.render({
- downloads: 1000000,
- interval: 'dm',
- }),
- keywords,
- documentation: cacheDocumentationFragment,
},
- {
- title: 'Packagist Downloads (custom server)',
- namedParams: {
- interval: 'dm',
- user: 'doctrine',
- repo: 'orm',
- },
- staticPreview: this.render({
- downloads: 1000000,
- interval: 'dm',
- }),
- queryParams: { server: 'https://packagist.org' },
- keywords,
- documentation:
- customServerDocumentationFragment + cacheDocumentationFragment,
- },
- ]
-
- static defaultBadgeData = {
- label: 'downloads',
}
- static render({ downloads, interval }) {
- return {
- message: metric(downloads) + periodMap[interval].suffix,
- color: downloadCount(downloads),
- }
- }
+ static defaultBadgeData = { label: 'downloads' }
- async handle({ interval, user, repo }, { server }) {
+ async handle({ interval: period, user, repo }, { server }) {
const {
package: { downloads },
- } = await this.fetchByJsonAPI({ user, repo, schema, server })
-
- return this.constructor.render({
- downloads: downloads[periodMap[interval].field],
+ } = await this.fetchByJsonAPI({
+ user,
+ repo,
+ schema,
+ server,
+ })
+ const { interval, field } = periodMap[period]
+ return renderDownloadsBadge({
+ downloads: downloads[field],
interval,
})
}
diff --git a/services/packagist/packagist-downloads.tester.js b/services/packagist/packagist-downloads.tester.js
index ee76c04e3f3cb..fa0a255ae21fb 100644
--- a/services/packagist/packagist-downloads.tester.js
+++ b/services/packagist/packagist-downloads.tester.js
@@ -17,7 +17,7 @@ t.create('daily downloads (valid, no package version specified, custom server)')
})
t.create(
- 'daily downloads (invalid, no package version specified, invalid custom server)'
+ 'daily downloads (invalid, no package version specified, invalid custom server)',
)
.get('/dd/doctrine/orm.json?server=https%3A%2F%2Fpackagist.com')
.expectBadge({
@@ -33,7 +33,7 @@ t.create('monthly downloads (valid, no package version specified)')
})
t.create(
- 'monthly downloads (valid, no package version specified, custom server)'
+ 'monthly downloads (valid, no package version specified, custom server)',
)
.get('/dm/doctrine/orm.json?server=https%3A%2F%2Fpackagist.org')
.expectBadge({
@@ -42,7 +42,7 @@ t.create(
})
t.create(
- 'monthly downloads (valid, no package version specified, invalid custom server)'
+ 'monthly downloads (valid, no package version specified, invalid custom server)',
)
.get('/dm/doctrine/orm.json?server=https%3A%2F%2Fpackagist.com')
.expectBadge({
@@ -65,7 +65,7 @@ t.create('total downloads (valid, no package version specified, custom server)')
})
t.create(
- 'total downloads (valid, no package version specified, invalid custom server)'
+ 'total downloads (valid, no package version specified, invalid custom server)',
)
.get('/dt/doctrine/orm.json?server=https%3A%2F%2Fpackagist.com')
.expectBadge({
@@ -87,7 +87,7 @@ t.create('monthly downloads (invalid, package version in request)')
.expectBadge({ label: '404', message: 'badge not found' })
t.create(
- 'monthly downloads (invalid, package version in request, custom server)'
+ 'monthly downloads (invalid, package version in request, custom server)',
)
.get('/dm/symfony/symfony/v2.8.0.json?server=https%3A%2F%2Fpackagist.org')
.expectBadge({ label: '404', message: 'badge not found' })
diff --git a/services/packagist/packagist-license.service.js b/services/packagist/packagist-license.service.js
index 241d9a291908e..b3c644d7e55f4 100644
--- a/services/packagist/packagist-license.service.js
+++ b/services/packagist/packagist-license.service.js
@@ -1,20 +1,19 @@
import Joi from 'joi'
import { renderLicenseBadge } from '../licenses.js'
import { optionalUrl } from '../validators.js'
-import { NotFound } from '../index.js'
+import { NotFound, pathParam, queryParam } from '../index.js'
import {
- keywords,
BasePackagistService,
customServerDocumentationFragment,
+ description,
} from './packagist-base.js'
-const packageSchema = Joi.object()
- .pattern(
- /^/,
+const packageSchema = Joi.array()
+ .items(
Joi.object({
- 'default-branch': Joi.bool(),
- license: Joi.array().required(),
- }).required()
+ version: Joi.string(),
+ license: Joi.array(),
+ }).required(),
)
.required()
@@ -35,39 +34,56 @@ export default class PackagistLicense extends BasePackagistService {
queryParamSchema,
}
- static examples = [
- {
- title: 'Packagist License',
- namedParams: { user: 'doctrine', repo: 'orm' },
- staticPreview: renderLicenseBadge({ license: 'MIT' }),
- keywords,
+ static openApi = {
+ '/packagist/l/{user}/{repo}': {
+ get: {
+ summary: 'Packagist License',
+ description,
+ parameters: [
+ pathParam({
+ name: 'user',
+ example: 'guzzlehttp',
+ }),
+ pathParam({
+ name: 'repo',
+ example: 'guzzle',
+ }),
+ queryParam({
+ name: 'server',
+ description: customServerDocumentationFragment,
+ example: 'https://packagist.org',
+ }),
+ ],
+ },
},
- {
- title: 'Packagist License (custom server)',
- namedParams: { user: 'doctrine', repo: 'orm' },
- queryParams: { server: 'https://packagist.org' },
- staticPreview: renderLicenseBadge({ license: 'MIT' }),
- keywords,
- documentation: customServerDocumentationFragment,
- },
- ]
+ }
static defaultBadgeData = {
label: 'license',
}
transform({ json, user, repo }) {
- const branch = this.getDefaultBranch(json, user, repo)
- if (!branch) {
- throw new NotFound({ prettyMessage: 'default branch not found' })
+ const packageName = this.getPackageName(user, repo)
+
+ const versions = BasePackagistService.expandPackageVersions(
+ json,
+ packageName,
+ )
+
+ const version = this.findLatestRelease(versions)
+ const license = version.license
+ if (!license) {
+ throw new NotFound({ prettyMessage: 'license not found' })
}
- const { license } = branch
+
return { license }
}
async handle({ user, repo }, { server }) {
const json = await this.fetch({ user, repo, schema, server })
+
const { license } = this.transform({ json, user, repo })
+
return renderLicenseBadge({ license })
}
}
diff --git a/services/packagist/packagist-license.spec.js b/services/packagist/packagist-license.spec.js
index 8c3aaf1d79ea2..5355681f2175e 100644
--- a/services/packagist/packagist-license.spec.js
+++ b/services/packagist/packagist-license.spec.js
@@ -3,50 +3,109 @@ import { NotFound } from '../index.js'
import PackagistLicense from './packagist-license.service.js'
describe('PackagistLicense', function () {
- it('should throw NotFound when default branch is missing', function () {
+ it('should return the license of the most recent release', function () {
const json = {
packages: {
- 'frodo/the-one-package': {
- '1.0.x-dev': { license: 'MIT' },
- '1.1.x-dev': { license: 'MIT' },
- '2.0.x-dev': { license: 'MIT' },
- '2.1.x-dev': { license: 'MIT' },
- },
+ 'frodo/the-one-package': [
+ {
+ version: '1.2.4',
+ license: 'MIT-latest',
+ },
+ {
+ version: '1.2.3',
+ license: 'MIT',
+ },
+ ],
},
}
- expect(() =>
+
+ expect(
PackagistLicense.prototype.transform({
json,
user: 'frodo',
repo: 'the-one-package',
- })
+ }),
)
- .to.throw(NotFound)
- .with.property('prettyMessage', 'default branch not found')
+ .to.have.property('license')
+ .that.equals('MIT-latest')
+ })
+
+ it('should return the license of the most recent stable release', function () {
+ const json = {
+ packages: {
+ 'frodo/the-one-package': [
+ {
+ version: '1.2.4-RC1', // Pre-release
+ license: 'MIT-latest',
+ },
+ {
+ version: '1.2.3', // Stable release
+ license: 'MIT',
+ },
+ ],
+ },
+ }
+
+ expect(
+ PackagistLicense.prototype.transform({
+ json,
+ user: 'frodo',
+ repo: 'the-one-package',
+ }),
+ )
+ .to.have.property('license')
+ .that.equals('MIT')
})
- it('should return default branch when default branch is found', function () {
+ it('should return the license of the most recent pre-release if no stable releases', function () {
const json = {
packages: {
- 'frodo/the-one-package': {
- '1.0.x-dev': { license: 'MIT' },
- '1.1.x-dev': { license: 'MIT' },
- '2.0.x-dev': {
- license: 'MIT-default-branch',
- 'default-branch': true,
- },
- '2.1.x-dev': { license: 'MIT' },
- },
+ 'frodo/the-one-package': [
+ {
+ version: '1.2.4-RC2',
+ license: 'MIT-latest',
+ },
+ {
+ version: '1.2.4-RC1',
+ license: 'MIT',
+ },
+ ],
},
}
+
expect(
PackagistLicense.prototype.transform({
json,
user: 'frodo',
repo: 'the-one-package',
- })
+ }),
)
.to.have.property('license')
- .that.equals('MIT-default-branch')
+ .that.equals('MIT-latest')
+ })
+
+ it('should throw NotFound when license key not in response', function () {
+ const json = {
+ packages: {
+ 'frodo/the-one-package': [
+ {
+ version: '1.2.4',
+ },
+ {
+ version: '1.2.3',
+ },
+ ],
+ },
+ }
+
+ expect(() =>
+ PackagistLicense.prototype.transform({
+ json,
+ user: 'frodo',
+ repo: 'the-one-package',
+ }),
+ )
+ .to.throw(NotFound)
+ .with.property('prettyMessage', 'license not found')
})
})
diff --git a/services/packagist/packagist-license.tester.js b/services/packagist/packagist-license.tester.js
index a9f3ef370bcb2..e43a5bf3d96f1 100644
--- a/services/packagist/packagist-license.tester.js
+++ b/services/packagist/packagist-license.tester.js
@@ -6,7 +6,7 @@ t.create('license (valid)')
.expectBadge({ label: 'license', message: 'MIT' })
// note: packagist does serve up license at the version level
-// but our endpoint only supports fetching license for the lastest version
+// but our endpoint only supports fetching license for the latest version
t.create('license (invalid, package version in request)')
.get('/symfony/symfony/v2.8.0.json')
.expectBadge({ label: '404', message: 'badge not found' })
diff --git a/services/packagist/packagist-php-version.service.js b/services/packagist/packagist-php-version.service.js
index 72acd7ad98fce..177d94bc94f17 100644
--- a/services/packagist/packagist-php-version.service.js
+++ b/services/packagist/packagist-php-version.service.js
@@ -1,101 +1,21 @@
import Joi from 'joi'
+import { redirector } from '../index.js'
import { optionalUrl } from '../validators.js'
-import { NotFound } from '../index.js'
-import {
- allVersionsSchema,
- BasePackagistService,
- customServerDocumentationFragment,
-} from './packagist-base.js'
const queryParamSchema = Joi.object({
server: optionalUrl,
}).required()
-export default class PackagistPhpVersion extends BasePackagistService {
- static category = 'platform-support'
-
- static route = {
+export default redirector({
+ category: 'platform-support',
+ route: {
base: 'packagist/php-v',
pattern: ':user/:repo/:version?',
queryParamSchema,
- }
-
- static examples = [
- {
- title: 'Packagist PHP Version Support',
- pattern: ':user/:repo',
- namedParams: {
- user: 'symfony',
- repo: 'symfony',
- },
- staticPreview: this.render({ php: '^7.1.3' }),
- },
- {
- title: 'Packagist PHP Version Support (specify version)',
- pattern: ':user/:repo/:version',
- namedParams: {
- user: 'symfony',
- repo: 'symfony',
- version: 'v2.8.0',
- },
- staticPreview: this.render({ php: '>=5.3.9' }),
- },
- {
- title: 'Packagist PHP Version Support (custom server)',
- pattern: ':user/:repo',
- namedParams: {
- user: 'symfony',
- repo: 'symfony',
- },
- queryParams: {
- server: 'https://packagist.org',
- },
- staticPreview: this.render({ php: '^7.1.3' }),
- documentation: customServerDocumentationFragment,
- },
- ]
-
- static defaultBadgeData = {
- label: 'php',
- color: 'blue',
- }
-
- static render({ php }) {
- return {
- message: php,
- }
- }
-
- transform({ json, user, repo, version = '' }) {
- const packageVersion =
- version === ''
- ? this.getDefaultBranch(json, user, repo)
- : json.packages[this.getPackageName(user, repo)][version]
-
- if (!packageVersion) {
- throw new NotFound({ prettyMessage: 'invalid version' })
- }
-
- if (!packageVersion.require || !packageVersion.require.php) {
- throw new NotFound({ prettyMessage: 'version requirement not found' })
- }
-
- return { phpVersion: packageVersion.require.php }
- }
-
- async handle({ user, repo, version = '' }, { server }) {
- const allData = await this.fetch({
- user,
- repo,
- schema: allVersionsSchema,
- server,
- })
- const { phpVersion } = this.transform({
- json: allData,
- user,
- repo,
- version,
- })
- return this.constructor.render({ php: phpVersion })
- }
-}
+ },
+ transformPath: ({ user, repo }) =>
+ `/packagist/dependency-v/${user}/${repo}/php`,
+ transformQueryParams: ({ version, server }) => ({ version, server }),
+ overrideTransformedQueryParams: true,
+ dateAdded: new Date('2022-09-07'),
+})
diff --git a/services/packagist/packagist-php-version.spec.js b/services/packagist/packagist-php-version.spec.js
deleted file mode 100644
index 1eee30010e1aa..0000000000000
--- a/services/packagist/packagist-php-version.spec.js
+++ /dev/null
@@ -1,99 +0,0 @@
-import { expect } from 'chai'
-import { NotFound } from '../index.js'
-import PackagistPhpVersion from './packagist-php-version.service.js'
-
-describe('PackagistPhpVersion', function () {
- const json = {
- packages: {
- 'frodo/the-one-package': {
- '1.0.0': { require: { php: '^5.6 || ^7' } },
- '2.0.0': { require: { php: '^7.2' } },
- '3.0.0': { require: { php: '^7.4 || 8' } },
- 'dev-main': { require: { php: '^8' }, 'default-branch': true },
- },
- 'samwise/gardening': {
- '1.0.x-dev': {},
- '2.0.x-dev': {},
- },
- 'pippin/mischief': {
- '1.0.0': {},
- 'dev-main': { require: {}, 'default-branch': true },
- },
- },
- }
-
- it('should throw NotFound when package version is missing', function () {
- expect(() =>
- PackagistPhpVersion.prototype.transform({
- json,
- user: 'frodo',
- repo: 'the-one-package',
- version: '4.0.0',
- })
- )
- .to.throw(NotFound)
- .with.property('prettyMessage', 'invalid version')
- })
-
- it('should throw NotFound when version not specified and no default branch found', function () {
- expect(() =>
- PackagistPhpVersion.prototype.transform({
- json,
- user: 'samwise',
- repo: 'gardening',
- })
- )
- .to.throw(NotFound)
- .with.property('prettyMessage', 'invalid version')
- })
-
- it('should throw NotFound when PHP version not found on package when using default branch', function () {
- expect(() =>
- PackagistPhpVersion.prototype.transform({
- json,
- user: 'pippin',
- repo: 'mischief',
- })
- )
- .to.throw(NotFound)
- .with.property('prettyMessage', 'version requirement not found')
- })
-
- it('should throw NotFound when PHP version not found on package when using specified version', function () {
- expect(() =>
- PackagistPhpVersion.prototype.transform({
- json,
- user: 'pippin',
- repo: 'mischief',
- version: '1.0.0',
- })
- )
- .to.throw(NotFound)
- .with.property('prettyMessage', 'version requirement not found')
- })
-
- it('should return PHP version for the default branch', function () {
- expect(
- PackagistPhpVersion.prototype.transform({
- json,
- user: 'frodo',
- repo: 'the-one-package',
- })
- )
- .to.have.property('phpVersion')
- .that.equals('^8')
- })
-
- it('should return PHP version for the specified branch', function () {
- expect(
- PackagistPhpVersion.prototype.transform({
- json,
- user: 'frodo',
- repo: 'the-one-package',
- version: '3.0.0',
- })
- )
- .to.have.property('phpVersion')
- .that.equals('^7.4 || 8')
- })
-})
diff --git a/services/packagist/packagist-php-version.tester.js b/services/packagist/packagist-php-version.tester.js
index c1c6015c3920a..0d3a997d21563 100644
--- a/services/packagist/packagist-php-version.tester.js
+++ b/services/packagist/packagist-php-version.tester.js
@@ -1,35 +1,24 @@
-import { isComposerVersion } from '../test-validators.js'
import { createServiceTester } from '../tester.js'
export const t = await createServiceTester()
-t.create('gets the package version of symfony')
+t.create(
+ 'redirect getting required php version for the dependency from packagist (valid, package version not specified in request)',
+)
.get('/symfony/symfony.json')
- .expectBadge({ label: 'php', message: isComposerVersion })
-
-t.create('gets the package version of symfony 2.8')
- .get('/symfony/symfony/v2.8.0.json')
- .expectBadge({ label: 'php', message: isComposerVersion })
-
-t.create('package with no requirements')
- .get('/bpampuch/pdfmake.json')
- .expectBadge({ label: 'php', message: 'version requirement not found' })
-
-t.create('package with no php version requirement')
- .get('/raulfraile/ladybug-theme-modern.json')
- .expectBadge({ label: 'php', message: 'version requirement not found' })
-
-t.create('invalid package name')
- .get('/frodo/is-not-a-package.json')
- .expectBadge({ label: 'php', message: 'not found' })
-
-t.create('invalid version')
- .get('/symfony/symfony/invalid.json')
- .expectBadge({ label: 'php', message: 'invalid version' })
-
-t.create('custom server')
- .get('/symfony/symfony.json?server=https%3A%2F%2Fpackagist.org')
- .expectBadge({ label: 'php', message: isComposerVersion })
-
-t.create('invalid custom server')
- .get('/symfony/symfony.json?server=https%3A%2F%2Fpackagist.com')
- .expectBadge({ label: 'php', message: 'not found' })
+ .expectRedirect('/packagist/dependency-v/symfony/symfony/php.json?')
+
+t.create(
+ 'redirect getting required php version for the dependency from packagist (valid, package version specified in request)',
+)
+ .get('/symfony/symfony/v3.2.8.json')
+ .expectRedirect(
+ '/packagist/dependency-v/symfony/symfony/php.json?version=v3.2.8',
+ )
+
+t.create(
+ 'redirect getting required php version for the dependency from packagist (valid, package version and server specified in request)',
+)
+ .get('/symfony/symfony/v3.2.8.json?server=https://packagist.org')
+ .expectRedirect(
+ '/packagist/dependency-v/symfony/symfony/php.json?version=v3.2.8&server=https%3A%2F%2Fpackagist.org',
+ )
diff --git a/services/packagist/packagist-stars.service.js b/services/packagist/packagist-stars.service.js
index 2b3820ffbcddc..5c32849aa07a4 100644
--- a/services/packagist/packagist-stars.service.js
+++ b/services/packagist/packagist-stars.service.js
@@ -1,11 +1,12 @@
import Joi from 'joi'
import { metric } from '../text-formatters.js'
import { nonNegativeInteger, optionalUrl } from '../validators.js'
+import { pathParam, queryParam } from '../index.js'
import {
- keywords,
BasePackagistService,
customServerDocumentationFragment,
cacheDocumentationFragment,
+ description,
} from './packagist-base.js'
const schema = Joi.object({
@@ -27,34 +28,29 @@ export default class PackagistStars extends BasePackagistService {
queryParamSchema,
}
- static examples = [
- {
- title: 'Packagist Stars',
- namedParams: {
- user: 'guzzlehttp',
- repo: 'guzzle',
+ static openApi = {
+ '/packagist/stars/{user}/{repo}': {
+ get: {
+ summary: 'Packagist Stars',
+ description: description + cacheDocumentationFragment,
+ parameters: [
+ pathParam({
+ name: 'user',
+ example: 'guzzlehttp',
+ }),
+ pathParam({
+ name: 'repo',
+ example: 'guzzle',
+ }),
+ queryParam({
+ name: 'server',
+ description: customServerDocumentationFragment,
+ example: 'https://packagist.org',
+ }),
+ ],
},
- staticPreview: this.render({
- stars: 1000,
- }),
- keywords,
- documentation: cacheDocumentationFragment,
},
- {
- title: 'Packagist Stars (custom server)',
- namedParams: {
- user: 'guzzlehttp',
- repo: 'guzzle',
- },
- staticPreview: this.render({
- stars: 1000,
- }),
- queryParams: { server: 'https://packagist.org' },
- keywords,
- documentation:
- customServerDocumentationFragment + cacheDocumentationFragment,
- },
- ]
+ }
static defaultBadgeData = {
label: 'stars',
diff --git a/services/packagist/packagist-stars.tester.js b/services/packagist/packagist-stars.tester.js
index 00b1e0cca071b..dccd23629f390 100644
--- a/services/packagist/packagist-stars.tester.js
+++ b/services/packagist/packagist-stars.tester.js
@@ -14,30 +14,16 @@ t.create('Stars (invalid package)')
message: 'not found',
})
-t.create('Stars (valid package, valid custom server)')
+t.create('Stars (valid package, custom server)')
.get('/guzzlehttp/guzzle.json?server=https%3A%2F%2Fpackagist.org')
.expectBadge({
label: 'stars',
message: isMetric,
})
-t.create('Stars (invalid package, valid custom server)')
+t.create('Stars (invalid package, custom server)')
.get('/frodo/is-not-a-package.json?server=https%3A%2F%2Fpackagist.org')
.expectBadge({
label: 'stars',
message: 'not found',
})
-
-t.create('Stars (valid package, invalid custom server)')
- .get('/guzzlehttp/guzzle.json?server=https%3A%2F%2Fexample.com')
- .expectBadge({
- label: 'stars',
- message: 'not found',
- })
-
-t.create('Stars (invalid package, invalid custom server)')
- .get('/frodo/is-not-a-package.json?server=https%3A%2F%2Fexample.com')
- .expectBadge({
- label: 'stars',
- message: 'not found',
- })
diff --git a/services/packagist/packagist-version.service.js b/services/packagist/packagist-version.service.js
index b20c699206696..aafd1256c1535 100644
--- a/services/packagist/packagist-version.service.js
+++ b/services/packagist/packagist-version.service.js
@@ -1,26 +1,18 @@
import Joi from 'joi'
import { renderVersionBadge } from '../version.js'
-import { compare, isStable, latest } from '../php-version.js'
import { optionalUrl } from '../validators.js'
-import { NotFound, redirector } from '../index.js'
+import { redirector, pathParam, queryParam } from '../index.js'
import {
- allVersionsSchema,
- keywords,
BasePackagistService,
customServerDocumentationFragment,
+ description,
} from './packagist-base.js'
-const packageSchema = Joi.object()
- .pattern(
- /^/,
- Joi.object({
- version: Joi.string(),
- extra: Joi.object({
- 'branch-alias': Joi.object().pattern(/^/, Joi.string()),
- }),
- }).required()
- )
- .required()
+const packageSchema = Joi.array().items(
+ Joi.object({
+ version: Joi.string().required(),
+ }),
+)
const schema = Joi.object({
packages: Joi.object().pattern(/^/, packageSchema).required(),
@@ -40,97 +32,56 @@ class PackagistVersion extends BasePackagistService {
queryParamSchema,
}
- static examples = [
- {
- title: 'Packagist Version',
- namedParams: {
- user: 'symfony',
- repo: 'symfony',
+ static openApi = {
+ '/packagist/v/{user}/{repo}': {
+ get: {
+ summary: 'Packagist Version',
+ description,
+ parameters: [
+ pathParam({
+ name: 'user',
+ example: 'symfony',
+ }),
+ pathParam({
+ name: 'repo',
+ example: 'symfony',
+ }),
+ queryParam({
+ name: 'include_prereleases',
+ schema: { type: 'boolean' },
+ example: null,
+ }),
+ queryParam({
+ name: 'server',
+ description: customServerDocumentationFragment,
+ example: 'https://packagist.org',
+ }),
+ ],
},
- staticPreview: renderVersionBadge({ version: '4.2.2' }),
- keywords,
},
- {
- title: 'Packagist Version (including pre-releases)',
- namedParams: {
- user: 'symfony',
- repo: 'symfony',
- },
- queryParams: { include_prereleases: null },
- staticPreview: renderVersionBadge({ version: '4.3-dev' }),
- keywords,
- },
- {
- title: 'Packagist Version (custom server)',
- namedParams: {
- user: 'symfony',
- repo: 'symfony',
- },
- queryParams: {
- server: 'https://packagist.org',
- },
- staticPreview: renderVersionBadge({ version: '4.2.2' }),
- keywords,
- documentation: customServerDocumentationFragment,
- },
- ]
+ }
static defaultBadgeData = {
label: 'packagist',
}
static render({ version }) {
- if (version === undefined) {
- throw new NotFound({ prettyMessage: 'no released version found' })
- }
return renderVersionBadge({ version })
}
- transform({ includePrereleases, json, user, repo }) {
- const versionsData = json.packages[this.getPackageName(user, repo)]
- let versions = Object.keys(versionsData)
- const aliasesMap = {}
- versions.forEach(version => {
- const versionData = versionsData[version]
- if (
- versionData.extra &&
- versionData.extra['branch-alias'] &&
- versionData.extra['branch-alias'][version]
- ) {
- // eg, version is 'dev-master', mapped to '2.0.x-dev'.
- const validVersion = versionData.extra['branch-alias'][version]
- if (
- aliasesMap[validVersion] === undefined ||
- compare(aliasesMap[validVersion], validVersion) < 0
- ) {
- versions.push(validVersion)
- aliasesMap[validVersion] = version
- }
- }
- })
-
- versions = versions.filter(version => !/^dev-/.test(version))
-
- if (includePrereleases) {
- return { version: latest(versions) }
- } else {
- const stableVersion = latest(versions.filter(isStable))
- return { version: stableVersion || latest(versions) }
- }
- }
-
async handle(
{ user, repo },
- { include_prereleases: includePrereleases, server }
+ { include_prereleases: includePrereleases, server },
) {
includePrereleases = includePrereleases !== undefined
const json = await this.fetch({
user,
repo,
- schema: includePrereleases ? schema : allVersionsSchema,
+ schema,
server,
})
- const { version } = this.transform({ includePrereleases, json, user, repo })
+ const versions = json.packages[this.getPackageName(user, repo)]
+ const { version } = this.findLatestRelease(versions, includePrereleases)
return this.constructor.render({ version })
}
}
diff --git a/services/packagist/packagist-version.tester.js b/services/packagist/packagist-version.tester.js
index ba2c2797448f2..bc115fbb90d6b 100644
--- a/services/packagist/packagist-version.tester.js
+++ b/services/packagist/packagist-version.tester.js
@@ -56,5 +56,5 @@ t.create('version (legacy redirect: vpre)')
t.create('version (legacy redirect: vpre) (custom server)')
.get('/vpre/symfony/symfony.svg?server=https%3A%2F%2Fpackagist.org')
.expectRedirect(
- '/packagist/v/symfony/symfony.svg?include_prereleases&server=https%3A%2F%2Fpackagist.org'
+ '/packagist/v/symfony/symfony.svg?server=https%3A%2F%2Fpackagist.org&include_prereleases',
)
diff --git a/services/pepy/pepy-downloads.service.js b/services/pepy/pepy-downloads.service.js
new file mode 100644
index 0000000000000..305bf4fbf0cc9
--- /dev/null
+++ b/services/pepy/pepy-downloads.service.js
@@ -0,0 +1,61 @@
+import Joi from 'joi'
+import { nonNegativeInteger } from '../validators.js'
+import { BaseJsonService, pathParams } from '../index.js'
+import { renderDownloadsBadge } from '../downloads.js'
+
+const schema = Joi.object({
+ total_downloads: nonNegativeInteger,
+}).required()
+
+const description = `
+Python package total downloads from [Pepy](https://www.pepy.tech/).
+
+Download stats from pepy count package downloads from PyPI and known mirrors.`
+
+export default class PepyDownloads extends BaseJsonService {
+ static category = 'downloads'
+
+ static route = {
+ base: 'pepy',
+ pattern: 'dt/:packageName',
+ }
+
+ static auth = {
+ passKey: 'pepy_key',
+ authorizedOrigins: ['https://api.pepy.tech'],
+ isRequired: true,
+ }
+
+ static openApi = {
+ '/pepy/dt/{packageName}': {
+ get: {
+ summary: 'Pepy Total Downloads',
+ description,
+ parameters: pathParams({
+ name: 'packageName',
+ example: 'django',
+ }),
+ },
+ },
+ }
+
+ static _cacheLength = 28800
+
+ static defaultBadgeData = { label: 'downloads' }
+
+ async fetch({ packageName }) {
+ return this._requestJson(
+ this.authHelper.withApiKeyHeader({
+ url: `https://api.pepy.tech/api/v2/projects/${packageName}`,
+ schema,
+ }),
+ )
+ }
+
+ async handle({ packageName }) {
+ const data = await this.fetch({ packageName })
+ return renderDownloadsBadge({
+ downloads: data.total_downloads,
+ })
+ }
+}
diff --git a/services/pepy/pepy-downloads.spec.js b/services/pepy/pepy-downloads.spec.js
new file mode 100644
index 0000000000000..9a529af63cf1f
--- /dev/null
+++ b/services/pepy/pepy-downloads.spec.js
@@ -0,0 +1,10 @@
+import { testAuth } from '../test-helpers.js'
+import PepyDownloads from './pepy-downloads.service.js'
+
+describe('PepyDownloads', function () {
+ describe('auth', function () {
+ it('sends the auth information as configured', async function () {
+ return testAuth(PepyDownloads, 'ApiKeyHeader', { total_downloads: 42 })
+ })
+ })
+})
diff --git a/services/pepy/pepy-downloads.tester.js b/services/pepy/pepy-downloads.tester.js
new file mode 100644
index 0000000000000..a90172f7b1073
--- /dev/null
+++ b/services/pepy/pepy-downloads.tester.js
@@ -0,0 +1,11 @@
+import { createServiceTester } from '../tester.js'
+import { isMetric } from '../test-validators.js'
+export const t = await createServiceTester()
+
+t.create('downloads (valid)')
+ .get('/dt/django.json')
+ .expectBadge({ label: 'downloads', message: isMetric })
+
+t.create('downloads (not found)')
+ .get('/dt/not-a-package.json')
+ .expectBadge({ label: 'downloads', message: 'not found' })
diff --git a/services/php-version.js b/services/php-version.js
index 091bca3c8b243..5c8927565d447 100644
--- a/services/php-version.js
+++ b/services/php-version.js
@@ -2,15 +2,23 @@
* Utilities relating to PHP version numbers. This compares version numbers
* using the algorithm followed by Composer (see
* https://getcomposer.org/doc/04-schema.md#version).
+ *
+ * @module
*/
-import { promisify } from 'util'
-import request from 'request'
-import { regularUpdate } from '../core/legacy/regular-update.js'
+
+import { fetch } from '../core/base-service/got.js'
+import { getCachedResource } from '../core/base-service/resource-cache.js'
import { listCompare } from './version.js'
import { omitv } from './text-formatters.js'
-// Return a negative value if v1 < v2,
-// zero if v1 = v2, a positive value otherwise.
+/**
+ * Return a negative value if v1 < v2,
+ * zero if v1 = v2, a positive value otherwise.
+ *
+ * @param {string} v1 - First version for comparison
+ * @param {string} v2 - Second version for comparison
+ * @returns {number} Comparison result (-1, 0 or 1)
+ */
function asciiVersionCompare(v1, v2) {
if (v1 < v2) {
return -1
@@ -21,83 +29,61 @@ function asciiVersionCompare(v1, v2) {
}
}
-// Take a version without the starting v.
-// eg, '1.0.x-beta'
-// Return { numbers: [1,0,something big], modifier: 2, modifierCount: 1 }
+/**
+ * Take a version without the starting v.
+ * eg, '1.0.x-beta'
+ * Return { numbers: [1,0,something big], modifier: 2, modifierCount: 1 }
+ *
+ * @param {string} version - Version number string
+ * @returns {object} Object containing version details
+ */
function numberedVersionData(version) {
- // A version has a numbered part and a modifier part
- // (eg, 1.0.0-patch, 2.0.x-dev).
- const parts = version.split('-')
- const numbered = parts[0]
-
- // Aliases that get caught here.
- if (numbered === 'dev') {
- return {
- numbers: parts[1],
- modifier: 5,
- modifierCount: 1,
- }
+ // https://github.com/composer/semver/blob/46d9139568ccb8d9e7cdd4539cab7347568a5e2e/src/VersionParser.php#L39
+ const regex =
+ /^(\d+(?:\.\d+)*)(?:[._-]?(stable|beta|b|RC|alpha|a|patch|pl|p)?((?:[.-]?\d+)*)?)?([.-]?dev)?$/i
+ const match = version.match(regex)
+
+ if (!match || match.length < 5) {
+ throw new Error(`Unparseable PHP version: ${version}`)
}
- let modifierLevel = 3
+ let modifierLevel = 3 // default: stable/without modifiers
let modifierLevelCount = 0
+ const modifier = match[2] ? match[2].toLowerCase() : ''
+ const modifierCountStr = match[3] || ''
+ const devModifier = match[4] ? match[4].toLowerCase() : ''
+
// Normalization based on
// https://github.com/composer/semver/blob/1.5.0/src/VersionParser.php
+ if (modifier === 'alpha' || modifier === 'a') {
+ modifierLevel = 0
+ modifierLevelCount = +modifierCountStr.replace(/[^\d]/g, '') || 1
+ } else if (modifier === 'beta' || modifier === 'b') {
+ modifierLevel = 1
+ modifierLevelCount = +modifierCountStr.replace(/[^\d]/g, '') || 1
+ } else if (modifier === 'rc') {
+ modifierLevel = 2
+ modifierLevelCount = +modifierCountStr.replace(/[^\d]/g, '') || 1
+ } else if (modifier === 'stable' || modifier === '') {
+ modifierLevel = 3
+ modifierLevelCount = 1
+ } else if (modifier === 'patch' || modifier === 'pl' || modifier === 'p') {
+ modifierLevel = 4
+ modifierLevelCount = +modifierCountStr.replace(/[^\d]/g, '') || 1
+ }
- if (parts.length > 1) {
- const modifier = parts[parts.length - 1]
- const firstLetter = modifier.charCodeAt(0)
- let modifierLevelCountString
-
- // Modifiers: alpha < beta < RC < normal < patch < dev
- if (firstLetter === 97 || firstLetter === 65) {
- // a / A
- modifierLevel = 0
- if (/^alpha/i.test(modifier)) {
- modifierLevelCountString = +modifier.slice(5)
- } else {
- modifierLevelCountString = +modifier.slice(1)
- }
- } else if (firstLetter === 98 || firstLetter === 66) {
- // b / B
- modifierLevel = 1
- if (/^beta/i.test(modifier)) {
- modifierLevelCountString = +modifier.slice(4)
- } else {
- modifierLevelCountString = +modifier.slice(1)
- }
- } else if (firstLetter === 82 || firstLetter === 114) {
- // R / r
- modifierLevel = 2
- modifierLevelCountString = +modifier.slice(2)
- } else if (firstLetter === 112) {
- // p
- modifierLevel = 4
- if (/^patch/.test(modifier)) {
- modifierLevelCountString = +modifier.slice(5)
- } else {
- modifierLevelCountString = +modifier.slice(1)
- }
- } else if (firstLetter === 100) {
- // d
- modifierLevel = 5
- if (/^dev/.test(modifier)) {
- modifierLevelCountString = +modifier.slice(3)
- } else {
- modifierLevelCountString = +modifier.slice(1)
- }
- }
-
- // If we got the empty string, it defaults to a modifier count of 1.
- if (!modifierLevelCountString) {
- modifierLevelCount = 1
- } else {
- modifierLevelCount = +modifierLevelCountString
- }
+ if (devModifier) {
+ modifierLevel = 5
+ modifierLevelCount = 1
}
- // Try to convert to a list of numbers.
+ /**
+ * Try to convert to a list of numbers.
+ *
+ * @param {string} s - Version number string
+ * @returns {number} Version number integer
+ */
function toNum(s) {
let n = +s
if (Number.isNaN(n)) {
@@ -105,7 +91,7 @@ function numberedVersionData(version) {
}
return n
}
- const numberList = numbered.split('.').map(toNum)
+ const numberList = match[1].split('.').map(toNum)
return {
numbers: numberList,
@@ -114,12 +100,15 @@ function numberedVersionData(version) {
}
}
-// Return a negative value if v1 < v2,
-// zero if v1 = v2,
-// a positive value otherwise.
-//
-// See https://getcomposer.org/doc/04-schema.md#version
-// and https://github.com/badges/shields/issues/319#issuecomment-74411045
+/**
+ * Compares two versions and return an integer based on the result.
+ * See https://getcomposer.org/doc/04-schema.md#version
+ * and https://github.com/badges/shields/issues/319#issuecomment-74411045
+ *
+ * @param {string} v1 - First version
+ * @param {string} v2 - Second version
+ * @returns {number} Negative value if v1 < v2, zero if v1 = v2, else a positive value
+ */
function compare(v1, v2) {
// Omit the starting `v`.
const rawv1 = omitv(v1)
@@ -155,6 +144,12 @@ function compare(v1, v2) {
return 0
}
+/**
+ * Determines the latest version from a list of versions.
+ *
+ * @param {string[]} versions - List of versions
+ * @returns {string} Latest version
+ */
function latest(versions) {
let latest = versions[0]
for (let i = 1; i < versions.length; i++) {
@@ -165,6 +160,12 @@ function latest(versions) {
return latest
}
+/**
+ * Determines if a version is stable or not.
+ *
+ * @param {string} version - Version number
+ * @returns {boolean} true if version is stable, else false
+ */
function isStable(version) {
const rawVersion = omitv(version)
let versionData
@@ -177,6 +178,12 @@ function isStable(version) {
return versionData.modifier === 3 || versionData.modifier === 4
}
+/**
+ * Checks if a version is valid and returns the minor version.
+ *
+ * @param {string} version - Version number
+ * @returns {string} Minor version
+ */
function minorVersion(version) {
const result = version.match(/^(\d+)(?:\.(\d+))?(?:\.(\d+))?/)
@@ -187,6 +194,13 @@ function minorVersion(version) {
return `${result[1]}.${result[2] ? result[2] : '0'}`
}
+/**
+ * Reduces the list of php versions that intersect with release versions to a version range (for eg. '5.4 - 7.1', '>= 5.5').
+ *
+ * @param {string[]} versions - List of php versions
+ * @param {string[]} phpReleases - List of php release versions
+ * @returns {string[]} Reduced Version Range (for eg. ['5.4 - 7.1'], ['>= 5.5'])
+ */
function versionReduction(versions, phpReleases) {
if (!versions.length) {
return []
@@ -217,24 +231,29 @@ function versionReduction(versions, phpReleases) {
return versions
}
+/**
+ * Fetches the PHP release versions from cache if exists, else fetch from the source url and save in cache.
+ *
+ * @async
+ * @param {object} githubApiProvider - Github API provider
+ * @returns {Promise<*>} Promise that resolves to parsed response
+ */
async function getPhpReleases(githubApiProvider) {
- return promisify(regularUpdate)({
+ return getCachedResource({
url: '/repos/php/php-src/git/refs/tags',
- intervalMillis: 24 * 3600 * 1000, // 1 day
scraper: tags =>
Array.from(
new Set(
tags
// only releases
.filter(
- tag => tag.ref.match(/^refs\/tags\/php-\d+\.\d+\.\d+$/) != null
+ tag => tag.ref.match(/^refs\/tags\/php-\d+\.\d+\.\d+$/) != null,
)
// get minor version of release
- .map(tag => tag.ref.match(/^refs\/tags\/php-(\d+\.\d+)\.\d+$/)[1])
- )
+ .map(tag => tag.ref.match(/^refs\/tags\/php-(\d+\.\d+)\.\d+$/)[1]),
+ ),
),
- request: (url, options, cb) =>
- githubApiProvider.request(request, url, {}, cb),
+ requestFetcher: githubApiProvider.fetch.bind(githubApiProvider, fetch),
})
}
diff --git a/services/php-version.spec.js b/services/php-version.spec.js
index 2ccd237dd3b25..3e2e0321545e7 100644
--- a/services/php-version.spec.js
+++ b/services/php-version.spec.js
@@ -1,5 +1,10 @@
import { test, given } from 'sazerac'
-import { compare, minorVersion, versionReduction } from './php-version.js'
+import {
+ compare,
+ isStable,
+ minorVersion,
+ versionReduction,
+} from './php-version.js'
const phpReleases = [
'5.0',
@@ -37,7 +42,7 @@ describe('Text PHP version', function () {
given(['7.0', '7.1', '7.2'], phpReleases).expect(['>= 7'])
given(
['5.0', '5.1', '5.2', '5.3', '5.4', '5.5', '5.6', '7.0', '7.1', '7.2'],
- phpReleases
+ phpReleases,
).expect(['>= 5'])
given(['7.1', '7.2'], phpReleases).expect(['>= 7.1'])
given(['7.1'], phpReleases).expect(['7.1'])
@@ -82,3 +87,55 @@ describe('Composer version comparison', function () {
given('1.0.0-rc10', '1.0.0-RC11').expect(-1)
})
})
+
+describe('isStable', function () {
+ test(isStable, () => {
+ // dash separator
+ given('1.0.0-alpha').expect(false)
+ given('1.0.0-alpha2').expect(false)
+ given('1.0.0-beta').expect(false)
+ given('1.0.0-beta2').expect(false)
+ given('1.0.0-RC').expect(false)
+ given('1.0.0-RC2').expect(false)
+ given('1.0.0').expect(true)
+ given('1.0.0-patch').expect(true)
+ given('1.0.0-dev').expect(false)
+ given('1.0.x-dev').expect(false)
+
+ // underscore separator
+ given('1.0.0_alpha').expect(false)
+ given('1.0.0_alpha2').expect(false)
+ given('1.0.0_beta').expect(false)
+ given('1.0.0_beta2').expect(false)
+ given('1.0.0_RC').expect(false)
+ given('1.0.0_RC2').expect(false)
+ given('1.0.0').expect(true)
+ given('1.0.0_patch').expect(true)
+ given('1.0.0_dev').expect(false)
+ given('1.0.x_dev').expect(false)
+
+ // dot separator
+ given('1.0.0.alpha').expect(false)
+ given('1.0.0.alpha2').expect(false)
+ given('1.0.0.beta').expect(false)
+ given('1.0.0.beta2').expect(false)
+ given('1.0.0.RC').expect(false)
+ given('1.0.0.RC2').expect(false)
+ given('1.0.0').expect(true)
+ given('1.0.0.patch').expect(true)
+ given('1.0.0.dev').expect(false)
+ given('1.0.x.dev').expect(false)
+
+ // no separator
+ given('1.0.0alpha').expect(false)
+ given('1.0.0alpha2').expect(false)
+ given('1.0.0beta').expect(false)
+ given('1.0.0beta2').expect(false)
+ given('1.0.0RC').expect(false)
+ given('1.0.0RC2').expect(false)
+ given('1.0.0').expect(true)
+ given('1.0.0patch').expect(true)
+ given('1.0.0dev').expect(false)
+ given('1.0.xdev').expect(false)
+ })
+})
diff --git a/services/pingpong/pingpong-base.js b/services/pingpong/pingpong-base.js
new file mode 100644
index 0000000000000..62a517613e696
--- /dev/null
+++ b/services/pingpong/pingpong-base.js
@@ -0,0 +1,22 @@
+import { BaseJsonService, InvalidParameter } from '../index.js'
+
+export const description = `
+[PingPong](https://pingpong.one/) is a status page and monitoring service.
+
+To see more details about this badge and obtain your api key, visit
+[https://my.pingpong.one/integrations/badge-uptime/](https://my.pingpong.one/integrations/badge-uptime/)
+`
+
+export const baseUrl = 'https://api.pingpong.one/widget/shields'
+
+export class BasePingPongService extends BaseJsonService {
+ static category = 'monitoring'
+
+ static validateApiKey({ apiKey }) {
+ if (!apiKey.startsWith('sp_')) {
+ throw new InvalidParameter({
+ prettyMessage: 'invalid api key',
+ })
+ }
+ }
+}
diff --git a/services/pingpong/pingpong-status.service.js b/services/pingpong/pingpong-status.service.js
index 7297f52a2e551..1752820d17ea8 100644
--- a/services/pingpong/pingpong-status.service.js
+++ b/services/pingpong/pingpong-status.service.js
@@ -1,41 +1,29 @@
import Joi from 'joi'
-import { BaseJsonService, InvalidParameter, InvalidResponse } from '../index.js'
+import { InvalidResponse, pathParams } from '../index.js'
+import { BasePingPongService, baseUrl, description } from './pingpong-base.js'
const schema = Joi.object({
status: Joi.string().required(),
}).required()
-const pingpongDocumentation = `
-
- To see more details about this badge and obtain your api key, visit
- https://my.pingpong.one/integrations/badge-status/
-
-`
-
-export default class PingPongStatus extends BaseJsonService {
- static category = 'monitoring'
+export default class PingPongStatus extends BasePingPongService {
static route = { base: 'pingpong/status', pattern: ':apiKey' }
- static examples = [
- {
- title: 'PingPong status',
- namedParams: { apiKey: 'sp_2e80bc00b6054faeb2b87e2464be337e' },
- staticPreview: this.render({ status: 'Operational' }),
- documentation: pingpongDocumentation,
- keywords: ['statuspage', 'status page'],
+ static openApi = {
+ '/pingpong/status/{apiKey}': {
+ get: {
+ summary: 'PingPong status',
+ description,
+ parameters: pathParams({
+ name: 'apiKey',
+ example: 'sp_2e80bc00b6054faeb2b87e2464be337e',
+ }),
+ },
},
- ]
+ }
static defaultBadgeData = { label: 'status' }
- static validateApiKey({ apiKey }) {
- if (!apiKey.startsWith('sp_')) {
- throw new InvalidParameter({
- prettyMessage: 'invalid api key',
- })
- }
- }
-
static render({ status }) {
switch (status) {
case 'Operational':
@@ -56,13 +44,13 @@ export default class PingPongStatus extends BaseJsonService {
async fetch({ apiKey }) {
return this._requestJson({
schema,
- url: `https://api.pingpong.one/widget/shields/status/${apiKey}`,
+ url: `${baseUrl}/status/${apiKey}`,
})
}
async handle({ apiKey }) {
this.constructor.validateApiKey({ apiKey })
- const { status } = await this.fetch({ apiKey })
+ const { status } = await this.fetch({ apiKey, schema })
return this.constructor.render({ status })
}
}
diff --git a/services/pingpong/pingpong-status.tester.js b/services/pingpong/pingpong-status.tester.js
index 021e6c1897854..fe147a63f9d76 100644
--- a/services/pingpong/pingpong-status.tester.js
+++ b/services/pingpong/pingpong-status.tester.js
@@ -6,7 +6,7 @@ const isCorrectStatus = Joi.string().valid(
'up',
'issues',
'down',
- 'maintenance'
+ 'maintenance',
)
t.create('PingPong: Status (valid)')
@@ -23,6 +23,6 @@ t.create('PingPong: Status (unexpected response)')
nock =>
nock('https://api.pingpong.one')
.get('/widget/shields/status/sp_key')
- .reply(200, '{"status": "up"}') // unexpected status message
+ .reply(200, '{"status": "up"}'), // unexpected status message
)
.expectBadge({ label: 'status', message: 'Unknown status received' })
diff --git a/services/pingpong/pingpong-uptime.service.js b/services/pingpong/pingpong-uptime.service.js
index eb58d7e456b43..b3160fe035186 100644
--- a/services/pingpong/pingpong-uptime.service.js
+++ b/services/pingpong/pingpong-uptime.service.js
@@ -1,42 +1,30 @@
import Joi from 'joi'
import { coveragePercentage } from '../color-formatters.js'
-import { BaseJsonService, InvalidParameter } from '../index.js'
+import { pathParams } from '../index.js'
+import { BasePingPongService, baseUrl, description } from './pingpong-base.js'
const schema = Joi.object({
uptime: Joi.number().min(0).max(100).required(),
}).required()
-const pingpongDocumentation = `
-
- To see more details about this badge and obtain your api key, visit
- https://my.pingpong.one/integrations/badge-uptime/
-
-`
-
-export default class PingPongUptime extends BaseJsonService {
- static category = 'monitoring'
+export default class PingPongUptime extends BasePingPongService {
static route = { base: 'pingpong/uptime', pattern: ':apiKey' }
- static examples = [
- {
- title: 'PingPong uptime (last 30 days)',
- namedParams: { apiKey: 'sp_2e80bc00b6054faeb2b87e2464be337e' },
- staticPreview: this.render({ uptime: 100 }),
- documentation: pingpongDocumentation,
- keywords: ['statuspage', 'status page'],
+ static openApi = {
+ '/pingpong/uptime/{apiKey}': {
+ get: {
+ summary: 'PingPong uptime (last 30 days)',
+ description,
+ parameters: pathParams({
+ name: 'apiKey',
+ example: 'sp_2e80bc00b6054faeb2b87e2464be337e',
+ }),
+ },
},
- ]
+ }
static defaultBadgeData = { label: 'uptime' }
- static validateApiKey({ apiKey }) {
- if (!apiKey.startsWith('sp_')) {
- throw new InvalidParameter({
- prettyMessage: 'invalid api key',
- })
- }
- }
-
static render({ uptime }) {
return {
message: `${uptime}%`,
@@ -47,13 +35,13 @@ export default class PingPongUptime extends BaseJsonService {
async fetch({ apiKey }) {
return this._requestJson({
schema,
- url: `https://api.pingpong.one/widget/shields/uptime/${apiKey}`,
+ url: `${baseUrl}/uptime/${apiKey}`,
})
}
async handle({ apiKey }) {
this.constructor.validateApiKey({ apiKey })
- const { uptime } = await this.fetch({ apiKey })
+ const { uptime } = await this.fetch({ apiKey, schema })
return this.constructor.render({ uptime })
}
}
diff --git a/services/pipenv-helpers.js b/services/pipenv-helpers.js
index f5663f6b363e8..90c201c4278af 100644
--- a/services/pipenv-helpers.js
+++ b/services/pipenv-helpers.js
@@ -1,25 +1,50 @@
+/**
+ * Common functions and utilities for tasks related to pipenv
+ *
+ * @module
+ */
+
import Joi from 'joi'
import { InvalidParameter } from './index.js'
-const isDependency = Joi.alternatives(
- Joi.object({
- version: Joi.string().required(),
- }).required(),
- Joi.object({
- ref: Joi.string().required(),
- }).required()
-)
+/**
+ * Joi schema for validating dependency.
+ *
+ * @type {Joi}
+ */
+const isDependency = Joi.object({
+ version: Joi.string(),
+ ref: Joi.string(),
+}).required()
+/**
+ * Joi schema for validating lock file object.
+ * Checks if the lock file object has required properties and the properties are valid.
+ *
+ * @type {Joi}
+ */
const isLockfile = Joi.object({
_meta: Joi.object({
requires: Joi.object({
python_version: Joi.string(),
}).required(),
}).required(),
- default: Joi.object().pattern(Joi.string().required(), isDependency),
- develop: Joi.object().pattern(Joi.string().required(), isDependency),
+ default: Joi.object().pattern(Joi.string(), isDependency),
+ develop: Joi.object().pattern(Joi.string(), isDependency),
}).required()
+/**
+ * Determines the dependency version based on the dependency type.
+ *
+ * @param {object} attrs - Refer to individual attributes
+ * @param {string} attrs.kind - Wanted dependency type ('dev' or 'default'), defaults to 'default'
+ * @param {string} attrs.wantedDependency - Name of the wanted dependency
+ * @param {object} attrs.lockfileData - Object containing lock file data
+ * @throws {Error} - Error if unknown dependency type provided
+ * @throws {InvalidParameter} - Error if wanted dependency is not present in lock file data
+ * @throws {InvalidParameter} - Error if version or ref is not present for the wanted dependency
+ * @returns {object} Object containing wanted dependency version or ref
+ */
function getDependencyVersion({
kind = 'default',
wantedDependency,
@@ -45,8 +70,16 @@ function getDependencyVersion({
if (version) {
// Strip the `==` which is always present.
return { version: version.replace('==', '') }
+ } else if (ref) {
+ if (ref.length === 40) {
+ // assume it is a commit hash
+ return { ref: ref.substring(0, 7) }
+ }
+ return { ref } // tag
} else {
- return { ref: ref.substring(1, 8) }
+ throw new InvalidParameter({
+ prettyMessage: `No version or ref for ${wantedDependency}`,
+ })
}
}
diff --git a/services/pipenv-helpers.spec.js b/services/pipenv-helpers.spec.js
new file mode 100644
index 0000000000000..af7244ac64013
--- /dev/null
+++ b/services/pipenv-helpers.spec.js
@@ -0,0 +1,131 @@
+import { expect } from 'chai'
+import { getDependencyVersion } from './pipenv-helpers.js'
+import { InvalidParameter } from './index.js'
+
+describe('getDependencyVersion', function () {
+ // loosely based on https://github.com/pypa/pipfile#pipfilelock
+ const packages = {
+ chardet: {
+ hashes: [
+ 'sha256:fc323ffcaeaed0e0a02bf4d117757b98aed530d9ed4531e3e15460124c106691',
+ 'sha256:84ab92ed1c4d4f16916e05906b6b75a6c0fb5db821cc65e70cbd64a3e2a5eaae',
+ ],
+ version: '==3.0.4',
+ },
+ django: {
+ editable: true,
+ git: 'https://github.com/django/django.git',
+ ref: '1.11.4001',
+ },
+ 'django-cms': {
+ file: 'https://github.com/divio/django-cms/archive/release/3.4.x.zip',
+ },
+ 'discordlists-py': {
+ git: 'https://github.com/HexCodeFFF/discordlists.py',
+ ref: '2df5a2b62144b49774728efa8267d909a8a9787f',
+ },
+ }
+
+ it('throws if dependency not found in (in default object with data)', function () {
+ expect(() =>
+ getDependencyVersion({
+ wantedDependency: 'requests',
+ lockfileData: {
+ default: packages,
+ develop: {},
+ },
+ }),
+ )
+ .to.throw(InvalidParameter)
+ .with.property('prettyMessage', 'default dependency not found')
+ })
+
+ it('throws if dependency not found in (in empty dev object)', function () {
+ expect(() =>
+ getDependencyVersion({
+ kind: 'dev',
+ wantedDependency: 'requests',
+ lockfileData: {
+ default: packages,
+ develop: {},
+ },
+ }),
+ )
+ .to.throw(InvalidParameter)
+ .with.property('prettyMessage', 'dev dependency not found')
+ })
+
+ it('tolerates missing keys', function () {
+ expect(
+ getDependencyVersion({
+ wantedDependency: 'chardet',
+ lockfileData: {
+ default: packages,
+ },
+ }),
+ ).to.deep.equal({ version: '3.0.4' })
+ })
+
+ it('finds package in develop object', function () {
+ expect(
+ getDependencyVersion({
+ kind: 'dev',
+ wantedDependency: 'chardet',
+ lockfileData: {
+ default: {},
+ develop: packages,
+ },
+ }),
+ ).to.deep.equal({ version: '3.0.4' })
+ })
+
+ it('returns version if present', function () {
+ expect(
+ getDependencyVersion({
+ wantedDependency: 'chardet',
+ lockfileData: {
+ default: packages,
+ develop: {},
+ },
+ }),
+ ).to.deep.equal({ version: '3.0.4' })
+ })
+
+ it('returns (complete) ref if ref is tag', function () {
+ expect(
+ getDependencyVersion({
+ wantedDependency: 'django',
+ lockfileData: {
+ default: packages,
+ develop: {},
+ },
+ }),
+ ).to.deep.equal({ ref: '1.11.4001' })
+ })
+
+ it('returns truncated ref if ref is commit hash', function () {
+ expect(
+ getDependencyVersion({
+ wantedDependency: 'discordlists-py',
+ lockfileData: {
+ default: packages,
+ develop: {},
+ },
+ }),
+ ).to.deep.equal({ ref: '2df5a2b' })
+ })
+
+ it('throws if no version or ref', function () {
+ expect(() =>
+ getDependencyVersion({
+ wantedDependency: 'django-cms',
+ lockfileData: {
+ default: packages,
+ develop: {},
+ },
+ }),
+ )
+ .to.throw(InvalidParameter)
+ .with.property('prettyMessage', 'No version or ref for django-cms')
+ })
+})
diff --git a/services/piwheels/piwheels-version.service.js b/services/piwheels/piwheels-version.service.js
new file mode 100644
index 0000000000000..49f9397fc014f
--- /dev/null
+++ b/services/piwheels/piwheels-version.service.js
@@ -0,0 +1,104 @@
+import Joi from 'joi'
+import {
+ BaseJsonService,
+ InvalidResponse,
+ pathParam,
+ queryParam,
+} from '../index.js'
+import { renderVersionBadge } from '../version.js'
+import { pep440VersionColor } from '../color-formatters.js'
+
+const schema = Joi.object({
+ releases: Joi.object()
+ .pattern(
+ Joi.string(),
+ Joi.object({
+ prerelease: Joi.boolean().required(),
+ yanked: Joi.boolean().required(),
+ files: Joi.object().required(),
+ }),
+ )
+ .required(),
+}).required()
+
+const queryParamSchema = Joi.object({
+ include_prereleases: Joi.equal(''),
+}).required()
+
+export default class PiWheelsVersion extends BaseJsonService {
+ static category = 'version'
+
+ static route = { base: 'piwheels/v', pattern: ':wheel', queryParamSchema }
+
+ static openApi = {
+ '/piwheels/v/{wheel}': {
+ get: {
+ summary: 'PiWheels Version',
+ description:
+ '[PiWheels](https://www.piwheels.org/) is a Python package repository providing Arm platform wheels for the Raspberry Pi',
+ parameters: [
+ pathParam({
+ name: 'wheel',
+ example: 'flask',
+ }),
+ queryParam({
+ name: 'include_prereleases',
+ schema: { type: 'boolean' },
+ example: null,
+ }),
+ ],
+ },
+ },
+ }
+
+ static defaultBadgeData = { label: 'piwheels' }
+
+ static render({ version }) {
+ return renderVersionBadge({ version, versionFormatter: pep440VersionColor })
+ }
+
+ async fetch({ wheel }) {
+ return this._requestJson({
+ schema,
+ url: `https://www.piwheels.org/project/${wheel}/json/`,
+ httpErrors: { 404: 'package not found' },
+ })
+ }
+
+ static transform(releases, includePrereleases) {
+ const allReleases = Object.keys(releases)
+ .reduce(
+ (acc, key) =>
+ acc.concat({
+ version: key,
+ prerelease: releases[key].prerelease,
+ yanked: releases[key].yanked,
+ hasFiles: Object.keys(releases[key].files).length > 0,
+ }),
+ [],
+ )
+ .filter(release => !release.yanked) // exclude any yanked releases
+ .filter(release => release.hasFiles) // exclude any releases with no wheels
+
+ if (allReleases.length === 0) {
+ throw new InvalidResponse({ prettyMessage: 'no versions found' })
+ }
+
+ if (includePrereleases) {
+ return allReleases[0].version
+ }
+
+ const stableReleases = allReleases.filter(release => !release.prerelease)
+ if (stableReleases.length > 0) {
+ return stableReleases[0].version
+ }
+ return allReleases[0].version
+ }
+
+ async handle({ wheel }, queryParams) {
+ const includePrereleases = queryParams.include_prereleases !== undefined
+ const { releases } = await this.fetch({ wheel })
+ const version = this.constructor.transform(releases, includePrereleases)
+ return this.constructor.render({ version })
+ }
+}
diff --git a/services/piwheels/piwheels-version.spec.js b/services/piwheels/piwheels-version.spec.js
new file mode 100644
index 0000000000000..70a9ee85c0373
--- /dev/null
+++ b/services/piwheels/piwheels-version.spec.js
@@ -0,0 +1,65 @@
+import { expect } from 'chai'
+import { test, given } from 'sazerac'
+import { InvalidResponse } from '../index.js'
+import PiWheelsVersion from './piwheels-version.service.js'
+
+describe('PiWheelsVersion', function () {
+ test(PiWheelsVersion.transform, () => {
+ given(
+ {
+ '2.0.0rc1': { prerelease: true, yanked: false, files: { foobar: {} } },
+ '1.9.0': { prerelease: false, yanked: false, files: { foobar: {} } },
+ },
+ false,
+ ).expect('1.9.0')
+ given(
+ {
+ '2.0.0rc1': { prerelease: true, yanked: false, files: { foobar: {} } },
+ '1.9.0': { prerelease: false, yanked: false, files: { foobar: {} } },
+ },
+ true,
+ ).expect('2.0.0rc1')
+ given(
+ {
+ '2.0.0': { prerelease: false, yanked: true, files: { foobar: {} } },
+ '1.9.0': { prerelease: false, yanked: false, files: { foobar: {} } },
+ },
+ false,
+ ).expect('1.9.0')
+ given(
+ {
+ '2.0.0': { prerelease: false, yanked: false, files: {} },
+ '1.9.0': { prerelease: false, yanked: false, files: { foobar: {} } },
+ },
+ false,
+ ).expect('1.9.0')
+ given(
+ {
+ '2.0.0': { prerelease: false, yanked: false, files: { foobar: {} } },
+ '1.9.0': { prerelease: false, yanked: false, files: { foobar: {} } },
+ },
+ false,
+ ).expect('2.0.0')
+ given(
+ {
+ '2.0.0rc2': { prerelease: true, yanked: false, files: { foobar: {} } },
+ '2.0.0rc1': { prerelease: true, yanked: false, files: { foobar: {} } },
+ },
+ false,
+ ).expect('2.0.0rc2')
+ })
+
+ it('throws `no releases` InvalidResponse if no versions', function () {
+ expect(() =>
+ PiWheelsVersion.transform(
+ {
+ '1.0.1': { prerelease: false, yanked: false, files: {} },
+ '1.0.0': { prerelease: false, yanked: true, files: { foobar: {} } },
+ },
+ false,
+ ),
+ )
+ .to.throw(InvalidResponse)
+ .with.property('prettyMessage', 'no versions found')
+ })
+})
diff --git a/services/piwheels/piwheels-version.tester.js b/services/piwheels/piwheels-version.tester.js
new file mode 100644
index 0000000000000..674596b1c0c49
--- /dev/null
+++ b/services/piwheels/piwheels-version.tester.js
@@ -0,0 +1,13 @@
+import { isVPlusDottedVersionNClauses } from '../test-validators.js'
+import { createServiceTester } from '../tester.js'
+export const t = await createServiceTester()
+
+t.create('version (valid)').get('/flask.json').expectBadge({
+ label: 'piwheels',
+ message: isVPlusDottedVersionNClauses,
+})
+
+t.create('version (does not exist)').get('/doesn-not-exist.json').expectBadge({
+ label: 'piwheels',
+ message: 'package not found',
+})
diff --git a/services/pkgreview/package-rating.service.js b/services/pkgreview/package-rating.service.js
deleted file mode 100644
index 25dbe0cba4bd5..0000000000000
--- a/services/pkgreview/package-rating.service.js
+++ /dev/null
@@ -1,83 +0,0 @@
-import Joi from 'joi'
-import { starRating, metric } from '../text-formatters.js'
-import { colorScale } from '../color-formatters.js'
-import { nonNegativeInteger } from '../validators.js'
-import { BaseJsonService } from '../index.js'
-
-const pkgReviewColor = colorScale([2, 3, 4])
-
-const schema = Joi.object({
- rating: Joi.number().min(0).max(1).precision(1).required().allow(null),
- reviewsCount: nonNegativeInteger,
-}).required()
-
-// Repository for this service is: https://github.com/iqubex-technologies/pkgreview.dev
-// Internally the service leverages the npms.io API (https://api.npms.io/v2)
-export default class PkgreviewRating extends BaseJsonService {
- static category = 'rating'
-
- static route = {
- base: 'pkgreview',
- pattern: ':format(rating|stars)/:pkgManager(npm)/:pkgSlug+',
- }
-
- static examples = [
- {
- title: 'pkgreview.dev Package Ratings',
- pattern: 'rating/:pkgManager/:pkgSlug+',
- namedParams: { pkgManager: 'npm', pkgSlug: 'react' },
- staticPreview: this.render({
- format: 'rating',
- rating: 3.5,
- reviewsCount: 237,
- }),
- },
- {
- title: 'pkgreview.dev Star Ratings',
- pattern: 'stars/:pkgManager/:pkgSlug+',
- namedParams: { pkgManager: 'npm', pkgSlug: 'react' },
- staticPreview: this.render({
- format: 'stars',
- rating: 1.5,
- reviewsCount: 200,
- }),
- },
- ]
-
- static render({ rating, reviewsCount, format }) {
- const message =
- format === 'rating'
- ? `${+parseFloat(rating).toFixed(1)}/5 (${metric(reviewsCount)})`
- : starRating(rating)
-
- return {
- message,
- label: format,
- color: pkgReviewColor(rating),
- }
- }
-
- async fetch({ pkgManager, pkgSlug }) {
- return this._requestJson({
- schema,
- url: `https://pkgreview.now.sh/api/v1/${pkgManager}/${encodeURIComponent(
- pkgSlug
- )}`,
- errorMessages: {
- 404: 'package not found',
- },
- })
- }
-
- async handle({ format, pkgManager, pkgSlug }) {
- const { reviewsCount, rating } = await this.fetch({
- pkgManager,
- pkgSlug,
- })
- return this.constructor.render({
- reviewsCount,
- format,
- rating: rating * 5,
- })
- }
-}
diff --git a/services/pkgreview/package-rating.tester.js b/services/pkgreview/package-rating.tester.js
deleted file mode 100644
index ad4b483e56d7c..0000000000000
--- a/services/pkgreview/package-rating.tester.js
+++ /dev/null
@@ -1,23 +0,0 @@
-import { withRegex, isStarRating } from '../test-validators.js'
-import { createServiceTester } from '../tester.js'
-export const t = await createServiceTester()
-
-const isRatingWithReviews = withRegex(
- /^(([0-4](.?([0-9]))?)|5)\/5?\s*\([0-9]*\)$/
-)
-
-t.create('Stars Badge renders')
- .get('/stars/npm/react.json')
- .expectBadge({ label: 'stars', message: isStarRating })
-
-t.create('Rating Badge renders')
- .get('/rating/npm/react.json')
- .expectBadge({ label: 'rating', message: isRatingWithReviews })
-
-t.create('nonexistent package')
- .get('/rating/npm/ohlolweallknowthispackagewontexist.json')
- .expectBadge({
- label: 'rating',
- message: 'package not found',
- color: 'red',
- })
diff --git a/services/poeditor/poeditor.service.js b/services/poeditor/poeditor.service.js
index 806d9bfcfca28..b040168596a57 100644
--- a/services/poeditor/poeditor.service.js
+++ b/services/poeditor/poeditor.service.js
@@ -1,17 +1,19 @@
import Joi from 'joi'
import { nonNegativeInteger } from '../validators.js'
import { coveragePercentage } from '../color-formatters.js'
-import { BaseJsonService, InvalidResponse } from '../index.js'
+import {
+ BaseJsonService,
+ InvalidResponse,
+ pathParam,
+ queryParam,
+} from '../index.js'
-const documentation = `
-
- You must specify the read-only API token from the POEditor account to which the project belongs.
-
-
- As per the POEditor API documentation ,
- all requests to the API must contain the parameter api_token. You can get a read-only key from your POEditor account.
- You'll find it in My Account > API Access .
-
+const description = `
+POEditor is an web-based tool for translation and internationalization
+
+All requests to must contain the parameter \`token\`.
+You can get a read-only token from your POEditor account in [My Account > API Access](https://poeditor.com/account/api).
+This token will be exposed as part of the badge URL so be sure to generate a read-only token.
`
const schema = Joi.object({
@@ -43,20 +45,25 @@ export default class POEditor extends BaseJsonService {
queryParamSchema,
}
- static examples = [
- {
- title: 'POEditor',
- namedParams: { projectId: '323337', languageCode: 'fr' },
- queryParams: { token: 'abc123def456' },
- staticPreview: this.render({
- code: 200,
- message: 'OK',
- language: { percentage: 93, code: 'fr', name: 'French' },
- }),
- keywords: ['l10n'],
- documentation,
+ static openApi = {
+ '/poeditor/progress/{projectId}/{languageCode}': {
+ get: {
+ summary: 'POEditor',
+ description,
+ parameters: [
+ pathParam({ name: 'projectId', example: '323337' }),
+ pathParam({ name: 'languageCode', example: 'fr' }),
+ queryParam({
+ name: 'token',
+ example: 'abc123def456',
+ description:
+ 'A read-only token from your POEditor account from [My Account > API Access](https://poeditor.com/account/api)',
+ required: true,
+ }),
+ ],
+ },
},
- ]
+ }
static render({ code, message, language }) {
if (code !== 200) {
diff --git a/services/poeditor/poeditor.tester.js b/services/poeditor/poeditor.tester.js
index 691f4d816fa4c..1797ae78acda0 100644
--- a/services/poeditor/poeditor.tester.js
+++ b/services/poeditor/poeditor.tester.js
@@ -51,7 +51,7 @@ t.create('gets mock POEditor progress')
id: '1234',
api_token: 'abc123def456',
})
- .reply(200, apiResponse)
+ .reply(200, apiResponse),
)
.expectBadge({
label: 'French',
@@ -66,7 +66,7 @@ t.create('handles requests for missing languages')
id: '1234',
api_token: 'abc123def456',
})
- .reply(200, apiResponse)
+ .reply(200, apiResponse),
)
.expectBadge({
label: 'other',
@@ -87,7 +87,7 @@ t.create('handles requests for wrong keys')
code: '403',
message: "You don't have permission to access this resource",
},
- })
+ }),
)
.expectBadge({
label: 'other',
diff --git a/services/polymart/polymart-base.js b/services/polymart/polymart-base.js
new file mode 100644
index 0000000000000..2efbd9941b254
--- /dev/null
+++ b/services/polymart/polymart-base.js
@@ -0,0 +1,50 @@
+import Joi from 'joi'
+import { BaseJsonService } from '../index.js'
+
+const resourceSchema = Joi.object({
+ response: Joi.object({
+ resource: Joi.object({
+ downloads: Joi.number().required(),
+ reviews: Joi.object({
+ count: Joi.number().required(),
+ stars: Joi.number().required(),
+ }).required(),
+ updates: Joi.object({
+ latest: Joi.object({
+ version: Joi.string().required(),
+ }).required(),
+ }).required(),
+ }).required(),
+ }).required(),
+}).required()
+
+const notFoundResourceSchema = Joi.object({
+ response: Joi.object({
+ success: Joi.boolean().required(),
+ errors: Joi.object().required(),
+ }).required(),
+})
+
+const resourceFoundOrNotSchema = Joi.alternatives(
+ resourceSchema,
+ notFoundResourceSchema,
+)
+
+const description = `
+You can find your resource ID in the url for your resource page.
+Example: https://polymart.org/resource/polymart-plugin.323 - Here the Resource ID is 323.
`
+
+class BasePolymartService extends BaseJsonService {
+ async fetch({
+ resourceId,
+ schema = resourceFoundOrNotSchema,
+ url = `https://api.polymart.org/v1/getResourceInfo/?resource_id=${resourceId}`,
+ }) {
+ return this._requestJson({
+ schema,
+ url,
+ })
+ }
+}
+
+export { description, BasePolymartService }
diff --git a/services/polymart/polymart-downloads.service.js b/services/polymart/polymart-downloads.service.js
new file mode 100644
index 0000000000000..70668149a973f
--- /dev/null
+++ b/services/polymart/polymart-downloads.service.js
@@ -0,0 +1,38 @@
+import { pathParams } from '../index.js'
+import { NotFound } from '../../core/base-service/errors.js'
+import { renderDownloadsBadge } from '../downloads.js'
+import { BasePolymartService, description } from './polymart-base.js'
+
+export default class PolymartDownloads extends BasePolymartService {
+ static category = 'downloads'
+
+ static route = {
+ base: 'polymart/downloads',
+ pattern: ':resourceId',
+ }
+
+ static openApi = {
+ '/polymart/downloads/{resourceId}': {
+ get: {
+ summary: 'Polymart Downloads',
+ description,
+ parameters: pathParams({
+ name: 'resourceId',
+ example: '323',
+ }),
+ },
+ },
+ }
+
+ static defaultBadgeData = {
+ label: 'downloads',
+ }
+
+ async handle({ resourceId }) {
+ const { response } = await this.fetch({ resourceId })
+ if (!response.resource) {
+ throw new NotFound()
+ }
+ return renderDownloadsBadge({ downloads: response.resource.downloads })
+ }
+}
diff --git a/services/polymart/polymart-downloads.tester.js b/services/polymart/polymart-downloads.tester.js
new file mode 100644
index 0000000000000..4bc7f3c356646
--- /dev/null
+++ b/services/polymart/polymart-downloads.tester.js
@@ -0,0 +1,13 @@
+import { isMetric } from '../test-validators.js'
+import { createServiceTester } from '../tester.js'
+export const t = await createServiceTester()
+
+t.create('Polymart Plugin (id 323)').get('/323.json').expectBadge({
+ label: 'downloads',
+ message: isMetric,
+})
+
+t.create('Invalid Resource (id 0)').get('/0.json').expectBadge({
+ label: 'downloads',
+ message: 'not found',
+})
diff --git a/services/polymart/polymart-latest-version.service.js b/services/polymart/polymart-latest-version.service.js
new file mode 100644
index 0000000000000..0f51013d35231
--- /dev/null
+++ b/services/polymart/polymart-latest-version.service.js
@@ -0,0 +1,40 @@
+import { pathParams } from '../index.js'
+import { NotFound } from '../../core/base-service/errors.js'
+import { renderVersionBadge } from '../version.js'
+import { BasePolymartService, description } from './polymart-base.js'
+
+export default class PolymartLatestVersion extends BasePolymartService {
+ static category = 'version'
+
+ static route = {
+ base: 'polymart/version',
+ pattern: ':resourceId',
+ }
+
+ static openApi = {
+ '/polymart/version/{resourceId}': {
+ get: {
+ summary: 'Polymart Version',
+ description,
+ parameters: pathParams({
+ name: 'resourceId',
+ example: '323',
+ }),
+ },
+ },
+ }
+
+ static defaultBadgeData = {
+ label: 'polymart',
+ }
+
+ async handle({ resourceId }) {
+ const { response } = await this.fetch({ resourceId })
+ if (!response.resource) {
+ throw new NotFound()
+ }
+ return renderVersionBadge({
+ version: response.resource.updates.latest.version,
+ })
+ }
+}
diff --git a/services/polymart/polymart-latest-version.tester.js b/services/polymart/polymart-latest-version.tester.js
new file mode 100644
index 0000000000000..bff2a8e3c37f6
--- /dev/null
+++ b/services/polymart/polymart-latest-version.tester.js
@@ -0,0 +1,13 @@
+import { isVPlusDottedVersionNClauses } from '../test-validators.js'
+import { createServiceTester } from '../tester.js'
+export const t = await createServiceTester()
+
+t.create('Polymart Plugin (id 323)').get('/323.json').expectBadge({
+ label: 'polymart',
+ message: isVPlusDottedVersionNClauses,
+})
+
+t.create('Invalid Resource (id 0)').get('/0.json').expectBadge({
+ label: 'polymart',
+ message: 'not found',
+})
diff --git a/services/polymart/polymart-rating.service.js b/services/polymart/polymart-rating.service.js
new file mode 100644
index 0000000000000..adca40d05d9de
--- /dev/null
+++ b/services/polymart/polymart-rating.service.js
@@ -0,0 +1,61 @@
+import { pathParams } from '../index.js'
+import { starRating, metric } from '../text-formatters.js'
+import { floorCount } from '../color-formatters.js'
+import { NotFound } from '../../core/base-service/errors.js'
+import { BasePolymartService, description } from './polymart-base.js'
+
+export default class PolymartRatings extends BasePolymartService {
+ static category = 'rating'
+
+ static route = {
+ base: 'polymart',
+ pattern: ':format(rating|stars)/:resourceId',
+ }
+
+ static openApi = {
+ '/polymart/{format}/{resourceId}': {
+ get: {
+ summary: 'Polymart Rating',
+ description,
+ parameters: pathParams(
+ {
+ name: 'format',
+ example: 'rating',
+ schema: { type: 'string', enum: this.getEnum('format') },
+ },
+ {
+ name: 'resourceId',
+ example: '323',
+ },
+ ),
+ },
+ },
+ }
+
+ static defaultBadgeData = {
+ label: 'rating',
+ }
+
+ static render({ format, total, average }) {
+ const message =
+ format === 'stars'
+ ? starRating(average)
+ : `${average}/5 (${metric(total)})`
+ return {
+ message,
+ color: floorCount(average, 2, 3, 4),
+ }
+ }
+
+ async handle({ format, resourceId }) {
+ const { response } = await this.fetch({ resourceId })
+ if (!response.resource) {
+ throw new NotFound()
+ }
+ return this.constructor.render({
+ format,
+ total: response.resource.reviews.count,
+ average: response.resource.reviews.stars.toFixed(2),
+ })
+ }
+}
diff --git a/services/polymart/polymart-rating.tester.js b/services/polymart/polymart-rating.tester.js
new file mode 100644
index 0000000000000..547d0d988f6c3
--- /dev/null
+++ b/services/polymart/polymart-rating.tester.js
@@ -0,0 +1,27 @@
+import { isStarRating, withRegex } from '../test-validators.js'
+import { createServiceTester } from '../tester.js'
+export const t = await createServiceTester()
+
+t.create('Stars - Polymart Plugin (id 323)')
+ .get('/stars/323.json')
+ .expectBadge({
+ label: 'rating',
+ message: isStarRating,
+ })
+
+t.create('Stars - Invalid Resource (id 0)').get('/stars/0.json').expectBadge({
+ label: 'rating',
+ message: 'not found',
+})
+
+t.create('Rating - Polymart Plugin (id 323)')
+ .get('/rating/323.json')
+ .expectBadge({
+ label: 'rating',
+ message: withRegex(/^(\d*\.\d+)(\/5 \()(\d+)(\))$/),
+ })
+
+t.create('Rating - Invalid Resource (id 0)').get('/rating/0.json').expectBadge({
+ label: 'rating',
+ message: 'not found',
+})
diff --git a/services/powershellgallery/powershellgallery.service.js b/services/powershellgallery/powershellgallery.service.js
index c5e66108deb60..24fc16780cc17 100644
--- a/services/powershellgallery/powershellgallery.service.js
+++ b/services/powershellgallery/powershellgallery.service.js
@@ -1,5 +1,5 @@
import { fetch, createServiceFamily } from '../nuget/nuget-v2-service-family.js'
-import { BaseXmlService } from '../index.js'
+import { BaseXmlService, pathParams } from '../index.js'
const WINDOWS_TAG_NAME = 'windows'
const MACOS_TAG_NAME = 'macos'
@@ -16,12 +16,8 @@ const {
defaultLabel: 'powershell gallery',
serviceBaseUrl: 'powershellgallery',
apiBaseUrl,
- odataFormat: 'xml',
title: 'PowerShell Gallery',
examplePackageName: 'Azure.Storage',
- exampleVersion: '4.4.0',
- examplePrereleaseVersion: '4.4.1-preview',
- exampleDownloadCount: 1.2e7,
})
class PowershellGalleryPlatformSupport extends BaseXmlService {
@@ -32,15 +28,17 @@ class PowershellGalleryPlatformSupport extends BaseXmlService {
pattern: ':packageName',
}
- static examples = [
- {
- title: 'PowerShell Gallery',
- namedParams: { packageName: 'DNS.1.1.1.1' },
- staticPreview: this.render({
- platforms: ['windows', 'macos', 'linux'],
- }),
+ static openApi = {
+ '/powershellgallery/p/{packageName}': {
+ get: {
+ summary: 'PowerShell Gallery Platform Support',
+ parameters: pathParams({
+ name: 'packageName',
+ example: 'PackageManagement',
+ }),
+ },
},
- ]
+ }
static defaultBadgeData = {
label: 'platform',
@@ -55,7 +53,6 @@ class PowershellGalleryPlatformSupport extends BaseXmlService {
async handle({ packageName }) {
const { Tags: tagStr } = await fetch(this, {
baseUrl: apiBaseUrl,
- odataFormat: 'xml',
packageName,
})
diff --git a/services/powershellgallery/powershellgallery.tester.js b/services/powershellgallery/powershellgallery.tester.js
index 14380ca4870de..61b7a95d70dde 100644
--- a/services/powershellgallery/powershellgallery.tester.js
+++ b/services/powershellgallery/powershellgallery.tester.js
@@ -6,7 +6,7 @@ import {
isVPlusDottedVersionNClausesWithOptionalSuffix,
} from '../test-validators.js'
const isPlatform = Joi.string().regex(
- /^(windows|linux|macos)( \| (windows|linux|macos))*$/
+ /^(windows|linux|macos)( \| (windows|linux|macos))*$/,
)
export const t = new ServiceTester({
@@ -44,10 +44,13 @@ t.create('version (pre) (not found)')
.expectBadge({ label: 'powershell gallery', message: 'not found' })
t.create('version (legacy redirect: vpre)')
- .get('/vpre/ACMESharp.svg')
- .expectRedirect('/powershellgallery/v/ACMESharp.svg?include_prereleases')
+ .get('/vpre/ACMESharp.json')
+ .expectBadge({
+ label: 'powershell gallery',
+ message: 'https://github.com/badges/shields/pull/11583',
+ })
-t.create('platform (valid').get('/p/DNS.1.1.1.1.json').expectBadge({
+t.create('platform (valid)').get('/p/PackageManagement.json').expectBadge({
label: 'platform',
message: isPlatform,
})
diff --git a/services/pub/pub-common.js b/services/pub/pub-common.js
new file mode 100644
index 0000000000000..a93d10bed5c52
--- /dev/null
+++ b/services/pub/pub-common.js
@@ -0,0 +1,4 @@
+const baseDescription =
+ 'Pub is a package registry for Dart and Flutter.
'
+
+export { baseDescription }
diff --git a/services/pub/pub-downloads.service.js b/services/pub/pub-downloads.service.js
new file mode 100644
index 0000000000000..eb79c0558e8ca
--- /dev/null
+++ b/services/pub/pub-downloads.service.js
@@ -0,0 +1,53 @@
+import Joi from 'joi'
+import { BaseJsonService, pathParams } from '../index.js'
+import { renderDownloadsBadge } from '../downloads.js'
+import { nonNegativeInteger } from '../validators.js'
+import { baseDescription } from './pub-common.js'
+
+const description = `${baseDescription}
+ This badge shows a measure of how many developers have downloaded a package monthly.
`
+
+const schema = Joi.object({
+ downloadCount30Days: nonNegativeInteger,
+}).required()
+
+export default class PubDownloads extends BaseJsonService {
+ static category = 'downloads'
+
+ static route = { base: 'pub/dm', pattern: ':packageName' }
+
+ static openApi = {
+ '/pub/dm/{packageName}': {
+ get: {
+ summary: 'Pub Monthly Downloads',
+ description,
+ parameters: pathParams({
+ name: 'packageName',
+ example: 'analysis_options',
+ }),
+ },
+ },
+ }
+
+ static defaultBadgeData = { label: 'downloads' }
+
+ static render({ downloadCount30Days }) {
+ return renderDownloadsBadge({
+ downloads: downloadCount30Days,
+ interval: 'month',
+ })
+ }
+
+ async fetch({ packageName }) {
+ return this._requestJson({
+ schema,
+ url: `https://pub.dev/api/packages/${packageName}/score`,
+ })
+ }
+
+ async handle({ packageName }) {
+ const score = await this.fetch({ packageName })
+ const downloadCount30Days = score.downloadCount30Days
+ return this.constructor.render({ downloadCount30Days })
+ }
+}
diff --git a/services/pub/pub-downloads.tester.js b/services/pub/pub-downloads.tester.js
new file mode 100644
index 0000000000000..573393f75ea84
--- /dev/null
+++ b/services/pub/pub-downloads.tester.js
@@ -0,0 +1,20 @@
+import { isMetricOverTimePeriod } from '../test-validators.js'
+import { createServiceTester } from '../tester.js'
+
+export const t = await createServiceTester()
+
+t.create('pub monthly downloads (valid)')
+ .get('/analysis_options.json')
+ .expectBadge({
+ label: 'downloads',
+ message: isMetricOverTimePeriod,
+ color: 'green',
+ })
+
+t.create('pub monthly downloads (not found)')
+ .get('/analysisoptions.json')
+ .expectBadge({
+ label: 'downloads',
+ message: 'not found',
+ color: 'red',
+ })
diff --git a/services/pub/pub-likes.service.js b/services/pub/pub-likes.service.js
new file mode 100644
index 0000000000000..33137bf5e38cb
--- /dev/null
+++ b/services/pub/pub-likes.service.js
@@ -0,0 +1,54 @@
+import Joi from 'joi'
+import { BaseJsonService, pathParams } from '../index.js'
+import { metric } from '../text-formatters.js'
+import { nonNegativeInteger } from '../validators.js'
+import { baseDescription } from './pub-common.js'
+
+const description = `${baseDescription}
+ This badge shows a measure of how many developers have liked a package. This provides a raw measure of the overall sentiment of a package from peer developers.
`
+
+const schema = Joi.object({
+ likeCount: nonNegativeInteger,
+}).required()
+
+export default class PubLikes extends BaseJsonService {
+ static category = 'rating'
+
+ static route = { base: 'pub/likes', pattern: ':packageName' }
+
+ static openApi = {
+ '/pub/likes/{packageName}': {
+ get: {
+ summary: 'Pub Likes',
+ description,
+ parameters: pathParams({
+ name: 'packageName',
+ example: 'analysis_options',
+ }),
+ },
+ },
+ }
+
+ static defaultBadgeData = { label: 'likes' }
+
+ static render({ likeCount }) {
+ return {
+ label: 'likes',
+ message: metric(likeCount),
+ color: 'blue',
+ }
+ }
+
+ async fetch({ packageName }) {
+ return this._requestJson({
+ schema,
+ url: `https://pub.dev/api/packages/${packageName}/score`,
+ })
+ }
+
+ async handle({ packageName }) {
+ const score = await this.fetch({ packageName })
+ const likeCount = score.likeCount
+ return this.constructor.render({ likeCount })
+ }
+}
diff --git a/services/pub/pub-likes.tester.js b/services/pub/pub-likes.tester.js
new file mode 100644
index 0000000000000..7bbe1d36af469
--- /dev/null
+++ b/services/pub/pub-likes.tester.js
@@ -0,0 +1,16 @@
+import { isMetric } from '../test-validators.js'
+import { createServiceTester } from '../tester.js'
+
+export const t = await createServiceTester()
+
+t.create('pub likes (valid)').get('/analysis_options.json').expectBadge({
+ label: 'likes',
+ message: isMetric,
+ color: 'blue',
+})
+
+t.create('pub likes (not found)').get('/analysisoptions.json').expectBadge({
+ label: 'likes',
+ message: 'not found',
+ color: 'red',
+})
diff --git a/services/pub/pub-points.service.js b/services/pub/pub-points.service.js
new file mode 100644
index 0000000000000..177d26868e479
--- /dev/null
+++ b/services/pub/pub-points.service.js
@@ -0,0 +1,56 @@
+import Joi from 'joi'
+import { floorCount } from '../color-formatters.js'
+import { BaseJsonService, pathParams } from '../index.js'
+import { nonNegativeInteger } from '../validators.js'
+import { baseDescription } from './pub-common.js'
+
+const description = `${baseDescription}
+ This badge shows a measure of quality. This includes several dimensions of quality such as code style, platform support, and maintainability.
`
+
+const schema = Joi.object({
+ grantedPoints: nonNegativeInteger,
+ maxPoints: nonNegativeInteger,
+}).required()
+
+export default class PubPoints extends BaseJsonService {
+ static category = 'rating'
+
+ static route = { base: 'pub/points', pattern: ':packageName' }
+
+ static openApi = {
+ '/pub/points/{packageName}': {
+ get: {
+ summary: 'Pub Points',
+ description,
+ parameters: pathParams({
+ name: 'packageName',
+ example: 'analysis_options',
+ }),
+ },
+ },
+ }
+
+ static defaultBadgeData = { label: 'points' }
+
+ static render({ grantedPoints, maxPoints }) {
+ return {
+ label: 'points',
+ message: `${grantedPoints}/${maxPoints}`,
+ color: floorCount((grantedPoints / maxPoints) * 100, 40, 60, 80),
+ }
+ }
+
+ async fetch({ packageName }) {
+ return this._requestJson({
+ schema,
+ url: `https://pub.dev/api/packages/${packageName}/score`,
+ })
+ }
+
+ async handle({ packageName }) {
+ const score = await this.fetch({ packageName })
+ const grantedPoints = score.grantedPoints
+ const maxPoints = score.maxPoints
+ return this.constructor.render({ grantedPoints, maxPoints })
+ }
+}
diff --git a/services/pub/pub-points.tester.js b/services/pub/pub-points.tester.js
new file mode 100644
index 0000000000000..61b27a3e869be
--- /dev/null
+++ b/services/pub/pub-points.tester.js
@@ -0,0 +1,17 @@
+import Joi from 'joi'
+import { createServiceTester } from '../tester.js'
+
+export const t = await createServiceTester()
+
+t.create('pub points (valid)')
+ .get('/analysis_options.json')
+ .expectBadge({
+ label: 'points',
+ message: Joi.string().regex(/^\d+\/\d+$/),
+ })
+
+t.create('pub points (not found)').get('/analysisoptions.json').expectBadge({
+ label: 'points',
+ message: 'not found',
+ color: 'red',
+})
diff --git a/services/pub/pub-popularity.service.js b/services/pub/pub-popularity.service.js
new file mode 100644
index 0000000000000..787c6889f5134
--- /dev/null
+++ b/services/pub/pub-popularity.service.js
@@ -0,0 +1,11 @@
+import { deprecatedService } from '../index.js'
+
+export const PubPopularity = deprecatedService({
+ category: 'rating',
+ route: {
+ base: 'pub/popularity',
+ pattern: ':packageName',
+ },
+ label: 'pubpopularity',
+ dateAdded: new Date('2025-05-11'),
+})
diff --git a/services/pub/pub-popularity.tester.js b/services/pub/pub-popularity.tester.js
new file mode 100644
index 0000000000000..68000d9e9cd19
--- /dev/null
+++ b/services/pub/pub-popularity.tester.js
@@ -0,0 +1,12 @@
+import { ServiceTester } from '../tester.js'
+
+export const t = new ServiceTester({
+ id: 'PubPopularity',
+ title: 'PubPopularity',
+ pathPrefix: '/pub/popularity',
+})
+
+t.create('pub popularity').get('/analysis_options.json').expectBadge({
+ label: 'pubpopularity',
+ message: 'no longer available',
+})
diff --git a/services/pub/pub-publisher.service.js b/services/pub/pub-publisher.service.js
new file mode 100644
index 0000000000000..d1e821b9e1482
--- /dev/null
+++ b/services/pub/pub-publisher.service.js
@@ -0,0 +1,54 @@
+import Joi from 'joi'
+import { BaseJsonService, pathParams } from '../index.js'
+import { baseDescription } from './pub-common.js'
+
+const schema = Joi.object({
+ publisherId: Joi.string().allow(null).required(),
+}).required()
+
+export class PubPublisher extends BaseJsonService {
+ static category = 'other'
+
+ static route = {
+ base: 'pub/publisher',
+ pattern: ':packageName',
+ }
+
+ static openApi = {
+ '/pub/publisher/{packageName}': {
+ get: {
+ summary: 'Pub Publisher',
+ description: baseDescription,
+ parameters: pathParams({
+ name: 'packageName',
+ example: 'path',
+ }),
+ },
+ },
+ }
+
+ static _cacheLength = 3600
+
+ static defaultBadgeData = { label: 'publisher' }
+
+ static render({ publisher }) {
+ return {
+ label: 'publisher',
+ message: publisher == null ? 'unverified' : publisher,
+ color: publisher == null ? 'lightgrey' : 'blue',
+ }
+ }
+
+ async fetch({ packageName }) {
+ return this._requestJson({
+ schema,
+ url: `https://pub.dev/api/packages/${packageName}/publisher`,
+ })
+ }
+
+ async handle({ packageName }) {
+ const data = await this.fetch({ packageName })
+ const publisher = data.publisherId
+ return this.constructor.render({ publisher })
+ }
+}
diff --git a/services/pub/pub-publisher.tester.js b/services/pub/pub-publisher.tester.js
new file mode 100644
index 0000000000000..d74d46d4b5962
--- /dev/null
+++ b/services/pub/pub-publisher.tester.js
@@ -0,0 +1,18 @@
+import { createServiceTester } from '../tester.js'
+export const t = await createServiceTester()
+
+t.create('package publisher').get('/path.json').expectBadge({
+ label: 'publisher',
+ message: 'dart.dev',
+})
+
+t.create('package not verified publisher').get('/utf.json').expectBadge({
+ label: 'publisher',
+ message: 'unverified',
+ color: 'lightgrey',
+})
+
+t.create('package not found').get('/doesnotexist.json').expectBadge({
+ label: 'publisher',
+ message: 'not found',
+})
diff --git a/services/pub/pub.service.js b/services/pub/pub.service.js
index 19822dc4d5cbd..bd520272328b6 100644
--- a/services/pub/pub.service.js
+++ b/services/pub/pub.service.js
@@ -1,9 +1,17 @@
import Joi from 'joi'
import { latest, renderVersionBadge } from '../version.js'
-import { BaseJsonService, redirector } from '../index.js'
+import {
+ BaseJsonService,
+ deprecatedService,
+ pathParam,
+ queryParam,
+} from '../index.js'
+import { baseDescription } from './pub-common.js'
const schema = Joi.object({
- versions: Joi.array().items(Joi.string()).required(),
+ versions: Joi.array()
+ .items(Joi.object({ version: Joi.string().required() }))
+ .required(),
}).required()
const queryParamSchema = Joi.object({
@@ -19,51 +27,53 @@ class PubVersion extends BaseJsonService {
queryParamSchema,
}
- static examples = [
- {
- title: 'Pub Version',
- namedParams: { packageName: 'box2d' },
- staticPreview: renderVersionBadge({ version: 'v0.4.0' }),
- keywords: ['dart', 'dartlang'],
+ static openApi = {
+ '/pub/v/{packageName}': {
+ get: {
+ summary: 'Pub Version',
+ description: baseDescription,
+ parameters: [
+ pathParam({
+ name: 'packageName',
+ example: 'box2d',
+ }),
+ queryParam({
+ name: 'include_prereleases',
+ schema: { type: 'boolean' },
+ example: null,
+ }),
+ ],
+ },
},
- {
- title: 'Pub Version (including pre-releases)',
- namedParams: { packageName: 'box2d' },
- queryParams: { include_prereleases: null },
- staticPreview: renderVersionBadge({ version: 'v0.4.0' }),
- keywords: ['dart', 'dartlang'],
- },
- ]
+ }
static defaultBadgeData = { label: 'pub' }
async fetch({ packageName }) {
return this._requestJson({
schema,
- url: `https://pub.dartlang.org/packages/${packageName}.json`,
+ url: `https://pub.dev/api/packages/${packageName}`,
})
}
async handle({ packageName }, queryParams) {
const data = await this.fetch({ packageName })
const includePre = queryParams.include_prereleases !== undefined
- const versions = data.versions
+ const versions = data.versions.map(x => x.version)
const version = latest(versions, { pre: includePre })
return renderVersionBadge({ version })
}
}
-const PubVersionRedirector = redirector({
+const PubVersionRedirector = deprecatedService({
category: 'version',
+ label: 'pub',
route: {
base: 'pub/vpre',
pattern: ':packageName',
},
- transformPath: ({ packageName }) => `/pub/v/${packageName}`,
- transformQueryParams: params => ({
- include_prereleases: null,
- }),
- dateAdded: new Date('2019-12-15'),
+ dateAdded: new Date('2025-12-20'),
+ issueUrl: 'https://github.com/badges/shields/pull/11583',
})
export { PubVersion, PubVersionRedirector }
diff --git a/services/pub/pub.tester.js b/services/pub/pub.tester.js
index 63ff3b6fee3aa..996795cb3b5e3 100644
--- a/services/pub/pub.tester.js
+++ b/services/pub/pub.tester.js
@@ -18,11 +18,14 @@ t.create('package pre-release version')
message: isVPlusTripleDottedVersion,
})
-t.create('package not found').get('/v/does-not-exist.json').expectBadge({
+t.create('package not found').get('/v/doesnotexist.json').expectBadge({
label: 'pub',
message: 'not found',
})
t.create('package version (legacy redirect: vpre)')
- .get('/vpre/box2d.svg')
- .expectRedirect('/pub/v/box2d.svg?include_prereleases')
+ .get('/vpre/box2d.json')
+ .expectBadge({
+ label: 'pub',
+ message: 'https://github.com/badges/shields/pull/11583',
+ })
diff --git a/services/pulsar/pulsar-downloads.service.js b/services/pulsar/pulsar-downloads.service.js
new file mode 100644
index 0000000000000..aa4e3d870a982
--- /dev/null
+++ b/services/pulsar/pulsar-downloads.service.js
@@ -0,0 +1,51 @@
+import Joi from 'joi'
+import { BaseJsonService, pathParams } from '../index.js'
+import { metric } from '../text-formatters.js'
+import { nonNegativeInteger } from '../validators.js'
+import { pulsarPurple } from './pulsar-helper.js'
+
+const schema = Joi.object({
+ downloads: nonNegativeInteger,
+})
+
+export default class PulsarDownloads extends BaseJsonService {
+ static category = 'downloads'
+
+ static route = { base: 'pulsar/dt', pattern: ':packageName' }
+
+ static openApi = {
+ '/pulsar/dt/{packageName}': {
+ get: {
+ summary: 'Pulsar Downloads',
+ parameters: pathParams({
+ name: 'packageName',
+ example: 'hey-pane',
+ }),
+ },
+ },
+ }
+
+ static defaultBadgeData = { label: 'downloads' }
+
+ static render({ downloadCount }) {
+ return {
+ label: 'downloads',
+ message: metric(downloadCount),
+ color: pulsarPurple,
+ }
+ }
+
+ async fetch({ packageName }) {
+ return this._requestJson({
+ schema,
+ url: `https://api.pulsar-edit.dev/api/packages/${packageName}`,
+ httpErrors: { 404: 'package not found' },
+ })
+ }
+
+ async handle({ packageName }) {
+ const packageData = await this.fetch({ packageName })
+ const downloadCount = packageData.downloads
+ return this.constructor.render({ downloadCount })
+ }
+}
diff --git a/services/pulsar/pulsar-downloads.tester.js b/services/pulsar/pulsar-downloads.tester.js
new file mode 100644
index 0000000000000..1758516a12fae
--- /dev/null
+++ b/services/pulsar/pulsar-downloads.tester.js
@@ -0,0 +1,18 @@
+import { isMetric } from '../test-validators.js'
+import { createServiceTester } from '../tester.js'
+import { pulsarPurple } from './pulsar-helper.js'
+
+export const t = await createServiceTester()
+
+t.create('pulsar downloads (valid)')
+ .get('/hey-pane.json')
+ .expectBadge({
+ label: 'downloads',
+ message: isMetric,
+ color: `#${pulsarPurple}`,
+ })
+
+t.create('pulsar downloads (not found)').get('/test-package.json').expectBadge({
+ label: 'downloads',
+ message: 'package not found',
+})
diff --git a/services/pulsar/pulsar-helper.js b/services/pulsar/pulsar-helper.js
new file mode 100644
index 0000000000000..42e437622cd21
--- /dev/null
+++ b/services/pulsar/pulsar-helper.js
@@ -0,0 +1,7 @@
+// This is based on the format the Docker badges have taken.
+// Seems Tests require `#` before colors, whereas the badges do not.
+// So a color variable can be exported for all modules to use as needed.
+
+const pulsarPurple = '662d91'
+
+export { pulsarPurple }
diff --git a/services/pulsar/pulsar-stargazers.service.js b/services/pulsar/pulsar-stargazers.service.js
new file mode 100644
index 0000000000000..eab81ebeca5c5
--- /dev/null
+++ b/services/pulsar/pulsar-stargazers.service.js
@@ -0,0 +1,51 @@
+import Joi from 'joi'
+import { BaseJsonService, pathParams } from '../index.js'
+import { metric } from '../text-formatters.js'
+import { nonNegativeInteger } from '../validators.js'
+import { pulsarPurple } from './pulsar-helper.js'
+
+const schema = Joi.object({
+ stargazers_count: nonNegativeInteger,
+})
+
+export default class PulsarStargazers extends BaseJsonService {
+ static category = 'rating'
+
+ static route = { base: 'pulsar/stargazers', pattern: ':packageName' }
+
+ static openApi = {
+ '/pulsar/stargazers/{packageName}': {
+ get: {
+ summary: 'Pulsar Stargazers',
+ parameters: pathParams({
+ name: 'packageName',
+ example: 'hey-pane',
+ }),
+ },
+ },
+ }
+
+ static defaultBadgeData = { label: 'stargazers' }
+
+ static render({ stargazerCount }) {
+ return {
+ label: 'stargazers',
+ message: metric(stargazerCount),
+ color: pulsarPurple,
+ }
+ }
+
+ async fetch({ packageName }) {
+ return this._requestJson({
+ schema,
+ url: `https://api.pulsar-edit.dev/api/packages/${packageName}`,
+ httpErrors: { 404: 'package not found' },
+ })
+ }
+
+ async handle({ packageName }) {
+ const packageData = await this.fetch({ packageName })
+ const stargazerCount = packageData.stargazers_count
+ return this.constructor.render({ stargazerCount })
+ }
+}
diff --git a/services/pulsar/pulsar-stargazers.tester.js b/services/pulsar/pulsar-stargazers.tester.js
new file mode 100644
index 0000000000000..3207c4ebd2184
--- /dev/null
+++ b/services/pulsar/pulsar-stargazers.tester.js
@@ -0,0 +1,20 @@
+import { isMetric } from '../test-validators.js'
+import { createServiceTester } from '../tester.js'
+import { pulsarPurple } from './pulsar-helper.js'
+
+export const t = await createServiceTester()
+
+t.create('pulsar stargazers (valid)')
+ .get('/hey-pane.json')
+ .expectBadge({
+ label: 'stargazers',
+ message: isMetric,
+ color: `#${pulsarPurple}`,
+ })
+
+t.create('pulsar stargazers (not found)')
+ .get('/test-package.json')
+ .expectBadge({
+ label: 'stargazers',
+ message: 'package not found',
+ })
diff --git a/services/puppetforge/puppetforge-base.js b/services/puppetforge/puppetforge-base.js
index 8adb266bec92a..b277b43a5ec78 100644
--- a/services/puppetforge/puppetforge-base.js
+++ b/services/puppetforge/puppetforge-base.js
@@ -20,10 +20,31 @@ const modulesSchema = Joi.object({
Joi.object({
pdk: Joi.boolean().valid(false).required(),
version: semver,
- }).required()
+ }).required(),
),
}).required()
+const modulesValidationSchema = Joi.array()
+ .items(
+ Joi.alternatives().try(
+ Joi.object({
+ name: Joi.string().valid('total').required(),
+ score: nonNegativeInteger,
+ }).required(),
+ Joi.object({}).required(),
+ ),
+ )
+ .custom((value, helpers) => {
+ // Custom validation to check for exactly one type1 object
+ const totalCount = value.filter(item => item.name === 'total').length
+ if (totalCount !== 1) {
+ return helpers.message(
+ 'Array must contain exactly one object of type "total"',
+ )
+ }
+ return value
+ })
+
class BasePuppetForgeUsersService extends BaseJsonService {
async fetch({ user }) {
return this._requestJson({
@@ -42,4 +63,17 @@ class BasePuppetForgeModulesService extends BaseJsonService {
}
}
-export { BasePuppetForgeModulesService, BasePuppetForgeUsersService }
+class BasePuppetForgeModulesValidationService extends BaseJsonService {
+ async fetch({ user, moduleName }) {
+ return this._requestJson({
+ schema: modulesValidationSchema,
+ url: `https://forgeapi.puppetlabs.com/private/validations/${user}-${moduleName}`,
+ })
+ }
+}
+
+export {
+ BasePuppetForgeModulesService,
+ BasePuppetForgeUsersService,
+ BasePuppetForgeModulesValidationService,
+}
diff --git a/services/puppetforge/puppetforge-module-downloads.service.js b/services/puppetforge/puppetforge-module-downloads.service.js
index 61a9795ff70ca..c07bc1b7cfbd2 100644
--- a/services/puppetforge/puppetforge-module-downloads.service.js
+++ b/services/puppetforge/puppetforge-module-downloads.service.js
@@ -1,5 +1,5 @@
-import { downloadCount } from '../color-formatters.js'
-import { metric } from '../text-formatters.js'
+import { pathParams } from '../index.js'
+import { renderDownloadsBadge } from '../downloads.js'
import { BasePuppetForgeModulesService } from './puppetforge-base.js'
export default class PuppetforgeModuleDownloads extends BasePuppetForgeModulesService {
@@ -10,28 +10,28 @@ export default class PuppetforgeModuleDownloads extends BasePuppetForgeModulesSe
pattern: ':user/:moduleName',
}
- static examples = [
- {
- title: 'Puppet Forge downloads',
- namedParams: {
- user: 'camptocamp',
- moduleName: 'openldap',
+ static openApi = {
+ '/puppetforge/dt/{user}/{moduleName}': {
+ get: {
+ summary: 'Puppet Forge downloads',
+ parameters: pathParams(
+ {
+ name: 'user',
+ example: 'camptocamp',
+ },
+ {
+ name: 'moduleName',
+ example: 'openldap',
+ },
+ ),
},
- staticPreview: this.render({ downloads: 720000 }),
},
- ]
+ }
static defaultBadgeData = { label: 'downloads' }
- static render({ downloads }) {
- return {
- message: metric(downloads),
- color: downloadCount(downloads),
- }
- }
-
async handle({ user, moduleName }) {
- const data = await this.fetch({ user, moduleName })
- return this.constructor.render({ downloads: data.downloads })
+ const { downloads } = await this.fetch({ user, moduleName })
+ return renderDownloadsBadge({ downloads })
}
}
diff --git a/services/puppetforge/puppetforge-module-endorsement.service.js b/services/puppetforge/puppetforge-module-endorsement.service.js
index 2ebc258cb7a1d..5329e542c9735 100644
--- a/services/puppetforge/puppetforge-module-endorsement.service.js
+++ b/services/puppetforge/puppetforge-module-endorsement.service.js
@@ -1,4 +1,4 @@
-import { NotFound } from '../index.js'
+import { NotFound, pathParams } from '../index.js'
import { BasePuppetForgeModulesService } from './puppetforge-base.js'
export default class PuppetforgeModuleEndorsement extends BasePuppetForgeModulesService {
@@ -9,16 +9,23 @@ export default class PuppetforgeModuleEndorsement extends BasePuppetForgeModules
pattern: ':user/:moduleName',
}
- static examples = [
- {
- title: 'Puppet Forge endorsement',
- namedParams: {
- user: 'camptocamp',
- moduleName: 'openssl',
+ static openApi = {
+ '/puppetforge/e/{user}/{moduleName}': {
+ get: {
+ summary: 'Puppet Forge endorsement',
+ parameters: pathParams(
+ {
+ name: 'user',
+ example: 'camptocamp',
+ },
+ {
+ name: 'moduleName',
+ example: 'openssl',
+ },
+ ),
},
- staticPreview: this.render({ endorsement: 'approved' }),
},
- ]
+ }
static defaultBadgeData = { label: 'endorsement' }
diff --git a/services/puppetforge/puppetforge-module-endorsement.tester.js b/services/puppetforge/puppetforge-module-endorsement.tester.js
index 2f03a591fbbfd..1b369d75d6bfc 100644
--- a/services/puppetforge/puppetforge-module-endorsement.tester.js
+++ b/services/puppetforge/puppetforge-module-endorsement.tester.js
@@ -19,7 +19,7 @@ t.create('module endorsement (no ratings)')
feedback_score: null,
downloads: 0,
current_release: { pdk: false, version: '1.0.0' },
- })
+ }),
)
.expectBadge({
label: 'endorsement',
diff --git a/services/puppetforge/puppetforge-module-feedback.service.js b/services/puppetforge/puppetforge-module-feedback.service.js
index 041d8b3e87dd0..74d0d710eee42 100644
--- a/services/puppetforge/puppetforge-module-feedback.service.js
+++ b/services/puppetforge/puppetforge-module-feedback.service.js
@@ -1,5 +1,5 @@
import { coveragePercentage as coveragePercentageColor } from '../color-formatters.js'
-import { NotFound } from '../index.js'
+import { NotFound, pathParams } from '../index.js'
import { BasePuppetForgeModulesService } from './puppetforge-base.js'
export default class PuppetforgeModuleFeedback extends BasePuppetForgeModulesService {
@@ -10,16 +10,23 @@ export default class PuppetforgeModuleFeedback extends BasePuppetForgeModulesSer
pattern: ':user/:moduleName',
}
- static examples = [
- {
- title: 'Puppet Forge feedback score',
- namedParams: {
- user: 'camptocamp',
- moduleName: 'openssl',
+ static openApi = {
+ '/puppetforge/f/{user}/{moduleName}': {
+ get: {
+ summary: 'Puppet Forge feedback score',
+ parameters: pathParams(
+ {
+ name: 'user',
+ example: 'camptocamp',
+ },
+ {
+ name: 'moduleName',
+ example: 'openssl',
+ },
+ ),
},
- staticPreview: this.render({ score: 61 }),
},
- ]
+ }
static defaultBadgeData = { label: 'score' }
diff --git a/services/puppetforge/puppetforge-module-feedback.tester.js b/services/puppetforge/puppetforge-module-feedback.tester.js
index 24128bd7e1d97..ebd60fef9773b 100644
--- a/services/puppetforge/puppetforge-module-feedback.tester.js
+++ b/services/puppetforge/puppetforge-module-feedback.tester.js
@@ -17,7 +17,7 @@ t.create('module feedback (no ratings)')
feedback_score: null,
downloads: 0,
current_release: { pdk: false, version: '1.0.0' },
- })
+ }),
)
.expectBadge({
label: 'score',
diff --git a/services/puppetforge/puppetforge-module-pdk-version.service.js b/services/puppetforge/puppetforge-module-pdk-version.service.js
index 69c8654214319..7d0af582e21fb 100644
--- a/services/puppetforge/puppetforge-module-pdk-version.service.js
+++ b/services/puppetforge/puppetforge-module-pdk-version.service.js
@@ -1,5 +1,5 @@
import { renderVersionBadge } from '../version.js'
-import { NotFound } from '../index.js'
+import { NotFound, pathParams } from '../index.js'
import { BasePuppetForgeModulesService } from './puppetforge-base.js'
export default class PuppetforgeModulePdkVersion extends BasePuppetForgeModulesService {
@@ -10,16 +10,23 @@ export default class PuppetforgeModulePdkVersion extends BasePuppetForgeModulesS
pattern: ':user/:moduleName',
}
- static examples = [
- {
- title: 'Puppet Forge – PDK version',
- namedParams: {
- user: 'tragiccode',
- moduleName: 'azure_key_vault',
+ static openApi = {
+ '/puppetforge/pdk-version/{user}/{moduleName}': {
+ get: {
+ summary: 'Puppet Forge - PDK version',
+ parameters: pathParams(
+ {
+ name: 'user',
+ example: 'tragiccode',
+ },
+ {
+ name: 'moduleName',
+ example: 'azure_key_vault',
+ },
+ ),
},
- staticPreview: renderVersionBadge({ version: '1.7.1' }),
},
- ]
+ }
static defaultBadgeData = { label: 'pdk version' }
diff --git a/services/puppetforge/puppetforge-module-quality-score.service.js b/services/puppetforge/puppetforge-module-quality-score.service.js
new file mode 100644
index 0000000000000..8b1e4b9b19c59
--- /dev/null
+++ b/services/puppetforge/puppetforge-module-quality-score.service.js
@@ -0,0 +1,45 @@
+import { coveragePercentage as coveragePercentageColor } from '../color-formatters.js'
+import { pathParams } from '../index.js'
+import { BasePuppetForgeModulesValidationService } from './puppetforge-base.js'
+
+export default class PuppetforgeModuleQualityScoreService extends BasePuppetForgeModulesValidationService {
+ static category = 'rating'
+
+ static route = {
+ base: 'puppetforge/qualityscore',
+ pattern: ':user/:moduleName',
+ }
+
+ static openApi = {
+ '/puppetforge/qualityscore/{user}/{moduleName}': {
+ get: {
+ summary: 'Puppet Forge quality score',
+ parameters: pathParams(
+ {
+ name: 'user',
+ example: 'camptocamp',
+ },
+ {
+ name: 'moduleName',
+ example: 'openssl',
+ },
+ ),
+ },
+ },
+ }
+
+ static defaultBadgeData = { label: 'quality score' }
+
+ static render({ score }) {
+ return {
+ message: `${score}%`,
+ color: coveragePercentageColor(score),
+ }
+ }
+
+ async handle({ user, moduleName }) {
+ const data = await this.fetch({ user, moduleName })
+ const qualityScore = data.find(el => el.name === 'total').score
+ return this.constructor.render({ score: qualityScore })
+ }
+}
diff --git a/services/puppetforge/puppetforge-module-quality-score.tester.js b/services/puppetforge/puppetforge-module-quality-score.tester.js
new file mode 100644
index 0000000000000..9693b4fcc2e5f
--- /dev/null
+++ b/services/puppetforge/puppetforge-module-quality-score.tester.js
@@ -0,0 +1,27 @@
+import { isPercentage } from '../test-validators.js'
+import { createServiceTester } from '../tester.js'
+export const t = await createServiceTester()
+
+t.create('module quality-score').get('/camptocamp/openssl.json').expectBadge({
+ label: 'quality score',
+ message: isPercentage,
+})
+
+t.create('module quality score (no ratings)')
+ .get('/camptocamp/openssl.json')
+ .intercept(nock =>
+ nock('https://forgeapi.puppetlabs.com/private/validations')
+ .get('/camptocamp-openssl')
+ .reply(200, []),
+ )
+ .expectBadge({
+ label: 'quality score',
+ message: 'invalid response data',
+ })
+
+t.create('module quality score (not found)')
+ .get('/notarealuser/notarealpackage.json')
+ .expectBadge({
+ label: 'quality score',
+ message: 'not found',
+ })
diff --git a/services/puppetforge/puppetforge-module-version.service.js b/services/puppetforge/puppetforge-module-version.service.js
index b3cb980f7e594..4bedf34d17c8f 100644
--- a/services/puppetforge/puppetforge-module-version.service.js
+++ b/services/puppetforge/puppetforge-module-version.service.js
@@ -1,3 +1,4 @@
+import { pathParams } from '../index.js'
import { renderVersionBadge } from '../version.js'
import { BasePuppetForgeModulesService } from './puppetforge-base.js'
@@ -9,16 +10,23 @@ export default class PuppetforgeModuleVersion extends BasePuppetForgeModulesServ
pattern: ':user/:moduleName',
}
- static examples = [
- {
- title: 'Puppet Forge version',
- namedParams: {
- user: 'vStone',
- moduleName: 'percona',
+ static openApi = {
+ '/puppetforge/v/{user}/{moduleName}': {
+ get: {
+ summary: 'Puppet Forge version',
+ parameters: pathParams(
+ {
+ name: 'user',
+ example: 'vStone',
+ },
+ {
+ name: 'moduleName',
+ example: 'percona',
+ },
+ ),
},
- staticPreview: renderVersionBadge({ version: '1.3.3' }),
},
- ]
+ }
static defaultBadgeData = { label: 'puppetforge' }
diff --git a/services/puppetforge/puppetforge-user-module-count.service.js b/services/puppetforge/puppetforge-user-module-count.service.js
index 1666b110197db..3f43d219a9b11 100644
--- a/services/puppetforge/puppetforge-user-module-count.service.js
+++ b/services/puppetforge/puppetforge-user-module-count.service.js
@@ -1,3 +1,4 @@
+import { pathParams } from '../index.js'
import { metric } from '../text-formatters.js'
import { floorCount as floorCountColor } from '../color-formatters.js'
import { BasePuppetForgeUsersService } from './puppetforge-base.js'
@@ -10,15 +11,17 @@ export default class PuppetForgeModuleCountService extends BasePuppetForgeUsersS
pattern: ':user',
}
- static examples = [
- {
- title: 'Puppet Forge modules by user',
- namedParams: {
- user: 'camptocamp',
+ static openApi = {
+ '/puppetforge/mc/{user}': {
+ get: {
+ summary: 'Puppet Forge modules by user',
+ parameters: pathParams({
+ name: 'user',
+ example: 'camptocamp',
+ }),
},
- staticPreview: this.render({ modules: 60 }),
},
- ]
+ }
static defaultBadgeData = { label: 'modules' }
diff --git a/services/puppetforge/puppetforge-user-release-count.service.js b/services/puppetforge/puppetforge-user-release-count.service.js
index d13703dc395f8..2e4c4d42331be 100644
--- a/services/puppetforge/puppetforge-user-release-count.service.js
+++ b/services/puppetforge/puppetforge-user-release-count.service.js
@@ -1,3 +1,4 @@
+import { pathParams } from '../index.js'
import { metric } from '../text-formatters.js'
import { floorCount as floorCountColor } from '../color-formatters.js'
import { BasePuppetForgeUsersService } from './puppetforge-base.js'
@@ -10,15 +11,17 @@ export default class PuppetForgeReleaseCountService extends BasePuppetForgeUsers
pattern: ':user',
}
- static examples = [
- {
- title: 'Puppet Forge releases by user',
- namedParams: {
- user: 'camptocamp',
+ static openApi = {
+ '/puppetforge/rc/{user}': {
+ get: {
+ summary: 'Puppet Forge releases by user',
+ parameters: pathParams({
+ name: 'user',
+ example: 'camptocamp',
+ }),
},
- staticPreview: this.render({ releases: 1000 }),
},
- ]
+ }
static defaultBadgeData = { label: 'releases' }
diff --git a/services/pypi/pypi-base.js b/services/pypi/pypi-base.js
index e19d6e23a510c..e8c4c597e9c43 100644
--- a/services/pypi/pypi-base.js
+++ b/services/pypi/pypi-base.js
@@ -1,40 +1,62 @@
import Joi from 'joi'
-import { BaseJsonService } from '../index.js'
+import config from 'config'
+import { optionalUrl } from '../validators.js'
+import { BaseJsonService, queryParam, pathParam } from '../index.js'
const schema = Joi.object({
info: Joi.object({
version: Joi.string().required(),
// https://github.com/badges/shields/issues/2022
- license: Joi.string().allow(''),
+ // https://github.com/badges/shields/issues/7728
+ license: Joi.string().allow('').allow(null),
+ license_expression: Joi.string().allow('').allow(null),
classifiers: Joi.array().items(Joi.string()).required(),
}).required(),
- releases: Joi.object()
- .pattern(
- Joi.string(),
- Joi.array()
- .items(
- Joi.object({
- packagetype: Joi.string().required(),
- })
- )
- .required()
+ urls: Joi.array()
+ .items(
+ Joi.object({
+ packagetype: Joi.string().required(),
+ }),
)
.required(),
}).required()
+export const queryParamSchema = Joi.object({
+ pypiBaseUrl: optionalUrl,
+}).required()
+
+export const pypiPackageParam = pathParam({
+ name: 'packageName',
+ example: 'Django',
+})
+
+export const pypiBaseUrlParam = queryParam({
+ name: 'pypiBaseUrl',
+ example: 'https://pypi.org',
+})
+
+export const pypiGeneralParams = [pypiPackageParam, pypiBaseUrlParam]
+
export default class PypiBase extends BaseJsonService {
+ constructor(...args) {
+ super(...args)
+ this._defaultPypiBaseUrl =
+ config.util.toObject().public.services.pypi.baseUri
+ }
+
static buildRoute(base) {
return {
base,
- pattern: ':egg*',
+ pattern: ':egg+',
+ queryParamSchema,
}
}
- async fetch({ egg }) {
+ async fetch({ egg, pypiBaseUrl = this._defaultPypiBaseUrl }) {
return this._requestJson({
schema,
- url: `https://pypi.org/pypi/${egg}/json`,
- errorMessages: { 404: 'package or version not found' },
+ url: `${pypiBaseUrl}/pypi/${egg}/json`,
+ httpErrors: { 404: 'package or version not found' },
})
}
}
diff --git a/services/pypi/pypi-django-versions.service.js b/services/pypi/pypi-django-versions.service.js
index ffe0fbf1bf37f..da07cf8f0cf9c 100644
--- a/services/pypi/pypi-django-versions.service.js
+++ b/services/pypi/pypi-django-versions.service.js
@@ -1,45 +1,12 @@
-import PypiBase from './pypi-base.js'
-import { sortDjangoVersions, parseClassifiers } from './pypi-helpers.js'
-
-export default class PypiDjangoVersions extends PypiBase {
- static category = 'platform-support'
-
- static route = this.buildRoute('pypi/djversions')
-
- static examples = [
- {
- title: 'PyPI - Django Version',
- pattern: ':packageName',
- namedParams: { packageName: 'djangorestframework' },
- staticPreview: this.render({ versions: ['1.11', '2.0', '2.1'] }),
- keywords: ['python'],
- },
- ]
-
- static defaultBadgeData = { label: 'django versions' }
-
- static render({ versions }) {
- if (versions.length > 0) {
- return {
- message: sortDjangoVersions(versions).join(' | '),
- color: 'blue',
- }
- } else {
- return {
- message: 'missing',
- color: 'red',
- }
- }
- }
-
- async handle({ egg }) {
- const packageData = await this.fetch({ egg })
-
- const versions = parseClassifiers(
- packageData,
- /^Framework :: Django :: ([\d.]+)$/
- )
-
- return this.constructor.render({ versions })
- }
-}
+import { redirector } from '../index.js'
+
+export default redirector({
+ category: 'platform-support',
+ route: {
+ base: 'pypi/djversions',
+ pattern: ':packageName*',
+ },
+ transformPath: ({ packageName }) =>
+ `/pypi/frameworkversions/django/${packageName}`,
+ dateAdded: new Date('2022-07-28'),
+})
diff --git a/services/pypi/pypi-django-versions.tester.js b/services/pypi/pypi-django-versions.tester.js
index 83fdca15ec086..c3901a3a0854c 100644
--- a/services/pypi/pypi-django-versions.tester.js
+++ b/services/pypi/pypi-django-versions.tester.js
@@ -1,32 +1,24 @@
-import Joi from 'joi'
import { createServiceTester } from '../tester.js'
export const t = await createServiceTester()
-const isPipeSeparatedDjangoVersions = Joi.string().regex(
- /^([1-9]\.[0-9]+(?: \| )?)+$/
+t.create(
+ 'redirect supported django versions (valid, package version in request)',
)
-
-t.create('supported django versions (valid, package version in request)')
.get('/djangorestframework/3.7.3.json')
- .expectBadge({
- label: 'django versions',
- message: isPipeSeparatedDjangoVersions,
- })
+ .expectRedirect(
+ '/pypi/frameworkversions/django/djangorestframework/3.7.3.json',
+ )
-t.create('supported django versions (valid, no package version specified)')
+t.create(
+ 'redirect supported django versions (valid, no package version specified)',
+)
.get('/djangorestframework.json')
- .expectBadge({
- label: 'django versions',
- message: isPipeSeparatedDjangoVersions,
- })
+ .expectRedirect('/pypi/frameworkversions/django/djangorestframework.json')
-t.create('supported django versions (no versions specified)')
+t.create('redirect supported django versions (no versions specified)')
.get('/django/1.11.json')
- .expectBadge({ label: 'django versions', message: 'missing' })
+ .expectRedirect('/pypi/frameworkversions/django/django/1.11.json')
-t.create('supported django versions (invalid)')
+t.create('redirect supported django versions (invalid)')
.get('/not-a-package.json')
- .expectBadge({
- label: 'django versions',
- message: 'package or version not found',
- })
+ .expectRedirect('/pypi/frameworkversions/django/not-a-package.json')
diff --git a/services/pypi/pypi-downloads.service.js b/services/pypi/pypi-downloads.service.js
index 21cc1133dd96a..d38ac34a282bd 100644
--- a/services/pypi/pypi-downloads.service.js
+++ b/services/pypi/pypi-downloads.service.js
@@ -1,10 +1,8 @@
import Joi from 'joi'
-import { downloadCount } from '../color-formatters.js'
-import { metric } from '../text-formatters.js'
import { nonNegativeInteger } from '../validators.js'
-import { BaseJsonService } from '../index.js'
-
-const keywords = ['python']
+import { BaseJsonService, pathParam } from '../index.js'
+import { renderDownloadsBadge } from '../downloads.js'
+import { pypiPackageParam } from './pypi-base.js'
const schema = Joi.object({
data: Joi.object({
@@ -16,16 +14,16 @@ const schema = Joi.object({
const periodMap = {
dd: {
- api_field: 'last_day',
- suffix: '/day',
+ apiField: 'last_day',
+ interval: 'day',
},
dw: {
- api_field: 'last_week',
- suffix: '/week',
+ apiField: 'last_week',
+ interval: 'week',
},
dm: {
- api_field: 'last_month',
- suffix: '/month',
+ apiField: 'last_month',
+ interval: 'month',
},
}
@@ -39,40 +37,43 @@ export default class PypiDownloads extends BaseJsonService {
pattern: ':period(dd|dw|dm)/:packageName',
}
- static examples = [
- {
- title: 'PyPI - Downloads',
- namedParams: {
- period: 'dd',
- packageName: 'Django',
+ static openApi = {
+ '/pypi/{period}/{packageName}': {
+ get: {
+ summary: 'PyPI - Downloads',
+ description:
+ 'Python package downloads from [pypistats](https://pypistats.org/)',
+ parameters: [
+ pathParam({
+ name: 'period',
+ example: 'dd',
+ schema: { type: 'string', enum: this.getEnum('period') },
+ description: 'Daily, Weekly, or Monthly downloads',
+ }),
+ pypiPackageParam,
+ ],
},
- staticPreview: this.render({ period: 'dd', downloads: 14000 }),
- keywords,
},
- ]
+ }
- static defaultBadgeData = { label: 'downloads' }
+ static _cacheLength = 43200
- static render({ period, downloads }) {
- return {
- message: `${metric(downloads)}${periodMap[period].suffix}`,
- color: downloadCount(downloads),
- }
- }
+ static defaultBadgeData = { label: 'downloads' }
async fetch({ packageName }) {
return this._requestJson({
url: `https://pypistats.org/api/packages/${packageName.toLowerCase()}/recent`,
schema,
- errorMessages: { 404: 'package not found' },
+ httpErrors: { 404: 'package not found' },
})
}
async handle({ period, packageName }) {
- const json = await this.fetch({ packageName })
- return this.constructor.render({
- period,
- downloads: json.data[periodMap[period].api_field],
+ const { apiField, interval } = periodMap[period]
+ const { data } = await this.fetch({ packageName })
+ return renderDownloadsBadge({
+ downloads: data[apiField],
+ interval,
})
}
}
diff --git a/services/pypi/pypi-format.service.js b/services/pypi/pypi-format.service.js
index cef3285e48433..59d735c953082 100644
--- a/services/pypi/pypi-format.service.js
+++ b/services/pypi/pypi-format.service.js
@@ -1,4 +1,4 @@
-import PypiBase from './pypi-base.js'
+import PypiBase, { pypiGeneralParams } from './pypi-base.js'
import { getPackageFormats } from './pypi-helpers.js'
export default class PypiFormat extends PypiBase {
@@ -6,15 +6,16 @@ export default class PypiFormat extends PypiBase {
static route = this.buildRoute('pypi/format')
- static examples = [
- {
- title: 'PyPI - Format',
- pattern: ':packageName',
- namedParams: { packageName: 'Django' },
- staticPreview: this.render({ hasWheel: true }),
- keywords: ['python'],
+ static openApi = {
+ '/pypi/format/{packageName}': {
+ get: {
+ summary: 'PyPI - Format',
+ parameters: pypiGeneralParams,
+ },
},
- ]
+ }
+
+ static _cacheLength = 43200
static defaultBadgeData = { label: 'format' }
@@ -37,8 +38,8 @@ export default class PypiFormat extends PypiBase {
}
}
- async handle({ egg }) {
- const packageData = await this.fetch({ egg })
+ async handle({ egg }, { pypiBaseUrl }) {
+ const packageData = await this.fetch({ egg, pypiBaseUrl })
const { hasWheel, hasEgg } = getPackageFormats(packageData)
return this.constructor.render({ hasWheel, hasEgg })
}
diff --git a/services/pypi/pypi-format.tester.js b/services/pypi/pypi-format.tester.js
index af025c67305b8..ba0230b8361b4 100644
--- a/services/pypi/pypi-format.tester.js
+++ b/services/pypi/pypi-format.tester.js
@@ -20,3 +20,22 @@ t.create('format (egg)')
t.create('format (invalid)')
.get('/not-a-package.json')
.expectBadge({ label: 'format', message: 'package or version not found' })
+
+t.create('format (explicit pypi base url)')
+ .get('/requests/2.18.4.json?pypiBaseUrl=https://some-other-pypi.org')
+ .intercept(nock =>
+ nock('https://some-other-pypi.org')
+ .get('/pypi/requests/2.18.4/json')
+ .reply(200, {
+ info: {
+ version: '2.18.4',
+ classifiers: [],
+ },
+ urls: [
+ {
+ packagetype: 'bdist_wheel',
+ },
+ ],
+ }),
+ )
+ .expectBadge({ label: 'format', message: 'wheel' })
diff --git a/services/pypi/pypi-framework-versions.service.js b/services/pypi/pypi-framework-versions.service.js
new file mode 100644
index 0000000000000..31d653c3ba1f6
--- /dev/null
+++ b/services/pypi/pypi-framework-versions.service.js
@@ -0,0 +1,104 @@
+import { InvalidResponse, pathParams } from '../index.js'
+import PypiBase, { pypiBaseUrlParam } from './pypi-base.js'
+import { sortPypiVersions, parseClassifiers } from './pypi-helpers.js'
+
+const frameworkNameMap = {
+ 'aws-cdk': {
+ name: 'AWS CDK',
+ classifier: 'AWS CDK',
+ },
+ django: {
+ name: 'Django',
+ classifier: 'Django',
+ },
+ 'django-cms': {
+ name: 'Django CMS',
+ classifier: 'Django CMS',
+ },
+ jupyterlab: {
+ name: 'JupyterLab',
+ classifier: 'Jupyter :: JupyterLab',
+ },
+ odoo: {
+ name: 'Odoo',
+ classifier: 'Odoo',
+ },
+ plone: {
+ name: 'Plone',
+ classifier: 'Plone',
+ },
+ wagtail: {
+ name: 'Wagtail',
+ classifier: 'Wagtail',
+ },
+ zope: {
+ name: 'Zope',
+ classifier: 'Zope',
+ },
+}
+
+const description = `
+This service currently support the following Frameworks:
+${Object.values(frameworkNameMap).map(obj => ` ${obj.name} `)}
+`
+export default class PypiFrameworkVersion extends PypiBase {
+ static category = 'platform-support'
+
+ static route = {
+ base: 'pypi/frameworkversions',
+ pattern: `:frameworkName(${Object.keys(frameworkNameMap).join(
+ '|',
+ )})/:packageName+`,
+ }
+
+ static openApi = {
+ '/pypi/frameworkversions/{frameworkName}/{packageName}': {
+ get: {
+ summary: 'PyPI - Versions from Framework Classifiers',
+ description,
+ parameters: pathParams(
+ {
+ name: 'frameworkName',
+ example: 'plone',
+ schema: { type: 'string', enum: Object.keys(frameworkNameMap) },
+ },
+ { name: 'packageName', example: 'plone.volto' },
+ ).concat(pypiBaseUrlParam),
+ },
+ },
+ }
+
+ static _cacheLength = 21600
+
+ static defaultBadgeData = { label: 'versions' }
+
+ static render({ name, versions }) {
+ name = name ? name.toLowerCase() : ''
+ const label = `${name} versions`
+ return {
+ label,
+ message: sortPypiVersions(versions).join(' | '),
+ color: 'blue',
+ }
+ }
+
+ async handle({ frameworkName, packageName }, { pypiBaseUrl }) {
+ const classifier = frameworkNameMap[frameworkName]
+ ? frameworkNameMap[frameworkName].classifier
+ : frameworkName
+ const name = frameworkNameMap[frameworkName]
+ ? frameworkNameMap[frameworkName].name
+ : frameworkName
+ const regex = new RegExp(`^Framework :: ${classifier} :: ([\\d.]+)$`)
+ const packageData = await this.fetch({ egg: packageName, pypiBaseUrl })
+ const versions = parseClassifiers(packageData, regex)
+
+ if (versions.length === 0) {
+ throw new InvalidResponse({
+ prettyMessage: `${name} versions are missing for ${packageName}`,
+ })
+ }
+
+ return this.constructor.render({ name, versions })
+ }
+}
diff --git a/services/pypi/pypi-framework-versions.tester.js b/services/pypi/pypi-framework-versions.tester.js
new file mode 100644
index 0000000000000..e65cb73e36d27
--- /dev/null
+++ b/services/pypi/pypi-framework-versions.tester.js
@@ -0,0 +1,164 @@
+import Joi from 'joi'
+import { createServiceTester } from '../tester.js'
+export const t = await createServiceTester()
+
+const isPipeSeparatedFrameworkVersions = Joi.string().regex(
+ /^([1-9]+(\.[0-9]+)?(?: \| )?)+$/,
+)
+
+t.create('supported django versions (valid, package version in request)')
+ .get('/django/djangorestframework/3.7.3.json')
+ .expectBadge({
+ label: 'django versions',
+ message: isPipeSeparatedFrameworkVersions,
+ })
+
+t.create('supported django versions (valid, no package version specified)')
+ .get('/django/djangorestframework.json')
+ .expectBadge({
+ label: 'django versions',
+ message: isPipeSeparatedFrameworkVersions,
+ })
+
+t.create('supported django versions (no versions specified)')
+ .get('/django/django/1.11.json')
+ .expectBadge({
+ label: 'versions',
+ message: 'Django versions are missing for django/1.11',
+ })
+
+t.create('supported django versions (invalid)')
+ .get('/django/not-a-package.json')
+ .expectBadge({
+ label: 'versions',
+ message: 'package or version not found',
+ })
+
+t.create('supported plone versions (valid, package version in request)')
+ .get('/plone/plone.rest/1.6.2.json')
+ .expectBadge({ label: 'plone versions', message: '4.3 | 5.0 | 5.1 | 5.2' })
+
+t.create('supported plone versions (valid, no package version specified)')
+ .get('/plone/plone.rest.json')
+ .expectBadge({
+ label: 'plone versions',
+ message: isPipeSeparatedFrameworkVersions,
+ })
+
+t.create('supported plone versions (invalid)')
+ .get('/plone/not-a-package.json')
+ .expectBadge({
+ label: 'versions',
+ message: 'package or version not found',
+ })
+
+t.create('supported zope versions (valid, package version in request)')
+ .get('/zope/plone/5.2.9.json')
+ .expectBadge({ label: 'zope versions', message: '4' })
+
+t.create('supported zope versions (valid, no package version specified)')
+ .get('/zope/Plone.json')
+ .expectBadge({
+ label: 'zope versions',
+ message: isPipeSeparatedFrameworkVersions,
+ })
+
+t.create('supported zope versions (invalid)')
+ .get('/zope/not-a-package.json')
+ .expectBadge({
+ label: 'versions',
+ message: 'package or version not found',
+ })
+
+t.create('supported wagtail versions (valid, package version in request)')
+ .get('/wagtail/wagtail-headless-preview/0.3.0.json')
+ .expectBadge({ label: 'wagtail versions', message: '2 | 3' })
+
+t.create('supported wagtail versions (valid, no package version specified)')
+ .get('/wagtail/wagtail-headless-preview.json')
+ .expectBadge({
+ label: 'wagtail versions',
+ message: isPipeSeparatedFrameworkVersions,
+ })
+
+t.create('supported wagtail versions (invalid)')
+ .get('/wagtail/not-a-package.json')
+ .expectBadge({
+ label: 'versions',
+ message: 'package or version not found',
+ })
+
+t.create('supported django cms versions (valid, package version in request)')
+ .get('/django-cms/djangocms-ads/1.1.0.json')
+ .expectBadge({
+ label: 'django cms versions',
+ message: '3.7 | 3.8 | 3.9 | 3.10',
+ })
+
+t.create('supported django cms versions (valid, no package version specified)')
+ .get('/django-cms/djangocms-ads.json')
+ .expectBadge({
+ label: 'django cms versions',
+ message: isPipeSeparatedFrameworkVersions,
+ })
+
+t.create('supported django cms versions (invalid)')
+ .get('/django-cms/not-a-package.json')
+ .expectBadge({
+ label: 'versions',
+ message: 'package or version not found',
+ })
+
+t.create('supported odoo versions (valid, package version in request)')
+ .get('/odoo/odoo-addon-sale-tier-validation/15.0.1.0.0.6.json')
+ .expectBadge({ label: 'odoo versions', message: '15.0' })
+
+t.create('supported odoo versions (valid, no package version specified)')
+ .get('/odoo/odoo-addon-sale-tier-validation.json')
+ .expectBadge({
+ label: 'odoo versions',
+ message: isPipeSeparatedFrameworkVersions,
+ })
+
+t.create('supported odoo versions (invalid)')
+ .get('/odoo/not-a-package.json')
+ .expectBadge({
+ label: 'versions',
+ message: 'package or version not found',
+ })
+
+t.create('supported aws cdk versions (valid, package version in request)')
+ .get('/aws-cdk/aws-cdk.aws-glue-alpha/2.34.0a0.json')
+ .expectBadge({ label: 'aws cdk versions', message: '2' })
+
+t.create('supported aws cdk versions (valid, no package version specified)')
+ .get('/aws-cdk/aws-cdk.aws-glue-alpha.json')
+ .expectBadge({
+ label: 'aws cdk versions',
+ message: isPipeSeparatedFrameworkVersions,
+ })
+
+t.create('supported aws cdk versions (invalid)')
+ .get('/aws-cdk/not-a-package.json')
+ .expectBadge({
+ label: 'versions',
+ message: 'package or version not found',
+ })
+
+t.create('supported jupyterlab versions (valid, package version in request)')
+ .get('/jupyterlab/structured-text/0.0.2.json')
+ .expectBadge({ label: 'jupyterlab versions', message: '3' })
+
+t.create('supported jupyterlab versions (valid, no package version specified)')
+ .get('/jupyterlab/structured-text.json')
+ .expectBadge({
+ label: 'jupyterlab versions',
+ message: isPipeSeparatedFrameworkVersions,
+ })
+
+t.create('supported jupyterlab versions (invalid)')
+ .get('/jupyterlab/not-a-package.json')
+ .expectBadge({
+ label: 'versions',
+ message: 'package or version not found',
+ })
diff --git a/services/pypi/pypi-helpers.js b/services/pypi/pypi-helpers.js
index 3d3fa9f9f55a6..2b18c5fc949f2 100644
--- a/services/pypi/pypi-helpers.js
+++ b/services/pypi/pypi-helpers.js
@@ -6,7 +6,7 @@
our own functions to parse and sort django versions
*/
-function parseDjangoVersionString(str) {
+function parsePypiVersionString(str) {
if (typeof str !== 'string') {
return false
}
@@ -20,18 +20,12 @@ function parseDjangoVersionString(str) {
}
// Sort an array of django versions low to high.
-function sortDjangoVersions(versions) {
+function sortPypiVersions(versions) {
return versions.sort((a, b) => {
- if (
- parseDjangoVersionString(a).major === parseDjangoVersionString(b).major
- ) {
- return (
- parseDjangoVersionString(a).minor - parseDjangoVersionString(b).minor
- )
+ if (parsePypiVersionString(a).major === parsePypiVersionString(b).major) {
+ return parsePypiVersionString(a).minor - parsePypiVersionString(b).minor
} else {
- return (
- parseDjangoVersionString(a).major - parseDjangoVersionString(b).major
- )
+ return parsePypiVersionString(a).major - parsePypiVersionString(b).major
}
})
}
@@ -53,22 +47,40 @@ function parseClassifiers(parsedData, pattern, preserveCase = false) {
}
function getLicenses(packageData) {
- const {
- info: { license },
- } = packageData
- if (license) {
+ const license = packageData.info.license
+ const licenseExpression = packageData.info.license_expression
+
+ if (licenseExpression) {
+ /*
+ The .license_expression field contains an SPDX expression, and it
+ is the preferred way of documenting a Python project's license.
+ See https://peps.python.org/pep-0639/
+ */
+ return [licenseExpression]
+ } else if (license && license.length < 40) {
+ /*
+ The .license field may either contain
+ - a short license description (e.g: 'MIT' or 'GPL-3.0') or
+ - the full text of a license
+ but there is nothing in the response that tells us explicitly.
+ We have to make an assumption based on the length.
+ See https://github.com/badges/shields/issues/8689 and
+ https://github.com/badges/shields/pull/8690 for more info.
+ */
return [license]
} else {
+ // else fall back to trove classifiers
const parenthesizedAcronymRegex = /\(([^)]+)\)/
const bareAcronymRegex = /^[a-z0-9]+$/
const spdxAliases = {
'OSI Approved :: Apache Software License': 'Apache-2.0',
'CC0 1.0 Universal (CC0 1.0) Public Domain Dedication': 'CC0-1.0',
'OSI Approved :: GNU Affero General Public License v3': 'AGPL-3.0',
+ 'OSI Approved :: Zero-Clause BSD (0BSD)': '0BSD',
}
let licenses = parseClassifiers(packageData, /^License :: (.+)$/, true)
.map(classifier =>
- classifier in spdxAliases ? spdxAliases[classifier] : classifier
+ classifier in spdxAliases ? spdxAliases[classifier] : classifier,
)
.map(classifier => classifier.split(' :: ').pop())
.map(license => license.replace(' License', ''))
@@ -88,25 +100,21 @@ function getLicenses(packageData) {
}
function getPackageFormats(packageData) {
- const {
- info: { version },
- releases,
- } = packageData
- const releasesForVersion = releases[version]
+ const { urls } = packageData
return {
- hasWheel: releasesForVersion.some(({ packagetype }) =>
- ['wheel', 'bdist_wheel'].includes(packagetype)
+ hasWheel: urls.some(({ packagetype }) =>
+ ['wheel', 'bdist_wheel'].includes(packagetype),
),
- hasEgg: releasesForVersion.some(({ packagetype }) =>
- ['egg', 'bdist_egg'].includes(packagetype)
+ hasEgg: urls.some(({ packagetype }) =>
+ ['egg', 'bdist_egg'].includes(packagetype),
),
}
}
export {
parseClassifiers,
- parseDjangoVersionString,
- sortDjangoVersions,
+ parsePypiVersionString,
+ sortPypiVersions,
getLicenses,
getPackageFormats,
}
diff --git a/services/pypi/pypi-helpers.spec.js b/services/pypi/pypi-helpers.spec.js
index eca6fed39b4b4..5b57a57ba4369 100644
--- a/services/pypi/pypi-helpers.spec.js
+++ b/services/pypi/pypi-helpers.spec.js
@@ -1,8 +1,8 @@
import { test, given, forCases } from 'sazerac'
import {
parseClassifiers,
- parseDjangoVersionString,
- sortDjangoVersions,
+ parsePypiVersionString,
+ sortPypiVersions,
getLicenses,
getPackageFormats,
} from './pypi-helpers.js'
@@ -35,10 +35,10 @@ const classifiersFixture = {
}
describe('PyPI helpers', function () {
- test(parseClassifiers, function () {
+ test(parseClassifiers, () => {
given(
classifiersFixture,
- /^Programming Language :: Python :: ([\d.]+)$/
+ /^Programming Language :: Python :: ([\d.]+)$/,
).expect(['2', '2.7', '3', '3.4', '3.5', '3.6'])
given(classifiersFixture, /^Framework :: Django :: ([\d.]+)$/).expect([
@@ -48,19 +48,19 @@ describe('PyPI helpers', function () {
given(
classifiersFixture,
- /^Programming Language :: Python :: Implementation :: (\S+)$/
+ /^Programming Language :: Python :: Implementation :: (\S+)$/,
).expect(['cpython', 'pypy'])
// regex that matches everything
given(classifiersFixture, /^([\S\s+]+)$/).expect(
- classifiersFixture.info.classifiers.map(e => e.toLowerCase())
+ classifiersFixture.info.classifiers.map(e => e.toLowerCase()),
)
// regex that matches nothing
given(classifiersFixture, /^(?!.*)*$/).expect([])
})
- test(parseDjangoVersionString, function () {
+ test(parsePypiVersionString, () => {
given('1').expect({ major: 1, minor: 0 })
given('1.0').expect({ major: 1, minor: 0 })
given('7.2').expect({ major: 7, minor: 2 })
@@ -69,7 +69,7 @@ describe('PyPI helpers', function () {
given('foo').expect({ major: 0, minor: 0 })
})
- test(sortDjangoVersions, function () {
+ test(sortPypiVersions, () => {
// Each of these includes a different variant: 2.0, 2, and 2.0rc1.
given(['2.0', '1.9', '10', '1.11', '2.1', '2.11']).expect([
'1.9',
@@ -100,19 +100,47 @@ describe('PyPI helpers', function () {
})
test(getLicenses, () => {
- forCases([given({ info: { license: 'MIT', classifiers: [] } })]).expect([
- 'MIT',
- ])
forCases([
+ given({
+ info: {
+ license: null,
+ license_expression: 'MIT',
+ classifiers: [],
+ },
+ }),
+ given({
+ info: {
+ license: 'MIT',
+ license_expression: null,
+ classifiers: [],
+ },
+ }),
+ given({
+ info: {
+ license: null,
+ license_expression: null,
+ classifiers: ['License :: OSI Approved :: MIT License'],
+ },
+ }),
given({
info: {
license: '',
+ license_expression: null,
+ classifiers: ['License :: OSI Approved :: MIT License'],
+ },
+ }),
+ given({
+ info: {
+ license:
+ 'this text is really really really really really really long',
+ license_expression: null,
classifiers: ['License :: OSI Approved :: MIT License'],
},
}),
given({
info: {
license: '',
+ license_expression: null,
classifiers: [
'License :: OSI Approved :: MIT License',
'License :: DFSG approved',
@@ -123,24 +151,28 @@ describe('PyPI helpers', function () {
given({
info: {
license: '',
+ license_expression: null,
classifiers: ['License :: Public Domain'],
},
}).expect(['Public Domain'])
given({
info: {
license: '',
+ license_expression: null,
classifiers: ['License :: Netscape Public License (NPL)'],
},
}).expect(['NPL'])
given({
info: {
license: '',
+ license_expression: null,
classifiers: ['License :: OSI Approved :: Apache Software License'],
},
}).expect(['Apache-2.0'])
given({
info: {
license: '',
+ license_expression: null,
classifiers: [
'License :: CC0 1.0 Universal (CC0 1.0) Public Domain Dedication',
],
@@ -149,43 +181,34 @@ describe('PyPI helpers', function () {
given({
info: {
license: '',
+ license_expression: null,
classifiers: [
'License :: OSI Approved :: GNU Affero General Public License v3',
],
},
}).expect(['AGPL-3.0'])
+ given({
+ info: {
+ license: '',
+ license_expression: null,
+ classifiers: ['License :: OSI Approved :: Zero-Clause BSD (0BSD)'],
+ },
+ }).expect(['0BSD'])
})
test(getPackageFormats, () => {
given({
- info: { version: '2.19.1' },
- releases: {
- '1.0.4': [{ packagetype: 'sdist' }],
- '2.19.1': [{ packagetype: 'bdist_wheel' }, { packagetype: 'sdist' }],
- },
+ urls: [{ packagetype: 'bdist_wheel' }, { packagetype: 'sdist' }],
}).expect({ hasWheel: true, hasEgg: false })
given({
- info: { version: '1.0.4' },
- releases: {
- '1.0.4': [{ packagetype: 'sdist' }],
- '2.19.1': [{ packagetype: 'bdist_wheel' }, { packagetype: 'sdist' }],
- },
+ urls: [{ packagetype: 'sdist' }],
}).expect({ hasWheel: false, hasEgg: false })
given({
- info: { version: '0.8.2' },
- releases: {
- 0.8: [{ packagetype: 'sdist' }],
- '0.8.1': [
- { packagetype: 'bdist_egg' },
- { packagetype: 'bdist_egg' },
- { packagetype: 'sdist' },
- ],
- '0.8.2': [
- { packagetype: 'bdist_egg' },
- { packagetype: 'bdist_egg' },
- { packagetype: 'sdist' },
- ],
- },
+ urls: [
+ { packagetype: 'bdist_egg' },
+ { packagetype: 'bdist_egg' },
+ { packagetype: 'sdist' },
+ ],
}).expect({ hasWheel: false, hasEgg: true })
})
})
diff --git a/services/pypi/pypi-implementation.service.js b/services/pypi/pypi-implementation.service.js
index b5a050fecdd37..4ad94a6781062 100644
--- a/services/pypi/pypi-implementation.service.js
+++ b/services/pypi/pypi-implementation.service.js
@@ -1,4 +1,4 @@
-import PypiBase from './pypi-base.js'
+import PypiBase, { pypiGeneralParams } from './pypi-base.js'
import { parseClassifiers } from './pypi-helpers.js'
export default class PypiImplementation extends PypiBase {
@@ -6,15 +6,16 @@ export default class PypiImplementation extends PypiBase {
static route = this.buildRoute('pypi/implementation')
- static examples = [
- {
- title: 'PyPI - Implementation',
- pattern: ':packageName',
- namedParams: { packageName: 'Django' },
- staticPreview: this.render({ implementations: ['cpython'] }),
- keywords: ['python'],
+ static openApi = {
+ '/pypi/implementation/{packageName}': {
+ get: {
+ summary: 'PyPI - Implementation',
+ parameters: pypiGeneralParams,
+ },
},
- ]
+ }
+
+ static _cacheLength = 43200
static defaultBadgeData = { label: 'implementation' }
@@ -25,12 +26,12 @@ export default class PypiImplementation extends PypiBase {
}
}
- async handle({ egg }) {
- const packageData = await this.fetch({ egg })
+ async handle({ egg }, { pypiBaseUrl }) {
+ const packageData = await this.fetch({ egg, pypiBaseUrl })
let implementations = parseClassifiers(
packageData,
- /^Programming Language :: Python :: Implementation :: (\S+)$/
+ /^Programming Language :: Python :: Implementation :: (\S+)$/,
)
if (implementations.length === 0) {
// Assume CPython.
diff --git a/services/pypi/pypi-license.service.js b/services/pypi/pypi-license.service.js
index c2d2a3228cf0a..f5effee5ae1fc 100644
--- a/services/pypi/pypi-license.service.js
+++ b/services/pypi/pypi-license.service.js
@@ -1,5 +1,5 @@
import { renderLicenseBadge } from '../licenses.js'
-import PypiBase from './pypi-base.js'
+import PypiBase, { pypiGeneralParams } from './pypi-base.js'
import { getLicenses } from './pypi-helpers.js'
export default class PypiLicense extends PypiBase {
@@ -7,22 +7,23 @@ export default class PypiLicense extends PypiBase {
static route = this.buildRoute('pypi/l')
- static examples = [
- {
- title: 'PyPI - License',
- pattern: ':packageName',
- namedParams: { packageName: 'Django' },
- staticPreview: this.render({ licenses: ['BSD'] }),
- keywords: ['python'],
+ static openApi = {
+ '/pypi/l/{packageName}': {
+ get: {
+ summary: 'PyPI - License',
+ parameters: pypiGeneralParams,
+ },
},
- ]
+ }
+
+ static _cacheLength = 43200
static render({ licenses }) {
return renderLicenseBadge({ licenses })
}
- async handle({ egg }) {
- const packageData = await this.fetch({ egg })
+ async handle({ egg }, { pypiBaseUrl }) {
+ const packageData = await this.fetch({ egg, pypiBaseUrl })
const licenses = getLicenses(packageData)
return this.constructor.render({ licenses })
}
diff --git a/services/pypi/pypi-license.tester.js b/services/pypi/pypi-license.tester.js
index 74cce2a414425..1893ee30e7f51 100644
--- a/services/pypi/pypi-license.tester.js
+++ b/services/pypi/pypi-license.tester.js
@@ -7,7 +7,7 @@ t.create('license (valid, package version in request)')
t.create('license (valid, no package version specified)')
.get('/requests.json')
- .expectBadge({ label: 'license', message: 'Apache 2.0', color: 'green' })
+ .expectBadge({ label: 'license', message: 'Apache-2.0', color: 'green' })
t.create('license (invalid)')
.get('/not-a-package.json')
@@ -24,8 +24,8 @@ t.create('license (from trove classifier)')
license: '',
classifiers: ['License :: OSI Approved :: MIT License'],
},
- releases: {},
- })
+ urls: [],
+ }),
)
.expectBadge({
label: 'license',
@@ -46,8 +46,8 @@ t.create('license (as acronym from trove classifier)')
'License :: OSI Approved :: GNU General Public License (GPL)',
],
},
- releases: {},
- })
+ urls: [],
+ }),
)
.expectBadge({
label: 'license',
diff --git a/services/pypi/pypi-python-versions.service.js b/services/pypi/pypi-python-versions.service.js
index 569fc7aa993ec..d78c42a44cabf 100644
--- a/services/pypi/pypi-python-versions.service.js
+++ b/services/pypi/pypi-python-versions.service.js
@@ -1,5 +1,5 @@
import semver from 'semver'
-import PypiBase from './pypi-base.js'
+import PypiBase, { pypiGeneralParams } from './pypi-base.js'
import { parseClassifiers } from './pypi-helpers.js'
export default class PypiPythonVersions extends PypiBase {
@@ -7,14 +7,16 @@ export default class PypiPythonVersions extends PypiBase {
static route = this.buildRoute('pypi/pyversions')
- static examples = [
- {
- title: 'PyPI - Python Version',
- pattern: ':packageName',
- namedParams: { packageName: 'Django' },
- staticPreview: this.render({ versions: ['3.5', '3.6', '3.7'] }),
+ static openApi = {
+ '/pypi/pyversions/{packageName}': {
+ get: {
+ summary: 'PyPI - Python Version',
+ parameters: pypiGeneralParams,
+ },
},
- ]
+ }
+
+ static _cacheLength = 21600
static defaultBadgeData = { label: 'python' }
@@ -31,7 +33,7 @@ export default class PypiPythonVersions extends PypiBase {
return {
message: Array.from(versionSet)
.sort((v1, v2) =>
- semver.compare(semver.coerce(v1), semver.coerce(v2))
+ semver.compare(semver.coerce(v1), semver.coerce(v2)),
)
.join(' | '),
color: 'blue',
@@ -44,20 +46,20 @@ export default class PypiPythonVersions extends PypiBase {
}
}
- async handle({ egg }) {
- const packageData = await this.fetch({ egg })
+ async handle({ egg }, { pypiBaseUrl }) {
+ const packageData = await this.fetch({ egg, pypiBaseUrl })
const versions = parseClassifiers(
packageData,
- /^Programming Language :: Python :: ([\d.]+)$/
+ /^Programming Language :: Python :: ([\d.]+)$/,
)
// If no versions are found yet, check "X :: Only" as a fallback.
if (versions.length === 0) {
versions.push(
...parseClassifiers(
packageData,
- /^Programming Language :: Python :: (\d+) :: Only$/
- )
+ /^Programming Language :: Python :: (\d+) :: Only$/,
+ ),
)
}
diff --git a/services/pypi/pypi-python-versions.spec.js b/services/pypi/pypi-python-versions.spec.js
index 8a8c483882e9e..b620b5706cc8a 100644
--- a/services/pypi/pypi-python-versions.spec.js
+++ b/services/pypi/pypi-python-versions.spec.js
@@ -2,7 +2,7 @@ import { test, given } from 'sazerac'
import PypiPythonVersions from './pypi-python-versions.service.js'
describe('PyPI Python Version', function () {
- test(PypiPythonVersions.render, function () {
+ test(PypiPythonVersions.render, () => {
// Major versions are hidden if minor are present.
given({ versions: ['3', '3.4', '3.5', '3.6', '2', '2.7'] }).expect({
message: '2.7 | 3.4 | 3.5 | 3.6',
diff --git a/services/pypi/pypi-python-versions.tester.js b/services/pypi/pypi-python-versions.tester.js
index 36146ddf92a97..ce39ddcd426f7 100644
--- a/services/pypi/pypi-python-versions.tester.js
+++ b/services/pypi/pypi-python-versions.tester.js
@@ -3,7 +3,7 @@ import { createServiceTester } from '../tester.js'
export const t = await createServiceTester()
const isPipeSeparatedPythonVersions = Joi.string().regex(
- /^([1-9]\.[0-9]+(?: \| )?)+$/
+ /^([1-9]\.[0-9]+(?: \| )?)+$/,
)
t.create('python versions (valid, package version in request)')
diff --git a/services/pypi/pypi-status.service.js b/services/pypi/pypi-status.service.js
index 77bff049aace1..7df16319d1379 100644
--- a/services/pypi/pypi-status.service.js
+++ b/services/pypi/pypi-status.service.js
@@ -1,4 +1,4 @@
-import PypiBase from './pypi-base.js'
+import PypiBase, { pypiGeneralParams } from './pypi-base.js'
import { parseClassifiers } from './pypi-helpers.js'
export default class PypiStatus extends PypiBase {
@@ -6,15 +6,16 @@ export default class PypiStatus extends PypiBase {
static route = this.buildRoute('pypi/status')
- static examples = [
- {
- title: 'PyPI - Status',
- pattern: ':packageName',
- namedParams: { packageName: 'Django' },
- staticPreview: this.render({ status: 'stable' }),
- keywords: ['python'],
+ static openApi = {
+ '/pypi/status/{packageName}': {
+ get: {
+ summary: 'PyPI - Status',
+ parameters: pypiGeneralParams,
+ },
},
- ]
+ }
+
+ static _cacheLength = 43200
static defaultBadgeData = { label: 'status' }
@@ -29,6 +30,7 @@ export default class PypiStatus extends PypiBase {
stable: 'brightgreen',
mature: 'brightgreen',
inactive: 'red',
+ unknown: 'lightgrey',
}[status]
return {
@@ -37,8 +39,8 @@ export default class PypiStatus extends PypiBase {
}
}
- async handle({ egg }) {
- const packageData = await this.fetch({ egg })
+ async handle({ egg }, { pypiBaseUrl }) {
+ const packageData = await this.fetch({ egg, pypiBaseUrl })
// Possible statuses:
// - Development Status :: 1 - Planning
@@ -49,15 +51,19 @@ export default class PypiStatus extends PypiBase {
// - Development Status :: 6 - Mature
// - Development Status :: 7 - Inactive
// https://pypi.org/pypi?%3Aaction=list_classifiers
- const status = parseClassifiers(
+ let status = parseClassifiers(
packageData,
- /^Development Status :: (\d - \S+)$/
+ /^Development Status :: (\d - \S+)$/,
)
.sort()
.map(classifier => classifier.split(' - ').pop())
.map(classifier => classifier.replace(/production\/stable/i, 'stable'))
.pop()
+ if (!status) {
+ status = 'Unknown'
+ }
+
return this.constructor.render({ status })
}
}
diff --git a/services/pypi/pypi-status.tester.js b/services/pypi/pypi-status.tester.js
index 764254fe0eba6..e2f32bb8e94b3 100644
--- a/services/pypi/pypi-status.tester.js
+++ b/services/pypi/pypi-status.tester.js
@@ -13,6 +13,10 @@ t.create('status (valid, beta)')
.get('/django/2.0rc1.json')
.expectBadge({ label: 'status', message: 'beta' })
+t.create('status (status not specified)')
+ .get('/arcgis2geojson/3.0.2.json')
+ .expectBadge({ label: 'status', message: 'unknown' })
+
t.create('status (invalid)')
.get('/not-a-package.json')
.expectBadge({ label: 'status', message: 'package or version not found' })
diff --git a/services/pypi/pypi-types.service.js b/services/pypi/pypi-types.service.js
new file mode 100644
index 0000000000000..992fffb9292ea
--- /dev/null
+++ b/services/pypi/pypi-types.service.js
@@ -0,0 +1,50 @@
+import PypiBase, { pypiGeneralParams } from './pypi-base.js'
+
+export default class PypiTypes extends PypiBase {
+ static category = 'platform-support'
+
+ static route = this.buildRoute('pypi/types')
+
+ static openApi = {
+ '/pypi/types/{packageName}': {
+ get: {
+ summary: 'PyPI - Types',
+ description:
+ 'Type information provided by the package, as indicated by the presence of the `Typing :: Typed` and `Typing :: Stubs Only` classifiers in the package metadata',
+ parameters: pypiGeneralParams,
+ },
+ },
+ }
+
+ static _cacheLength = 43200
+
+ static defaultBadgeData = { label: 'types' }
+
+ static render({ isTyped, isStubsOnly }) {
+ if (isTyped) {
+ return {
+ message: 'typed',
+ color: 'brightgreen',
+ }
+ } else if (isStubsOnly) {
+ return {
+ message: 'stubs',
+ color: 'brightgreen',
+ }
+ } else {
+ return {
+ message: 'untyped',
+ color: 'red',
+ }
+ }
+ }
+
+ async handle({ egg }, { pypiBaseUrl }) {
+ const packageData = await this.fetch({ egg, pypiBaseUrl })
+ const isTyped = packageData.info.classifiers.includes('Typing :: Typed')
+ const isStubsOnly = packageData.info.classifiers.includes(
+ 'Typing :: Stubs Only',
+ )
+ return this.constructor.render({ isTyped, isStubsOnly })
+ }
+}
diff --git a/services/pypi/pypi-types.tester.js b/services/pypi/pypi-types.tester.js
new file mode 100644
index 0000000000000..450feea729916
--- /dev/null
+++ b/services/pypi/pypi-types.tester.js
@@ -0,0 +1,18 @@
+import { createServiceTester } from '../tester.js'
+export const t = await createServiceTester()
+
+t.create('types (yes)')
+ .get('/pyre-check.json')
+ .expectBadge({ label: 'types', message: 'typed' })
+
+t.create('types (no)')
+ .get('/z3-solver.json')
+ .expectBadge({ label: 'types', message: 'untyped' })
+
+t.create('types (stubs)')
+ .get('/types-requests.json')
+ .expectBadge({ label: 'types', message: 'stubs' })
+
+t.create('types (invalid)')
+ .get('/not-a-package.json')
+ .expectBadge({ label: 'types', message: 'package or version not found' })
diff --git a/services/pypi/pypi-version.service.js b/services/pypi/pypi-version.service.js
index 9a421bd01c444..01b9baed019da 100644
--- a/services/pypi/pypi-version.service.js
+++ b/services/pypi/pypi-version.service.js
@@ -1,31 +1,33 @@
+import { pep440VersionColor } from '../color-formatters.js'
import { renderVersionBadge } from '../version.js'
-import PypiBase from './pypi-base.js'
+import PypiBase, { pypiGeneralParams } from './pypi-base.js'
export default class PypiVersion extends PypiBase {
static category = 'version'
static route = this.buildRoute('pypi/v')
- static examples = [
- {
- title: 'PyPI',
- pattern: ':packageName',
- namedParams: { packageName: 'nine' },
- staticPreview: this.render({ version: '1.0.0' }),
- keywords: ['python'],
+ static openApi = {
+ '/pypi/v/{packageName}': {
+ get: {
+ summary: 'PyPI - Version',
+ parameters: pypiGeneralParams,
+ },
},
- ]
+ }
+
+ static _cacheLength = 10800
static defaultBadgeData = { label: 'pypi' }
static render({ version }) {
- return renderVersionBadge({ version })
+ return renderVersionBadge({ version, versionFormatter: pep440VersionColor })
}
- async handle({ egg }) {
+ async handle({ egg }, { pypiBaseUrl }) {
const {
info: { version },
- } = await this.fetch({ egg })
+ } = await this.fetch({ egg, pypiBaseUrl })
return this.constructor.render({ version })
}
}
diff --git a/services/pypi/pypi-version.tester.js b/services/pypi/pypi-version.tester.js
index ed083d92c6504..17059084dcb86 100644
--- a/services/pypi/pypi-version.tester.js
+++ b/services/pypi/pypi-version.tester.js
@@ -22,7 +22,7 @@ t.create('version (semver)').get('/requests.json').expectBadge({
message: isSemver,
})
-// ..whereas this project does not folow SemVer
+// ..whereas this project does not follow SemVer
t.create('version (not semver)').get('/psycopg2.json').expectBadge({
label: 'pypi',
message: isPsycopg2Version,
@@ -43,8 +43,8 @@ t.create('no trove classifiers')
license: 'foo',
classifiers: [],
},
- releases: {},
- })
+ urls: [],
+ }),
)
.expectBadge({
label: 'pypi',
diff --git a/services/pypi/pypi-wheel.service.js b/services/pypi/pypi-wheel.service.js
index a81e2e4d42bd5..18dc306026304 100644
--- a/services/pypi/pypi-wheel.service.js
+++ b/services/pypi/pypi-wheel.service.js
@@ -1,4 +1,4 @@
-import PypiBase from './pypi-base.js'
+import PypiBase, { pypiGeneralParams } from './pypi-base.js'
import { getPackageFormats } from './pypi-helpers.js'
export default class PypiWheel extends PypiBase {
@@ -6,15 +6,16 @@ export default class PypiWheel extends PypiBase {
static route = this.buildRoute('pypi/wheel')
- static examples = [
- {
- title: 'PyPI - Wheel',
- pattern: ':packageName',
- namedParams: { packageName: 'Django' },
- staticPreview: this.render({ hasWheel: true }),
- keywords: ['python'],
+ static openApi = {
+ '/pypi/wheel/{packageName}': {
+ get: {
+ summary: 'PyPI - Wheel',
+ parameters: pypiGeneralParams,
+ },
},
- ]
+ }
+
+ static _cacheLength = 43200
static defaultBadgeData = { label: 'wheel' }
@@ -32,8 +33,8 @@ export default class PypiWheel extends PypiBase {
}
}
- async handle({ egg }) {
- const packageData = await this.fetch({ egg })
+ async handle({ egg }, { pypiBaseUrl }) {
+ const packageData = await this.fetch({ egg, pypiBaseUrl })
const { hasWheel } = getPackageFormats(packageData)
return this.constructor.render({ hasWheel })
}
diff --git a/services/python/python-version-from-toml.service.js b/services/python/python-version-from-toml.service.js
new file mode 100644
index 0000000000000..9f6b06b43ada1
--- /dev/null
+++ b/services/python/python-version-from-toml.service.js
@@ -0,0 +1,68 @@
+import Joi from 'joi'
+import BaseTomlService from '../../core/base-service/base-toml.js'
+import { queryParams } from '../index.js'
+import { url } from '../validators.js'
+
+const queryParamSchema = Joi.object({
+ tomlFilePath: url,
+}).required()
+
+const schema = Joi.object({
+ project: Joi.object({
+ 'requires-python': Joi.string().required(),
+ }).required(),
+}).required()
+
+const description = `Shows the required python version for a package based on the values in the requires-python field in PEP 621 compliant pyproject.toml \n
+a URL of the toml is required, please note that when linking to files in github or similar sites, provide URL to raw file, for example:
+
+Use https://raw.githubusercontent.com/numpy/numpy/main/pyproject.toml \n
+And not https://github.com/numpy/numpy/blob/main/pyproject.toml
+`
+
+class PythonVersionFromToml extends BaseTomlService {
+ static category = 'platform-support'
+
+ static route = {
+ base: '',
+ pattern: 'python/required-version-toml',
+ queryParamSchema,
+ }
+
+ static openApi = {
+ '/python/required-version-toml': {
+ get: {
+ summary: 'Python Version from PEP 621 TOML',
+ description,
+ parameters: queryParams({
+ name: 'tomlFilePath',
+ example:
+ 'https://raw.githubusercontent.com/numpy/numpy/main/pyproject.toml',
+ required: true,
+ }),
+ },
+ },
+ }
+
+ static defaultBadgeData = { label: 'python' }
+
+ static render({ requiresPythonString }) {
+ // we only show requries-python as is
+ // for more info read the following issues:
+ // https://github.com/badges/shields/issues/9410
+ // https://github.com/badges/shields/issues/5551
+ return {
+ message: requiresPythonString,
+ color: 'blue',
+ }
+ }
+
+ async handle(namedParams, { tomlFilePath }) {
+ const tomlData = await this._requestToml({ url: tomlFilePath, schema })
+ const requiresPythonString = tomlData.project['requires-python']
+
+ return this.constructor.render({ requiresPythonString })
+ }
+}
+
+export { PythonVersionFromToml }
diff --git a/services/python/python-version-from-toml.tester.js b/services/python/python-version-from-toml.tester.js
new file mode 100644
index 0000000000000..fab5b588818c3
--- /dev/null
+++ b/services/python/python-version-from-toml.tester.js
@@ -0,0 +1,27 @@
+import Joi from 'joi'
+import pep440 from '@renovatebot/pep440'
+import { createServiceTester } from '../tester.js'
+export const t = await createServiceTester()
+
+const validatePep440 = (value, helpers) => {
+ if (!pep440.validRange(value)) {
+ return helpers.error('any.invalid')
+ }
+ return value
+}
+
+const isCommaSeparatedPythonVersions = Joi.string().custom(validatePep440)
+
+t.create('python versions (valid)')
+ .get(
+ '/python/required-version-toml.json?tomlFilePath=https://raw.githubusercontent.com/numpy/numpy/main/pyproject.toml',
+ )
+ .expectBadge({ label: 'python', message: isCommaSeparatedPythonVersions })
+
+t.create(
+ 'python versions - valid toml with missing python-requires field (invalid)',
+)
+ .get(
+ '/python/required-version-toml.json?tomlFilePath=https://raw.githubusercontent.com/psf/requests/main/pyproject.toml',
+ )
+ .expectBadge({ label: 'python', message: 'invalid response data' })
diff --git a/services/raycast/installs.service.js b/services/raycast/installs.service.js
new file mode 100644
index 0000000000000..9bbde6bcf4c83
--- /dev/null
+++ b/services/raycast/installs.service.js
@@ -0,0 +1,55 @@
+import Joi from 'joi'
+import { nonNegativeInteger } from '../validators.js'
+import { BaseJsonService, pathParams } from '../index.js'
+import { renderDownloadsBadge } from '../downloads.js'
+
+const schema = Joi.object({
+ download_count: nonNegativeInteger,
+}).required()
+
+export default class RaycastInstalls extends BaseJsonService {
+ static category = 'downloads'
+
+ static route = {
+ base: 'raycast/dt',
+ pattern: ':user/:extension',
+ }
+
+ static openApi = {
+ '/raycast/dt/{user}/{extension}': {
+ get: {
+ summary: 'Raycast extension downloads count',
+ parameters: pathParams(
+ { name: 'user', example: 'Fatpandac' },
+ { name: 'extension', example: 'bilibili' },
+ ),
+ },
+ },
+ }
+
+ static render({ downloads }) {
+ return renderDownloadsBadge({ downloads })
+ }
+
+ async fetch({ user, extension }) {
+ return this._requestJson({
+ schema,
+ url: `https://www.raycast.com/api/v1/extensions/${user}/${extension}`,
+ httpErrors: {
+ 404: 'user/extension not found',
+ },
+ })
+ }
+
+ transform(json) {
+ const downloads = json.download_count
+
+ return { downloads }
+ }
+
+ async handle({ user, extension }) {
+ const json = await this.fetch({ user, extension })
+ const { downloads } = this.transform(json)
+ return this.constructor.render({ downloads })
+ }
+}
diff --git a/services/raycast/installs.tester.js b/services/raycast/installs.tester.js
new file mode 100644
index 0000000000000..e6e8e957f0493
--- /dev/null
+++ b/services/raycast/installs.tester.js
@@ -0,0 +1,30 @@
+import { createServiceTester } from '../tester.js'
+import { isMetric } from '../test-validators.js'
+
+export const t = await createServiceTester()
+
+t.create('installs (invalid user)')
+ .get('/fatpandac/bilibili.json')
+ .expectBadge({
+ label: 'downloads',
+ message: 'user/extension not found',
+ })
+
+t.create('installs (not existing extension)')
+ .get('/Fatpandac/safdsaklfhe.json')
+ .expectBadge({
+ label: 'downloads',
+ message: 'user/extension not found',
+ })
+
+t.create('installs (not existing user and extension)')
+ .get('/fatpandac/safdsaklfhe.json')
+ .expectBadge({
+ label: 'downloads',
+ message: 'user/extension not found',
+ })
+
+t.create('installs (valid)').get('/Fatpandac/bilibili.json').expectBadge({
+ label: 'downloads',
+ message: isMetric,
+})
diff --git a/services/readthedocs/readthedocs.service.js b/services/readthedocs/readthedocs.service.js
index 7522e69c3fc5f..fa36f80238c12 100644
--- a/services/readthedocs/readthedocs.service.js
+++ b/services/readthedocs/readthedocs.service.js
@@ -1,8 +1,6 @@
import Joi from 'joi'
import { isBuildStatus, renderBuildStatusBadge } from '../build-status.js'
-import { BaseSvgScrapingService, NotFound } from '../index.js'
-
-const keywords = ['documentation']
+import { BaseSvgScrapingService, NotFound, pathParams } from '../index.js'
const schema = Joi.object({
message: Joi.alternatives()
@@ -10,6 +8,9 @@ const schema = Joi.object({
.required(),
}).required()
+const description =
+ '[ReadTheDocs](https://readthedocs.com/) is a hosting service for documentation.'
+
export default class ReadTheDocs extends BaseSvgScrapingService {
static category = 'build'
@@ -18,22 +19,34 @@ export default class ReadTheDocs extends BaseSvgScrapingService {
pattern: ':project/:version?',
}
- static examples = [
- {
- title: 'Read the Docs',
- pattern: ':packageName',
- namedParams: { packageName: 'pip' },
- staticPreview: this.render({ status: 'passing' }),
- keywords,
+ static openApi = {
+ '/readthedocs/{packageName}': {
+ get: {
+ summary: 'Read the Docs',
+ description,
+ parameters: pathParams({
+ name: 'packageName',
+ example: 'pip',
+ }),
+ },
},
- {
- title: 'Read the Docs (version)',
- pattern: ':packageName/:version',
- namedParams: { packageName: 'pip', version: 'stable' },
- staticPreview: this.render({ status: 'passing' }),
- keywords,
+ '/readthedocs/{packageName}/{version}': {
+ get: {
+ summary: 'Read the Docs (version)',
+ description,
+ parameters: pathParams(
+ {
+ name: 'packageName',
+ example: 'pip',
+ },
+ {
+ name: 'version',
+ example: 'stable',
+ },
+ ),
+ },
},
- ]
+ }
static defaultBadgeData = {
label: 'docs',
@@ -47,9 +60,9 @@ export default class ReadTheDocs extends BaseSvgScrapingService {
const { message: status } = await this._requestSvg({
schema,
url: `https://readthedocs.org/projects/${encodeURIComponent(
- project
+ project,
)}/badge/`,
- options: { qs: { version } },
+ options: { searchParams: { version } },
})
if (status === 'unknown') {
throw new NotFound({
diff --git a/services/reddit/reddit-base.js b/services/reddit/reddit-base.js
new file mode 100644
index 0000000000000..992f2f4dcbd0b
--- /dev/null
+++ b/services/reddit/reddit-base.js
@@ -0,0 +1,89 @@
+import Joi from 'joi'
+import { BaseJsonService } from '../index.js'
+
+const tokenSchema = Joi.object({
+ access_token: Joi.string().required(),
+ expires_in: Joi.number(),
+})
+
+// Abstract class for Reddit badges
+// Authorization flow based on https://github.com/reddit-archive/reddit/wiki/OAuth2#application-only-oauth.
+export default class RedditBase extends BaseJsonService {
+ static category = 'social'
+
+ static auth = {
+ userKey: 'reddit_client_id',
+ passKey: 'reddit_client_secret',
+ authorizedOrigins: ['https://www.reddit.com'],
+ isRequired: false,
+ }
+
+ constructor(...args) {
+ super(...args)
+ if (!RedditBase._redditToken && this.authHelper.isConfigured) {
+ RedditBase._redditToken = this._getNewToken()
+ }
+ }
+
+ async _getNewToken() {
+ const tokenRes = await super._requestJson(
+ this.authHelper.withBasicAuth({
+ schema: tokenSchema,
+ url: 'https://www.reddit.com/api/v1/access_token',
+ options: {
+ method: 'POST',
+ body: 'grant_type=client_credentials',
+ },
+ httpErrors: {
+ 401: 'invalid token',
+ },
+ }),
+ )
+
+ // replace the token when we are 80% near the expire time
+ // 2147483647 is the max 32-bit value that is accepted by setTimeout(), it's about 24.9 days
+ const replaceTokenMs = Math.min(
+ tokenRes.expires_in * 1000 * 0.8,
+ 2147483647,
+ )
+ const timeout = setTimeout(() => {
+ RedditBase._redditToken = this._getNewToken()
+ }, replaceTokenMs)
+
+ // do not block program exit
+ timeout.unref()
+
+ return tokenRes.access_token
+ }
+
+ async _requestJson(request) {
+ if (!this.authHelper.isConfigured) {
+ return super._requestJson(request)
+ }
+
+ request = await this.addBearerAuthHeader(request)
+ try {
+ return await super._requestJson(request)
+ } catch (err) {
+ if (err.response && err.response.statusCode === 401) {
+ // if the token is expired or has been revoked, retry once
+ RedditBase._redditToken = this._getNewToken()
+ request = await this.addBearerAuthHeader(request)
+ return super._requestJson(request)
+ }
+ // cannot recover
+ throw err
+ }
+ }
+
+ async addBearerAuthHeader(request) {
+ return {
+ ...request,
+ options: {
+ headers: {
+ Authorization: `Bearer ${await RedditBase._redditToken}`,
+ },
+ },
+ }
+ }
+}
diff --git a/services/reddit/subreddit-subscribers.service.js b/services/reddit/subreddit-subscribers.service.js
index 2d0ce5c53d7d3..2a505536b81f6 100644
--- a/services/reddit/subreddit-subscribers.service.js
+++ b/services/reddit/subreddit-subscribers.service.js
@@ -1,7 +1,8 @@
import Joi from 'joi'
import { optionalNonNegativeInteger } from '../validators.js'
import { metric } from '../text-formatters.js'
-import { BaseJsonService, NotFound } from '../index.js'
+import { NotFound, pathParams } from '../index.js'
+import RedditBase from './reddit-base.js'
const schema = Joi.object({
data: Joi.object({
@@ -9,26 +10,23 @@ const schema = Joi.object({
}).required(),
}).required()
-export default class RedditSubredditSubscribers extends BaseJsonService {
- static category = 'social'
-
+export default class RedditSubredditSubscribers extends RedditBase {
static route = {
base: 'reddit/subreddit-subscribers',
pattern: ':subreddit',
}
- static examples = [
- {
- title: 'Subreddit subscribers',
- namedParams: { subreddit: 'drums' },
- staticPreview: {
- label: 'follow r/drums',
- message: '77k',
- color: 'red',
- style: 'social',
+ static openApi = {
+ '/reddit/subreddit-subscribers/{subreddit}': {
+ get: {
+ summary: 'Subreddit subscribers',
+ parameters: pathParams({
+ name: 'subreddit',
+ example: 'drums',
+ }),
},
},
- ]
+ }
static defaultBadgeData = {
label: 'reddit',
@@ -39,6 +37,7 @@ export default class RedditSubredditSubscribers extends BaseJsonService {
return {
label: `follow r/${subreddit}`,
message: metric(subscribers),
+ style: 'social',
color: 'red',
link: [`https://www.reddit.com/r/${subreddit}`],
}
@@ -47,8 +46,11 @@ export default class RedditSubredditSubscribers extends BaseJsonService {
async fetch({ subreddit }) {
return this._requestJson({
schema,
- url: `https://www.reddit.com/r/${subreddit}/about.json`,
- errorMessages: {
+ // API requests with a bearer token should be made to https://oauth.reddit.com, NOT www.reddit.com.
+ url: this.authHelper.isConfigured
+ ? `https://oauth.reddit.com/r/${subreddit}/about.json`
+ : `https://www.reddit.com/r/${subreddit}/about.json`,
+ httpErrors: {
404: 'subreddit not found',
403: 'subreddit is private',
},
diff --git a/services/reddit/subreddit-subscribers.tester.js b/services/reddit/subreddit-subscribers.tester.js
index 1038718d523c8..b993551c02841 100644
--- a/services/reddit/subreddit-subscribers.tester.js
+++ b/services/reddit/subreddit-subscribers.tester.js
@@ -1,6 +1,10 @@
+import { noToken } from '../test-helpers.js'
import { isMetric } from '../test-validators.js'
import { createServiceTester } from '../tester.js'
+import _serviceClass from './subreddit-subscribers.service.js'
export const t = await createServiceTester()
+const noRedditToken = noToken(_serviceClass)
+const hasRedditToken = () => !noRedditToken()
t.create('subreddit-subscribers (valid subreddit)')
.get('/drums.json')
@@ -30,13 +34,28 @@ t.create('subreddit-subscribers (private sub)')
message: 'subreddit is private',
})
-t.create('subreddit-subscribers (private sub)')
+t.create('subreddit-subscribers (private sub, without token)')
+ .skipWhen(hasRedditToken)
.get('/centuryclub.json')
.intercept(nock =>
nock('https://www.reddit.com/r')
.get('/centuryclub/about.json')
- .reply(200, { kind: 't5', data: {} })
+ .reply(200, { kind: 't5', data: {} }),
+ )
+ .expectBadge({
+ label: 'reddit',
+ message: 'subreddit not found',
+ })
+
+t.create('subreddit-subscribers (private sub, with token)')
+ .skipWhen(noRedditToken)
+ .get('/centuryclub.json')
+ .intercept(nock =>
+ nock('https://oauth.reddit.com/r')
+ .get('/centuryclub/about.json')
+ .reply(200, { kind: 't5', data: {} }),
)
+ .networkOn() // API /access_token may or may not be called depending on whether another test ran before and cached the token. Rather than conditionally intercepting it, let it go through and only mock the API call we're validating specific behaviour against.
.expectBadge({
label: 'reddit',
message: 'subreddit not found',
diff --git a/services/reddit/user-karma.service.js b/services/reddit/user-karma.service.js
index 76eeb7769b0b2..460ae2d771d88 100644
--- a/services/reddit/user-karma.service.js
+++ b/services/reddit/user-karma.service.js
@@ -1,7 +1,8 @@
import Joi from 'joi'
import { anyInteger } from '../validators.js'
import { metric } from '../text-formatters.js'
-import { BaseJsonService } from '../index.js'
+import { pathParams } from '../index.js'
+import RedditBase from './reddit-base.js'
const schema = Joi.object({
data: Joi.object({
@@ -10,26 +11,30 @@ const schema = Joi.object({
}).required(),
}).required()
-export default class RedditUserKarma extends BaseJsonService {
- static category = 'social'
-
+export default class RedditUserKarma extends RedditBase {
static route = {
base: 'reddit/user-karma',
pattern: ':variant(link|comment|combined)/:user',
}
- static examples = [
- {
- title: 'Reddit User Karma',
- namedParams: { variant: 'combined', user: 'example' },
- staticPreview: {
- label: 'combined karma',
- message: 56,
- color: 'red',
- style: 'social',
+ static openApi = {
+ '/reddit/user-karma/{variant}/{user}': {
+ get: {
+ summary: 'Reddit User Karma',
+ parameters: pathParams(
+ {
+ name: 'variant',
+ example: 'combined',
+ schema: { type: 'string', enum: this.getEnum('variant') },
+ },
+ {
+ name: 'user',
+ example: 'example',
+ },
+ ),
},
},
- ]
+ }
static defaultBadgeData = {
label: 'reddit karma',
@@ -44,7 +49,8 @@ export default class RedditUserKarma extends BaseJsonService {
return {
label,
message: metric(karma),
- color: 'red',
+ style: 'social',
+ color: karma > 0 ? 'brightgreen' : karma === 0 ? 'orange' : 'red',
link: [`https://www.reddit.com/u/${user}`],
}
}
@@ -52,8 +58,11 @@ export default class RedditUserKarma extends BaseJsonService {
async fetch({ user }) {
return this._requestJson({
schema,
- url: `https://www.reddit.com/u/${user}/about.json`,
- errorMessages: {
+ // API requests with a bearer token should be made to https://oauth.reddit.com, NOT www.reddit.com.
+ url: this.authHelper.isConfigured
+ ? `https://oauth.reddit.com/u/${user}/about.json`
+ : `https://www.reddit.com/u/${user}/about.json`,
+ httpErrors: {
404: 'user not found',
},
})
diff --git a/services/reddit/user-karma.tester.js b/services/reddit/user-karma.tester.js
index 9023023dbb630..418d1533f2678 100644
--- a/services/reddit/user-karma.tester.js
+++ b/services/reddit/user-karma.tester.js
@@ -1,26 +1,30 @@
-import { isMetric } from '../test-validators.js'
+import { noToken } from '../test-helpers.js'
+import { isMetricAllowNegative } from '../test-validators.js'
import { createServiceTester } from '../tester.js'
+import _serviceClass from './subreddit-subscribers.service.js'
export const t = await createServiceTester()
+const noRedditToken = noToken(_serviceClass)
+const hasRedditToken = () => !noRedditToken()
t.create('user-karma (valid - link)')
.get('/link/user_simulator.json')
.expectBadge({
label: 'u/user_simulator karma (link)',
- message: isMetric,
+ message: isMetricAllowNegative,
})
t.create('user-karma (valid - comment')
.get('/comment/user_simulator.json')
.expectBadge({
label: 'u/user_simulator karma (comment)',
- message: isMetric,
+ message: isMetricAllowNegative,
})
t.create('user-karma (valid - combined)')
.get('/combined/user_simulator.json')
.expectBadge({
label: 'u/user_simulator karma',
- message: isMetric,
+ message: isMetricAllowNegative,
})
t.create('user-karma (non-existing user)')
@@ -30,49 +34,109 @@ t.create('user-karma (non-existing user)')
message: 'user not found',
})
-t.create('user-karma (link - math check)')
+t.create('user-karma (link - math check, without token)')
+ .skipWhen(hasRedditToken)
.get('/link/user_simulator.json')
.intercept(nock =>
nock('https://www.reddit.com/u')
.get('/user_simulator/about.json')
- .reply(200, { kind: 't2', data: { link_karma: 20, comment_karma: 80 } })
+ .reply(200, { kind: 't2', data: { link_karma: 20, comment_karma: 80 } }),
)
.expectBadge({
label: 'u/user_simulator karma (link)',
message: '20',
})
-t.create('user-karma (comment - math check)')
+t.create('user-karma (link - math check, with token)')
+ .skipWhen(noRedditToken)
+ .get('/link/user_simulator.json')
+ .intercept(nock =>
+ nock('https://oauth.reddit.com/u')
+ .get('/user_simulator/about.json')
+ .reply(200, { kind: 't2', data: { link_karma: 20, comment_karma: 80 } }),
+ )
+ .networkOn() // API /access_token may or may not be called depending on whether another test ran before and cached the token. Rather than conditionally intercepting it, let it go through and only mock the API call we're validating specific behaviour against.
+ .expectBadge({
+ label: 'u/user_simulator karma (link)',
+ message: '20',
+ })
+
+t.create('user-karma (comment - math check, without token)')
+ .skipWhen(hasRedditToken)
.get('/comment/user_simulator.json')
.intercept(nock =>
nock('https://www.reddit.com/u')
.get('/user_simulator/about.json')
- .reply(200, { kind: 't2', data: { link_karma: 20, comment_karma: 80 } })
+ .reply(200, { kind: 't2', data: { link_karma: 20, comment_karma: 80 } }),
+ )
+ .expectBadge({
+ label: 'u/user_simulator karma (comment)',
+ message: '80',
+ })
+
+t.create('user-karma (comment - math check, with token)')
+ .skipWhen(noRedditToken)
+ .get('/comment/user_simulator.json')
+ .intercept(nock =>
+ nock('https://oauth.reddit.com/u')
+ .get('/user_simulator/about.json')
+ .reply(200, { kind: 't2', data: { link_karma: 20, comment_karma: 80 } }),
)
+ .networkOn() // API /access_token may or may not be called depending on whether another test ran before and cached the token. Rather than conditionally intercepting it, let it go through and only mock the API call we're validating specific behaviour against.
.expectBadge({
label: 'u/user_simulator karma (comment)',
message: '80',
})
-t.create('user-karma (combined - math check)')
+t.create('user-karma (combined - math check, without token)')
+ .skipWhen(hasRedditToken)
.get('/combined/user_simulator.json')
.intercept(nock =>
nock('https://www.reddit.com/u')
.get('/user_simulator/about.json')
- .reply(200, { kind: 't2', data: { link_karma: 20, comment_karma: 80 } })
+ .reply(200, { kind: 't2', data: { link_karma: 20, comment_karma: 80 } }),
)
.expectBadge({
label: 'u/user_simulator karma',
message: '100',
})
-t.create('user-karma (combined - missing data)')
+t.create('user-karma (combined - math check, with token)')
+ .skipWhen(noRedditToken)
+ .get('/combined/user_simulator.json')
+ .intercept(nock =>
+ nock('https://oauth.reddit.com/u')
+ .get('/user_simulator/about.json')
+ .reply(200, { kind: 't2', data: { link_karma: 20, comment_karma: 80 } }),
+ )
+ .networkOn() // API /access_token may or may not be called depending on whether another test ran before and cached the token. Rather than conditionally intercepting it, let it go through and only mock the API call we're validating specific behaviour against.
+ .expectBadge({
+ label: 'u/user_simulator karma',
+ message: '100',
+ })
+
+t.create('user-karma (combined - missing data, without token)')
+ .skipWhen(hasRedditToken)
.get('/combined/user_simulator.json')
.intercept(nock =>
nock('https://www.reddit.com/u')
.get('/user_simulator/about.json')
- .reply(200, { kind: 't2', data: { link_karma: 20 } })
+ .reply(200, { kind: 't2', data: { link_karma: 20 } }),
+ )
+ .expectBadge({
+ label: 'reddit karma',
+ message: 'invalid response data',
+ })
+
+t.create('user-karma (combined - missing data, with token)')
+ .skipWhen(noRedditToken)
+ .get('/combined/user_simulator.json')
+ .intercept(nock =>
+ nock('https://oauth.reddit.com/u')
+ .get('/user_simulator/about.json')
+ .reply(200, { kind: 't2', data: { link_karma: 20 } }),
)
+ .networkOn() // API /access_token may or may not be called depending on whether another test ran before and cached the token. Rather than conditionally intercepting it, let it go through and only mock the API call we're validating specific behaviour against.
.expectBadge({
label: 'reddit karma',
message: 'invalid response data',
diff --git a/services/redmine/redmine.service.js b/services/redmine/redmine.service.js
deleted file mode 100644
index 288a36ffe4719..0000000000000
--- a/services/redmine/redmine.service.js
+++ /dev/null
@@ -1,79 +0,0 @@
-import Joi from 'joi'
-import { starRating } from '../text-formatters.js'
-import { floorCount as floorCountColor } from '../color-formatters.js'
-import { BaseXmlService } from '../index.js'
-
-const schema = Joi.object({
- 'redmine-plugin': Joi.object({
- 'ratings-average': Joi.number().min(0).required(),
- }).required(),
-})
-
-class BaseRedminePluginRating extends BaseXmlService {
- static category = 'rating'
-
- static render({ rating }) {
- throw new Error(`render() function not implemented for ${this.name}`)
- }
-
- async fetch({ plugin }) {
- const url = `https://www.redmine.org/plugins/${plugin}.xml`
- return this._requestXml({ schema, url })
- }
-
- async handle({ plugin }) {
- const data = await this.fetch({ plugin })
- const rating = data['redmine-plugin']['ratings-average']
- return this.constructor.render({ rating })
- }
-}
-
-class RedminePluginRating extends BaseRedminePluginRating {
- static route = {
- base: 'redmine/plugin/rating',
- pattern: ':plugin',
- }
-
- static examples = [
- {
- title: 'Plugin on redmine.org',
- namedParams: { plugin: 'redmine_xlsx_format_issue_exporter' },
- staticPreview: this.render({ rating: 5 }),
- },
- ]
-
- static defaultBadgeData = { label: 'redmine' }
-
- static render({ rating }) {
- return {
- label: 'rating',
- message: `${rating.toFixed(1)}/5.0`,
- color: floorCountColor(rating, 2, 3, 4),
- }
- }
-}
-
-class RedminePluginStars extends BaseRedminePluginRating {
- static route = {
- base: 'redmine/plugin/stars',
- pattern: ':plugin',
- }
-
- static examples = [
- {
- title: 'Plugin on redmine.org',
- namedParams: { plugin: 'redmine_xlsx_format_issue_exporter' },
- staticPreview: this.render({ rating: 5 }),
- },
- ]
-
- static render({ rating }) {
- return {
- label: 'stars',
- message: starRating(Math.round(rating)),
- color: floorCountColor(rating, 2, 3, 4),
- }
- }
-}
-
-export { RedminePluginRating, RedminePluginStars }
diff --git a/services/redmine/redmine.tester.js b/services/redmine/redmine.tester.js
deleted file mode 100644
index c69e6e6eceac0..0000000000000
--- a/services/redmine/redmine.tester.js
+++ /dev/null
@@ -1,29 +0,0 @@
-import Joi from 'joi'
-import { ServiceTester } from '../tester.js'
-import { isStarRating } from '../test-validators.js'
-
-export const t = new ServiceTester({
- id: 'redmine',
- title: 'Redmine',
-})
-
-t.create('plugin rating')
- .get('/plugin/rating/redmine_xlsx_format_issue_exporter.json')
- .expectBadge({
- label: 'rating',
- message: Joi.string().regex(/^[0-9]+\.[0-9]+\/5\.0$/),
- })
-
-t.create('plugin stars')
- .get('/plugin/stars/redmine_xlsx_format_issue_exporter.json')
- .expectBadge({
- label: 'stars',
- message: isStarRating,
- })
-
-t.create('plugin not found')
- .get('/plugin/rating/plugin_not_found.json')
- .expectBadge({
- label: 'redmine',
- message: 'not found',
- })
diff --git a/services/repology/repology-repositories.service.js b/services/repology/repology-repositories.service.js
index 98b5e3ff55dc6..bd89fc5ef15a6 100644
--- a/services/repology/repology-repositories.service.js
+++ b/services/repology/repology-repositories.service.js
@@ -1,7 +1,7 @@
import Joi from 'joi'
import { metric } from '../text-formatters.js'
import { nonNegativeInteger } from '../validators.js'
-import { BaseSvgScrapingService } from '../index.js'
+import { BaseSvgScrapingService, pathParams } from '../index.js'
const schema = Joi.object({
message: nonNegativeInteger,
@@ -15,13 +15,17 @@ export default class RepologyRepositories extends BaseSvgScrapingService {
pattern: ':projectName',
}
- static examples = [
- {
- title: 'Repology - Repositories',
- namedParams: { projectName: 'starship' },
- staticPreview: this.render({ repositoryCount: '18' }),
+ static openApi = {
+ '/repology/repositories/{projectName}': {
+ get: {
+ summary: 'Repology - Repositories',
+ parameters: pathParams({
+ name: 'projectName',
+ example: 'starship',
+ }),
+ },
},
- ]
+ }
static defaultBadgeData = {
label: 'repositories',
diff --git a/services/reproducible-central/reproducible-central.service.js b/services/reproducible-central/reproducible-central.service.js
new file mode 100644
index 0000000000000..93f87920e8062
--- /dev/null
+++ b/services/reproducible-central/reproducible-central.service.js
@@ -0,0 +1,94 @@
+import Joi from 'joi'
+import { BaseJsonService, pathParams } from '../index.js'
+
+const schema = Joi.object()
+ .pattern(Joi.string(), Joi.string().regex(/^\d+\/\d+$|^[X?]$/))
+ .required()
+
+const description = `
+[Reproducible Central](https://github.com/jvm-repo-rebuild/reproducible-central)
+provides [Reproducible Builds](https://reproducible-builds.org/) check status
+for projects published to [Maven Central](https://central.sonatype.com/).
+`
+
+export default class ReproducibleCentral extends BaseJsonService {
+ static category = 'dependencies'
+
+ static route = {
+ base: 'reproducible-central/artifact',
+ pattern: ':groupId/:artifactId/:version',
+ }
+
+ static openApi = {
+ '/reproducible-central/artifact/{groupId}/{artifactId}/{version}': {
+ get: {
+ summary: 'Reproducible Central Artifact',
+ description,
+ parameters: pathParams(
+ {
+ name: 'groupId',
+ example: 'org.apache.maven',
+ },
+ {
+ name: 'artifactId',
+ example: 'maven-core',
+ },
+ {
+ name: 'version',
+ example: '3.9.9',
+ },
+ ),
+ },
+ },
+ }
+
+ static defaultBadgeData = {
+ label: 'reproducible builds',
+ }
+
+ static render(rebuildResult) {
+ if (rebuildResult === undefined) {
+ return { color: 'red', message: 'version not available in Maven Central' }
+ } else if (rebuildResult === 'X') {
+ return { color: 'red', message: 'unable to rebuild' }
+ } else if (rebuildResult === '?') {
+ return { color: 'grey', message: 'version not evaluated' }
+ }
+
+ const [ok, count] = rebuildResult.split('/')
+ let color
+ if (ok === count) {
+ color = 'brightgreen'
+ } else if (ok > count - ok) {
+ color = 'yellow'
+ } else {
+ color = 'red'
+ }
+ return { color, message: rebuildResult }
+ }
+
+ async fetch({ groupId, artifactId }) {
+ return this._requestJson({
+ schema,
+ url: `https://jvm-repo-rebuild.github.io/reproducible-central/badge/artifact/${groupId.replace(/\./g, '/')}/${artifactId}.json`,
+ httpErrors: {
+ 404: 'groupId:artifactId unknown',
+ },
+ })
+ }
+
+ async handle({ groupId, artifactId, version }) {
+ if (version.endsWith('-SNAPSHOT')) {
+ return {
+ message: 'SNAPSHOT, not evaluated',
+ color: 'grey',
+ }
+ }
+
+ const versions = await this.fetch({
+ groupId,
+ artifactId,
+ })
+ return this.constructor.render(versions[version])
+ }
+}
diff --git a/services/reproducible-central/reproducible-central.tester.js b/services/reproducible-central/reproducible-central.tester.js
new file mode 100644
index 0000000000000..9a9857885b54c
--- /dev/null
+++ b/services/reproducible-central/reproducible-central.tester.js
@@ -0,0 +1,62 @@
+import { createServiceTester } from '../tester.js'
+export const t = await createServiceTester()
+
+t.create('reproducible gav')
+ .get('/org.apache.maven/maven-core/3.9.9.json')
+ .expectBadge({
+ label: 'reproducible builds',
+ message: '75/75',
+ color: 'brightgreen',
+ })
+
+t.create('mostly reproducible gav')
+ .get('/org.apache.maven/maven-core/3.8.5.json')
+ .expectBadge({
+ label: 'reproducible builds',
+ message: '43/47',
+ color: 'yellow',
+ })
+
+t.create('mostly non-reproducible gav')
+ .get('/org.apache.maven/maven-core/3.6.3.json')
+ .expectBadge({
+ label: 'reproducible builds',
+ message: '2/32',
+ color: 'red',
+ })
+
+t.create('non-rebuildable gav')
+ .get('/org.apache.maven/maven-core/4.0.0-alpha-2.json')
+ .expectBadge({
+ label: 'reproducible builds',
+ message: 'unable to rebuild',
+ color: 'red',
+ })
+
+t.create('unknown v for known ga')
+ .get('/org.apache.maven/maven-core/3.9.9.1.json')
+ .expectBadge({
+ label: 'reproducible builds',
+ message: 'version not available in Maven Central',
+ color: 'red',
+ })
+
+t.create('untested v for known ga')
+ .get('/org.apache.maven/maven-core/2.2.1.json')
+ .expectBadge({
+ label: 'reproducible builds',
+ message: 'version not evaluated',
+ color: 'grey',
+ })
+
+t.create('unknown ga').get('/org.apache.maven/any/3.9.9.json').expectBadge({
+ label: 'reproducible builds',
+ message: 'groupId:artifactId unknown',
+ color: 'red',
+})
+
+t.create('SNAPSHOT').get('/any/any/anything-SNAPSHOT.json').expectBadge({
+ label: 'reproducible builds',
+ message: 'SNAPSHOT, not evaluated',
+ color: 'grey',
+})
diff --git a/services/requires/requires.service.js b/services/requires/requires.service.js
deleted file mode 100644
index e3abdacde8996..0000000000000
--- a/services/requires/requires.service.js
+++ /dev/null
@@ -1,65 +0,0 @@
-import Joi from 'joi'
-import { BaseJsonService } from '../index.js'
-
-const statusSchema = Joi.object({
- status: Joi.string().required(),
-}).required()
-
-export default class RequiresIo extends BaseJsonService {
- static category = 'dependencies'
-
- static route = {
- base: 'requires',
- pattern: ':service/:user/:repo/:branch*',
- }
-
- static examples = [
- {
- title: 'Requires.io',
- pattern: ':service/:user/:repo',
- namedParams: { service: 'github', user: 'zulip', repo: 'zulip' },
- staticPreview: this.render({ status: 'up-to-date' }),
- },
- {
- title: 'Requires.io (branch)',
- pattern: ':service/:user/:repo/:branch',
- namedParams: {
- service: 'github',
- user: 'zulip',
- repo: 'zulip',
- branch: 'master',
- },
- staticPreview: this.render({ status: 'up-to-date' }),
- },
- ]
-
- static defaultBadgeData = { label: 'requirements' }
-
- static render({ status }) {
- let message = status
- let color = 'lightgrey'
- if (status === 'up-to-date') {
- message = 'up to date'
- color = 'brightgreen'
- } else if (status === 'outdated') {
- color = 'yellow'
- } else if (status === 'insecure') {
- color = 'red'
- }
- return { message, color }
- }
-
- async fetch({ service, user, repo, branch }) {
- const url = `https://requires.io/api/v1/status/${service}/${user}/${repo}`
- return this._requestJson({
- url,
- schema: statusSchema,
- options: { qs: { branch } },
- })
- }
-
- async handle({ service, user, repo, branch }) {
- const { status } = await this.fetch({ service, user, repo, branch })
- return this.constructor.render({ status })
- }
-}
diff --git a/services/requires/requires.tester.js b/services/requires/requires.tester.js
deleted file mode 100644
index 566c5b34c11ec..0000000000000
--- a/services/requires/requires.tester.js
+++ /dev/null
@@ -1,25 +0,0 @@
-import Joi from 'joi'
-import { createServiceTester } from '../tester.js'
-export const t = await createServiceTester()
-
-const isRequireStatus = Joi.string().regex(
- /^(up to date|outdated|insecure|unknown)$/
-)
-
-t.create('requirements (valid, without branch)')
- .get('/github/zulip/zulip.json')
- .expectBadge({
- label: 'requirements',
- message: isRequireStatus,
- })
-
-t.create('requirements (valid, with branch)')
- .get('/github/zulip/zulip/master.json')
- .expectBadge({
- label: 'requirements',
- message: isRequireStatus,
- })
-
-t.create('requirements (not found)')
- .get('/github/PyvesB/EmptyRepo.json')
- .expectBadge({ label: 'requirements', message: 'not found' })
diff --git a/services/resharper/resharper.service.js b/services/resharper/resharper.service.js
index ef05a293379ac..8c6864e629709 100644
--- a/services/resharper/resharper.service.js
+++ b/services/resharper/resharper.service.js
@@ -5,10 +5,6 @@ export default createServiceFamily({
defaultLabel: 'resharper',
serviceBaseUrl: 'resharper',
apiBaseUrl: 'https://resharper-plugins.jetbrains.com/api/v2',
- odataFormat: 'xml',
title: 'JetBrains ReSharper plugins',
examplePackageName: 'StyleCop.StyleCop',
- exampleVersion: '2017.2.0',
- examplePrereleaseVersion: '2017.3.0-pre0001',
- exampleDownloadCount: 9e4,
})
diff --git a/services/resharper/resharper.tester.js b/services/resharper/resharper.tester.js
index d4332f8961cd0..bee0fa6c5d948 100644
--- a/services/resharper/resharper.tester.js
+++ b/services/resharper/resharper.tester.js
@@ -12,10 +12,12 @@ export const t = new ServiceTester({
// downloads
-t.create('total downloads (valid)').get('/dt/ReSharper.Nuke.json').expectBadge({
- label: 'downloads',
- message: isMetric,
-})
+t.create('total downloads (valid)')
+ .get('/dt/StyleCop.StyleCop.json')
+ .expectBadge({
+ label: 'downloads',
+ message: isMetric,
+ })
t.create('total downloads (not found)')
.get('/dt/not-a-real-package.json')
@@ -23,7 +25,7 @@ t.create('total downloads (not found)')
// version
-t.create('version (valid)').get('/v/ReSharper.Nuke.json').expectBadge({
+t.create('version (valid)').get('/v/StyleCop.StyleCop.json').expectBadge({
label: 'resharper',
message: isVPlusDottedVersionNClauses,
})
@@ -35,7 +37,7 @@ t.create('version (not found)')
// version (pre)
t.create('version (pre) (valid)')
- .get('/v/ReSharper.Nuke.json?include_prereleases')
+ .get('/v/StyleCop.StyleCop.json?include_prereleases')
.expectBadge({
label: 'resharper',
message: isVPlusDottedVersionNClausesWithOptionalSuffix,
@@ -45,6 +47,9 @@ t.create('version (pre) (not found)')
.get('/v/not-a-real-package.json?include_prereleases')
.expectBadge({ label: 'resharper', message: 'not found' })
-t.create('version (legacy redirect: vpre)')
- .get('/vpre/ReSharper.Nuke.svg')
- .expectRedirect('/resharper/v/ReSharper.Nuke.svg?include_prereleases')
+t.create('version (legacy redirect: vpre - deprecated)')
+ .get('/vpre/StyleCop.StyleCop.json')
+ .expectBadge({
+ label: 'resharper',
+ message: 'https://github.com/badges/shields/pull/11583',
+ })
diff --git a/services/reuse/reuse-compliance.service.js b/services/reuse/reuse-compliance.service.js
index 93933f5f9008d..5f1a51582d725 100644
--- a/services/reuse/reuse-compliance.service.js
+++ b/services/reuse/reuse-compliance.service.js
@@ -1,5 +1,5 @@
import Joi from 'joi'
-import { BaseJsonService } from '../index.js'
+import { BaseJsonService, pathParams } from '../index.js'
import { isReuseCompliance, COLOR_MAP } from './reuse-compliance-helper.js'
const responseSchema = Joi.object({
@@ -14,16 +14,17 @@ export default class Reuse extends BaseJsonService {
pattern: ':remote+',
}
- static examples = [
- {
- title: 'REUSE Compliance',
- namedParams: {
- remote: 'github.com/fsfe/reuse-tool',
+ static openApi = {
+ '/reuse/compliance/{remote}': {
+ get: {
+ summary: 'REUSE Compliance',
+ parameters: pathParams({
+ name: 'remote',
+ example: 'github.com/fsfe/reuse-tool',
+ }),
},
- staticPreview: this.render({ status: 'compliant' }),
- keywords: ['license'],
},
- ]
+ }
static defaultBadgeData = {
label: 'reuse',
@@ -41,9 +42,6 @@ export default class Reuse extends BaseJsonService {
return await this._requestJson({
schema: responseSchema,
url: `https://api.reuse.software/status/${remote}`,
- errorMessages: {
- 400: 'Not a Git repository',
- },
})
}
diff --git a/services/reuse/reuse-compliance.tester.js b/services/reuse/reuse-compliance.tester.js
index de7202f6dbf76..5a6c02f5e911f 100644
--- a/services/reuse/reuse-compliance.tester.js
+++ b/services/reuse/reuse-compliance.tester.js
@@ -15,7 +15,7 @@ t.create('valid repo -- compliant')
.intercept(nock =>
nock('https://api.reuse.software/status')
.get('/github.com/username/repo')
- .reply(200, { status: 'compliant' })
+ .reply(200, { status: 'compliant' }),
)
.expectBadge({
label: 'reuse',
@@ -28,7 +28,7 @@ t.create('valid repo -- non-compliant')
.intercept(nock =>
nock('https://api.reuse.software/status')
.get('/github.com/username/repo')
- .reply(200, { status: 'non-compliant' })
+ .reply(200, { status: 'non-compliant' }),
)
.expectBadge({
label: 'reuse',
@@ -41,7 +41,7 @@ t.create('valid repo -- checking')
.intercept(nock =>
nock('https://api.reuse.software/status')
.get('/github.com/username/repo')
- .reply(200, { status: 'checking' })
+ .reply(200, { status: 'checking' }),
)
.expectBadge({
label: 'reuse',
@@ -50,19 +50,9 @@ t.create('valid repo -- checking')
})
t.create('valid repo -- unregistered')
- .get('/github.com/username/repo.json')
- .intercept(nock =>
- nock('https://api.reuse.software/status')
- .get('/github.com/username/repo')
- .reply(200, { status: 'unregistered' })
- )
+ .get('/github.com/badges/shields.json')
.expectBadge({
label: 'reuse',
message: 'unregistered',
color: COLOR_MAP.unregistered,
})
-
-t.create('invalid repo').get('/github.com/repo/invalid-repo.json').expectBadge({
- label: 'reuse',
- message: 'Not a Git repository',
-})
diff --git a/services/revolt/revolt.service.js b/services/revolt/revolt.service.js
new file mode 100644
index 0000000000000..328a5f28d1269
--- /dev/null
+++ b/services/revolt/revolt.service.js
@@ -0,0 +1,80 @@
+import Joi from 'joi'
+import { BaseJsonService, pathParam, queryParam } from '../index.js'
+import { metric } from '../text-formatters.js'
+import { nonNegativeInteger, optionalUrl } from '../validators.js'
+
+const schema = Joi.object({
+ member_count: nonNegativeInteger,
+}).required()
+
+const description = `
+The Revolt badge requires an INVITE CODE to access the Revolt API,
+which can be located at the end of the invitation url.
+
+For example, both
+https://app.revolt.chat/invite/01F7ZSBSFHQ8TA81725KQCSDDP and
+https://rvlt.gg/01F7ZSBSFHQ8TA81725KQCSDDP contains an invite code
+of 01F7ZSBSFHQ8TA81725KQCSDDP.
+`
+
+const queryParamSchema = Joi.object({
+ revolt_api_url: optionalUrl,
+}).required()
+
+export default class RevoltServerInvite extends BaseJsonService {
+ static category = 'chat'
+
+ static route = {
+ base: 'revolt/invite',
+ pattern: ':inviteId',
+ queryParamSchema,
+ }
+
+ static openApi = {
+ '/revolt/invite/{inviteId}': {
+ get: {
+ summary: 'Revolt',
+ description,
+ parameters: [
+ pathParam({
+ name: 'inviteId',
+ example: '01F7ZSBSFHQ8TA81725KQCSDDP',
+ }),
+ queryParam({
+ name: 'revolt_api_url',
+ example: 'https://api.revolt.chat',
+ }),
+ ],
+ },
+ },
+ }
+
+ static defaultBadgeData = { label: 'chat' }
+
+ static render({ memberCount }) {
+ return {
+ message: `${metric(memberCount)} members`,
+ color: 'brightgreen',
+ }
+ }
+
+ async fetch({ inviteId, baseUrl }) {
+ return this._requestJson({
+ schema,
+ url: `${baseUrl}/invites/${inviteId}`,
+ })
+ }
+
+ async handle(
+ { inviteId },
+ { revolt_api_url: baseUrl = 'https://api.revolt.chat' },
+ ) {
+ const { member_count: memberCount } = await this.fetch({
+ inviteId,
+ baseUrl,
+ })
+ return this.constructor.render({
+ memberCount,
+ })
+ }
+}
diff --git a/services/revolt/revolt.tester.js b/services/revolt/revolt.tester.js
new file mode 100644
index 0000000000000..3b2eb963eb222
--- /dev/null
+++ b/services/revolt/revolt.tester.js
@@ -0,0 +1,26 @@
+import { createServiceTester } from '../tester.js'
+import { isMetricWithPattern } from '../test-validators.js'
+
+export const t = await createServiceTester()
+
+t.create('get status of #revolt')
+ .get('/01F7ZSBSFHQ8TA81725KQCSDDP.json')
+ .expectBadge({
+ label: 'chat',
+ message: isMetricWithPattern(/ members/),
+ color: 'brightgreen',
+ })
+
+t.create('custom api url')
+ .get(
+ '/01F7ZSBSFHQ8TA81725KQCSDDP.json?revolt_api_url=https://api.revolt.chat',
+ )
+ .expectBadge({
+ label: 'chat',
+ message: isMetricWithPattern(/ members/),
+ color: 'brightgreen',
+ })
+
+t.create('invalid invite code')
+ .get('/12345.json')
+ .expectBadge({ label: 'chat', message: 'not found' })
diff --git a/services/ros/ros-version.service.js b/services/ros/ros-version.service.js
new file mode 100644
index 0000000000000..affa75801d062
--- /dev/null
+++ b/services/ros/ros-version.service.js
@@ -0,0 +1,171 @@
+import gql from 'graphql-tag'
+import Joi from 'joi'
+import yaml from 'js-yaml'
+import { renderVersionBadge } from '../version.js'
+import { GithubAuthV4Service } from '../github/github-auth-service.js'
+import { NotFound, InvalidResponse, pathParams } from '../index.js'
+
+const tagsSchema = Joi.object({
+ data: Joi.object({
+ repository: Joi.object({
+ refs: Joi.object({
+ edges: Joi.array()
+ .items({
+ node: Joi.object({
+ name: Joi.string().required(),
+ }).required(),
+ })
+ .required(),
+ }).required(),
+ }).required(),
+ }).required(),
+}).required()
+
+const contentSchema = Joi.object({
+ data: Joi.object({
+ repository: Joi.object({
+ object: Joi.object({
+ text: Joi.string().required(),
+ }).allow(null),
+ }).required(),
+ }).required(),
+}).required()
+
+const distroSchema = Joi.object({
+ repositories: Joi.object().required(),
+})
+const repoSchema = Joi.object({
+ release: Joi.object({
+ version: Joi.string().required(),
+ }).required(),
+})
+
+const description = `
+To use this badge, specify the ROS distribution
+(e.g. noetic or humble) and the package repository name
+(in the case of single-package repos, this may be the same as the package name).
+This badge determines which versions are part of an official ROS distribution by
+fetching from the rosdistro YAML files,
+at the tag corresponding to the latest release.
+`
+
+export default class RosVersion extends GithubAuthV4Service {
+ static category = 'version'
+
+ static route = { base: 'ros/v', pattern: ':distro/:repoName' }
+
+ static openApi = {
+ '/ros/v/{distro}/{repoName}': {
+ get: {
+ summary: 'ROS Package Index',
+ description,
+ parameters: pathParams(
+ {
+ name: 'distro',
+ example: 'humble',
+ },
+ {
+ name: 'repoName',
+ example: 'vision_msgs',
+ },
+ ),
+ },
+ },
+ }
+
+ static defaultBadgeData = { label: 'ros' }
+
+ async handle({ distro, repoName }) {
+ const tagsJson = await this._requestGraphql({
+ query: gql`
+ query ($refPrefix: String!) {
+ repository(owner: "ros", name: "rosdistro") {
+ refs(
+ refPrefix: $refPrefix
+ first: 30
+ orderBy: { field: TAG_COMMIT_DATE, direction: DESC }
+ ) {
+ edges {
+ node {
+ name
+ }
+ }
+ }
+ }
+ }
+ `,
+ variables: { refPrefix: `refs/tags/${distro}/` },
+ schema: tagsSchema,
+ })
+
+ // Filter for tags that look like dates: humble/2022-06-10
+ const tags = tagsJson.data.repository.refs.edges
+ .map(edge => edge.node.name)
+ .filter(tag => /^\d+-\d+-\d+$/.test(tag))
+ .sort()
+ .reverse()
+
+ const ref = tags[0] ? `refs/tags/${distro}/${tags[0]}` : 'refs/heads/master'
+ const prettyRef = tags[0] ? `${distro}/${tags[0]}` : 'master'
+
+ const contentJson = await this._requestGraphql({
+ query: gql`
+ query ($expression: String!) {
+ repository(owner: "ros", name: "rosdistro") {
+ object(expression: $expression) {
+ ... on Blob {
+ text
+ }
+ }
+ }
+ }
+ `,
+ variables: {
+ expression: `${ref}:${distro}/distribution.yaml`,
+ },
+ schema: contentSchema,
+ })
+
+ if (!contentJson.data.repository.object) {
+ throw new NotFound({
+ prettyMessage: `distribution.yaml not found: ${distro}@${prettyRef}`,
+ })
+ }
+ const version = this.constructor._parseReleaseVersionFromDistro(
+ contentJson.data.repository.object.text,
+ repoName,
+ )
+
+ return { ...renderVersionBadge({ version }), label: `ros | ${distro}` }
+ }
+
+ static _parseReleaseVersionFromDistro(distroYaml, repoName) {
+ let distro
+ try {
+ distro = yaml.load(distroYaml)
+ } catch (err) {
+ throw new InvalidResponse({
+ prettyMessage: 'unparseable distribution.yml',
+ underlyingError: err,
+ })
+ }
+
+ const validatedDistro = this._validate(distro, distroSchema, {
+ prettyErrorMessage: 'invalid distribution.yml',
+ })
+ if (!validatedDistro.repositories[repoName]) {
+ throw new NotFound({ prettyMessage: `repo not found: ${repoName}` })
+ }
+
+ const repoInfo = this._validate(
+ validatedDistro.repositories[repoName],
+ repoSchema,
+ {
+ prettyErrorMessage: `invalid section for ${repoName} in distribution.yml`,
+ },
+ )
+
+ // Strip off "release inc" suffix
+ return repoInfo.release.version.replace(/-\d+$/, '')
+ }
+}
diff --git a/services/ros/ros-version.service.spec.js b/services/ros/ros-version.service.spec.js
new file mode 100644
index 0000000000000..c6c2d34956ec5
--- /dev/null
+++ b/services/ros/ros-version.service.spec.js
@@ -0,0 +1,44 @@
+import { expect } from 'chai'
+import RosVersion from './ros-version.service.js'
+
+describe('parseReleaseVersionFromDistro', function () {
+ it('returns correct version', function () {
+ expect(
+ RosVersion._parseReleaseVersionFromDistro(
+ `
+%YAML 1.1
+# ROS distribution file
+# see REP 143: http://ros.org/reps/rep-0143.html
+---
+release_platforms:
+ debian:
+ - bullseye
+ rhel:
+ - '8'
+ ubuntu:
+ - jammy
+repositories:
+ vision_msgs:
+ doc:
+ type: git
+ url: https://github.com/ros-perception/vision_msgs.git
+ version: ros2
+ release:
+ tags:
+ release: release/humble/{package}/{version}
+ url: https://github.com/ros2-gbp/vision_msgs-release.git
+ version: 4.0.0-2
+ source:
+ test_pull_requests: true
+ type: git
+ url: https://github.com/ros-perception/vision_msgs.git
+ version: ros2
+ status: developed
+type: distribution
+version: 2
+ `,
+ 'vision_msgs',
+ ),
+ ).to.equal('4.0.0')
+ })
+})
diff --git a/services/ros/ros-version.tester.js b/services/ros/ros-version.tester.js
new file mode 100644
index 0000000000000..4e7e3c7d6d6f3
--- /dev/null
+++ b/services/ros/ros-version.tester.js
@@ -0,0 +1,28 @@
+import { isSemver } from '../test-validators.js'
+import { createServiceTester } from '../tester.js'
+
+export const t = await createServiceTester()
+
+t.create('gets the version of vision_msgs in active distro')
+ .get('/humble/vision_msgs.json')
+ .expectBadge({ label: 'ros | humble', message: isSemver })
+
+t.create('gets the version of vision_msgs in EOL distro')
+ .get('/lunar/vision_msgs.json')
+ .expectBadge({ label: 'ros | lunar', message: isSemver })
+
+t.create('returns not found for invalid repo')
+ .get('/humble/this repo does not exist - ros test.json')
+ .expectBadge({
+ label: 'ros',
+ color: 'red',
+ message: 'repo not found: this repo does not exist - ros test',
+ })
+
+t.create('returns error for invalid distro')
+ .get('/xxxxxx/vision_msgs.json')
+ .expectBadge({
+ label: 'ros',
+ color: 'red',
+ message: 'distribution.yaml not found: xxxxxx@master',
+ })
diff --git a/services/route-builder.js b/services/route-builder.js
index a1c894c945a1c..eba2152f65572 100644
--- a/services/route-builder.js
+++ b/services/route-builder.js
@@ -1,3 +1,9 @@
+/**
+ * Common functions and utilities for tasks related to route building
+ *
+ * @module
+ */
+
import toArray from '../core/base-service/to-array.js'
/*
@@ -9,6 +15,12 @@ import toArray from '../core/base-service/to-array.js'
* haven't done so yet.
*/
export default class RouteBuilder {
+ /**
+ * Creates a RouteBuilder object.
+ *
+ * @param {object} attrs - Refer to individual attributes
+ * @param {string} attrs.base - Base URL, defaults to ''
+ */
constructor({ base = '' } = {}) {
this.base = base
@@ -16,10 +28,22 @@ export default class RouteBuilder {
this.capture = []
}
+ /**
+ * Get the format components separated by '/'
+ *
+ * @returns {string} Format components, for example: "format1/format2/format3"
+ */
get format() {
return this._formatComponents.join('/')
}
+ /**
+ * Saves the format and capture values in the RouteBuilder instance.
+ *
+ * @param {string} format - Pattern based on path-to-regex, for example: (?:(.+)\\.)?${serviceBaseUrl}
+ * @param {string} capture - Value to capture
+ * @returns {object} RouteBuilder instance for chaining
+ */
push(format, capture) {
this._formatComponents = this._formatComponents.concat(toArray(format))
this.capture = this.capture.concat(toArray(capture))
@@ -27,6 +51,11 @@ export default class RouteBuilder {
return this
}
+ /**
+ * Returns a new object based on RouteBuilder instance containing its base, format and capture properties.
+ *
+ * @returns {object} Object containing base, format and capture properties of the RouteBuilder instance
+ */
toObject() {
const { base, format, capture } = this
return { base, format, capture }
diff --git a/services/scoop/scoop-base.js b/services/scoop/scoop-base.js
new file mode 100644
index 0000000000000..6af71f83cf3ac
--- /dev/null
+++ b/services/scoop/scoop-base.js
@@ -0,0 +1,81 @@
+import Joi from 'joi'
+import { ConditionalGithubAuthV3Service } from '../github/github-auth-service.js'
+import { fetchJsonFromRepo } from '../github/github-common-fetch.js'
+import { NotFound } from '../index.js'
+
+const gitHubRepoRegExp =
+ /https:\/\/github.com\/(?.*?)\/(?.*?)(\/|$)/
+
+const bucketsSchema = Joi.object()
+ .pattern(/.+/, Joi.string().pattern(gitHubRepoRegExp).required())
+ .required()
+
+export const queryParamSchema = Joi.object({
+ bucket: Joi.string(),
+})
+
+export class ScoopBase extends ConditionalGithubAuthV3Service {
+ // The buckets file (https://github.com/lukesampson/scoop/blob/master/buckets.json) changes very rarely.
+ // Cache it for the lifetime of the current Node.js process.
+ buckets = null
+
+ async fetch({ app, schema }, queryParams) {
+ if (!this.buckets) {
+ this.buckets = await fetchJsonFromRepo(this, {
+ schema: bucketsSchema,
+ user: 'ScoopInstaller',
+ repo: 'Scoop',
+ branch: 'master',
+ filename: 'buckets.json',
+ })
+ }
+ const bucket = queryParams.bucket || 'main'
+ let bucketUrl = this.buckets[bucket]
+ if (!bucketUrl) {
+ // Parsing URL here will throw an error if the url is invalid
+ try {
+ const url = new URL(decodeURIComponent(bucket))
+
+ // Throw errors to go to jump to catch statement
+ // The error messages here are purely for code readability, and will never reach the user.
+ if (url.hostname !== 'github.com') {
+ throw new Error('Not a GitHub URL')
+ }
+ const path = url.pathname.split('/').filter(value => value !== '')
+
+ if (path.length !== 2) {
+ throw new Error('Not a valid GitHub Repo')
+ }
+
+ const [user, repo] = path
+
+ // Reconstructing the url here ensures that the url will match the regex
+ bucketUrl = `https://github.com/${user}/${repo}`
+ } catch (e) {
+ throw new NotFound({ prettyMessage: `bucket "${bucket}" not found` })
+ }
+ }
+ const {
+ groups: { user, repo },
+ } = gitHubRepoRegExp.exec(bucketUrl)
+ try {
+ return await fetchJsonFromRepo(this, {
+ schema,
+ user,
+ repo,
+ branch: 'master',
+ filename: `bucket/${app}.json`,
+ })
+ } catch (error) {
+ if (error instanceof NotFound) {
+ throw new NotFound({
+ prettyMessage: `${app} not found in bucket "${bucket}"`,
+ })
+ }
+ throw error
+ }
+ }
+}
+
+export const description =
+ '[Scoop](https://scoop.sh/) is a command-line installer for Windows'
diff --git a/services/scoop/scoop-license.service.js b/services/scoop/scoop-license.service.js
new file mode 100644
index 0000000000000..be7be0b473106
--- /dev/null
+++ b/services/scoop/scoop-license.service.js
@@ -0,0 +1,63 @@
+import Joi from 'joi'
+import { pathParam, queryParam } from '../index.js'
+import { renderLicenseBadge } from '../licenses.js'
+import toArray from '../../core/base-service/to-array.js'
+import { queryParamSchema, description, ScoopBase } from './scoop-base.js'
+
+const scoopLicenseSchema = Joi.object({
+ license: Joi.alternatives()
+ .try(
+ Joi.string().required(),
+ Joi.object({
+ identifier: Joi.string().required(),
+ }),
+ )
+ .required(),
+}).required()
+
+export default class ScoopLicense extends ScoopBase {
+ static category = 'license'
+
+ static route = {
+ base: 'scoop/l',
+ pattern: ':app',
+ queryParamSchema,
+ }
+
+ static openApi = {
+ '/scoop/l/{app}': {
+ get: {
+ summary: 'Scoop License',
+ description,
+ parameters: [
+ pathParam({ name: 'app', example: 'ngrok' }),
+ queryParam({
+ name: 'bucket',
+ description:
+ "App's containing bucket. Can either be a name (e.g `extras`) or a URL to a GitHub Repo (e.g `https://github.com/jewlexx/personal-scoop`)",
+ example: 'extras',
+ }),
+ ],
+ },
+ },
+ }
+
+ static defaultBadgeData = { label: 'license' }
+
+ static render({ licenses }) {
+ return renderLicenseBadge({ licenses })
+ }
+
+ async handle({ app }, queryParams) {
+ const { license } = await this.fetch(
+ { app, schema: scoopLicenseSchema },
+ queryParams,
+ )
+
+ const licenses = toArray(license).map(license =>
+ typeof license === 'string' ? license : license.identifier,
+ )
+
+ return this.constructor.render({ licenses })
+ }
+}
diff --git a/services/scoop/scoop-license.tester.js b/services/scoop/scoop-license.tester.js
new file mode 100644
index 0000000000000..f69ebad9dd349
--- /dev/null
+++ b/services/scoop/scoop-license.tester.js
@@ -0,0 +1,94 @@
+import { createServiceTester } from '../tester.js'
+
+export const t = await createServiceTester()
+
+t.create('License (valid) - with nested response')
+ .get('/ngrok.json')
+ .expectBadge({
+ label: 'license',
+ message: 'Freeware',
+ })
+
+t.create('License (valid) - with string response')
+ .get('/nvs.json')
+ .expectBadge({
+ label: 'license',
+ message: 'MIT',
+ })
+
+t.create('License (invalid)').get('/not-a-real-app.json').expectBadge({
+ label: 'license',
+ message: 'not-a-real-app not found in bucket "main"',
+})
+
+t.create('License (valid custom bucket)')
+ .get('/atom.json?bucket=extras')
+ .expectBadge({
+ label: 'license',
+ message: 'MIT',
+ })
+
+t.create('license (not found in custom bucket)')
+ .get('/not-a-real-app.json?bucket=extras')
+ .expectBadge({
+ label: 'license',
+ message: 'not-a-real-app not found in bucket "extras"',
+ })
+
+t.create('license (wrong bucket)')
+ .get('/not-a-real-app.json?bucket=not-a-real-bucket')
+ .expectBadge({
+ label: 'license',
+ message: 'bucket "not-a-real-bucket" not found',
+ })
+
+// version (bucket url)
+const validBucketUrl = encodeURIComponent(
+ 'https://github.com/jewlexx/personal-scoop',
+)
+
+t.create('license (valid bucket url)')
+ .get(`/sfsu.json?bucket=${validBucketUrl}`)
+ .expectBadge({
+ label: 'license',
+ message: 'Apache-2.0',
+ })
+
+const validBucketUrlTrailingSlash = encodeURIComponent(
+ 'https://github.com/jewlexx/personal-scoop/',
+)
+
+t.create('license (valid bucket url)')
+ .get(`/sfsu.json?bucket=${validBucketUrlTrailingSlash}`)
+ .expectBadge({
+ label: 'license',
+ message: 'Apache-2.0',
+ })
+
+t.create('license (not found in custom bucket)')
+ .get(`/not-a-real-app.json?bucket=${validBucketUrl}`)
+ .expectBadge({
+ label: 'license',
+ message: `not-a-real-app not found in bucket "${decodeURIComponent(validBucketUrl)}"`,
+ })
+
+const nonGithubUrl = encodeURIComponent('https://example.com/')
+
+t.create('license (non-github url)')
+ .get(`/not-a-real-app.json?bucket=${nonGithubUrl}`)
+ .expectBadge({
+ label: 'license',
+ message: `bucket "${decodeURIComponent(nonGithubUrl)}" not found`,
+ })
+
+const nonBucketRepo = encodeURIComponent('https://github.com/jewlexx/sfsu')
+
+t.create('version (non-bucket repo)')
+ .get(`/sfsu.json?bucket=${nonBucketRepo}`)
+ .expectBadge({
+ label: 'license',
+ // !!! Important note here
+ // It is hard to tell if a repo is actually a scoop bucket, without getting the contents
+ // As such, a helpful error message here, which would require testing if the url is a valid scoop bucket, is difficult.
+ message: `sfsu not found in bucket "${decodeURIComponent(nonBucketRepo)}"`,
+ })
diff --git a/services/scoop/scoop-version.service.js b/services/scoop/scoop-version.service.js
index 566a259bf7b8c..1676959b5752a 100644
--- a/services/scoop/scoop-version.service.js
+++ b/services/scoop/scoop-version.service.js
@@ -1,26 +1,13 @@
import Joi from 'joi'
-import { NotFound } from '../index.js'
-import { ConditionalGithubAuthV3Service } from '../github/github-auth-service.js'
-import { fetchJsonFromRepo } from '../github/github-common-fetch.js'
+import { pathParam, queryParam } from '../index.js'
import { renderVersionBadge } from '../version.js'
+import { queryParamSchema, description, ScoopBase } from './scoop-base.js'
-const gitHubRepoRegExp =
- /https:\/\/github.com\/(?.*?)\/(?.*?)(\/|$)/
-const bucketsSchema = Joi.object()
- .pattern(/.+/, Joi.string().pattern(gitHubRepoRegExp).required())
- .required()
const scoopSchema = Joi.object({
version: Joi.string().required(),
}).required()
-const queryParamSchema = Joi.object({
- bucket: Joi.string(),
-})
-
-export default class ScoopVersion extends ConditionalGithubAuthV3Service {
- // The buckets file (https://github.com/lukesampson/scoop/blob/master/buckets.json) changes very rarely.
- // Cache it for the lifetime of the current Node.js process.
- buckets = null
+export default class ScoopVersion extends ScoopBase {
static category = 'version'
static route = {
@@ -29,19 +16,23 @@ export default class ScoopVersion extends ConditionalGithubAuthV3Service {
queryParamSchema,
}
- static examples = [
- {
- title: 'Scoop Version',
- namedParams: { app: 'ngrok' },
- staticPreview: this.render({ version: '2.3.35' }),
- },
- {
- title: 'Scoop Version (extras bucket)',
- namedParams: { app: 'dnspy' },
- queryParams: { bucket: 'extras' },
- staticPreview: this.render({ version: '6.1.4' }),
+ static openApi = {
+ '/scoop/v/{app}': {
+ get: {
+ summary: 'Scoop Version',
+ description,
+ parameters: [
+ pathParam({ name: 'app', example: 'ngrok' }),
+ queryParam({
+ name: 'bucket',
+ description:
+ "App's containing bucket. Can either be a name (e.g `extras`) or a URL to a GitHub Repo (e.g `https://github.com/jewlexx/personal-scoop`)",
+ example: 'extras',
+ }),
+ ],
+ },
},
- ]
+ }
static defaultBadgeData = { label: 'scoop' }
@@ -50,39 +41,11 @@ export default class ScoopVersion extends ConditionalGithubAuthV3Service {
}
async handle({ app }, queryParams) {
- if (!this.buckets) {
- this.buckets = await fetchJsonFromRepo(this, {
- schema: bucketsSchema,
- user: 'lukesampson',
- repo: 'scoop',
- branch: 'master',
- filename: 'buckets.json',
- })
- }
- const bucket = queryParams.bucket || 'main'
- const bucketUrl = this.buckets[bucket]
- if (!bucketUrl) {
- throw new NotFound({ prettyMessage: `bucket "${bucket}" not found` })
- }
- const {
- groups: { user, repo },
- } = gitHubRepoRegExp.exec(bucketUrl)
- try {
- const { version } = await fetchJsonFromRepo(this, {
- schema: scoopSchema,
- user,
- repo,
- branch: 'master',
- filename: `bucket/${app}.json`,
- })
- return this.constructor.render({ version })
- } catch (error) {
- if (error instanceof NotFound) {
- throw new NotFound({
- prettyMessage: `${app} not found in bucket "${bucket}"`,
- })
- }
- throw error
- }
+ const { version } = await this.fetch(
+ { app, schema: scoopSchema },
+ queryParams,
+ )
+
+ return this.constructor.render({ version })
}
}
diff --git a/services/scoop/scoop-version.tester.js b/services/scoop/scoop-version.tester.js
index 6e3950e8a58ef..8c25e03ccf0f7 100644
--- a/services/scoop/scoop-version.tester.js
+++ b/services/scoop/scoop-version.tester.js
@@ -40,3 +40,54 @@ t.create('version (wrong bucket)')
label: 'scoop',
message: 'bucket "not-a-real-bucket" not found',
})
+
+// version (bucket url)
+const validBucketUrl = encodeURIComponent(
+ 'https://github.com/jewlexx/personal-scoop',
+)
+
+t.create('version (valid bucket url)')
+ .get(`/v/sfsu.json?bucket=${validBucketUrl}`)
+ .expectBadge({
+ label: 'scoop',
+ message: isVPlusDottedVersionNClauses,
+ })
+
+const validBucketUrlTrailingSlash = encodeURIComponent(
+ 'https://github.com/jewlexx/personal-scoop/',
+)
+
+t.create('version (valid bucket url)')
+ .get(`/v/sfsu.json?bucket=${validBucketUrlTrailingSlash}`)
+ .expectBadge({
+ label: 'scoop',
+ message: isVPlusDottedVersionNClauses,
+ })
+
+t.create('version (not found in custom bucket)')
+ .get(`/v/not-a-real-app.json?bucket=${validBucketUrl}`)
+ .expectBadge({
+ label: 'scoop',
+ message: `not-a-real-app not found in bucket "${decodeURIComponent(validBucketUrl)}"`,
+ })
+
+const nonGithubUrl = encodeURIComponent('https://example.com/')
+
+t.create('version (non-github url)')
+ .get(`/v/not-a-real-app.json?bucket=${nonGithubUrl}`)
+ .expectBadge({
+ label: 'scoop',
+ message: `bucket "${decodeURIComponent(nonGithubUrl)}" not found`,
+ })
+
+const nonBucketRepo = encodeURIComponent('https://github.com/jewlexx/sfsu')
+
+t.create('version (non-bucket repo)')
+ .get(`/v/sfsu.json?bucket=${nonBucketRepo}`)
+ .expectBadge({
+ label: 'scoop',
+ // !!! Important note here
+ // It is hard to tell if a repo is actually a scoop bucket, without getting the contents
+ // As such, a helpful error message here, which would require testing if the url is a valid scoop bucket, is difficult.
+ message: `sfsu not found in bucket "${decodeURIComponent(nonBucketRepo)}"`,
+ })
diff --git a/services/scrutinizer/scrutinizer-base.js b/services/scrutinizer/scrutinizer-base.js
index 918a9b6dec49d..90f4fe9062f35 100644
--- a/services/scrutinizer/scrutinizer-base.js
+++ b/services/scrutinizer/scrutinizer-base.js
@@ -6,7 +6,7 @@ export default class ScrutinizerBase extends BaseJsonService {
return this._requestJson({
schema,
url: `https://scrutinizer-ci.com/api/repositories/${vcs}/${slug}`,
- errorMessages: {
+ httpErrors: {
401: 'not authorized to access project',
404: 'project not found',
},
diff --git a/services/scrutinizer/scrutinizer-build.service.js b/services/scrutinizer/scrutinizer-build.service.js
index 44c1f54e44b9e..975838644e73e 100644
--- a/services/scrutinizer/scrutinizer-build.service.js
+++ b/services/scrutinizer/scrutinizer-build.service.js
@@ -1,5 +1,6 @@
import Joi from 'joi'
import { isBuildStatus, renderBuildStatusBadge } from '../build-status.js'
+import { pathParams } from '../index.js'
import ScrutinizerBase from './scrutinizer-base.js'
const schema = Joi.object({
@@ -11,7 +12,7 @@ const schema = Joi.object({
build_status: Joi.object({
status: Joi.alternatives().try(isBuildStatus, Joi.equal('unknown')),
}).required(),
- })
+ }),
)
.required(),
}).required()
@@ -38,19 +39,39 @@ class ScrutinizerBuild extends ScrutinizerBuildBase {
pattern: ':vcs(g|b)/:user/:repo/:branch*',
}
- static examples = [
- {
- title: 'Scrutinizer build (GitHub/Bitbucket)',
- pattern: ':vcs(g|b)/:user/:repo/:branch?',
- namedParams: {
- vcs: 'g',
- user: 'filp',
- repo: 'whoops',
- branch: 'master',
+ static openApi = {
+ '/scrutinizer/build/{vcs}/{user}/{repo}': {
+ get: {
+ summary: 'Scrutinizer build (GitHub/Bitbucket)',
+ parameters: pathParams(
+ {
+ name: 'vcs',
+ example: 'g',
+ description: 'Platform: Either GitHub or Bitbucket',
+ schema: { type: 'string', enum: this.getEnum('vcs') },
+ },
+ { name: 'user', example: 'filp' },
+ { name: 'repo', example: 'whoops' },
+ ),
},
- staticPreview: renderBuildStatusBadge({ status: 'passing' }),
},
- ]
+ '/scrutinizer/build/{vcs}/{user}/{repo}/{branch}': {
+ get: {
+ summary: 'Scrutinizer build (GitHub/Bitbucket) with branch',
+ parameters: pathParams(
+ {
+ name: 'vcs',
+ example: 'g',
+ description: 'Platform: Either GitHub or Bitbucket',
+ schema: { type: 'string', enum: this.getEnum('vcs') },
+ },
+ { name: 'user', example: 'filp' },
+ { name: 'repo', example: 'whoops' },
+ { name: 'branch', example: 'master' },
+ ),
+ },
+ },
+ }
async handle({ vcs, user, repo, branch }) {
return this.makeBadge({
@@ -71,19 +92,29 @@ class ScrutinizerGitLabBuild extends ScrutinizerBuildBase {
// The example used is valid, but the project will not be accessible if Shields users try to use it.
// https://gitlab.propertywindow.nl/propertywindow/client
// https://scrutinizer-ci.com/gl/propertywindow/propertywindow/client/badges/quality-score.png?b=master&s=dfae6992a48184cc2333b4c349cec0447f0d67c2
- static examples = [
- {
- title: 'Scrutinizer build (GitLab)',
- pattern: ':instance/:user/:repo/:branch?',
- namedParams: {
- instance: 'propertywindow',
- user: 'propertywindow',
- repo: 'client',
- branch: 'master',
+ static openApi = {
+ '/scrutinizer/build/gl/{instance}/{user}/{repo}': {
+ get: {
+ summary: 'Scrutinizer build (GitLab)',
+ parameters: pathParams(
+ { name: 'instance', example: 'propertywindow' },
+ { name: 'user', example: 'propertywindow' },
+ { name: 'repo', example: 'client' },
+ ),
},
- staticPreview: renderBuildStatusBadge({ status: 'passing' }),
},
- ]
+ '/scrutinizer/build/gl/{instance}/{user}/{repo}/{branch}': {
+ get: {
+ summary: 'Scrutinizer build (GitLab) with branch',
+ parameters: pathParams(
+ { name: 'instance', example: 'propertywindow' },
+ { name: 'user', example: 'propertywindow' },
+ { name: 'repo', example: 'client' },
+ { name: 'branch', example: 'master' },
+ ),
+ },
+ },
+ }
async handle({ instance, user, repo, branch }) {
return this.makeBadge({
diff --git a/services/scrutinizer/scrutinizer-build.tester.js b/services/scrutinizer/scrutinizer-build.tester.js
index 37185bf16ba52..00223515061fe 100644
--- a/services/scrutinizer/scrutinizer-build.tester.js
+++ b/services/scrutinizer/scrutinizer-build.tester.js
@@ -42,7 +42,7 @@ t.create('build - unknown status')
},
},
},
- })
+ }),
)
.expectBadge({
label: 'build',
diff --git a/services/scrutinizer/scrutinizer-coverage.service.js b/services/scrutinizer/scrutinizer-coverage.service.js
index cedd137e74203..ce3fd05b53a6c 100644
--- a/services/scrutinizer/scrutinizer-coverage.service.js
+++ b/services/scrutinizer/scrutinizer-coverage.service.js
@@ -1,6 +1,6 @@
import Joi from 'joi'
import { colorScale } from '../color-formatters.js'
-import { NotFound } from '../index.js'
+import { NotFound, pathParams } from '../index.js'
import ScrutinizerBase from './scrutinizer-base.js'
const schema = Joi.object({
@@ -18,7 +18,7 @@ const schema = Joi.object({
}).required(),
}).required(),
}),
- })
+ }),
)
.required(),
}).required()
@@ -70,19 +70,39 @@ class ScrutinizerCoverage extends ScrutinizerCoverageBase {
pattern: ':vcs(g|b)/:user/:repo/:branch*',
}
- static examples = [
- {
- title: 'Scrutinizer coverage (GitHub/BitBucket)',
- pattern: ':vcs(g|b)/:user/:repo/:branch?',
- namedParams: {
- vcs: 'g',
- user: 'filp',
- repo: 'whoops',
- branch: 'master',
+ static openApi = {
+ '/scrutinizer/coverage/{vcs}/{user}/{repo}': {
+ get: {
+ summary: 'Scrutinizer coverage (GitHub/Bitbucket)',
+ parameters: pathParams(
+ {
+ name: 'vcs',
+ example: 'g',
+ description: 'Platform: Either GitHub or Bitbucket',
+ schema: { type: 'string', enum: this.getEnum('vcs') },
+ },
+ { name: 'user', example: 'filp' },
+ { name: 'repo', example: 'whoops' },
+ ),
},
- staticPreview: this.render({ coverage: 86 }),
},
- ]
+ '/scrutinizer/coverage/{vcs}/{user}/{repo}/{branch}': {
+ get: {
+ summary: 'Scrutinizer coverage (GitHub/Bitbucket) with branch',
+ parameters: pathParams(
+ {
+ name: 'vcs',
+ example: 'g',
+ description: 'Platform: Either GitHub or Bitbucket',
+ schema: { type: 'string', enum: this.getEnum('vcs') },
+ },
+ { name: 'user', example: 'filp' },
+ { name: 'repo', example: 'whoops' },
+ { name: 'branch', example: 'master' },
+ ),
+ },
+ },
+ }
async handle({ vcs, user, repo, branch }) {
return this.makeBadge({
@@ -103,19 +123,29 @@ class ScrutinizerCoverageGitLab extends ScrutinizerCoverageBase {
// The example used is valid, but the project will not be accessible if Shields users try to use it.
// https://gitlab.propertywindow.nl/propertywindow/client
// https://scrutinizer-ci.com/gl/propertywindow/propertywindow/client/badges/quality-score.png?b=master&s=dfae6992a48184cc2333b4c349cec0447f0d67c2
- static examples = [
- {
- title: 'Scrutinizer coverage (GitLab)',
- pattern: ':instance/:user/:repo/:branch?',
- namedParams: {
- instance: 'propertywindow',
- user: 'propertywindow',
- repo: 'client',
- branch: 'master',
+ static openApi = {
+ '/scrutinizer/coverage/gl/{instance}/{user}/{repo}': {
+ get: {
+ summary: 'Scrutinizer coverage (GitLab)',
+ parameters: pathParams(
+ { name: 'instance', example: 'propertywindow' },
+ { name: 'user', example: 'propertywindow' },
+ { name: 'repo', example: 'client' },
+ ),
},
- staticPreview: this.render({ coverage: 94 }),
},
- ]
+ '/scrutinizer/coverage/gl/{instance}/{user}/{repo}/{branch}': {
+ get: {
+ summary: 'Scrutinizer coverage (GitLab) with branch',
+ parameters: pathParams(
+ { name: 'instance', example: 'propertywindow' },
+ { name: 'user', example: 'propertywindow' },
+ { name: 'repo', example: 'client' },
+ { name: 'branch', example: 'master' },
+ ),
+ },
+ },
+ }
async handle({ instance, user, repo, branch }) {
return this.makeBadge({
diff --git a/services/scrutinizer/scrutinizer-coverage.spec.js b/services/scrutinizer/scrutinizer-coverage.spec.js
index 2928f54f5f690..e83768d8ce639 100644
--- a/services/scrutinizer/scrutinizer-coverage.spec.js
+++ b/services/scrutinizer/scrutinizer-coverage.spec.js
@@ -62,7 +62,7 @@ describe('ScrutinizerCoverage', function () {
},
},
},
- })
+ }),
)
.to.throw(InvalidResponse)
.with.property('prettyMessage', 'metrics missing for branch')
diff --git a/services/scrutinizer/scrutinizer-quality.service.js b/services/scrutinizer/scrutinizer-quality.service.js
index 93939a554da62..51ae91c245409 100644
--- a/services/scrutinizer/scrutinizer-quality.service.js
+++ b/services/scrutinizer/scrutinizer-quality.service.js
@@ -1,5 +1,6 @@
import Joi from 'joi'
import { colorScale } from '../color-formatters.js'
+import { pathParams } from '../index.js'
import ScrutinizerBase from './scrutinizer-base.js'
const schema = Joi.object({
@@ -17,14 +18,14 @@ const schema = Joi.object({
}).required(),
}).required(),
}),
- })
+ }),
)
.required(),
}).required()
const scale = colorScale(
[4, 5, 7, 9],
- ['red', 'orange', 'yellow', 'green', 'brightgreen']
+ ['red', 'orange', 'yellow', 'green', 'brightgreen'],
)
class ScrutinizerQualityBase extends ScrutinizerBase {
@@ -58,19 +59,39 @@ class ScrutinizerQuality extends ScrutinizerQualityBase {
pattern: ':vcs(g|b)/:user/:repo/:branch*',
}
- static examples = [
- {
- title: 'Scrutinizer code quality (GitHub/Bitbucket)',
- pattern: ':vcs(g|b)/:user/:repo/:branch?',
- namedParams: {
- vcs: 'g',
- user: 'filp',
- repo: 'whoops',
- branch: 'master',
+ static openApi = {
+ '/scrutinizer/quality/{vcs}/{user}/{repo}': {
+ get: {
+ summary: 'Scrutinizer quality (GitHub/Bitbucket)',
+ parameters: pathParams(
+ {
+ name: 'vcs',
+ example: 'g',
+ description: 'Platform: Either GitHub or Bitbucket',
+ schema: { type: 'string', enum: this.getEnum('vcs') },
+ },
+ { name: 'user', example: 'filp' },
+ { name: 'repo', example: 'whoops' },
+ ),
},
- staticPreview: this.render({ score: 8.26 }),
},
- ]
+ '/scrutinizer/quality/{vcs}/{user}/{repo}/{branch}': {
+ get: {
+ summary: 'Scrutinizer quality (GitHub/Bitbucket) with branch',
+ parameters: pathParams(
+ {
+ name: 'vcs',
+ example: 'g',
+ description: 'Platform: Either GitHub or Bitbucket',
+ schema: { type: 'string', enum: this.getEnum('vcs') },
+ },
+ { name: 'user', example: 'filp' },
+ { name: 'repo', example: 'whoops' },
+ { name: 'branch', example: 'master' },
+ ),
+ },
+ },
+ }
async handle({ vcs, user, repo, branch }) {
return this.makeBadge({
@@ -91,19 +112,29 @@ class ScrutinizerQualityGitLab extends ScrutinizerQualityBase {
// The example used is valid, but the project will not be accessible if Shields users try to use it.
// https://gitlab.propertywindow.nl/propertywindow/client
// https://scrutinizer-ci.com/gl/propertywindow/propertywindow/client/badges/quality-score.png?b=master&s=dfae6992a48184cc2333b4c349cec0447f0d67c2
- static examples = [
- {
- title: 'Scrutinizer coverage (GitLab)',
- pattern: ':instance/:user/:repo/:branch?',
- namedParams: {
- instance: 'propertywindow',
- user: 'propertywindow',
- repo: 'client',
- branch: 'master',
+ static openApi = {
+ '/scrutinizer/quality/gl/{instance}/{user}/{repo}': {
+ get: {
+ summary: 'Scrutinizer quality (GitLab)',
+ parameters: pathParams(
+ { name: 'instance', example: 'propertywindow' },
+ { name: 'user', example: 'propertywindow' },
+ { name: 'repo', example: 'client' },
+ ),
},
- staticPreview: this.render({ score: 10.0 }),
},
- ]
+ '/scrutinizer/quality/gl/{instance}/{user}/{repo}/{branch}': {
+ get: {
+ summary: 'Scrutinizer quality (GitLab) with branch',
+ parameters: pathParams(
+ { name: 'instance', example: 'propertywindow' },
+ { name: 'user', example: 'propertywindow' },
+ { name: 'repo', example: 'client' },
+ { name: 'branch', example: 'master' },
+ ),
+ },
+ },
+ }
async handle({ instance, user, repo, branch }) {
return this.makeBadge({
diff --git a/services/scrutinizer/scrutinizer-quality.tester.js b/services/scrutinizer/scrutinizer-quality.tester.js
index 9009a9d1823a7..e4dfbe1e2f2cb 100644
--- a/services/scrutinizer/scrutinizer-quality.tester.js
+++ b/services/scrutinizer/scrutinizer-quality.tester.js
@@ -59,7 +59,7 @@ t.create('code quality data missing for default branch')
},
},
},
- })
+ }),
)
.expectBadge({
label: 'code quality',
diff --git a/services/scrutinizer/scrutinizer-redirect.tester.js b/services/scrutinizer/scrutinizer-redirect.tester.js
index b1d8abc51083b..d13a38bd2a547 100644
--- a/services/scrutinizer/scrutinizer-redirect.tester.js
+++ b/services/scrutinizer/scrutinizer-redirect.tester.js
@@ -21,7 +21,7 @@ t.create('scrutinizer quality Bitbucket')
t.create('scrutinizer quality Bitbucket (branch)')
.get('/b/atlassian/python-bitbucket/develop.svg')
.expectRedirect(
- '/scrutinizer/quality/b/atlassian/python-bitbucket/develop.svg'
+ '/scrutinizer/quality/b/atlassian/python-bitbucket/develop.svg',
)
t.create('scrutinizer quality GitLab')
diff --git a/services/security-headers/security-headers.service.js b/services/security-headers/security-headers.service.js
index 0b935a871eb45..72725335d72f7 100644
--- a/services/security-headers/security-headers.service.js
+++ b/services/security-headers/security-headers.service.js
@@ -1,94 +1,11 @@
-import Joi from 'joi'
-import { optionalUrl } from '../validators.js'
-import { BaseService, NotFound } from '../index.js'
-
-const queryParamSchema = Joi.object({
- url: optionalUrl.required(),
- ignoreRedirects: Joi.equal(''),
-}).required()
-
-const documentation = `
-
- The Security Headers
- provide an easy mechanism to analyze HTTP response headers and
- give information on how to deploy missing headers.
-
-
- The scan result will be hidden from the public result list and follow redirects will be on too.
-
-`
-
-export default class SecurityHeaders extends BaseService {
- static category = 'monitoring'
-
- static route = {
- base: '',
- pattern: 'security-headers',
- queryParamSchema,
- }
-
- static examples = [
- {
- title: 'Security Headers',
- namedParams: {},
- queryParams: { url: 'https://shields.io' },
- staticPreview: this.render({
- grade: 'A+',
- }),
- documentation,
- },
- {
- title: "Security Headers (Don't follow redirects)",
- namedParams: {},
- queryParams: { url: 'https://www.shields.io', ignoreRedirects: null },
- staticPreview: this.render({
- grade: 'R',
- }),
- documentation,
- },
- ]
-
- static defaultBadgeData = {
- label: 'security headers',
- }
-
- static render({ grade }) {
- const colorMap = {
- 'A+': 'brightgreen',
- A: 'green',
- B: 'yellow',
- C: 'yellow',
- D: 'orange',
- E: 'orange',
- F: 'red',
- R: 'blue',
- }
-
- return {
- message: grade,
- color: colorMap[grade],
- }
- }
-
- async handle(namedParams, { url, ignoreRedirects }) {
- const { res } = await this._request({
- url: `https://securityheaders.com`,
- options: {
- method: 'HEAD',
- qs: {
- q: url,
- hide: 'on',
- followRedirects: ignoreRedirects !== undefined ? null : 'on',
- },
- },
- })
-
- const grade = res.headers['x-grade']
-
- if (!grade) {
- throw new NotFound({ prettyMessage: 'not available' })
- }
-
- return this.constructor.render({ grade })
- }
-}
+import { deprecatedService } from '../index.js'
+
+export const SecurityHeaders = deprecatedService({
+ category: 'monitoring',
+ route: {
+ base: 'security-headers',
+ pattern: ':various+',
+ },
+ label: 'securityheaders',
+ dateAdded: new Date('2025-11-08'),
+})
diff --git a/services/security-headers/security-headers.tester.js b/services/security-headers/security-headers.tester.js
index 3be2b605117c7..a6672fac53568 100644
--- a/services/security-headers/security-headers.tester.js
+++ b/services/security-headers/security-headers.tester.js
@@ -1,10 +1,11 @@
-import { createServiceTester } from '../tester.js'
-export const t = await createServiceTester()
+import { ServiceTester } from '../tester.js'
-t.create('grade of https://shields.io')
- .get('/security-headers.json?url=https://shields.io')
- .expectBadge({ label: 'security headers', message: 'F', color: 'red' })
+export const t = new ServiceTester({
+ id: 'security-headers',
+ title: 'SecurityHeaders',
+})
-t.create('grade of https://httpstat.us/301 as redirect')
- .get('/security-headers.json?ignoreRedirects&url=https://httpstat.us/301')
- .expectBadge({ label: 'security headers', message: 'R', color: 'blue' })
+t.create('deprecated service').get('/security-headers.json').expectBadge({
+ label: 'securityheaders',
+ message: 'no longer available',
+})
diff --git a/services/shippable/shippable.service.js b/services/shippable/shippable.service.js
deleted file mode 100644
index 426c773e18416..0000000000000
--- a/services/shippable/shippable.service.js
+++ /dev/null
@@ -1,80 +0,0 @@
-import Joi from 'joi'
-import { renderBuildStatusBadge } from '../build-status.js'
-import { BaseJsonService, NotFound, redirector } from '../index.js'
-
-// source: https://github.com/badges/shields/pull/1362#discussion_r161693830
-const statusCodes = {
- 0: 'waiting',
- 10: 'queued',
- 20: 'processing',
- 30: 'success',
- 40: 'skipped',
- 50: 'unstable',
- 60: 'timeout',
- 70: 'cancelled',
- 80: 'failed',
- 90: 'stopped',
-}
-
-const schema = Joi.array()
- .items(
- Joi.object({
- branchName: Joi.string().required(),
- statusCode: Joi.number()
- .valid(...Object.keys(statusCodes).map(key => parseInt(key)))
- .required(),
- }).required()
- )
- .required()
-
-class Shippable extends BaseJsonService {
- static category = 'build'
-
- static route = {
- base: 'shippable',
- pattern: ':projectId/:branch+',
- }
-
- static examples = [
- {
- title: 'Shippable',
- namedParams: {
- projectId: '5444c5ecb904a4b21567b0ff',
- branch: 'master',
- },
- staticPreview: this.render({ code: 30 }),
- },
- ]
-
- static defaultBadgeData = { label: 'shippable' }
-
- static render({ code }) {
- return renderBuildStatusBadge({ label: 'build', status: statusCodes[code] })
- }
-
- async fetch({ projectId }) {
- const url = `https://api.shippable.com/projects/${projectId}/branchRunStatus`
- return this._requestJson({ schema, url })
- }
-
- async handle({ projectId, branch }) {
- const data = await this.fetch({ projectId })
- const builds = data.filter(result => result.branchName === branch)
- if (builds.length === 0) {
- throw new NotFound({ prettyMessage: 'branch not found' })
- }
- return this.constructor.render({ code: builds[0].statusCode })
- }
-}
-
-const ShippableRedirect = redirector({
- category: 'build',
- route: {
- base: 'shippable',
- pattern: ':projectId',
- },
- transformPath: ({ projectId }) => `/shippable/${projectId}/master`,
- dateAdded: new Date('2020-07-18'),
-})
-
-export { Shippable, ShippableRedirect }
diff --git a/services/shippable/shippable.tester.js b/services/shippable/shippable.tester.js
deleted file mode 100644
index 789a357b7d1e4..0000000000000
--- a/services/shippable/shippable.tester.js
+++ /dev/null
@@ -1,35 +0,0 @@
-import { isBuildStatus } from '../build-status.js'
-import { ServiceTester } from '../tester.js'
-export const t = new ServiceTester({
- id: 'Shippable',
- title: 'Shippable',
- pathPrefix: '/shippable',
-})
-
-t.create('build status (valid)')
- .get('/5444c5ecb904a4b21567b0ff/master.json')
- .expectBadge({
- label: 'build',
- message: isBuildStatus,
- })
-
-t.create('build status (branch not found)')
- .get('/5444c5ecb904a4b21567b0ff/not-a-branch.json')
- .expectBadge({ label: 'shippable', message: 'branch not found' })
-
-t.create('build status (build not found)')
- .get('/not-a-build/master.json')
- .expectBadge({ label: 'shippable', message: 'not found' })
-
-t.create('build status (unexpected status code)')
- .get('/5444c5ecb904a4b21567b0ff/master.json')
- .intercept(nock =>
- nock('https://api.shippable.com/')
- .get('/projects/5444c5ecb904a4b21567b0ff/branchRunStatus')
- .reply(200, '[{ "branchName": "master", "statusCode": 63 }]')
- )
- .expectBadge({ label: 'shippable', message: 'invalid response data' })
-
-t.create('build status (no branch redirect)')
- .get('/5444c5ecb904a4b21567b0ff.svg')
- .expectRedirect('/shippable/5444c5ecb904a4b21567b0ff/master.svg')
diff --git a/services/size.js b/services/size.js
new file mode 100644
index 0000000000000..970e31e4a5b59
--- /dev/null
+++ b/services/size.js
@@ -0,0 +1,25 @@
+/**
+ * @module
+ */
+
+import byteSize from 'byte-size'
+
+/**
+ * Creates a badge object that displays information about a size in bytes number.
+ * It should usually be used to output a size badge.
+ *
+ * @param {number} bytes - Raw number of bytes to be formatted
+ * @param {'metric'|'iec'} units - Either 'metric' (multiples of 1000) or 'iec' (multiples of 1024).
+ * This should align with how the upstream displays sizes.
+ * @param {string} [label='size'] - Custom label
+ * @returns {object} A badge object that has three properties: label, message, and color
+ */
+function renderSizeBadge(bytes, units, label = 'size') {
+ return {
+ label,
+ message: byteSize(bytes, { units }).toString(),
+ color: 'blue',
+ }
+}
+
+export { renderSizeBadge }
diff --git a/services/snapcraft/snapcraft-base.js b/services/snapcraft/snapcraft-base.js
new file mode 100644
index 0000000000000..ba1a72473095e
--- /dev/null
+++ b/services/snapcraft/snapcraft-base.js
@@ -0,0 +1,23 @@
+import { BaseJsonService, pathParam } from '../index.js'
+
+export const snapcraftPackageParam = pathParam({
+ name: 'package',
+ example: 'redis',
+})
+
+export const snapcraftBaseParams = [snapcraftPackageParam]
+
+const snapcraftBaseUrl = 'https://api.snapcraft.io/v2/snaps/info'
+
+export default class SnapcraftBase extends BaseJsonService {
+ async fetch(schema, { packageName }) {
+ return await this._requestJson({
+ schema,
+ url: `${snapcraftBaseUrl}/${packageName}`,
+ options: {
+ headers: { 'Snap-Device-Series': 16 },
+ },
+ httpErrors: { 404: 'package not found' },
+ })
+ }
+}
diff --git a/services/snapcraft/snapcraft-last-update.service.js b/services/snapcraft/snapcraft-last-update.service.js
new file mode 100644
index 0000000000000..64f55f54fdd4d
--- /dev/null
+++ b/services/snapcraft/snapcraft-last-update.service.js
@@ -0,0 +1,95 @@
+import Joi from 'joi'
+import { pathParams, queryParam, NotFound } from '../index.js'
+import { renderDateBadge } from '../date.js'
+import SnapcraftBase, { snapcraftPackageParam } from './snapcraft-base.js'
+
+const queryParamSchema = Joi.object({
+ arch: Joi.string(),
+})
+
+const lastUpdateSchema = Joi.object({
+ 'channel-map': Joi.array()
+ .items(
+ Joi.object({
+ channel: Joi.object({
+ architecture: Joi.string().required(),
+ risk: Joi.string().required(),
+ track: Joi.string().required(),
+ 'released-at': Joi.string().required(),
+ }),
+ }).required(),
+ )
+ .min(1)
+ .required(),
+}).required()
+
+export default class SnapcraftLastUpdate extends SnapcraftBase {
+ static category = 'activity'
+
+ static route = {
+ base: 'snapcraft/last-update',
+ pattern: ':package/:track/:risk',
+ queryParamSchema,
+ }
+
+ static openApi = {
+ '/snapcraft/last-update/{package}/{track}/{risk}': {
+ get: {
+ summary: 'Snapcraft Last Update',
+ parameters: [
+ snapcraftPackageParam,
+ ...pathParams(
+ { name: 'track', example: 'latest' },
+ { name: 'risk', example: 'stable' },
+ ),
+ queryParam({
+ name: 'arch',
+ example: 'amd64',
+ description:
+ 'Architecture, when not specified, this will default to `amd64`.',
+ }),
+ ],
+ },
+ },
+ }
+
+ static defaultBadgeData = { label: 'last updated' }
+
+ static transform(apiData, track, risk, arch) {
+ const channelMap = apiData['channel-map']
+ let filteredChannelMap = channelMap.filter(
+ ({ channel }) => channel.architecture === arch,
+ )
+ if (filteredChannelMap.length === 0) {
+ throw new NotFound({ prettyMessage: 'arch not found' })
+ }
+ filteredChannelMap = filteredChannelMap.filter(
+ ({ channel }) => channel.track === track,
+ )
+ if (filteredChannelMap.length === 0) {
+ throw new NotFound({ prettyMessage: 'track not found' })
+ }
+ filteredChannelMap = filteredChannelMap.filter(
+ ({ channel }) => channel.risk === risk,
+ )
+ if (filteredChannelMap.length === 0) {
+ throw new NotFound({ prettyMessage: 'risk not found' })
+ }
+
+ return filteredChannelMap[0]
+ }
+
+ async handle({ package: packageName, track, risk }, { arch = 'amd64' }) {
+ const parsedData = await this.fetch(lastUpdateSchema, { packageName })
+
+ // filter results by track, risk and arch
+ const { channel } = this.constructor.transform(
+ parsedData,
+ track,
+ risk,
+ arch,
+ )
+
+ return renderDateBadge(channel['released-at'])
+ }
+}
diff --git a/services/snapcraft/snapcraft-last-update.tester.js b/services/snapcraft/snapcraft-last-update.tester.js
new file mode 100644
index 0000000000000..b9451be6f1630
--- /dev/null
+++ b/services/snapcraft/snapcraft-last-update.tester.js
@@ -0,0 +1,46 @@
+import { isFormattedDate } from '../test-validators.js'
+import { createServiceTester } from '../tester.js'
+
+export const t = await createServiceTester()
+
+t.create('last update for redis/latest/stable')
+ .get('/redis/latest/stable.json')
+ .expectBadge({
+ label: 'last updated',
+ message: isFormattedDate,
+ })
+
+t.create('last update for redis/latest/stable and query param arch=arm64')
+ .get('/redis/latest/stable.json?arch=arm64')
+ .expectBadge({
+ label: 'last updated',
+ message: isFormattedDate,
+ })
+
+t.create('last update when package not found')
+ .get('/fake_package/fake/fake.json')
+ .expectBadge({
+ label: 'last updated',
+ message: 'package not found',
+ })
+
+t.create('last update for redis and invalid track')
+ .get('/redis/notFound/stable.json')
+ .expectBadge({
+ label: 'last updated',
+ message: 'track not found',
+ })
+
+t.create('last update for redis/latest and invalid risk')
+ .get('/redis/latest/notFound.json')
+ .expectBadge({
+ label: 'last updated',
+ message: 'risk not found',
+ })
+
+t.create('last update for redis/latest/stable and invalid arch (query param)')
+ .get('/redis/latest/stable.json?arch=fake')
+ .expectBadge({
+ label: 'last updated',
+ message: 'arch not found',
+ })
diff --git a/services/snapcraft/snapcraft-licence.service.js b/services/snapcraft/snapcraft-licence.service.js
new file mode 100644
index 0000000000000..3be42f1a92260
--- /dev/null
+++ b/services/snapcraft/snapcraft-licence.service.js
@@ -0,0 +1,41 @@
+import Joi from 'joi'
+import { renderLicenseBadge } from '../licenses.js'
+import SnapcraftBase, { snapcraftPackageParam } from './snapcraft-base.js'
+
+const licenseSchema = Joi.object({
+ snap: Joi.object({
+ license: Joi.string().required(),
+ }).required(),
+}).required()
+
+export default class SnapcraftLicense extends SnapcraftBase {
+ static category = 'license'
+
+ static route = {
+ base: 'snapcraft/l',
+ pattern: ':package',
+ }
+
+ static openApi = {
+ '/snapcraft/l/{package}': {
+ get: {
+ summary: 'Snapcraft License',
+ parameters: [snapcraftPackageParam],
+ },
+ },
+ }
+
+ static render({ license }) {
+ return renderLicenseBadge({ license })
+ }
+
+ static transform(apiData) {
+ return apiData.snap.license
+ }
+
+ async handle({ package: packageName }) {
+ const parsedData = await this.fetch(licenseSchema, { packageName })
+ const license = this.constructor.transform(parsedData)
+ return this.constructor.render({ license })
+ }
+}
diff --git a/services/snapcraft/snapcraft-licence.spec.js b/services/snapcraft/snapcraft-licence.spec.js
new file mode 100644
index 0000000000000..91f1247f34c8c
--- /dev/null
+++ b/services/snapcraft/snapcraft-licence.spec.js
@@ -0,0 +1,14 @@
+import { test, given } from 'sazerac'
+import SnapcraftLicense from './snapcraft-licence.service.js'
+
+describe('SnapcraftLicense', function () {
+ const testApiData = {
+ snap: {
+ license: 'BSD-3-Clause',
+ },
+ }
+
+ test(SnapcraftLicense.transform, () => {
+ given(testApiData).expect('BSD-3-Clause')
+ })
+})
diff --git a/services/snapcraft/snapcraft-licence.tester.js b/services/snapcraft/snapcraft-licence.tester.js
new file mode 100644
index 0000000000000..a2217ba5955ab
--- /dev/null
+++ b/services/snapcraft/snapcraft-licence.tester.js
@@ -0,0 +1,14 @@
+import { createServiceTester } from '../tester.js'
+export const t = await createServiceTester()
+
+t.create('Snapcraft license (valid)').get('/redis.json').expectBadge({
+ label: 'license',
+ message: 'BSD-3-Clause',
+})
+
+t.create('Snapcraft license(invalid)')
+ .get('/this_package_doesnt_exist.json')
+ .expectBadge({
+ label: 'license',
+ message: 'package not found',
+ })
diff --git a/services/snapcraft/snapcraft-version.service.js b/services/snapcraft/snapcraft-version.service.js
new file mode 100644
index 0000000000000..77639fee66028
--- /dev/null
+++ b/services/snapcraft/snapcraft-version.service.js
@@ -0,0 +1,98 @@
+import Joi from 'joi'
+import { NotFound, pathParams, queryParam } from '../index.js'
+import { renderVersionBadge } from '../version.js'
+import SnapcraftBase, { snapcraftPackageParam } from './snapcraft-base.js'
+
+const queryParamSchema = Joi.object({
+ arch: Joi.string(),
+})
+
+const versionSchema = Joi.object({
+ 'channel-map': Joi.array()
+ .items(
+ Joi.object({
+ channel: Joi.object({
+ architecture: Joi.string().required(),
+ risk: Joi.string().required(),
+ track: Joi.string().required(),
+ }),
+ version: Joi.string().required(),
+ }).required(),
+ )
+ .min(1)
+ .required(),
+}).required()
+
+export default class SnapcraftVersion extends SnapcraftBase {
+ static category = 'version'
+
+ static route = {
+ base: 'snapcraft/v',
+ pattern: ':package/:track/:risk',
+ queryParamSchema,
+ }
+
+ static openApi = {
+ '/snapcraft/v/{package}/{track}/{risk}': {
+ get: {
+ summary: 'Snapcraft Version',
+ parameters: [
+ snapcraftPackageParam,
+ ...pathParams(
+ { name: 'track', example: 'latest' },
+ { name: 'risk', example: 'stable' },
+ ),
+ queryParam({
+ name: 'arch',
+ example: 'amd64',
+ description:
+ 'Architecture, When not specified, this will default to `amd64`.',
+ }),
+ ],
+ },
+ },
+ }
+
+ static defaultBadgeData = { label: 'snapcraft' }
+
+ static render({ version }) {
+ return renderVersionBadge({ version })
+ }
+
+ static transform(apiData, track, risk, arch) {
+ const channelMap = apiData['channel-map']
+ let filteredChannelMap = channelMap.filter(
+ ({ channel }) => channel.architecture === arch,
+ )
+ if (filteredChannelMap.length === 0) {
+ throw new NotFound({ prettyMessage: 'arch not found' })
+ }
+ filteredChannelMap = filteredChannelMap.filter(
+ ({ channel }) => channel.track === track,
+ )
+ if (filteredChannelMap.length === 0) {
+ throw new NotFound({ prettyMessage: 'track not found' })
+ }
+ filteredChannelMap = filteredChannelMap.filter(
+ ({ channel }) => channel.risk === risk,
+ )
+ if (filteredChannelMap.length === 0) {
+ throw new NotFound({ prettyMessage: 'risk not found' })
+ }
+
+ return filteredChannelMap[0]
+ }
+
+ async handle({ package: packageName, track, risk }, { arch = 'amd64' }) {
+ const parsedData = await this.fetch(versionSchema, { packageName })
+
+ // filter results by track, risk and arch
+ const { version } = this.constructor.transform(
+ parsedData,
+ track,
+ risk,
+ arch,
+ )
+ return this.constructor.render({ version })
+ }
+}
diff --git a/services/snapcraft/snapcraft-version.spec.js b/services/snapcraft/snapcraft-version.spec.js
new file mode 100644
index 0000000000000..9944422eb75ee
--- /dev/null
+++ b/services/snapcraft/snapcraft-version.spec.js
@@ -0,0 +1,103 @@
+import { expect } from 'chai'
+import { test, given } from 'sazerac'
+import _ from 'lodash'
+import { NotFound } from '../index.js'
+import SnapcraftVersion from './snapcraft-version.service.js'
+
+describe('SnapcraftVersion', function () {
+ const exampleChannel = {
+ channel: {
+ architecture: 'amd64',
+ risk: 'stable',
+ track: 'latest',
+ },
+ version: '1.2.3',
+ }
+ const exampleArchChange = _.merge(_.cloneDeep(exampleChannel), {
+ channel: { architecture: 'arm64' },
+ version: '2.3.4',
+ })
+ const exampleTrackChange = _.merge(_.cloneDeep(exampleChannel), {
+ channel: { track: 'beta' },
+ version: '3.4.5',
+ })
+ const exampleRiskChange = _.merge(_.cloneDeep(exampleChannel), {
+ channel: { risk: 'edge' },
+ version: '5.4.6',
+ })
+ const testApiData = {
+ 'channel-map': [
+ exampleChannel,
+ exampleArchChange,
+ exampleTrackChange,
+ exampleRiskChange,
+ ],
+ }
+
+ test(SnapcraftVersion.transform, () => {
+ given(
+ testApiData,
+ exampleChannel.channel.track,
+ exampleChannel.channel.risk,
+ exampleChannel.channel.architecture,
+ ).expect(exampleChannel)
+ // change arch
+ given(
+ testApiData,
+ exampleChannel.channel.track,
+ exampleChannel.channel.risk,
+ exampleArchChange.channel.architecture,
+ ).expect(exampleArchChange)
+ // change track
+ given(
+ testApiData,
+ exampleTrackChange.channel.track,
+ exampleChannel.channel.risk,
+ exampleChannel.channel.architecture,
+ ).expect(exampleTrackChange)
+ // change risk
+ given(
+ testApiData,
+ exampleChannel.channel.track,
+ exampleRiskChange.channel.risk,
+ exampleChannel.channel.architecture,
+ ).expect(exampleRiskChange)
+ })
+
+ it('throws NotFound error with missing arch', function () {
+ expect(() => {
+ SnapcraftVersion.transform(
+ testApiData,
+ exampleChannel.channel.track,
+ exampleChannel.channel.risk,
+ 'missing',
+ )
+ })
+ .to.throw(NotFound)
+ .with.property('prettyMessage', 'arch not found')
+ })
+ it('throws NotFound error with missing track', function () {
+ expect(() => {
+ SnapcraftVersion.transform(
+ testApiData,
+ 'missing',
+ exampleChannel.channel.risk,
+ exampleChannel.channel.architecture,
+ )
+ })
+ .to.throw(NotFound)
+ .with.property('prettyMessage', 'track not found')
+ })
+ it('throws NotFound error with missing risk', function () {
+ expect(() => {
+ SnapcraftVersion.transform(
+ testApiData,
+ exampleChannel.channel.track,
+ 'missing',
+ exampleChannel.channel.architecture,
+ )
+ })
+ .to.throw(NotFound)
+ .with.property('prettyMessage', 'risk not found')
+ })
+})
diff --git a/services/snapcraft/snapcraft-version.tester.js b/services/snapcraft/snapcraft-version.tester.js
new file mode 100644
index 0000000000000..dc4a5001b66e4
--- /dev/null
+++ b/services/snapcraft/snapcraft-version.tester.js
@@ -0,0 +1,45 @@
+import { isSemver } from '../test-validators.js'
+import { createServiceTester } from '../tester.js'
+export const t = await createServiceTester()
+
+t.create('Snapcraft Version for redis')
+ .get('/redis/latest/stable.json')
+ .expectBadge({
+ label: 'snapcraft',
+ message: isSemver,
+ })
+
+t.create('Snapcraft Version for redis (query param arch=arm64)')
+ .get('/redis/latest/stable.json?arch=arm64')
+ .expectBadge({
+ label: 'snapcraft',
+ message: isSemver,
+ })
+
+t.create('Snapcraft Version for redis (invalid package)')
+ .get('/this_package_doesnt_exist/fake/fake.json')
+ .expectBadge({
+ label: 'snapcraft',
+ message: 'package not found',
+ })
+
+t.create('Snapcraft Version for redis (invalid track)')
+ .get('/redis/notfound/stable.json')
+ .expectBadge({
+ label: 'snapcraft',
+ message: 'track not found',
+ })
+
+t.create('Snapcraft Version for redis (invalid risk)')
+ .get('/redis/latest/notfound.json')
+ .expectBadge({
+ label: 'snapcraft',
+ message: 'risk not found',
+ })
+
+t.create('Snapcraft Version for redis (invalid arch)')
+ .get('/redis/latest/stable.json?arch=fake')
+ .expectBadge({
+ label: 'snapcraft',
+ message: 'arch not found',
+ })
diff --git a/services/snyk/snyk-test-helpers.js b/services/snyk/snyk-test-helpers.js
deleted file mode 100644
index 9f37e02ecdbe1..0000000000000
--- a/services/snyk/snyk-test-helpers.js
+++ /dev/null
@@ -1,6 +0,0 @@
-const zeroVulnerabilitiesSvg =
- 'vulnerabilities vulnerabilities 0 0 '
-const twoVulnerabilitiesSvg =
- 'vulnerabilities vulnerabilities 2 2 '
-
-export { zeroVulnerabilitiesSvg, twoVulnerabilitiesSvg }
diff --git a/services/snyk/snyk-vulnerability-base.js b/services/snyk/snyk-vulnerability-base.js
deleted file mode 100644
index 1316490a71445..0000000000000
--- a/services/snyk/snyk-vulnerability-base.js
+++ /dev/null
@@ -1,40 +0,0 @@
-import Joi from 'joi'
-import { BaseSvgScrapingService } from '../index.js'
-
-const schema = Joi.object({
- message: Joi.alternatives()
- .try(Joi.string().regex(/^\d*$/), Joi.equal('unknown'))
- .required(),
-}).required()
-
-export default class SnykVulnerabilityBase extends BaseSvgScrapingService {
- static category = 'analysis'
-
- static defaultBadgeData = {
- label: 'vulnerabilities',
- }
-
- static render({ vulnerabilities }) {
- let color = 'red'
- if (vulnerabilities === '0') {
- color = 'brightgreen'
- }
- return {
- message: vulnerabilities,
- color,
- }
- }
-
- async fetch({ url, qs, errorMessages }) {
- const { message: vulnerabilities } = await this._requestSvg({
- url,
- schema,
- options: {
- qs,
- },
- errorMessages,
- })
-
- return { vulnerabilities }
- }
-}
diff --git a/services/snyk/snyk-vulnerability-github.service.js b/services/snyk/snyk-vulnerability-github.service.js
deleted file mode 100644
index fd187e7fbebce..0000000000000
--- a/services/snyk/snyk-vulnerability-github.service.js
+++ /dev/null
@@ -1,49 +0,0 @@
-import SynkVulnerabilityBase from './snyk-vulnerability-base.js'
-
-export default class SnykVulnerabilityGitHub extends SynkVulnerabilityBase {
- static route = {
- base: 'snyk/vulnerabilities/github',
- pattern: ':user/:repo/:manifestFilePath*',
- }
-
- static examples = [
- {
- title: 'Snyk Vulnerabilities for GitHub Repo',
- pattern: ':user/:repo',
- namedParams: {
- user: 'badges',
- repo: 'shields',
- },
- staticPreview: this.render({ vulnerabilities: '0' }),
- },
- {
- title: 'Snyk Vulnerabilities for GitHub Repo (Specific Manifest)',
- pattern: ':user/:repo/:manifestFilePath',
- namedParams: {
- user: 'badges',
- repo: 'shields',
- manifestFilePath: 'badge-maker/package.json',
- },
- staticPreview: this.render({ vulnerabilities: '0' }),
- documentation: `
-
- Provide the path to your target manifest file relative to the base of your repository.
- Snyk does not support using a specific branch for this, so do not include "blob" nor a branch name.
-
- `,
- },
- ]
-
- async handle({ user, repo, manifestFilePath }) {
- const url = `https://snyk.io/test/github/${user}/${repo}/badge.svg`
- const qs = { targetFile: manifestFilePath }
- const { vulnerabilities } = await this.fetch({
- url,
- qs,
- errorMessages: {
- 404: 'repo or manifest not found',
- },
- })
- return this.constructor.render({ vulnerabilities })
- }
-}
diff --git a/services/snyk/snyk-vulnerability-github.tester.js b/services/snyk/snyk-vulnerability-github.tester.js
deleted file mode 100644
index 0601ef2710620..0000000000000
--- a/services/snyk/snyk-vulnerability-github.tester.js
+++ /dev/null
@@ -1,94 +0,0 @@
-import Joi from 'joi'
-import { createServiceTester } from '../tester.js'
-import {
- twoVulnerabilitiesSvg,
- zeroVulnerabilitiesSvg,
-} from './snyk-test-helpers.js'
-export const t = await createServiceTester()
-
-t.create('valid repo').get('/snyk/snyk.json').timeout(20000).expectBadge({
- label: 'vulnerabilities',
- message: Joi.number().required(),
-})
-
-t.create('non existent repo')
- .get('/badges/not-real.json')
- .timeout(20000)
- .expectBadge({
- label: 'vulnerabilities',
- message: 'repo or manifest not found',
- })
-
-t.create('valid target manifest path')
- .get('/snyk/vulndb-fixtures/packages/cli/0.1.0/package.json.json')
- .timeout(20000)
- .expectBadge({
- label: 'vulnerabilities',
- message: Joi.number().required(),
- })
-
-t.create('invalid target manifest path')
- .get('/badges/shields/badge-maker/requirements.txt.json')
- .timeout(20000)
- .expectBadge({
- label: 'vulnerabilities',
- message: 'repo or manifest not found',
- })
-
-t.create('repo has no vulnerabilities')
- .get('/badges/shields.json')
- .intercept(nock =>
- nock('https://snyk.io/test/github/badges/shields')
- .get('/badge.svg')
- .reply(200, zeroVulnerabilitiesSvg)
- )
- .expectBadge({
- label: 'vulnerabilities',
- message: '0',
- color: 'brightgreen',
- })
-
-t.create('repo has vulnerabilities')
- .get('/badges/shields.json')
- .intercept(nock =>
- nock('https://snyk.io/test/github/badges/shields')
- .get('/badge.svg')
- .reply(200, twoVulnerabilitiesSvg)
- )
- .expectBadge({
- label: 'vulnerabilities',
- message: '2',
- color: 'red',
- })
-
-t.create('target manifest file has no vulnerabilities')
- .get('/badges/shields/badge-maker/package.json.json')
- .intercept(nock =>
- nock('https://snyk.io/test/github/badges/shields')
- .get('/badge.svg')
- .query({
- targetFile: 'badge-maker/package.json',
- })
- .reply(200, zeroVulnerabilitiesSvg)
- )
- .expectBadge({
- label: 'vulnerabilities',
- message: '0',
- color: 'brightgreen',
- })
-
-t.create('target manifest file has vulnerabilities')
- .get('/badges/shields/badge-maker/package.json.json')
- .intercept(nock =>
- nock('https://snyk.io/test/github/badges/shields')
- .get('/badge.svg')
- .query({
- targetFile: 'badge-maker/package.json',
- })
- .reply(200, twoVulnerabilitiesSvg)
- )
- .expectBadge({
- label: 'vulnerabilities',
- message: '2',
- color: 'red',
- })
diff --git a/services/snyk/snyk-vulnerability-npm.service.js b/services/snyk/snyk-vulnerability-npm.service.js
deleted file mode 100644
index 4e5f99db3e18f..0000000000000
--- a/services/snyk/snyk-vulnerability-npm.service.js
+++ /dev/null
@@ -1,60 +0,0 @@
-import { NotFound } from '../index.js'
-import SynkVulnerabilityBase from './snyk-vulnerability-base.js'
-
-export default class SnykVulnerabilityNpm extends SynkVulnerabilityBase {
- static route = {
- base: 'snyk/vulnerabilities/npm',
- pattern: ':packageName(.+?)',
- }
-
- static examples = [
- {
- title: 'Snyk Vulnerabilities for npm package',
- pattern: ':packageName',
- namedParams: {
- packageName: 'mocha',
- },
- staticPreview: this.render({ vulnerabilities: '0' }),
- },
- {
- title: 'Snyk Vulnerabilities for npm package version',
- pattern: ':packageName',
- namedParams: {
- packageName: 'mocha@4.0.0',
- },
- staticPreview: this.render({ vulnerabilities: '1' }),
- },
- {
- title: 'Snyk Vulnerabilities for npm scoped package',
- pattern: ':packageName',
- namedParams: {
- packageName: '@babel/core',
- },
- staticPreview: this.render({ vulnerabilities: '0' }),
- },
- ]
-
- async handle({ packageName }) {
- const url = `https://snyk.io/test/npm/${packageName}/badge.svg`
-
- try {
- const { vulnerabilities } = await this.fetch({
- url,
- // Snyk returns an HTTP 200 with an HTML page when the specified
- // npm package is not found that contains the text 404.
- // Including this in case Snyk starts returning a 404 response code instead.
- errorMessages: {
- 404: 'npm package is invalid or does not exist',
- },
- })
- return this.constructor.render({ vulnerabilities })
- } catch (e) {
- // If the package is invalid/nonexistent Snyk will return an HTML page
- // which will result in an InvalidResponse error being thrown by the valueFromSvgBadge()
- // function. Catching it here to switch to a more contextualized error message.
- throw new NotFound({
- prettyMessage: 'npm package is invalid or does not exist',
- })
- }
- }
-}
diff --git a/services/snyk/snyk-vulnerability-npm.tester.js b/services/snyk/snyk-vulnerability-npm.tester.js
deleted file mode 100644
index 5df48e99502e1..0000000000000
--- a/services/snyk/snyk-vulnerability-npm.tester.js
+++ /dev/null
@@ -1,86 +0,0 @@
-import Joi from 'joi'
-import { createServiceTester } from '../tester.js'
-import {
- twoVulnerabilitiesSvg,
- zeroVulnerabilitiesSvg,
-} from './snyk-test-helpers.js'
-export const t = await createServiceTester()
-
-t.create('valid package latest version')
- .get('/commander.json')
- .timeout(20000)
- .expectBadge({
- label: 'vulnerabilities',
- message: Joi.number().required(),
- })
-
-t.create('valid scoped package latest version')
- .get('/@babel/core.json')
- .timeout(20000)
- .expectBadge({
- label: 'vulnerabilities',
- message: Joi.number().required(),
- })
-
-t.create('non existent package')
- .get('/mochaabcdef.json')
- .timeout(20000)
- .expectBadge({
- label: 'vulnerabilities',
- message: 'npm package is invalid or does not exist',
- })
-
-t.create('valid package specific version')
- .get('/commander@2.20.0.json')
- .timeout(20000)
- .expectBadge({
- label: 'vulnerabilities',
- message: Joi.number().required(),
- })
-
-t.create('non existent package version')
- .get('/gh-badges@0.3.4.json')
- .timeout(20000)
- .expectBadge({
- label: 'vulnerabilities',
- message: 'npm package is invalid or does not exist',
- })
-
-t.create('package has no vulnerabilities')
- .get('/mocha.json')
- .intercept(nock =>
- nock('https://snyk.io/test/npm/mocha')
- .get('/badge.svg')
- .reply(200, zeroVulnerabilitiesSvg)
- )
- .expectBadge({
- label: 'vulnerabilities',
- message: '0',
- color: 'brightgreen',
- })
-
-t.create('package has vulnerabilities')
- .get('/mocha.json')
- .intercept(nock =>
- nock('https://snyk.io/test/npm/mocha')
- .get('/badge.svg')
- .reply(200, twoVulnerabilitiesSvg)
- )
- .expectBadge({
- label: 'vulnerabilities',
- message: '2',
- color: 'red',
- })
-
-t.create('package not found')
- .get('/not-mocha-fake-ish@13.0.0.json')
- .intercept(nock =>
- nock('https://snyk.io/test/npm/not-mocha-fake-ish@13.0.0')
- .get('/badge.svg')
- .reply(200, 'foo')
- )
- .expectBadge({
- label: 'vulnerabilities',
- message: 'npm package is invalid or does not exist',
- color: 'red',
- })
diff --git a/services/sonar/sonar-base.js b/services/sonar/sonar-base.js
index 72139f14f9aea..09b8905204f0f 100644
--- a/services/sonar/sonar-base.js
+++ b/services/sonar/sonar-base.js
@@ -22,9 +22,9 @@ const modernSchema = Joi.object({
metric: Joi.string().required(),
value: Joi.alternatives(
Joi.number().min(0),
- Joi.allow('OK', 'ERROR')
+ Joi.equal('OK', 'ERROR'),
).required(),
- })
+ }),
)
.min(0)
.required(),
@@ -40,30 +40,31 @@ const legacySchema = Joi.array()
key: Joi.string().required(),
val: Joi.alternatives(
Joi.number().min(0),
- Joi.allow('OK', 'ERROR')
+ Joi.equal('OK', 'ERROR'),
).required(),
- })
+ }),
)
.required(),
- }).required()
+ }).required(),
)
.required()
export default class SonarBase extends BaseJsonService {
static auth = { userKey: 'sonarqube_token', serviceKey: 'sonar' }
- async fetch({ sonarVersion, server, component, metricName }) {
+ async fetch({ sonarVersion, server, component, metricName, branch }) {
const useLegacyApi = isLegacyVersion({ sonarVersion })
- let qs, url, schema
+ let searchParams, url, schema
if (useLegacyApi) {
schema = legacySchema
url = `${server}/api/resources`
- qs = {
+ searchParams = {
resource: component,
depth: 0,
metrics: metricName,
includeTrends: true,
+ branch,
}
} else {
schema = modernSchema
@@ -71,9 +72,10 @@ export default class SonarBase extends BaseJsonService {
// componentKey query param was renamed in version 6.6
const componentKey =
parseFloat(sonarVersion) >= 6.6 ? 'component' : 'componentKey'
- qs = {
+ searchParams = {
[componentKey]: component,
metricKeys: metricName,
+ branch,
}
}
@@ -81,11 +83,11 @@ export default class SonarBase extends BaseJsonService {
this.authHelper.withBasicAuth({
schema,
url,
- options: { qs },
- errorMessages: {
+ options: { searchParams },
+ httpErrors: {
404: 'component or metric not found, or legacy API not supported',
},
- })
+ }),
)
}
diff --git a/services/sonar/sonar-coverage.service.js b/services/sonar/sonar-coverage.service.js
index ca71ab8264bc5..a73bfa9f9de61 100644
--- a/services/sonar/sonar-coverage.service.js
+++ b/services/sonar/sonar-coverage.service.js
@@ -1,31 +1,44 @@
+import { pathParam } from '../index.js'
import { coveragePercentage } from '../color-formatters.js'
import SonarBase from './sonar-base.js'
-import { documentation, keywords, queryParamSchema } from './sonar-helpers.js'
+import {
+ documentation,
+ queryParamSchema,
+ openApiQueryParams,
+} from './sonar-helpers.js'
export default class SonarCoverage extends SonarBase {
static category = 'coverage'
static route = {
base: 'sonar/coverage',
- pattern: ':component',
+ pattern: ':component/:branch*',
queryParamSchema,
}
- static examples = [
- {
- title: 'Sonar Coverage',
- namedParams: {
- component: 'org.ow2.petals:petals-se-ase',
+ static openApi = {
+ '/sonar/coverage/{component}': {
+ get: {
+ summary: 'Sonar Coverage',
+ description: documentation,
+ parameters: [
+ pathParam({ name: 'component', example: 'gitify-app_gitify' }),
+ ...openApiQueryParams,
+ ],
},
- queryParams: {
- server: 'http://sonar.petalslink.com',
- sonarVersion: '4.2',
+ },
+ '/sonar/coverage/{component}/{branch}': {
+ get: {
+ summary: 'Sonar Coverage (branch)',
+ description: documentation,
+ parameters: [
+ pathParam({ name: 'component', example: 'gitify-app_gitify' }),
+ pathParam({ name: 'branch', example: 'main' }),
+ ...openApiQueryParams,
+ ],
},
- staticPreview: this.render({ coverage: 63 }),
- keywords,
- documentation,
},
- ]
+ }
static defaultBadgeData = { label: 'coverage' }
@@ -36,11 +49,12 @@ export default class SonarCoverage extends SonarBase {
}
}
- async handle({ component }, { server, sonarVersion }) {
+ async handle({ component, branch }, { server, sonarVersion }) {
const json = await this.fetch({
sonarVersion,
server,
component,
+ branch,
metricName: 'coverage',
})
const { coverage } = this.transform({
diff --git a/services/sonar/sonar-coverage.spec.js b/services/sonar/sonar-coverage.spec.js
new file mode 100644
index 0000000000000..c6c2e1e5086b2
--- /dev/null
+++ b/services/sonar/sonar-coverage.spec.js
@@ -0,0 +1,19 @@
+import { testAuth } from '../test-helpers.js'
+import SonarCoverage from './sonar-coverage.service.js'
+import {
+ legacySonarResponse,
+ testAuthConfigOverride,
+} from './sonar-spec-helpers.js'
+
+describe('SonarCoverage', function () {
+ describe('auth', function () {
+ it('sends the auth information as configured', async function () {
+ return testAuth(
+ SonarCoverage,
+ 'BasicAuth',
+ legacySonarResponse('coverage', 95),
+ { configOverride: testAuthConfigOverride },
+ )
+ })
+ })
+})
diff --git a/services/sonar/sonar-coverage.tester.js b/services/sonar/sonar-coverage.tester.js
index 6a03fa2171d4d..68fab4e88cd65 100644
--- a/services/sonar/sonar-coverage.tester.js
+++ b/services/sonar/sonar-coverage.tester.js
@@ -9,7 +9,14 @@ export const t = await createServiceTester()
// for other service tests.
t.create('Coverage')
- .get('/swellaby%3Aletra.json?server=https://sonarcloud.io')
+ .get('/gitify-app_gitify.json?server=https://sonarcloud.io')
+ .expectBadge({
+ label: 'coverage',
+ message: isIntegerPercentage,
+ })
+
+t.create('Coverage (branch)')
+ .get('/gitify-app_gitify/main.json?server=https://sonarcloud.io')
.expectBadge({
label: 'coverage',
message: isIntegerPercentage,
@@ -17,7 +24,7 @@ t.create('Coverage')
t.create('Coverage (legacy API supported)')
.get(
- '/org.ow2.petals%3Apetals-se-ase.json?server=http://sonar.petalslink.com&sonarVersion=4.2'
+ '/org.ow2.petals%3Apetals-se-ase.json?server=http://sonar.petalslink.com&sonarVersion=4.2',
)
.intercept(nock =>
nock('http://sonar.petalslink.com/api')
@@ -37,7 +44,7 @@ t.create('Coverage (legacy API supported)')
},
],
},
- ])
+ ]),
)
.expectBadge({
label: 'coverage',
diff --git a/services/sonar/sonar-documented-api-density.service.js b/services/sonar/sonar-documented-api-density.service.js
index 9c605f94beb5a..326a96aa5ee41 100644
--- a/services/sonar/sonar-documented-api-density.service.js
+++ b/services/sonar/sonar-documented-api-density.service.js
@@ -1,9 +1,10 @@
+import { pathParam } from '../index.js'
import SonarBase from './sonar-base.js'
import {
queryParamSchema,
+ openApiQueryParams,
getLabel,
positiveMetricColorScale,
- keywords,
documentation,
} from './sonar-helpers.js'
@@ -14,25 +15,35 @@ export default class SonarDocumentedApiDensity extends SonarBase {
static route = {
base: `sonar/${metric}`,
- pattern: ':component',
+ pattern: ':component/:branch*',
queryParamSchema,
}
- static examples = [
- {
- title: 'Sonar Documented API Density',
- namedParams: {
- component: 'org.ow2.petals:petals-se-ase',
+ static get openApi() {
+ const routes = {}
+ routes[`/sonar/${metric}/{component}`] = {
+ get: {
+ summary: 'Sonar Documented API Density',
+ description: documentation,
+ parameters: [
+ pathParam({ name: 'component', example: 'brave_brave-core' }),
+ ...openApiQueryParams,
+ ],
},
- queryParams: {
- server: 'http://sonar.petalslink.com',
- sonarVersion: '4.2',
+ }
+ routes[`/sonar/${metric}/{component}/{branch}`] = {
+ get: {
+ summary: 'Sonar Documented API Density (branch)',
+ description: documentation,
+ parameters: [
+ pathParam({ name: 'component', example: 'michelin_kstreamplify' }),
+ pathParam({ name: 'branch', example: 'main' }),
+ ...openApiQueryParams,
+ ],
},
- staticPreview: this.render({ density: 82 }),
- keywords,
- documentation,
- },
- ]
+ }
+ return routes
+ }
static defaultBadgeData = { label: getLabel({ metric }) }
@@ -43,11 +54,12 @@ export default class SonarDocumentedApiDensity extends SonarBase {
}
}
- async handle({ component }, { server, sonarVersion }) {
+ async handle({ component, branch }, { server, sonarVersion }) {
const json = await this.fetch({
sonarVersion,
server,
component,
+ branch,
metricName: metric,
})
const metrics = this.transform({ json, sonarVersion })
diff --git a/services/sonar/sonar-documented-api-density.spec.js b/services/sonar/sonar-documented-api-density.spec.js
index a5ca4fe55267b..d8b01594bb1b6 100644
--- a/services/sonar/sonar-documented-api-density.spec.js
+++ b/services/sonar/sonar-documented-api-density.spec.js
@@ -1,5 +1,10 @@
import { test, given } from 'sazerac'
+import { testAuth } from '../test-helpers.js'
import SonarDocumentedApiDensity from './sonar-documented-api-density.service.js'
+import {
+ legacySonarResponse,
+ testAuthConfigOverride,
+} from './sonar-spec-helpers.js'
describe('SonarDocumentedApiDensity', function () {
test(SonarDocumentedApiDensity.render, () => {
@@ -24,4 +29,15 @@ describe('SonarDocumentedApiDensity', function () {
color: 'brightgreen',
})
})
+
+ describe('auth', function () {
+ it('sends the auth information as configured', async function () {
+ return testAuth(
+ SonarDocumentedApiDensity,
+ 'BasicAuth',
+ legacySonarResponse('density', 93),
+ { configOverride: testAuthConfigOverride },
+ )
+ })
+ })
})
diff --git a/services/sonar/sonar-documented-api-density.tester.js b/services/sonar/sonar-documented-api-density.tester.js
index d1fad44e3b8c1..cde30fe3fc15b 100644
--- a/services/sonar/sonar-documented-api-density.tester.js
+++ b/services/sonar/sonar-documented-api-density.tester.js
@@ -12,9 +12,7 @@ export const t = await createServiceTester()
// https://docs.sonarqube.org/7.0/MetricDefinitions.html
// https://sonarcloud.io/api/measures/component?componentKey=org.sonarsource.sonarqube:sonarqube&metricKeys=public_documented_api_density
t.create('Documented API Density (not found)')
- .get(
- '/org.sonarsource.sonarqube%3Asonarqube.json?server=https://sonarcloud.io'
- )
+ .get('/brave_brave-core.json?server=https://sonarcloud.io')
.expectBadge({
label: 'public documented api density',
message: 'metric not found',
@@ -22,7 +20,7 @@ t.create('Documented API Density (not found)')
t.create('Documented API Density')
.get(
- '/org.ow2.petals%3Apetals-se-ase.json?server=http://sonar.somewhatold.com&sonarVersion=6.1'
+ '/org.ow2.petals%3Apetals-se-ase.json?server=http://sonar.somewhatold.com&sonarVersion=6.1',
)
.intercept(nock =>
nock('http://sonar.somewhatold.com/api')
@@ -40,7 +38,7 @@ t.create('Documented API Density')
},
],
},
- })
+ }),
)
.expectBadge({
label: 'public documented api density',
@@ -49,7 +47,7 @@ t.create('Documented API Density')
t.create('Documented API Density (legacy API supported)')
.get(
- '/org.ow2.petals%3Apetals-se-ase.json?server=http://sonar.petalslink.com&sonarVersion=4.2'
+ '/org.ow2.petals%3Apetals-se-ase.json?server=http://sonar.petalslink.com&sonarVersion=4.2',
)
.intercept(nock =>
nock('http://sonar.petalslink.com/api')
@@ -69,7 +67,7 @@ t.create('Documented API Density (legacy API supported)')
},
],
},
- ])
+ ]),
)
.expectBadge({
label: 'public documented api density',
diff --git a/services/sonar/sonar-fortify-rating.service.js b/services/sonar/sonar-fortify-rating.service.js
index 2e375c18f0345..e623ca4609574 100644
--- a/services/sonar/sonar-fortify-rating.service.js
+++ b/services/sonar/sonar-fortify-rating.service.js
@@ -1,5 +1,10 @@
+import { pathParam } from '../index.js'
import SonarBase from './sonar-base.js'
-import { queryParamSchema, keywords, documentation } from './sonar-helpers.js'
+import {
+ queryParamSchema,
+ openApiQueryParams,
+ documentation,
+} from './sonar-helpers.js'
const colorMap = {
0: 'red',
@@ -10,36 +15,45 @@ const colorMap = {
5: 'brightgreen',
}
+const description = `
+Note that the Fortify Security Rating badge will only work on Sonar instances that have the Fortify SonarQube Plugin installed.
+The badge is not available for projects analyzed on SonarCloud.io
+
+${documentation}
+`
+
export default class SonarFortifyRating extends SonarBase {
static category = 'analysis'
static route = {
base: 'sonar/fortify-security-rating',
- pattern: ':component',
+ pattern: ':component/:branch*',
queryParamSchema,
}
- static examples = [
- {
- title: 'Sonar Fortify Security Rating',
- namedParams: {
- component: 'org.ow2.petals:petals-se-ase',
+ static openApi = {
+ '/sonar/fortify-security-rating/{component}': {
+ get: {
+ summary: 'Sonar Fortify Security Rating',
+ description,
+ parameters: [
+ pathParam({ name: 'component', example: 'michelin_kstreamplify' }),
+ ...openApiQueryParams,
+ ],
},
- queryParams: {
- server: 'http://sonar.petalslink.com',
- sonarVersion: '4.2',
+ },
+ '/sonar/fortify-security-rating/{component}/{branch}': {
+ get: {
+ summary: 'Sonar Fortify Security Rating (branch)',
+ description,
+ parameters: [
+ pathParam({ name: 'component', example: 'michelin_kstreamplify' }),
+ pathParam({ name: 'branch', example: 'main' }),
+ ...openApiQueryParams,
+ ],
},
- staticPreview: this.render({ rating: 4 }),
- keywords,
- documentation: `
-
- Note that the Fortify Security Rating badge will only work on Sonar instances that have the Fortify SonarQube Plugin installed.
- The badge is not available for projects analyzed on SonarCloud.io
-
- ${documentation}
- `,
},
- ]
+ }
static defaultBadgeData = { label: 'fortify-security-rating' }
@@ -50,11 +64,12 @@ export default class SonarFortifyRating extends SonarBase {
}
}
- async handle({ component }, { server, sonarVersion }) {
+ async handle({ component, branch }, { server, sonarVersion }) {
const json = await this.fetch({
sonarVersion,
server,
component,
+ branch,
metricName: 'fortify-security-rating',
})
diff --git a/services/sonar/sonar-fortify-rating.spec.js b/services/sonar/sonar-fortify-rating.spec.js
index ba5869b0dc88e..b226d64762015 100644
--- a/services/sonar/sonar-fortify-rating.spec.js
+++ b/services/sonar/sonar-fortify-rating.spec.js
@@ -1,51 +1,19 @@
-import { expect } from 'chai'
-import nock from 'nock'
-import { cleanUpNockAfterEach, defaultContext } from '../test-helpers.js'
+import { testAuth } from '../test-helpers.js'
import SonarFortifyRating from './sonar-fortify-rating.service.js'
-
-const token = 'abc123def456'
-const config = {
- public: {
- services: {
- sonar: { authorizedOrigins: ['http://sonar.petalslink.com'] },
- },
- },
- private: {
- sonarqube_token: token,
- },
-}
+import {
+ legacySonarResponse,
+ testAuthConfigOverride,
+} from './sonar-spec-helpers.js'
describe('SonarFortifyRating', function () {
- cleanUpNockAfterEach()
-
- it('sends the auth information as configured', async function () {
- const scope = nock('http://sonar.petalslink.com')
- .get('/api/measures/component')
- .query({
- componentKey: 'org.ow2.petals:petals-se-ase',
- metricKeys: 'fortify-security-rating',
- })
- // 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.
- .basicAuth({ user: token })
- .reply(200, {
- component: {
- measures: [{ metric: 'fortify-security-rating', value: 4 }],
- },
- })
-
- expect(
- await SonarFortifyRating.invoke(
- defaultContext,
- config,
- { component: 'org.ow2.petals:petals-se-ase' },
- { server: 'http://sonar.petalslink.com' }
+ describe('auth', function () {
+ it('sends the auth information as configured', async function () {
+ return testAuth(
+ SonarFortifyRating,
+ 'BasicAuth',
+ legacySonarResponse('fortify-security-rating', 4),
+ { configOverride: testAuthConfigOverride },
)
- ).to.deep.equal({
- color: 'green',
- message: '4/5',
})
-
- scope.done()
})
})
diff --git a/services/sonar/sonar-fortify-rating.tester.js b/services/sonar/sonar-fortify-rating.tester.js
index c6efb951777e9..99b72d89114f0 100644
--- a/services/sonar/sonar-fortify-rating.tester.js
+++ b/services/sonar/sonar-fortify-rating.tester.js
@@ -29,7 +29,7 @@ t.create('Fortify Security Rating')
},
],
},
- })
+ }),
)
.expectBadge({
label: 'fortify-security-rating',
@@ -38,7 +38,7 @@ t.create('Fortify Security Rating')
t.create('Fortify Security Rating (legacy API supported)')
.get(
- '/org.ow2.petals%3Apetals-se-ase.json?server=http://sonar.petalslink.com&sonarVersion=4.2'
+ '/org.ow2.petals%3Apetals-se-ase.json?server=http://sonar.petalslink.com&sonarVersion=4.2',
)
.intercept(nock =>
nock('http://sonar.petalslink.com/api')
@@ -58,7 +58,7 @@ t.create('Fortify Security Rating (legacy API supported)')
},
],
},
- ])
+ ]),
)
.expectBadge({
label: 'fortify-security-rating',
@@ -67,7 +67,7 @@ t.create('Fortify Security Rating (legacy API supported)')
t.create('Fortify Security Rating (legacy API not supported)')
.get(
- '/swellaby:azdo-shellcheck.json?server=https://sonarcloud.io&sonarVersion=4.2'
+ '/michelin_kstreamplify.json?server=https://sonarcloud.io&sonarVersion=4.2',
)
.expectBadge({
label: 'fortify-security-rating',
@@ -83,7 +83,7 @@ t.create('Fortify Security Rating (nonexistent component)')
t.create('Fortify Security Rating (legacy API metric not found)')
.get(
- '/org.ow2.petals%3Apetals-se-ase.json?server=http://sonar.petalslink.com&sonarVersion=4.2'
+ '/org.ow2.petals%3Apetals-se-ase.json?server=http://sonar.petalslink.com&sonarVersion=4.2',
)
.intercept(nock =>
nock('http://sonar.petalslink.com/api')
@@ -98,7 +98,7 @@ t.create('Fortify Security Rating (legacy API metric not found)')
{
msr: [],
},
- ])
+ ]),
)
.expectBadge({
label: 'fortify-security-rating',
diff --git a/services/sonar/sonar-generic.service.js b/services/sonar/sonar-generic.service.js
index adc6bcd6af36c..9f18e39272c0d 100644
--- a/services/sonar/sonar-generic.service.js
+++ b/services/sonar/sonar-generic.service.js
@@ -109,7 +109,7 @@ export default class SonarGeneric extends SonarBase {
static route = {
base: 'sonar',
- pattern: `:metricName(${metricNameRouteParam})/:component`,
+ pattern: `:metricName(${metricNameRouteParam})/:component/:branch*`,
queryParamSchema,
}
@@ -123,11 +123,12 @@ export default class SonarGeneric extends SonarBase {
}
}
- async handle({ component, metricName }, { server, sonarVersion }) {
+ async handle({ component, metricName, branch }, { server, sonarVersion }) {
const json = await this.fetch({
sonarVersion,
server,
component,
+ branch,
metricName,
})
diff --git a/services/sonar/sonar-generic.spec.js b/services/sonar/sonar-generic.spec.js
new file mode 100644
index 0000000000000..d1cf759fef550
--- /dev/null
+++ b/services/sonar/sonar-generic.spec.js
@@ -0,0 +1,29 @@
+import { testAuth } from '../test-helpers.js'
+import SonarGeneric from './sonar-generic.service.js'
+import {
+ legacySonarResponse,
+ testAuthConfigOverride,
+} from './sonar-spec-helpers.js'
+
+describe('SonarGeneric', function () {
+ describe('auth', function () {
+ it('sends the auth information as configured', async function () {
+ return testAuth(
+ SonarGeneric,
+ 'BasicAuth',
+ legacySonarResponse('test', 903),
+ {
+ configOverride: testAuthConfigOverride,
+ exampleOverride: {
+ component: 'test',
+ metricName: 'test',
+ branch: 'home',
+ server:
+ testAuthConfigOverride.public.services.sonar.authorizedOrigins[0],
+ sonarVersion: '4.2',
+ },
+ },
+ )
+ })
+ })
+})
diff --git a/services/sonar/sonar-generic.tester.js b/services/sonar/sonar-generic.tester.js
index 2c17598222760..d95eda3f9ef54 100644
--- a/services/sonar/sonar-generic.tester.js
+++ b/services/sonar/sonar-generic.tester.js
@@ -3,9 +3,18 @@ import { createServiceTester } from '../tester.js'
export const t = await createServiceTester()
t.create('Security Rating')
+ .timeout(10000)
+ .get('/security_rating/WebExtensions.Net.json?server=https://sonarcloud.io')
+ .expectBadge({
+ label: 'security rating',
+ message: isMetric,
+ color: 'blue',
+ })
+
+t.create('Security Rating (branch)')
.timeout(10000)
.get(
- '/security_rating/com.luckybox:luckybox.json?server=https://sonarcloud.io'
+ '/security_rating/WebExtensions.Net/main.json?server=https://sonarcloud.io',
)
.expectBadge({
label: 'security rating',
diff --git a/services/sonar/sonar-helpers.js b/services/sonar/sonar-helpers.js
index c5e49b2728952..7f93d0fe0e0c0 100644
--- a/services/sonar/sonar-helpers.js
+++ b/services/sonar/sonar-helpers.js
@@ -1,6 +1,7 @@
import Joi from 'joi'
+import { queryParams } from '../index.js'
import { colorScale } from '../color-formatters.js'
-import { optionalUrl } from '../validators.js'
+import { url } from '../validators.js'
const ratingPercentageScaleSteps = [10, 20, 50, 100]
const ratingScaleColors = [
@@ -12,12 +13,12 @@ const ratingScaleColors = [
]
const negativeMetricColorScale = colorScale(
ratingPercentageScaleSteps,
- ratingScaleColors
+ ratingScaleColors,
)
const positiveMetricColorScale = colorScale(
ratingPercentageScaleSteps,
ratingScaleColors,
- true
+ true,
)
function isLegacyVersion({ sonarVersion }) {
@@ -32,39 +33,40 @@ const sonarVersionSchema = Joi.alternatives(
Joi.string()
.regex(/[0-9.]+/)
.optional(),
- Joi.number().optional()
+ Joi.number().optional(),
)
const queryParamSchema = Joi.object({
sonarVersion: sonarVersionSchema,
- server: optionalUrl.required(),
+ server: url,
}).required()
+const openApiQueryParams = queryParams(
+ { name: 'server', example: 'https://sonarcloud.io', required: true },
+ { name: 'sonarVersion', example: '4.2' },
+)
+
const queryParamWithFormatSchema = Joi.object({
sonarVersion: sonarVersionSchema,
- server: optionalUrl.required(),
- format: Joi.string().allow('short', 'long').optional(),
+ server: url,
+ format: Joi.equal('short', 'long').optional(),
}).required()
-const keywords = ['sonarcloud', 'sonarqube']
const documentation = `
-
- The Sonar badges will work with both SonarCloud.io and self-hosted SonarQube instances.
- Just enter the correct protocol and path for your target Sonar deployment.
-
-
- If you are targeting a legacy SonarQube instance that is version 5.3 or earlier, then be sure
- to include the version query parameter with the value of your SonarQube version.
-
{
@@ -12,4 +17,15 @@ describe('SonarQualityGate', function () {
color: 'critical',
})
})
+
+ describe('auth', function () {
+ it('sends the auth information as configured', async function () {
+ return testAuth(
+ SonarQualityGate,
+ 'BasicAuth',
+ legacySonarResponse('alert_status', 'OK'),
+ { configOverride: testAuthConfigOverride },
+ )
+ })
+ })
})
diff --git a/services/sonar/sonar-quality-gate.tester.js b/services/sonar/sonar-quality-gate.tester.js
index d6e2ca30d224d..9f069e309e2ec 100644
--- a/services/sonar/sonar-quality-gate.tester.js
+++ b/services/sonar/sonar-quality-gate.tester.js
@@ -2,7 +2,7 @@ import Joi from 'joi'
import { createServiceTester } from '../tester.js'
export const t = await createServiceTester()
-const isQualityGateStatus = Joi.allow('passed', 'failed')
+const isQualityGateStatus = Joi.string().valid('passed', 'failed')
// The service tests targeting the legacy SonarQube API are mocked
// because of the lack of publicly accessible, self-hosted, legacy SonarQube instances
@@ -11,8 +11,15 @@ const isQualityGateStatus = Joi.allow('passed', 'failed')
// for other service tests.
t.create('Quality Gate')
+ .get('/quality_gate/michelin_kstreamplify.json?server=https://sonarcloud.io')
+ .expectBadge({
+ label: 'quality gate',
+ message: isQualityGateStatus,
+ })
+
+t.create('Quality Gate (branch)')
.get(
- '/quality_gate/swellaby%3Aazdo-shellcheck.json?server=https://sonarcloud.io'
+ '/quality_gate/michelin_kstreamplify/main.json?server=https://sonarcloud.io',
)
.expectBadge({
label: 'quality gate',
@@ -21,7 +28,7 @@ t.create('Quality Gate')
t.create('Quality Gate (Alert Status)')
.get(
- '/alert_status/org.ow2.petals%3Apetals-se-ase.json?server=http://sonar.petalslink.com&sonarVersion=4.2'
+ '/alert_status/org.ow2.petals%3Apetals-se-ase.json?server=http://sonar.petalslink.com&sonarVersion=4.2',
)
.intercept(nock =>
nock('http://sonar.petalslink.com/api')
@@ -41,7 +48,7 @@ t.create('Quality Gate (Alert Status)')
},
],
},
- ])
+ ]),
)
.expectBadge({
label: 'quality gate',
@@ -52,7 +59,7 @@ t.create('Quality Gate (Alert Status)')
// https://github.com/badges/shields/pull/6636#issuecomment-886172161
t.create('Quality Gate (version >= 6.6)')
.get(
- '/quality_gate/de.chkpnt%3Atruststorebuilder-gradle-plugin.json?server=https://sonar.chkpnt.de&sonarVersion=8.9'
+ '/quality_gate/de.chkpnt%3Atruststorebuilder-gradle-plugin.json?server=https://sonar.chkpnt.de&sonarVersion=8.9',
)
.expectBadge({
label: 'quality gate',
diff --git a/services/sonar/sonar-redirector.tester.js b/services/sonar/sonar-redirector.tester.js
index 286d92be7a2df..ade1f66c9b5fc 100644
--- a/services/sonar/sonar-redirector.tester.js
+++ b/services/sonar/sonar-redirector.tester.js
@@ -9,38 +9,38 @@ export const t = new ServiceTester({
t.create('sonar version')
.get(
- '/4.2/http/sonar.petalslink.com/org.ow2.petals:petals-se-ase/alert_status.svg'
+ '/4.2/http/sonar.petalslink.com/org.ow2.petals:petals-se-ase/alert_status.svg',
)
.expectRedirect(
`/sonar/alert_status/org.ow2.petals:petals-se-ase.svg?${queryString.stringify(
{
server: 'http://sonar.petalslink.com',
sonarVersion: '4.2',
- }
- )}`
+ },
+ )}`,
)
t.create('sonar host parameter')
.get(
- '/http/sonar.petalslink.com/org.ow2.petals:petals-se-ase/alert_status.svg'
+ '/http/sonar.petalslink.com/org.ow2.petals:petals-se-ase/alert_status.svg',
)
.expectRedirect(
`/sonar/alert_status/org.ow2.petals:petals-se-ase.svg?${queryString.stringify(
{
server: 'http://sonar.petalslink.com',
- }
- )}`
+ },
+ )}`,
)
t.create('sonar host parameter with version')
.get(
- '/http/sonar.petalslink.com/org.ow2.petals:petals-se-ase/alert_status.svg?sonarVersion=4.2'
+ '/http/sonar.petalslink.com/org.ow2.petals:petals-se-ase/alert_status.svg?sonarVersion=4.2',
)
.expectRedirect(
`/sonar/alert_status/org.ow2.petals:petals-se-ase.svg?${queryString.stringify(
{
- server: 'http://sonar.petalslink.com',
sonarVersion: '4.2',
- }
- )}`
+ server: 'http://sonar.petalslink.com',
+ },
+ )}`,
)
diff --git a/services/sonar/sonar-spec-helpers.js b/services/sonar/sonar-spec-helpers.js
new file mode 100644
index 0000000000000..1d142f4800576
--- /dev/null
+++ b/services/sonar/sonar-spec-helpers.js
@@ -0,0 +1,36 @@
+import SonarBase from './sonar-base.js'
+import { openApiQueryParams } from './sonar-helpers.js'
+
+const testAuthConfigOverride = {
+ public: {
+ services: {
+ [SonarBase.auth.serviceKey]: {
+ authorizedOrigins: [
+ openApiQueryParams.find(v => v.name === 'server').example,
+ ],
+ },
+ },
+ },
+}
+
+/**
+ * Returns a legacy sonar api response with desired key and value
+ *
+ * @param {string} key Key for the response value
+ * @param {string|number} val Value to assign to response key
+ * @returns {object} Sonar api response
+ */
+function legacySonarResponse(key, val) {
+ return [
+ {
+ msr: [
+ {
+ key,
+ val,
+ },
+ ],
+ },
+ ]
+}
+
+export { testAuthConfigOverride, legacySonarResponse }
diff --git a/services/sonar/sonar-tech-debt.service.js b/services/sonar/sonar-tech-debt.service.js
index b8c1de4cd25ed..315e36cc7a22a 100644
--- a/services/sonar/sonar-tech-debt.service.js
+++ b/services/sonar/sonar-tech-debt.service.js
@@ -1,10 +1,11 @@
+import { pathParam } from '../index.js'
import SonarBase from './sonar-base.js'
import {
negativeMetricColorScale,
getLabel,
documentation,
- keywords,
queryParamSchema,
+ openApiQueryParams,
} from './sonar-helpers.js'
export default class SonarTechDebt extends SonarBase {
@@ -12,29 +13,33 @@ export default class SonarTechDebt extends SonarBase {
static route = {
base: 'sonar',
- pattern: ':metric(tech_debt|sqale_debt_ratio)/:component',
+ pattern: ':metric(tech_debt|sqale_debt_ratio)/:component/:branch*',
queryParamSchema,
}
- static examples = [
- {
- title: 'Sonar Tech Debt',
- namedParams: {
- component: 'org.ow2.petals:petals-se-ase',
- metric: 'tech_debt',
+ static openApi = {
+ '/sonar/tech_debt/{component}': {
+ get: {
+ summary: 'Sonar Tech Debt',
+ description: documentation,
+ parameters: [
+ pathParam({ name: 'component', example: 'brave_brave-core' }),
+ ...openApiQueryParams,
+ ],
},
- queryParams: {
- server: 'http://sonar.petalslink.com',
- sonarVersion: '4.2',
+ },
+ '/sonar/tech_debt/{component}/{branch}': {
+ get: {
+ summary: 'Sonar Tech Debt (branch)',
+ description: documentation,
+ parameters: [
+ pathParam({ name: 'component', example: 'brave_brave-core' }),
+ pathParam({ name: 'branch', example: 'master' }),
+ ...openApiQueryParams,
+ ],
},
- staticPreview: this.render({
- debt: 1,
- metric: 'tech_debt',
- }),
- keywords,
- documentation,
},
- ]
+ }
static defaultBadgeData = { label: 'tech debt' }
@@ -46,11 +51,12 @@ export default class SonarTechDebt extends SonarBase {
}
}
- async handle({ component, metric }, { server, sonarVersion }) {
+ async handle({ component, metric, branch }, { server, sonarVersion }) {
const json = await this.fetch({
sonarVersion,
server,
component,
+ branch,
// Special condition for backwards compatibility.
metricName: 'sqale_debt_ratio',
})
diff --git a/services/sonar/sonar-tech-debt.spec.js b/services/sonar/sonar-tech-debt.spec.js
index b6ef2009205bd..a636f8c78facb 100644
--- a/services/sonar/sonar-tech-debt.spec.js
+++ b/services/sonar/sonar-tech-debt.spec.js
@@ -1,5 +1,10 @@
import { test, given } from 'sazerac'
+import { testAuth } from '../test-helpers.js'
import SonarTechDebt from './sonar-tech-debt.service.js'
+import {
+ legacySonarResponse,
+ testAuthConfigOverride,
+} from './sonar-spec-helpers.js'
describe('SonarTechDebt', function () {
test(SonarTechDebt.render, () => {
@@ -29,4 +34,15 @@ describe('SonarTechDebt', function () {
color: 'red',
})
})
+
+ describe('auth', function () {
+ it('sends the auth information as configured', async function () {
+ return testAuth(
+ SonarTechDebt,
+ 'BasicAuth',
+ legacySonarResponse('sqale_debt_ratio', 95),
+ { configOverride: testAuthConfigOverride },
+ )
+ })
+ })
})
diff --git a/services/sonar/sonar-tech-debt.tester.js b/services/sonar/sonar-tech-debt.tester.js
index f9137fe570a87..27da73743a66b 100644
--- a/services/sonar/sonar-tech-debt.tester.js
+++ b/services/sonar/sonar-tech-debt.tester.js
@@ -10,16 +10,23 @@ export const t = await createServiceTester()
t.create('Tech Debt')
.get(
- '/tech_debt/org.sonarsource.sonarqube%3Asonarqube.json?server=https://sonarcloud.io'
+ '/tech_debt/brave_brave-core.json?server=https://sonarcloud.io&sonarVersion=9.0',
)
.expectBadge({
label: 'tech debt',
message: isPercentage,
})
+t.create('Tech Debt (branch)')
+ .get('/tech_debt/brave_brave-core/master.json?server=https://sonarcloud.io')
+ .expectBadge({
+ label: 'tech debt',
+ message: isPercentage,
+ })
+
t.create('Tech Debt (legacy API supported)')
.get(
- '/tech_debt/org.ow2.petals%3Apetals-se-ase.json?server=http://sonar.petalslink.com&sonarVersion=4.2'
+ '/tech_debt/org.ow2.petals%3Apetals-se-ase.json?server=http://sonar.petalslink.com&sonarVersion=4.2',
)
.intercept(nock =>
nock('http://sonar.petalslink.com/api')
@@ -39,7 +46,7 @@ t.create('Tech Debt (legacy API supported)')
},
],
},
- ])
+ ]),
)
.expectBadge({
label: 'tech debt',
diff --git a/services/sonar/sonar-tests.service.js b/services/sonar/sonar-tests.service.js
index ec10994ba3f7e..12ccd0e02703a 100644
--- a/services/sonar/sonar-tests.service.js
+++ b/services/sonar/sonar-tests.service.js
@@ -1,5 +1,7 @@
+import { pathParam } from '../index.js'
import {
testResultQueryParamSchema,
+ testResultOpenApiQueryParams,
renderTestResultBadge,
documentation as testResultsDocumentation,
} from '../test-results.js'
@@ -7,48 +9,48 @@ import { metric as metricCount } from '../text-formatters.js'
import SonarBase from './sonar-base.js'
import {
documentation,
- keywords,
queryParamSchema,
+ openApiQueryParams,
getLabel,
} from './sonar-helpers.js'
class SonarTestsSummary extends SonarBase {
- static category = 'build'
-
+ static category = 'test-results'
static route = {
base: 'sonar/tests',
- pattern: ':component',
+ pattern: ':component/:branch*',
queryParamSchema: queryParamSchema.concat(testResultQueryParamSchema),
}
- static examples = [
- {
- title: 'Sonar Tests',
- namedParams: {
- component: 'org.ow2.petals:petals-se-ase',
+ static openApi = {
+ '/sonar/tests/{component}': {
+ get: {
+ summary: 'Sonar Tests',
+ description: `${documentation}
+ ${testResultsDocumentation}
+ `,
+ parameters: [
+ pathParam({ name: 'component', example: 'michelin_kstreamplify' }),
+ ...openApiQueryParams,
+ ...testResultOpenApiQueryParams,
+ ],
},
- queryParams: {
- server: 'http://sonar.petalslink.com',
- sonarVersion: '4.2',
- compact_message: null,
- passed_label: 'passed',
- failed_label: 'failed',
- skipped_label: 'skipped',
+ },
+ '/sonar/tests/{component}/{branch}': {
+ get: {
+ summary: 'Sonar Tests (branch)',
+ description: `${documentation}
+ ${testResultsDocumentation}
+ `,
+ parameters: [
+ pathParam({ name: 'component', example: 'michelin_kstreamplify' }),
+ pathParam({ name: 'branch', example: 'main' }),
+ ...openApiQueryParams,
+ ...testResultOpenApiQueryParams,
+ ],
},
- staticPreview: this.render({
- passed: 5,
- failed: 1,
- skipped: 0,
- total: 6,
- isCompact: false,
- }),
- keywords,
- documentation: `
- ${documentation}
- ${testResultsDocumentation}
- `,
},
- ]
+ }
static defaultBadgeData = {
label: 'tests',
@@ -95,7 +97,7 @@ class SonarTestsSummary extends SonarBase {
}
async handle(
- { component },
+ { component, branch },
{
server,
sonarVersion,
@@ -103,12 +105,13 @@ class SonarTestsSummary extends SonarBase {
passed_label: passedLabel,
failed_label: failedLabel,
skipped_label: skippedLabel,
- }
+ },
) {
const json = await this.fetch({
sonarVersion,
server,
component,
+ branch,
metricName: 'tests,test_failures,skipped_tests',
})
const { total, passed, failed, skipped } = this.transform({
@@ -134,65 +137,101 @@ class SonarTests extends SonarBase {
static route = {
base: 'sonar',
pattern:
- ':metric(total_tests|skipped_tests|test_failures|test_errors|test_execution_time|test_success_density)/:component',
+ ':metric(total_tests|skipped_tests|test_failures|test_errors|test_execution_time|test_success_density)/:component/:branch*',
queryParamSchema,
}
- static examples = [
- {
- title: 'Sonar Test Count',
- pattern:
- ':metric(total_tests|skipped_tests|test_failures|test_errors)/:component',
- namedParams: {
- component: 'org.ow2.petals:petals-log',
- metric: 'total_tests',
+ static openApi = {
+ '/sonar/{metric}/{component}': {
+ get: {
+ summary: 'Sonar Test Count',
+ description: documentation,
+ parameters: [
+ pathParam({
+ name: 'metric',
+ example: 'total_tests',
+ schema: {
+ type: 'string',
+ enum: [
+ 'total_tests',
+ 'skipped_tests',
+ 'test_failures',
+ 'test_errors',
+ ],
+ },
+ }),
+ pathParam({ name: 'component', example: 'michelin_kstreamplify' }),
+ ...openApiQueryParams,
+ ],
},
- queryParams: {
- server: 'http://sonar.petalslink.com',
- sonarVersion: '4.2',
+ },
+ '/sonar/{metric}/{component}/{branch}': {
+ get: {
+ summary: 'Sonar Test Count (branch)',
+ description: documentation,
+ parameters: [
+ pathParam({
+ name: 'metric',
+ example: 'total_tests',
+ schema: {
+ type: 'string',
+ enum: [
+ 'total_tests',
+ 'skipped_tests',
+ 'test_failures',
+ 'test_errors',
+ ],
+ },
+ }),
+ pathParam({ name: 'component', example: 'michelin_kstreamplify' }),
+ pathParam({ name: 'branch', example: 'main' }),
+ ...openApiQueryParams,
+ ],
},
- staticPreview: this.render({
- metric: 'total_tests',
- value: 131,
- }),
- keywords,
- documentation,
},
- {
- title: 'Sonar Test Execution Time',
- pattern: 'test_execution_time/:component',
- namedParams: {
- component: 'swellaby:azure-pipelines-templates',
+ '/sonar/test_execution_time/{component}': {
+ get: {
+ summary: 'Sonar Test Execution Time',
+ description: documentation,
+ parameters: [
+ pathParam({ name: 'component', example: 'michelin_kstreamplify' }),
+ ...openApiQueryParams,
+ ],
},
- queryParams: {
- server: 'https://sonarcloud.io',
- sonarVersion: '4.2',
+ },
+ '/sonar/test_execution_time/{component}/{branch}': {
+ get: {
+ summary: 'Sonar Test Execution Time (branch)',
+ description: documentation,
+ parameters: [
+ pathParam({ name: 'component', example: 'michelin_kstreamplify' }),
+ pathParam({ name: 'branch', example: 'main' }),
+ ...openApiQueryParams,
+ ],
},
- staticPreview: this.render({
- metric: 'test_execution_time',
- value: 2,
- }),
- keywords,
- documentation,
},
- {
- title: 'Sonar Test Success Rate',
- pattern: 'test_success_density/:component',
- namedParams: {
- component: 'swellaby:azure-pipelines-templates',
+ '/sonar/test_success_density/{component}': {
+ get: {
+ summary: 'Sonar Test Success Rate',
+ description: documentation,
+ parameters: [
+ pathParam({ name: 'component', example: 'michelin_kstreamplify' }),
+ ...openApiQueryParams,
+ ],
},
- queryParams: {
- server: 'https://sonarcloud.io',
- sonarVersion: '4.2',
+ },
+ '/sonar/test_success_density/{component}/{branch}': {
+ get: {
+ summary: 'Sonar Test Success Rate (branch)',
+ description: documentation,
+ parameters: [
+ pathParam({ name: 'component', example: 'michelin_kstreamplify' }),
+ pathParam({ name: 'branch', example: 'main' }),
+ ...openApiQueryParams,
+ ],
},
- staticPreview: this.render({
- metric: 'test_success_density',
- value: 100,
- }),
- keywords,
- documentation,
},
- ]
+ }
static defaultBadgeData = {
label: 'tests',
@@ -218,11 +257,12 @@ class SonarTests extends SonarBase {
}
}
- async handle({ component, metric }, { server, sonarVersion }) {
+ async handle({ component, metric, branch }, { server, sonarVersion }) {
const json = await this.fetch({
sonarVersion,
server,
component,
+ branch,
// We're using 'tests' as the metric key to provide our standard
// formatted test badge (passed, failed, skipped) that exists for other
// services. Therefore, we're exposing 'total_tests' to the user, and
diff --git a/services/sonar/sonar-tests.spec.js b/services/sonar/sonar-tests.spec.js
index 81909602e18d3..9dd7681076bc1 100644
--- a/services/sonar/sonar-tests.spec.js
+++ b/services/sonar/sonar-tests.spec.js
@@ -1,5 +1,10 @@
import { test, given } from 'sazerac'
+import { testAuth } from '../test-helpers.js'
import { SonarTests } from './sonar-tests.service.js'
+import {
+ legacySonarResponse,
+ testAuthConfigOverride,
+} from './sonar-spec-helpers.js'
describe('SonarTests', function () {
test(SonarTests.render, () => {
@@ -34,4 +39,15 @@ describe('SonarTests', function () {
color: 'red',
})
})
+
+ describe('auth', function () {
+ it('sends the auth information as configured', async function () {
+ return testAuth(
+ SonarTests,
+ 'BasicAuth',
+ legacySonarResponse('tests', 95),
+ { configOverride: testAuthConfigOverride },
+ )
+ })
+ })
})
diff --git a/services/sonar/sonar-tests.tester.js b/services/sonar/sonar-tests.tester.js
index 86f26d68a576f..c91d6182553f3 100644
--- a/services/sonar/sonar-tests.tester.js
+++ b/services/sonar/sonar-tests.tester.js
@@ -15,7 +15,7 @@ export const t = new ServiceTester({
})
const isMetricAllowZero = Joi.alternatives(
isMetric,
- Joi.number().valid(0).required()
+ Joi.number().valid(0).required(),
)
// The service tests targeting the legacy SonarQube API are mocked
@@ -26,9 +26,15 @@ const isMetricAllowZero = Joi.alternatives(
t.create('Tests')
.timeout(10000)
- .get(
- '/tests/swellaby:azure-pipelines-templates.json?server=https://sonarcloud.io'
- )
+ .get('/tests/michelin_kstreamplify.json?server=https://sonarcloud.io')
+ .expectBadge({
+ label: 'tests',
+ message: isDefaultTestTotals,
+ })
+
+t.create('Tests (branch)')
+ .timeout(10000)
+ .get('/tests/michelin_kstreamplify/main.json?server=https://sonarcloud.io')
.expectBadge({
label: 'tests',
message: isDefaultTestTotals,
@@ -36,7 +42,7 @@ t.create('Tests')
t.create('Tests (legacy API supported)')
.get(
- '/tests/org.ow2.petals%3Apetals-se-ase.json?server=http://sonar.petalslink.com&sonarVersion=4.2'
+ '/tests/org.ow2.petals%3Apetals-se-ase.json?server=http://sonar.petalslink.com&sonarVersion=4.2',
)
.intercept(nock =>
nock('http://sonar.petalslink.com/api')
@@ -64,7 +70,7 @@ t.create('Tests (legacy API supported)')
},
],
},
- ])
+ ]),
)
.expectBadge({
label: 'tests',
@@ -73,7 +79,7 @@ t.create('Tests (legacy API supported)')
t.create('Tests with compact message')
.timeout(10000)
- .get('/tests/swellaby:azure-pipelines-templates.json', {
+ .get('/tests/michelin_kstreamplify.json', {
qs: {
compact_message: null,
server: 'https://sonarcloud.io',
@@ -83,7 +89,7 @@ t.create('Tests with compact message')
t.create('Tests with custom labels')
.timeout(10000)
- .get('/tests/swellaby:azure-pipelines-templates.json', {
+ .get('/tests/michelin_kstreamplify.json', {
qs: {
server: 'https://sonarcloud.io',
passed_label: 'good',
@@ -95,7 +101,7 @@ t.create('Tests with custom labels')
t.create('Tests with compact message and custom labels')
.timeout(10000)
- .get('/tests/swellaby:azure-pipelines-templates.json', {
+ .get('/tests/michelin_kstreamplify.json', {
qs: {
server: 'https://sonarcloud.io',
compact_message: null,
@@ -110,9 +116,17 @@ t.create('Tests with compact message and custom labels')
})
t.create('Total Test Count')
+ .timeout(10000)
+ .get('/total_tests/michelin_kstreamplify.json?server=https://sonarcloud.io')
+ .expectBadge({
+ label: 'total tests',
+ message: isMetric,
+ })
+
+t.create('Total Test Count (branch)')
.timeout(10000)
.get(
- '/total_tests/swellaby:azdo-shellcheck.json?server=https://sonarcloud.io'
+ '/total_tests/michelin_kstreamplify/main.json?server=https://sonarcloud.io',
)
.expectBadge({
label: 'total tests',
@@ -121,7 +135,7 @@ t.create('Total Test Count')
t.create('Total Test Count (legacy API supported)')
.get(
- '/total_tests/org.ow2.petals%3Apetals-se-ase.json?server=http://sonar.petalslink.com&sonarVersion=4.2'
+ '/total_tests/org.ow2.petals%3Apetals-se-ase.json?server=http://sonar.petalslink.com&sonarVersion=4.2',
)
.intercept(nock =>
nock('http://sonar.petalslink.com/api')
@@ -141,7 +155,7 @@ t.create('Total Test Count (legacy API supported)')
},
],
},
- ])
+ ]),
)
.expectBadge({
label: 'total tests',
@@ -150,9 +164,7 @@ t.create('Total Test Count (legacy API supported)')
t.create('Test Failures Count')
.timeout(10000)
- .get(
- '/test_failures/swellaby:azdo-shellcheck.json?server=https://sonarcloud.io'
- )
+ .get('/test_failures/michelin_kstreamplify.json?server=https://sonarcloud.io')
.expectBadge({
label: 'test failures',
message: isMetricAllowZero,
@@ -160,7 +172,7 @@ t.create('Test Failures Count')
t.create('Test Failures Count (legacy API supported)')
.get(
- '/test_failures/org.ow2.petals%3Apetals-se-ase.json?server=http://sonar.petalslink.com&sonarVersion=4.2'
+ '/test_failures/org.ow2.petals%3Apetals-se-ase.json?server=http://sonar.petalslink.com&sonarVersion=4.2',
)
.intercept(nock =>
nock('http://sonar.petalslink.com/api')
@@ -180,7 +192,7 @@ t.create('Test Failures Count (legacy API supported)')
},
],
},
- ])
+ ]),
)
.expectBadge({
label: 'test failures',
@@ -189,9 +201,7 @@ t.create('Test Failures Count (legacy API supported)')
t.create('Test Errors Count')
.timeout(10000)
- .get(
- '/test_errors/swellaby:azdo-shellcheck.json?server=https://sonarcloud.io'
- )
+ .get('/test_errors/michelin_kstreamplify.json?server=https://sonarcloud.io')
.expectBadge({
label: 'test errors',
message: isMetricAllowZero,
@@ -199,7 +209,7 @@ t.create('Test Errors Count')
t.create('Test Errors Count (legacy API supported)')
.get(
- '/test_errors/org.ow2.petals%3Apetals-se-ase.json?server=http://sonar.petalslink.com&sonarVersion=4.2'
+ '/test_errors/org.ow2.petals%3Apetals-se-ase.json?server=http://sonar.petalslink.com&sonarVersion=4.2',
)
.intercept(nock =>
nock('http://sonar.petalslink.com/api')
@@ -219,7 +229,7 @@ t.create('Test Errors Count (legacy API supported)')
},
],
},
- ])
+ ]),
)
.expectBadge({
label: 'test errors',
@@ -228,9 +238,7 @@ t.create('Test Errors Count (legacy API supported)')
t.create('Skipped Tests Count')
.timeout(10000)
- .get(
- '/skipped_tests/swellaby:azdo-shellcheck.json?server=https://sonarcloud.io'
- )
+ .get('/skipped_tests/michelin_kstreamplify.json?server=https://sonarcloud.io')
.expectBadge({
label: 'skipped tests',
message: isMetricAllowZero,
@@ -238,7 +246,7 @@ t.create('Skipped Tests Count')
t.create('Skipped Tests Count (legacy API supported)')
.get(
- '/skipped_tests/org.ow2.petals%3Apetals-se-ase.json?server=http://sonar.petalslink.com&sonarVersion=4.2'
+ '/skipped_tests/org.ow2.petals%3Apetals-se-ase.json?server=http://sonar.petalslink.com&sonarVersion=4.2',
)
.intercept(nock =>
nock('http://sonar.petalslink.com/api')
@@ -258,7 +266,7 @@ t.create('Skipped Tests Count (legacy API supported)')
},
],
},
- ])
+ ]),
)
.expectBadge({
label: 'skipped tests',
@@ -268,7 +276,7 @@ t.create('Skipped Tests Count (legacy API supported)')
t.create('Test Success Rate')
.timeout(10000)
.get(
- '/test_success_density/swellaby:azdo-shellcheck.json?server=https://sonarcloud.io'
+ '/test_success_density/michelin_kstreamplify.json?server=https://sonarcloud.io',
)
.expectBadge({
label: 'tests',
@@ -277,7 +285,7 @@ t.create('Test Success Rate')
t.create('Test Success Rate (legacy API supported)')
.get(
- '/test_success_density/org.ow2.petals%3Apetals-se-ase.json?server=http://sonar.petalslink.com&sonarVersion=4.2'
+ '/test_success_density/org.ow2.petals%3Apetals-se-ase.json?server=http://sonar.petalslink.com&sonarVersion=4.2',
)
.intercept(nock =>
nock('http://sonar.petalslink.com/api')
@@ -297,7 +305,7 @@ t.create('Test Success Rate (legacy API supported)')
},
],
},
- ])
+ ]),
)
.expectBadge({
label: 'tests',
diff --git a/services/sonar/sonar-violations.service.js b/services/sonar/sonar-violations.service.js
index e82364bb9e184..10291fcd8bdbe 100644
--- a/services/sonar/sonar-violations.service.js
+++ b/services/sonar/sonar-violations.service.js
@@ -1,16 +1,17 @@
+import { pathParam, queryParam } from '../index.js'
import { colorScale } from '../color-formatters.js'
import { metric } from '../text-formatters.js'
import SonarBase from './sonar-base.js'
import {
getLabel,
documentation,
- keywords,
queryParamWithFormatSchema,
+ openApiQueryParams,
} from './sonar-helpers.js'
const violationsColorScale = colorScale(
[1, 2, 3, 5],
- ['brightgreen', 'yellowgreen', 'yellow', 'orange', 'red']
+ ['brightgreen', 'yellowgreen', 'yellow', 'orange', 'red'],
)
const violationCategoryColorMap = {
@@ -27,52 +28,61 @@ export default class SonarViolations extends SonarBase {
static route = {
base: 'sonar',
pattern:
- ':metric(violations|blocker_violations|critical_violations|major_violations|minor_violations|info_violations)/:component',
+ ':metric(violations|blocker_violations|critical_violations|major_violations|minor_violations|info_violations)/:component/:branch*',
queryParamSchema: queryParamWithFormatSchema,
}
- static examples = [
- {
- title: 'Sonar Violations (short format)',
- namedParams: {
- component: 'swellaby:azdo-shellcheck',
- metric: 'violations',
+ static openApi = {
+ '/sonar/{metric}/{component}': {
+ get: {
+ summary: 'Sonar Violations',
+ description: documentation,
+ parameters: [
+ pathParam({
+ name: 'metric',
+ example: 'violations',
+ schema: { type: 'string', enum: this.getEnum('metric') },
+ }),
+ pathParam({ name: 'component', example: 'brave_brave-core' }),
+ ...openApiQueryParams,
+ queryParam({
+ name: 'format',
+ example: 'long',
+ schema: {
+ type: 'string',
+ enum: ['short', 'long'],
+ },
+ description: 'If not specified, the default is `short`.',
+ }),
+ ],
},
- queryParams: {
- server: 'https://sonarcloud.io',
- format: 'short',
- sonarVersion: '4.2',
- },
- staticPreview: this.render({
- violations: 0,
- metricName: 'violations',
- format: 'short',
- }),
- keywords,
- documentation,
},
- {
- title: 'Sonar Violations (long format)',
- namedParams: {
- component: 'org.ow2.petals:petals-se-ase',
- metric: 'violations',
- },
- queryParams: {
- server: 'http://sonar.petalslink.com',
- format: 'long',
+ '/sonar/{metric}/{component}/{branch}': {
+ get: {
+ summary: 'Sonar Violations (branch)',
+ description: documentation,
+ parameters: [
+ pathParam({
+ name: 'metric',
+ example: 'violations',
+ schema: { type: 'string', enum: this.getEnum('metric') },
+ }),
+ pathParam({ name: 'component', example: ':brave_brave-core' }),
+ pathParam({ name: 'branch', example: 'master' }),
+ ...openApiQueryParams,
+ queryParam({
+ name: 'format',
+ example: 'long',
+ schema: {
+ type: 'string',
+ enum: ['short', 'long'],
+ },
+ description: 'If not specified, the default is `short`.',
+ }),
+ ],
},
- staticPreview: this.render({
- violations: {
- info_violations: 2,
- minor_violations: 1,
- },
- metricName: 'violations',
- format: 'long',
- }),
- keywords,
- documentation,
},
- ]
+ }
static defaultBadgeData = { label: 'violations' }
@@ -144,7 +154,10 @@ export default class SonarViolations extends SonarBase {
return { violations: metrics }
}
- async handle({ component, metric }, { server, sonarVersion, format }) {
+ async handle(
+ { component, metric, branch },
+ { server, sonarVersion, format },
+ ) {
// If the user has requested the long format for the violations badge
// then we need to include each individual violation metric in the call to the API
// in order to get the count breakdown per each violation category.
@@ -156,6 +169,7 @@ export default class SonarViolations extends SonarBase {
sonarVersion,
server,
component,
+ branch,
metricName: metricKeys,
})
diff --git a/services/sonar/sonar-violations.spec.js b/services/sonar/sonar-violations.spec.js
index 08aa8279125a9..2c24087279bc2 100644
--- a/services/sonar/sonar-violations.spec.js
+++ b/services/sonar/sonar-violations.spec.js
@@ -1,6 +1,11 @@
import { test, given } from 'sazerac'
import { metric } from '../text-formatters.js'
+import { testAuth } from '../test-helpers.js'
import SonarViolations from './sonar-violations.service.js'
+import {
+ legacySonarResponse,
+ testAuthConfigOverride,
+} from './sonar-spec-helpers.js'
describe('SonarViolations', function () {
test(SonarViolations.render, () => {
@@ -110,4 +115,18 @@ describe('SonarViolations', function () {
color: 'red',
})
})
+
+ describe('auth', function () {
+ it('sends the auth information as configured', async function () {
+ return testAuth(
+ SonarViolations,
+ 'BasicAuth',
+ legacySonarResponse('violations', 95),
+ {
+ configOverride: testAuthConfigOverride,
+ exampleOverride: { format: 'short' },
+ },
+ )
+ })
+ })
})
diff --git a/services/sonar/sonar-violations.tester.js b/services/sonar/sonar-violations.tester.js
index b757df1712bec..580ab4f9fe51f 100644
--- a/services/sonar/sonar-violations.tester.js
+++ b/services/sonar/sonar-violations.tester.js
@@ -3,10 +3,10 @@ import { isMetric, withRegex } from '../test-validators.js'
import { createServiceTester } from '../tester.js'
export const t = await createServiceTester()
const isViolationsLongFormMetric = Joi.alternatives(
- Joi.allow(0),
+ Joi.equal(0),
withRegex(
- /(([\d]+) (blocker|critical|major|minor|info))(,\s([\d]+) (critical|major|minor|info))?/
- )
+ /(([\d]+) (blocker|critical|major|minor|info))(,\s([\d]+) (critical|major|minor|info))?/,
+ ),
)
// The service tests targeting the legacy SonarQube API are mocked
@@ -17,9 +17,15 @@ const isViolationsLongFormMetric = Joi.alternatives(
t.create('Violations')
.timeout(10000)
- .get(
- '/violations/org.sonarsource.sonarqube%3Asonarqube.json?server=https://sonarcloud.io'
- )
+ .get('/violations/brave_brave-core.json?server=https://sonarcloud.io')
+ .expectBadge({
+ label: 'violations',
+ message: isMetric,
+ })
+
+t.create('Violations (branch)')
+ .timeout(10000)
+ .get('/violations/brave_brave-core/master.json?server=https://sonarcloud.io')
.expectBadge({
label: 'violations',
message: isMetric,
@@ -27,7 +33,7 @@ t.create('Violations')
t.create('Violations (legacy API supported)')
.get(
- '/violations/org.ow2.petals%3Apetals-se-ase.json?server=http://sonar.petalslink.com&sonarVersion=4.2'
+ '/violations/org.ow2.petals%3Apetals-se-ase.json?server=http://sonar.petalslink.com&sonarVersion=4.2',
)
.intercept(nock =>
nock('http://sonar.petalslink.com/api')
@@ -47,7 +53,7 @@ t.create('Violations (legacy API supported)')
},
],
},
- ])
+ ]),
)
.expectBadge({
label: 'violations',
@@ -57,7 +63,7 @@ t.create('Violations (legacy API supported)')
t.create('Violations Long Format')
.timeout(10000)
.get(
- '/violations/org.sonarsource.sonarqube%3Asonarqube.json?server=https://sonarcloud.io&format=long'
+ '/violations/brave_brave-core.json?server=https://sonarcloud.io&format=long',
)
.expectBadge({
label: 'violations',
@@ -66,7 +72,7 @@ t.create('Violations Long Format')
t.create('Violations Long Format (legacy API supported)')
.get(
- '/violations/org.ow2.petals%3Apetals-se-ase.json?server=http://sonar.petalslink.com&sonarVersion=4.2&format=long'
+ '/violations/org.ow2.petals%3Apetals-se-ase.json?server=http://sonar.petalslink.com&sonarVersion=4.2&format=long',
)
.intercept(nock =>
nock('http://sonar.petalslink.com/api')
@@ -107,7 +113,7 @@ t.create('Violations Long Format (legacy API supported)')
},
],
},
- ])
+ ]),
)
.expectBadge({
label: 'violations',
@@ -116,9 +122,7 @@ t.create('Violations Long Format (legacy API supported)')
t.create('Blocker Violations')
.timeout(10000)
- .get(
- '/blocker_violations/org.sonarsource.sonarqube%3Asonarqube.json?server=https://sonarcloud.io'
- )
+ .get('/blocker_violations/brave_brave-core.json?server=https://sonarcloud.io')
.expectBadge({
label: 'blocker violations',
message: isMetric,
@@ -126,7 +130,7 @@ t.create('Blocker Violations')
t.create('Blocker Violations (legacy API supported)')
.get(
- '/blocker_violations/org.ow2.petals%3Apetals-se-ase.json?server=http://sonar.petalslink.com&sonarVersion=4.2'
+ '/blocker_violations/org.ow2.petals%3Apetals-se-ase.json?server=http://sonar.petalslink.com&sonarVersion=4.2',
)
.intercept(nock =>
nock('http://sonar.petalslink.com/api')
@@ -146,7 +150,7 @@ t.create('Blocker Violations (legacy API supported)')
},
],
},
- ])
+ ]),
)
.expectBadge({
label: 'blocker violations',
@@ -156,7 +160,7 @@ t.create('Blocker Violations (legacy API supported)')
t.create('Critical Violations')
.timeout(10000)
.get(
- '/critical_violations/org.sonarsource.sonarqube%3Asonarqube.json?server=https://sonarcloud.io'
+ '/critical_violations/brave_brave-core.json?server=https://sonarcloud.io',
)
.expectBadge({
label: 'critical violations',
@@ -165,7 +169,7 @@ t.create('Critical Violations')
t.create('Critical Violations (legacy API supported)')
.get(
- '/critical_violations/org.ow2.petals%3Apetals-se-ase.json?server=http://sonar.petalslink.com&sonarVersion=4.2'
+ '/critical_violations/org.ow2.petals%3Apetals-se-ase.json?server=http://sonar.petalslink.com&sonarVersion=4.2',
)
.intercept(nock =>
nock('http://sonar.petalslink.com/api')
@@ -185,7 +189,7 @@ t.create('Critical Violations (legacy API supported)')
},
],
},
- ])
+ ]),
)
.expectBadge({
label: 'critical violations',
diff --git a/services/sourceforge/sourceforge-base.js b/services/sourceforge/sourceforge-base.js
new file mode 100644
index 0000000000000..e1c973a8cf16e
--- /dev/null
+++ b/services/sourceforge/sourceforge-base.js
@@ -0,0 +1,13 @@
+import { BaseJsonService } from '../index.js'
+
+export default class BaseSourceForgeService extends BaseJsonService {
+ async fetch({ project, schema }) {
+ return this._requestJson({
+ url: `https://sourceforge.net/rest/p/${project}/`,
+ schema,
+ httpErrors: {
+ 404: 'project not found',
+ },
+ })
+ }
+}
diff --git a/services/sourceforge/sourceforge-commit-count-redirect.service.js b/services/sourceforge/sourceforge-commit-count-redirect.service.js
new file mode 100644
index 0000000000000..1efcd416bd56d
--- /dev/null
+++ b/services/sourceforge/sourceforge-commit-count-redirect.service.js
@@ -0,0 +1,15 @@
+import { redirector } from '../index.js'
+
+export default redirector({
+ // SourceForge commit count service used to only have project name as a parameter
+ // and the repository name was always `git`.
+ // The service was later updated to have the repository name as a parameter.
+ // This redirector is used to keep the old URLs working.
+ category: 'activity',
+ route: {
+ base: 'sourceforge/commit-count',
+ pattern: ':project',
+ },
+ transformPath: ({ project }) => `/sourceforge/commit-count/${project}/git`,
+ dateAdded: new Date('2025-03-15'),
+})
diff --git a/services/sourceforge/sourceforge-commit-count-redirect.tester.js b/services/sourceforge/sourceforge-commit-count-redirect.tester.js
new file mode 100644
index 0000000000000..3e09678b72f33
--- /dev/null
+++ b/services/sourceforge/sourceforge-commit-count-redirect.tester.js
@@ -0,0 +1,6 @@
+import { createServiceTester } from '../tester.js'
+export const t = await createServiceTester()
+
+t.create('commit count (redirect)')
+ .get('/guitarix.json')
+ .expectRedirect('/sourceforge/commit-count/guitarix/git.json')
diff --git a/services/sourceforge/sourceforge-commit-count.service.js b/services/sourceforge/sourceforge-commit-count.service.js
new file mode 100644
index 0000000000000..0a8d2bf916189
--- /dev/null
+++ b/services/sourceforge/sourceforge-commit-count.service.js
@@ -0,0 +1,62 @@
+import Joi from 'joi'
+import { BaseJsonService, pathParams } from '../index.js'
+import { metric } from '../text-formatters.js'
+
+const schema = Joi.object({
+ commit_count: Joi.number().required(),
+}).required()
+
+export default class SourceforgeCommitCount extends BaseJsonService {
+ static category = 'activity'
+
+ static route = {
+ base: 'sourceforge/commit-count',
+ pattern: ':project/:repo',
+ }
+
+ static openApi = {
+ '/sourceforge/commit-count/{project}/{repo}': {
+ get: {
+ summary: 'SourceForge Commit Count',
+ parameters: pathParams(
+ {
+ name: 'project',
+ example: 'guitarix',
+ },
+ {
+ name: 'repo',
+ example: 'git',
+ description:
+ 'The repository name, usually `git` but might be different.',
+ },
+ ),
+ },
+ },
+ }
+
+ static defaultBadgeData = { label: 'commit count' }
+
+ static render({ commitCount }) {
+ return {
+ message: metric(commitCount),
+ color: 'blue',
+ }
+ }
+
+ async fetch({ project, repo }) {
+ return this._requestJson({
+ url: `https://sourceforge.net/rest/p/${project}/${repo}`,
+ schema,
+ httpErrors: {
+ 404: 'project or repo not found',
+ },
+ })
+ }
+
+ async handle({ project, repo }) {
+ const body = await this.fetch({ project, repo })
+ return this.constructor.render({
+ commitCount: body.commit_count,
+ })
+ }
+}
diff --git a/services/sourceforge/sourceforge-commit-count.tester.js b/services/sourceforge/sourceforge-commit-count.tester.js
new file mode 100644
index 0000000000000..24ad6e7db0326
--- /dev/null
+++ b/services/sourceforge/sourceforge-commit-count.tester.js
@@ -0,0 +1,19 @@
+import { isMetric } from '../test-validators.js'
+import { createServiceTester } from '../tester.js'
+export const t = await createServiceTester()
+
+t.create('commit count')
+ .get('/guitarix/git.json')
+ .expectBadge({ label: 'commit count', message: isMetric })
+
+t.create('commit count (non default repo)')
+ .get('/opencamera/code.json')
+ .expectBadge({ label: 'commit count', message: isMetric })
+
+t.create('commit count (project not found)')
+ .get('/that-doesnt-exist/git.json')
+ .expectBadge({ label: 'commit count', message: 'project or repo not found' })
+
+t.create('commit count (repo not found)')
+ .get('/guitarix/invalid-repo.json')
+ .expectBadge({ label: 'commit count', message: 'project or repo not found' })
diff --git a/services/sourceforge/sourceforge-contributors.service.js b/services/sourceforge/sourceforge-contributors.service.js
new file mode 100644
index 0000000000000..8d08dd8c35bad
--- /dev/null
+++ b/services/sourceforge/sourceforge-contributors.service.js
@@ -0,0 +1,44 @@
+import Joi from 'joi'
+import { pathParams } from '../index.js'
+import { renderContributorBadge } from '../contributor-count.js'
+import BaseSourceForgeService from './sourceforge-base.js'
+
+const schema = Joi.object({
+ developers: Joi.array().required(),
+}).required()
+
+export default class SourceforgeContributors extends BaseSourceForgeService {
+ static category = 'activity'
+
+ static route = {
+ base: 'sourceforge/contributors',
+ pattern: ':project',
+ }
+
+ static openApi = {
+ '/sourceforge/contributors/{project}': {
+ get: {
+ summary: 'SourceForge Contributors',
+ parameters: pathParams({
+ name: 'project',
+ example: 'guitarix',
+ }),
+ },
+ },
+ }
+
+ static defaultBadgeData = { label: 'contributors' }
+
+ static render({ contributorCount }) {
+ return renderContributorBadge({
+ contributorCount,
+ })
+ }
+
+ async handle({ project }) {
+ const body = await this.fetch({ project, schema })
+ return this.constructor.render({
+ contributorCount: body.developers.length,
+ })
+ }
+}
diff --git a/services/sourceforge/sourceforge-contributors.tester.js b/services/sourceforge/sourceforge-contributors.tester.js
new file mode 100644
index 0000000000000..b0fa07216963d
--- /dev/null
+++ b/services/sourceforge/sourceforge-contributors.tester.js
@@ -0,0 +1,11 @@
+import { isMetric } from '../test-validators.js'
+import { createServiceTester } from '../tester.js'
+export const t = await createServiceTester()
+
+t.create('contributors')
+ .get('/guitarix.json')
+ .expectBadge({ label: 'contributors', message: isMetric })
+
+t.create('contributors (project not found)')
+ .get('/that-doesnt-exist.json')
+ .expectBadge({ label: 'contributors', message: 'project not found' })
diff --git a/services/sourceforge/sourceforge-downloads.service.js b/services/sourceforge/sourceforge-downloads.service.js
new file mode 100644
index 0000000000000..269e5952c82ec
--- /dev/null
+++ b/services/sourceforge/sourceforge-downloads.service.js
@@ -0,0 +1,110 @@
+import Joi from 'joi'
+import dayjs from 'dayjs'
+import { renderDownloadsBadge } from '../downloads.js'
+import { nonNegativeInteger } from '../validators.js'
+import { BaseJsonService, pathParams } from '../index.js'
+
+const schema = Joi.object({
+ total: nonNegativeInteger,
+}).required()
+
+const intervalMap = {
+ dd: {
+ startDate: endDate => endDate,
+ interval: 'day',
+ },
+ dw: {
+ // 6 days, since date range is inclusive,
+ startDate: endDate => dayjs(endDate).subtract(6, 'days'),
+ interval: 'week',
+ },
+ dm: {
+ startDate: endDate => dayjs(endDate).subtract(30, 'days'),
+ interval: 'month',
+ },
+ dt: {
+ startDate: () => dayjs(0),
+ },
+}
+
+export default class SourceforgeDownloads extends BaseJsonService {
+ static category = 'downloads'
+
+ static route = {
+ base: 'sourceforge',
+ pattern: ':interval(dd|dw|dm|dt)/:project/:folder*',
+ }
+
+ static openApi = {
+ '/sourceforge/{interval}/{project}': {
+ get: {
+ summary: 'SourceForge Downloads',
+ parameters: pathParams(
+ {
+ name: 'interval',
+ example: 'dm',
+ description: 'Daily, Weekly, Monthly, or Total downloads',
+ schema: { type: 'string', enum: this.getEnum('interval') },
+ },
+ { name: 'project', example: 'sevenzip' },
+ ),
+ },
+ },
+ '/sourceforge/{interval}/{project}/{folder}': {
+ get: {
+ summary: 'SourceForge Downloads (folder)',
+ parameters: pathParams(
+ {
+ name: 'interval',
+ example: 'dm',
+ description: 'Daily, Weekly, Monthly, or Total downloads',
+ schema: { type: 'string', enum: this.getEnum('interval') },
+ },
+ { name: 'project', example: 'arianne' },
+ { name: 'folder', example: 'stendhal' },
+ ),
+ },
+ },
+ }
+
+ static _cacheLength = 2400
+
+ static defaultBadgeData = { label: 'sourceforge' }
+
+ static render({ downloads, interval }) {
+ return renderDownloadsBadge({
+ downloads,
+ labelOverride: 'downloads',
+ interval: intervalMap[interval].interval,
+ })
+ }
+
+ async fetch({ interval, project, folder }) {
+ const url = `https://sourceforge.net/projects/${project}/files/${
+ folder ? `${folder}/` : ''
+ }stats/json`
+ // get yesterday since today is incomplete
+ const endDate = dayjs().subtract(24, 'hours')
+ const startDate = intervalMap[interval].startDate(endDate)
+ const options = {
+ searchParams: {
+ start_date: startDate.format('YYYY-MM-DD'),
+ end_date: endDate.format('YYYY-MM-DD'),
+ },
+ }
+
+ return this._requestJson({
+ schema,
+ url,
+ options,
+ httpErrors: {
+ 404: 'project not found',
+ },
+ })
+ }
+
+ async handle({ interval, project, folder }) {
+ const { total: downloads } = await this.fetch({ interval, project, folder })
+ return this.constructor.render({ interval, downloads })
+ }
+}
diff --git a/services/sourceforge/sourceforge.tester.js b/services/sourceforge/sourceforge-downloads.tester.js
similarity index 100%
rename from services/sourceforge/sourceforge.tester.js
rename to services/sourceforge/sourceforge-downloads.tester.js
diff --git a/services/sourceforge/sourceforge-languages.service.js b/services/sourceforge/sourceforge-languages.service.js
new file mode 100644
index 0000000000000..d8deee1a57632
--- /dev/null
+++ b/services/sourceforge/sourceforge-languages.service.js
@@ -0,0 +1,45 @@
+import Joi from 'joi'
+import { pathParams } from '../index.js'
+import { metric } from '../text-formatters.js'
+import BaseSourceForgeService from './sourceforge-base.js'
+
+const schema = Joi.object({
+ categories: Joi.object({
+ language: Joi.array().required(),
+ }).required(),
+}).required()
+
+export default class SourceforgeLanguages extends BaseSourceForgeService {
+ static category = 'analysis'
+
+ static route = {
+ base: 'sourceforge/languages',
+ pattern: ':project',
+ }
+
+ static openApi = {
+ '/sourceforge/languages/{project}': {
+ get: {
+ summary: 'SourceForge Languages',
+ parameters: pathParams({
+ name: 'project',
+ example: 'mingw',
+ }),
+ },
+ },
+ }
+
+ static defaultBadgeData = { label: 'languages' }
+
+ static render(languages) {
+ return {
+ message: metric(languages),
+ color: 'blue',
+ }
+ }
+
+ async handle({ project }) {
+ const body = await this.fetch({ project, schema })
+ return this.constructor.render(body.categories.language.length)
+ }
+}
diff --git a/services/sourceforge/sourceforge-languages.tester.js b/services/sourceforge/sourceforge-languages.tester.js
new file mode 100644
index 0000000000000..f79186e205e22
--- /dev/null
+++ b/services/sourceforge/sourceforge-languages.tester.js
@@ -0,0 +1,11 @@
+import { isMetric } from '../test-validators.js'
+import { createServiceTester } from '../tester.js'
+export const t = await createServiceTester()
+
+t.create('languages')
+ .get('/guitarix.json')
+ .expectBadge({ label: 'languages', message: isMetric })
+
+t.create('languages (project not found)')
+ .get('/that-doesnt-exist.json')
+ .expectBadge({ label: 'languages', message: 'project not found' })
diff --git a/services/sourceforge/sourceforge-last-commit-redirect.service.js b/services/sourceforge/sourceforge-last-commit-redirect.service.js
new file mode 100644
index 0000000000000..b08627250efb9
--- /dev/null
+++ b/services/sourceforge/sourceforge-last-commit-redirect.service.js
@@ -0,0 +1,15 @@
+import { redirector } from '../index.js'
+
+export default redirector({
+ // SourceForge last commit service used to only have project name as a parameter
+ // and the repository name was always `git`.
+ // The service was later updated to have the repository name as a parameter.
+ // This redirector is used to keep the old URLs working.
+ category: 'activity',
+ route: {
+ base: 'sourceforge/last-commit',
+ pattern: ':project',
+ },
+ transformPath: ({ project }) => `/sourceforge/last-commit/${project}/git`,
+ dateAdded: new Date('2025-03-08'),
+})
diff --git a/services/sourceforge/sourceforge-last-commit-redirect.tester.js b/services/sourceforge/sourceforge-last-commit-redirect.tester.js
new file mode 100644
index 0000000000000..b8d5b4f66c267
--- /dev/null
+++ b/services/sourceforge/sourceforge-last-commit-redirect.tester.js
@@ -0,0 +1,6 @@
+import { createServiceTester } from '../tester.js'
+export const t = await createServiceTester()
+
+t.create('last commit (redirect)')
+ .get('/guitarix.json')
+ .expectRedirect('/sourceforge/last-commit/guitarix/git.json')
diff --git a/services/sourceforge/sourceforge-last-commit.service.js b/services/sourceforge/sourceforge-last-commit.service.js
new file mode 100644
index 0000000000000..fd7dc766c38b8
--- /dev/null
+++ b/services/sourceforge/sourceforge-last-commit.service.js
@@ -0,0 +1,59 @@
+import Joi from 'joi'
+import { BaseJsonService, pathParams } from '../index.js'
+import { renderDateBadge } from '../date.js'
+
+const schema = Joi.object({
+ commits: Joi.array()
+ .items(
+ Joi.object({
+ committed_date: Joi.string().required(),
+ }).required(),
+ )
+ .required(),
+}).required()
+
+export default class SourceforgeLastCommit extends BaseJsonService {
+ static category = 'activity'
+
+ static route = {
+ base: 'sourceforge/last-commit',
+ pattern: ':project/:repo',
+ }
+
+ static openApi = {
+ '/sourceforge/last-commit/{project}/{repo}': {
+ get: {
+ summary: 'SourceForge Last Commit',
+ parameters: pathParams(
+ {
+ name: 'project',
+ example: 'guitarix',
+ },
+ {
+ name: 'repo',
+ example: 'git',
+ description:
+ 'The repository name, usually `git` but might be different.',
+ },
+ ),
+ },
+ },
+ }
+
+ static defaultBadgeData = { label: 'last commit' }
+
+ async fetch({ project, repo }) {
+ return this._requestJson({
+ url: `https://sourceforge.net/rest/p/${project}/${repo}/commits`,
+ schema,
+ httpErrors: {
+ 404: 'project or repo not found',
+ },
+ })
+ }
+
+ async handle({ project, repo }) {
+ const body = await this.fetch({ project, repo })
+ return renderDateBadge(body.commits[0].committed_date)
+ }
+}
diff --git a/services/sourceforge/sourceforge-last-commit.tester.js b/services/sourceforge/sourceforge-last-commit.tester.js
new file mode 100644
index 0000000000000..94bee370396fc
--- /dev/null
+++ b/services/sourceforge/sourceforge-last-commit.tester.js
@@ -0,0 +1,19 @@
+import { isFormattedDate } from '../test-validators.js'
+import { createServiceTester } from '../tester.js'
+export const t = await createServiceTester()
+
+t.create('last commit')
+ .get('/guitarix/git.json')
+ .expectBadge({ label: 'last commit', message: isFormattedDate })
+
+t.create('last commit (non default repo)')
+ .get('/opencamera/code.json')
+ .expectBadge({ label: 'last commit', message: isFormattedDate })
+
+t.create('last commit (project not found)')
+ .get('/that-doesnt-exist/fake.json')
+ .expectBadge({ label: 'last commit', message: 'project or repo not found' })
+
+t.create('last commit (repo not found)')
+ .get('/guitarix/fake-repo.json')
+ .expectBadge({ label: 'last commit', message: 'project or repo not found' })
diff --git a/services/sourceforge/sourceforge-open-tickets.service.js b/services/sourceforge/sourceforge-open-tickets.service.js
index 7cf956105b830..7c1cf76ae31a5 100644
--- a/services/sourceforge/sourceforge-open-tickets.service.js
+++ b/services/sourceforge/sourceforge-open-tickets.service.js
@@ -1,7 +1,7 @@
import Joi from 'joi'
import { metric } from '../text-formatters.js'
import { nonNegativeInteger } from '../validators.js'
-import { BaseJsonService } from '../index.js'
+import { BaseJsonService, pathParams } from '../index.js'
const schema = Joi.object({
count: nonNegativeInteger.required(),
@@ -15,16 +15,24 @@ export default class SourceforgeOpenTickets extends BaseJsonService {
pattern: ':project/:type(bugs|feature-requests)',
}
- static examples = [
- {
- title: 'Sourceforge Open Tickets',
- namedParams: {
- type: 'bugs',
- project: 'sevenzip',
+ static openApi = {
+ '/sourceforge/open-tickets/{project}/{type}': {
+ get: {
+ summary: 'Sourceforge Open Tickets',
+ parameters: pathParams(
+ {
+ name: 'project',
+ example: 'sevenzip',
+ },
+ {
+ name: 'type',
+ example: 'bugs',
+ schema: { type: 'string', enum: this.getEnum('type') },
+ },
+ ),
},
- staticPreview: this.render({ count: 1338 }),
},
- ]
+ }
static defaultBadgeData = {
label: 'open tickets',
@@ -43,7 +51,7 @@ export default class SourceforgeOpenTickets extends BaseJsonService {
return this._requestJson({
schema,
url,
- errorMessages: {
+ httpErrors: {
404: 'project not found',
},
})
diff --git a/services/sourceforge/sourceforge-platform.service.js b/services/sourceforge/sourceforge-platform.service.js
new file mode 100644
index 0000000000000..27bf4f06db30b
--- /dev/null
+++ b/services/sourceforge/sourceforge-platform.service.js
@@ -0,0 +1,49 @@
+import Joi from 'joi'
+import { pathParams } from '../index.js'
+import BaseSourceForgeService from './sourceforge-base.js'
+
+const schema = Joi.object({
+ categories: Joi.object({
+ os: Joi.array()
+ .items({
+ fullname: Joi.string().required(),
+ })
+ .required(),
+ }).required(),
+}).required()
+
+export default class SourceforgePlatform extends BaseSourceForgeService {
+ static category = 'platform-support'
+
+ static route = {
+ base: 'sourceforge/platform',
+ pattern: ':project',
+ }
+
+ static openApi = {
+ '/sourceforge/platform/{project}': {
+ get: {
+ summary: 'SourceForge Platform',
+ parameters: pathParams({
+ name: 'project',
+ example: 'guitarix',
+ }),
+ },
+ },
+ }
+
+ static defaultBadgeData = { label: 'platform' }
+
+ static render({ platforms }) {
+ return {
+ message: platforms.join(' | '),
+ }
+ }
+
+ async handle({ project }) {
+ const body = await this.fetch({ project, schema })
+ return this.constructor.render({
+ platforms: body.categories.os.map(obj => obj.fullname),
+ })
+ }
+}
diff --git a/services/sourceforge/sourceforge-platform.tester.js b/services/sourceforge/sourceforge-platform.tester.js
new file mode 100644
index 0000000000000..836fa86754433
--- /dev/null
+++ b/services/sourceforge/sourceforge-platform.tester.js
@@ -0,0 +1,11 @@
+import Joi from 'joi'
+import { createServiceTester } from '../tester.js'
+export const t = await createServiceTester()
+
+t.create('platform')
+ .get('/guitarix.json')
+ .expectBadge({ label: 'platform', message: Joi.string().required() })
+
+t.create('platform (project not found)')
+ .get('/that-doesnt-exist.json')
+ .expectBadge({ label: 'platform', message: 'project not found' })
diff --git a/services/sourceforge/sourceforge-translations.service.js b/services/sourceforge/sourceforge-translations.service.js
new file mode 100644
index 0000000000000..9f0edfc700e55
--- /dev/null
+++ b/services/sourceforge/sourceforge-translations.service.js
@@ -0,0 +1,47 @@
+import Joi from 'joi'
+import { pathParams } from '../index.js'
+import { metric } from '../text-formatters.js'
+import BaseSourceForgeService from './sourceforge-base.js'
+
+const schema = Joi.object({
+ categories: Joi.object({
+ translation: Joi.array().required(),
+ }).required(),
+}).required()
+
+export default class SourceforgeTranslations extends BaseSourceForgeService {
+ static category = 'activity'
+
+ static route = {
+ base: 'sourceforge/translations',
+ pattern: ':project',
+ }
+
+ static openApi = {
+ '/sourceforge/translations/{project}': {
+ get: {
+ summary: 'SourceForge Translations',
+ parameters: pathParams({
+ name: 'project',
+ example: 'guitarix',
+ }),
+ },
+ },
+ }
+
+ static defaultBadgeData = { label: 'translations' }
+
+ static render({ translationCount }) {
+ return {
+ message: metric(translationCount),
+ color: 'blue',
+ }
+ }
+
+ async handle({ project }) {
+ const body = await this.fetch({ project, schema })
+ return this.constructor.render({
+ translationCount: body.categories.translation.length,
+ })
+ }
+}
diff --git a/services/sourceforge/sourceforge-translations.tester.js b/services/sourceforge/sourceforge-translations.tester.js
new file mode 100644
index 0000000000000..b66947190d93a
--- /dev/null
+++ b/services/sourceforge/sourceforge-translations.tester.js
@@ -0,0 +1,11 @@
+import { isMetric } from '../test-validators.js'
+import { createServiceTester } from '../tester.js'
+export const t = await createServiceTester()
+
+t.create('translations')
+ .get('/guitarix.json')
+ .expectBadge({ label: 'translations', message: isMetric })
+
+t.create('translations (project not found)')
+ .get('/that-doesnt-exist.json')
+ .expectBadge({ label: 'translations', message: 'project not found' })
diff --git a/services/sourceforge/sourceforge.service.js b/services/sourceforge/sourceforge.service.js
deleted file mode 100644
index fb3ed63e604d6..0000000000000
--- a/services/sourceforge/sourceforge.service.js
+++ /dev/null
@@ -1,106 +0,0 @@
-import Joi from 'joi'
-import moment from 'moment'
-import { metric } from '../text-formatters.js'
-import { downloadCount } from '../color-formatters.js'
-import { nonNegativeInteger } from '../validators.js'
-import { BaseJsonService } from '../index.js'
-
-const schema = Joi.object({
- total: nonNegativeInteger,
-}).required()
-
-const intervalMap = {
- dd: {
- startDate: endDate => endDate,
- suffix: '/day',
- },
- dw: {
- // 6 days, since date range is inclusive,
- startDate: endDate => moment(endDate).subtract(6, 'days'),
- suffix: '/week',
- },
- dm: {
- startDate: endDate => moment(endDate).subtract(30, 'days'),
- suffix: '/month',
- },
- dt: {
- startDate: () => moment(0),
- suffix: '',
- },
-}
-
-export default class Sourceforge extends BaseJsonService {
- static category = 'downloads'
-
- static route = {
- base: 'sourceforge',
- pattern: ':interval(dt|dm|dw|dd)/:project/:folder*',
- }
-
- static examples = [
- {
- title: 'SourceForge',
- pattern: ':interval(dt|dm|dw|dd)/:project',
- namedParams: {
- interval: 'dm',
- project: 'sevenzip',
- },
- staticPreview: this.render({
- downloads: 215990,
- interval: 'dm',
- }),
- },
- {
- title: 'SourceForge',
- pattern: ':interval(dt|dm|dw|dd)/:project/:folder',
- namedParams: {
- interval: 'dm',
- project: 'arianne',
- folder: 'stendhal',
- },
- staticPreview: this.render({
- downloads: 550,
- interval: 'dm',
- }),
- },
- ]
-
- static defaultBadgeData = { label: 'sourceforge' }
-
- static render({ downloads, interval }) {
- return {
- label: 'downloads',
- message: `${metric(downloads)}${intervalMap[interval].suffix}`,
- color: downloadCount(downloads),
- }
- }
-
- async fetch({ interval, project, folder }) {
- const url = `https://sourceforge.net/projects/${project}/files/${
- folder ? `${folder}/` : ''
- }stats/json`
- // get yesterday since today is incomplete
- const endDate = moment().subtract(24, 'hours')
- const startDate = intervalMap[interval].startDate(endDate)
- const options = {
- qs: {
- start_date: startDate.format('YYYY-MM-DD'),
- end_date: endDate.format('YYYY-MM-DD'),
- },
- }
-
- return this._requestJson({
- schema,
- url,
- options,
- errorMessages: {
- 404: 'project not found',
- },
- })
- }
-
- async handle({ interval, project, folder }) {
- const json = await this.fetch({ interval, project, folder })
- return this.constructor.render({ interval, downloads: json.total })
- }
-}
diff --git a/services/sourcegraph/sourcegraph.service.js b/services/sourcegraph/sourcegraph.service.js
index 054685d1b284b..6cbbdf6da0f49 100644
--- a/services/sourcegraph/sourcegraph.service.js
+++ b/services/sourcegraph/sourcegraph.service.js
@@ -1,5 +1,5 @@
import Joi from 'joi'
-import { BaseJsonService } from '../index.js'
+import { BaseJsonService, pathParams } from '../index.js'
const projectsCountRegex = /^\s[0-9]*(\.[0-9]k)?\sprojects$/
const schema = Joi.object({
@@ -14,16 +14,17 @@ export default class Sourcegraph extends BaseJsonService {
pattern: ':repo(.*?)',
}
- static examples = [
- {
- title: 'Sourcegraph for Repo Reference Count',
- pattern: ':repo',
- namedParams: {
- repo: 'github.com/gorilla/mux',
+ static openApi = {
+ '/sourcegraph/rrc/{repo}': {
+ get: {
+ summary: 'Sourcegraph for Repo Reference Count',
+ parameters: pathParams({
+ name: 'repo',
+ example: 'github.com/gorilla/mux',
+ }),
},
- staticPreview: this.render({ projectsCount: '9.9k projects' }),
},
- ]
+ }
static defaultBadgeData = { color: 'brightgreen', label: 'used by' }
diff --git a/services/spack/spack.service.js b/services/spack/spack.service.js
index 54eb60bb7c67d..5548548f6e28f 100644
--- a/services/spack/spack.service.js
+++ b/services/spack/spack.service.js
@@ -1,6 +1,6 @@
import Joi from 'joi'
import { renderVersionBadge } from '..//version.js'
-import { BaseJsonService } from '../index.js'
+import { BaseJsonService, pathParams } from '../index.js'
const schema = Joi.object({
latest_version: Joi.string().required(),
}).required()
@@ -13,14 +13,17 @@ export default class SpackVersion extends BaseJsonService {
pattern: ':packageName',
}
- static examples = [
- {
- title: 'Spack',
- namedParams: { packageName: 'adios2' },
- staticPreview: this.render({ version: '2.3.1' }),
- keywords: ['hpc'],
+ static openApi = {
+ '/spack/v/{packageName}': {
+ get: {
+ summary: 'Spack',
+ parameters: pathParams({
+ name: 'packageName',
+ example: 'adios2',
+ }),
+ },
},
- ]
+ }
static defaultBadgeData = { label: 'spack' }
@@ -29,11 +32,10 @@ export default class SpackVersion extends BaseJsonService {
}
async fetch({ packageName }) {
- const firstLetter = packageName[0]
return this._requestJson({
schema,
- url: `https://packages.spack.io/api/${firstLetter}/${packageName}.json`,
- errorMessages: {
+ url: `https://packages.spack.io/data/packages/${packageName}.json`,
+ httpErrors: {
404: 'package not found',
},
})
diff --git a/services/spiget/spiget-base.js b/services/spiget/spiget-base.js
index 0dfb19af624db..39ccc6254563e 100644
--- a/services/spiget/spiget-base.js
+++ b/services/spiget/spiget-base.js
@@ -15,12 +15,11 @@ const resourceSchema = Joi.object({
}).required(),
}).required()
-const documentation = `
+const description = `
+Spiget holds information about SpigotMC Resources, Plugins and Authors.
You can find your resource ID in the url for your resource page.
Example: https://www.spigotmc.org/resources/essentialsx.9089/ - Here the Resource ID is 9089.
`
-const keywords = ['spigot', 'spigotmc']
-
class BaseSpigetService extends BaseJsonService {
async fetch({
resourceId,
@@ -34,4 +33,4 @@ class BaseSpigetService extends BaseJsonService {
}
}
-export { keywords, documentation, BaseSpigetService }
+export { description, BaseSpigetService }
diff --git a/services/spiget/spiget-download-size.service.js b/services/spiget/spiget-download-size.service.js
index 21966b8d291a7..ed6337367b0ae 100644
--- a/services/spiget/spiget-download-size.service.js
+++ b/services/spiget/spiget-download-size.service.js
@@ -1,4 +1,5 @@
-import { BaseSpigetService, documentation, keywords } from './spiget-base.js'
+import { pathParams } from '../index.js'
+import { BaseSpigetService, description } from './spiget-base.js'
export default class SpigetDownloadSize extends BaseSpigetService {
static category = 'size'
@@ -8,17 +9,18 @@ export default class SpigetDownloadSize extends BaseSpigetService {
pattern: ':resourceId',
}
- static examples = [
- {
- title: 'Spiget Download Size',
- namedParams: {
- resourceId: '9089',
+ static openApi = {
+ '/spiget/download-size/{resourceId}': {
+ get: {
+ summary: 'Spiget Download Size',
+ description,
+ parameters: pathParams({
+ name: 'resourceId',
+ example: '15904',
+ }),
},
- staticPreview: this.render({ size: 2.5, unit: 'MB' }),
- documentation,
- keywords,
},
- ]
+ }
static defaultBadgeData = {
label: 'size',
@@ -28,7 +30,7 @@ export default class SpigetDownloadSize extends BaseSpigetService {
static render({ size, unit, type }) {
if (type === 'external') {
return {
- message: `resource hosted externally`,
+ message: 'resource hosted externally',
color: 'lightgrey',
}
}
diff --git a/services/spiget/spiget-download-size.tester.js b/services/spiget/spiget-download-size.tester.js
index eb3022d1560bb..aba190e865c69 100644
--- a/services/spiget/spiget-download-size.tester.js
+++ b/services/spiget/spiget-download-size.tester.js
@@ -1,13 +1,13 @@
-import { isFileSize } from '../test-validators.js'
+import { isMetricFileSize } from '../test-validators.js'
import { createServiceTester } from '../tester.js'
export const t = await createServiceTester()
t.create('EssentialsX (hosted resource)')
.get('/771.json')
- .expectBadge({ label: 'size', message: isFileSize })
+ .expectBadge({ label: 'size', message: isMetricFileSize })
-t.create('Pet Master (external resource)').get('/15904.json').expectBadge({
- lavel: 'size',
+t.create('external resource').get('/9089.json').expectBadge({
+ label: 'size',
message: 'resource hosted externally',
})
diff --git a/services/spiget/spiget-downloads.service.js b/services/spiget/spiget-downloads.service.js
index 8a3a78a19c372..60ef6a406577c 100644
--- a/services/spiget/spiget-downloads.service.js
+++ b/services/spiget/spiget-downloads.service.js
@@ -1,6 +1,6 @@
-import { metric } from '../text-formatters.js'
-import { downloadCount } from '../color-formatters.js'
-import { BaseSpigetService, documentation, keywords } from './spiget-base.js'
+import { pathParams } from '../index.js'
+import { renderDownloadsBadge } from '../downloads.js'
+import { BaseSpigetService, description } from './spiget-base.js'
export default class SpigetDownloads extends BaseSpigetService {
static category = 'downloads'
@@ -10,31 +10,23 @@ export default class SpigetDownloads extends BaseSpigetService {
pattern: ':resourceId',
}
- static examples = [
- {
- title: 'Spiget Downloads',
- namedParams: {
- resourceId: '9089',
+ static openApi = {
+ '/spiget/downloads/{resourceId}': {
+ get: {
+ summary: 'Spiget Downloads',
+ description,
+ parameters: pathParams({
+ name: 'resourceId',
+ example: '9089',
+ }),
},
- staticPreview: this.render({ downloads: 560891 }),
- documentation,
- keywords,
},
- ]
-
- static defaultBadgeData = {
- label: 'downloads',
}
- static render({ downloads }) {
- return {
- message: metric(downloads),
- color: downloadCount(downloads),
- }
- }
+ static defaultBadgeData = { label: 'downloads' }
async handle({ resourceId }) {
const { downloads } = await this.fetch({ resourceId })
- return this.constructor.render({ downloads })
+ return renderDownloadsBadge({ downloads })
}
}
diff --git a/services/spiget/spiget-latest-version.service.js b/services/spiget/spiget-latest-version.service.js
index 321c03a7aab6b..d237a81a5e0bb 100644
--- a/services/spiget/spiget-latest-version.service.js
+++ b/services/spiget/spiget-latest-version.service.js
@@ -1,6 +1,7 @@
import Joi from 'joi'
+import { pathParams } from '../index.js'
import { renderVersionBadge } from '../version.js'
-import { BaseSpigetService, documentation, keywords } from './spiget-base.js'
+import { BaseSpigetService, description } from './spiget-base.js'
const versionSchema = Joi.object({
downloads: Joi.number().required(),
@@ -15,17 +16,18 @@ export default class SpigetLatestVersion extends BaseSpigetService {
pattern: ':resourceId',
}
- static examples = [
- {
- title: 'Spiget Version',
- namedParams: {
- resourceId: '9089',
+ static openApi = {
+ '/spiget/version/{resourceId}': {
+ get: {
+ summary: 'Spiget Version',
+ description,
+ parameters: pathParams({
+ name: 'resourceId',
+ example: '9089',
+ }),
},
- staticPreview: renderVersionBadge({ version: 2.1 }),
- documentation,
- keywords,
},
- ]
+ }
static defaultBadgeData = {
label: 'spiget',
diff --git a/services/spiget/spiget-rating.service.js b/services/spiget/spiget-rating.service.js
index 4d1a41b7c65c5..0ab0097fd33cf 100644
--- a/services/spiget/spiget-rating.service.js
+++ b/services/spiget/spiget-rating.service.js
@@ -1,6 +1,7 @@
+import { pathParams } from '../index.js'
import { starRating, metric } from '../text-formatters.js'
import { floorCount } from '../color-formatters.js'
-import { BaseSpigetService, documentation, keywords } from './spiget-base.js'
+import { BaseSpigetService, description } from './spiget-base.js'
export default class SpigetRatings extends BaseSpigetService {
static category = 'rating'
@@ -10,31 +11,25 @@ export default class SpigetRatings extends BaseSpigetService {
pattern: ':format(rating|stars)/:resourceId',
}
- static examples = [
- {
- title: 'Spiget Stars',
- pattern: 'stars/:resourceId',
- namedParams: {
- resourceId: '9089',
+ static openApi = {
+ '/spiget/{format}/{resourceId}': {
+ get: {
+ summary: 'Spiget Rating',
+ description,
+ parameters: pathParams(
+ {
+ name: 'format',
+ example: 'rating',
+ schema: { type: 'string', enum: this.getEnum('format') },
+ },
+ {
+ name: 'resourceId',
+ example: '9089',
+ },
+ ),
},
- staticPreview: this.render({
- format: 'stars',
- total: 325,
- average: 4.5,
- }),
- documentation,
},
- {
- title: 'Spiget Rating',
- pattern: 'rating/:resourceId',
- namedParams: {
- resourceId: '9089',
- },
- staticPreview: this.render({ total: 325, average: 4.5 }),
- documentation,
- keywords,
- },
- ]
+ }
static defaultBadgeData = {
label: 'rating',
diff --git a/services/spiget/spiget-tested-versions.service.js b/services/spiget/spiget-tested-versions.service.js
index 725bf68f34079..30835ccdd2e73 100644
--- a/services/spiget/spiget-tested-versions.service.js
+++ b/services/spiget/spiget-tested-versions.service.js
@@ -1,4 +1,5 @@
-import { BaseSpigetService, documentation, keywords } from './spiget-base.js'
+import { pathParams } from '../index.js'
+import { BaseSpigetService, description } from './spiget-base.js'
export default class SpigetTestedVersions extends BaseSpigetService {
static category = 'platform-support'
@@ -8,17 +9,18 @@ export default class SpigetTestedVersions extends BaseSpigetService {
pattern: ':resourceId',
}
- static examples = [
- {
- title: 'Spiget tested server versions',
- namedParams: {
- resourceId: '9089',
+ static openApi = {
+ '/spiget/tested-versions/{resourceId}': {
+ get: {
+ summary: 'Spiget tested server versions',
+ description,
+ parameters: pathParams({
+ name: 'resourceId',
+ example: '9089',
+ }),
},
- staticPreview: this.render({ versions: '1.7-1.13' }),
- documentation,
- keywords,
},
- ]
+ }
static defaultBadgeData = {
label: 'tested versions',
diff --git a/services/spiget/spiget-tested-versions.tester.js b/services/spiget/spiget-tested-versions.tester.js
index 723164f118912..130fffa97d832 100644
--- a/services/spiget/spiget-tested-versions.tester.js
+++ b/services/spiget/spiget-tested-versions.tester.js
@@ -33,7 +33,7 @@ t.create('Nock - single version supported')
count: 1,
average: 1,
},
- })
+ }),
)
.expectBadge({
label: 'tested versions',
@@ -57,7 +57,7 @@ t.create('Nock - multiple versions supported')
count: 1,
average: 1,
},
- })
+ }),
)
.expectBadge({
label: 'tested versions',
diff --git a/services/stackexchange/stackexchange-base.js b/services/stackexchange/stackexchange-base.js
new file mode 100644
index 0000000000000..18c9ead9c24c2
--- /dev/null
+++ b/services/stackexchange/stackexchange-base.js
@@ -0,0 +1,37 @@
+import { BaseJsonService } from '../index.js'
+import { metric } from '../text-formatters.js'
+import { floorCount as floorCountColor } from '../color-formatters.js'
+
+export function renderQuestionsBadge({
+ suffix,
+ stackexchangesite,
+ query,
+ numValue,
+}) {
+ const label = `${stackexchangesite} ${query} questions`
+ return {
+ label,
+ message: `${metric(numValue)}${suffix}`,
+ color: floorCountColor(numValue, 1000, 10000, 20000),
+ }
+}
+
+export class StackExchangeBase extends BaseJsonService {
+ static category = 'chat'
+
+ static auth = {
+ passKey: 'stackapps_api_key',
+ authorizedOrigins: ['https://api.stackexchange.com'],
+ isRequired: false,
+ }
+
+ static defaultBadgeData = {
+ label: 'stackoverflow',
+ }
+
+ async fetch(params) {
+ return this._requestJson(
+ this.authHelper.withQueryStringAuth({ passKey: 'key' }, params),
+ )
+ }
+}
diff --git a/services/stackexchange/stackexchange-helpers.js b/services/stackexchange/stackexchange-helpers.js
deleted file mode 100644
index e86c4064d0160..0000000000000
--- a/services/stackexchange/stackexchange-helpers.js
+++ /dev/null
@@ -1,16 +0,0 @@
-import { metric } from '../text-formatters.js'
-import { floorCount as floorCountColor } from '../color-formatters.js'
-
-export default function renderQuestionsBadge({
- suffix,
- stackexchangesite,
- query,
- numValue,
-}) {
- const label = `${stackexchangesite} ${query} questions`
- return {
- label,
- message: `${metric(numValue)}${suffix}`,
- color: floorCountColor(numValue, 1000, 10000, 20000),
- }
-}
diff --git a/services/stackexchange/stackexchange-monthlyquestions.service.js b/services/stackexchange/stackexchange-monthlyquestions.service.js
index ff0143c63ddbc..4c164a4d73664 100644
--- a/services/stackexchange/stackexchange-monthlyquestions.service.js
+++ b/services/stackexchange/stackexchange-monthlyquestions.service.js
@@ -1,36 +1,38 @@
-import moment from 'moment'
+import dayjs from 'dayjs'
import Joi from 'joi'
+import { pathParams } from '../index.js'
import { nonNegativeInteger } from '../validators.js'
-import { BaseJsonService } from '../index.js'
-import renderQuestionsBadge from './stackexchange-helpers.js'
+import {
+ renderQuestionsBadge,
+ StackExchangeBase,
+} from './stackexchange-base.js'
const tagSchema = Joi.object({
total: nonNegativeInteger,
}).required()
-export default class StackExchangeMonthlyQuestions extends BaseJsonService {
- static category = 'chat'
-
+export default class StackExchangeMonthlyQuestions extends StackExchangeBase {
static route = {
base: 'stackexchange',
pattern: ':stackexchangesite/qm/:query',
}
- static examples = [
- {
- title: 'Stack Exchange monthly questions',
- namedParams: { stackexchangesite: 'stackoverflow', query: 'momentjs' },
- staticPreview: this.render({
- stackexchangesite: 'stackoverflow',
- query: 'momentjs',
- numValue: 2000,
- }),
- keywords: ['stackexchange', 'stackoverflow'],
+ static openApi = {
+ '/stackexchange/{stackexchangesite}/qm/{query}': {
+ get: {
+ summary: 'Stack Exchange monthly questions',
+ parameters: pathParams(
+ {
+ name: 'stackexchangesite',
+ example: 'stackoverflow',
+ },
+ {
+ name: 'query',
+ example: 'javascript',
+ },
+ ),
+ },
},
- ]
-
- static defaultBadgeData = {
- label: 'stackoverflow',
}
static render(props) {
@@ -41,21 +43,21 @@ export default class StackExchangeMonthlyQuestions extends BaseJsonService {
}
async handle({ stackexchangesite, query }) {
- const today = moment().toDate()
- const prevMonthStart = moment(today)
+ const today = dayjs().toDate()
+ const prevMonthStart = dayjs(today)
.subtract(1, 'months')
.startOf('month')
.unix()
- const prevMonthEnd = moment(today)
+ const prevMonthEnd = dayjs(today)
.subtract(1, 'months')
.endOf('month')
.unix()
- const parsedData = await this._requestJson({
+ const parsedData = await this.fetch({
schema: tagSchema,
options: {
- gzip: true,
- qs: {
+ decompress: true,
+ searchParams: {
site: stackexchangesite,
fromdate: prevMonthStart,
todate: prevMonthEnd,
@@ -63,7 +65,7 @@ export default class StackExchangeMonthlyQuestions extends BaseJsonService {
tagged: query,
},
},
- url: `https://api.stackexchange.com/2.2/questions`,
+ url: 'https://api.stackexchange.com/2.2/questions',
})
const numValue = parsedData.total
diff --git a/services/stackexchange/stackexchange-monthlyquestions.spec.js b/services/stackexchange/stackexchange-monthlyquestions.spec.js
new file mode 100644
index 0000000000000..b014718eaefc0
--- /dev/null
+++ b/services/stackexchange/stackexchange-monthlyquestions.spec.js
@@ -0,0 +1,17 @@
+import { testAuth } from '../test-helpers.js'
+import StackExchangeMonthlyQuestions from './stackexchange-monthlyquestions.service.js'
+
+describe('StackExchangeMonthlyQuestions', function () {
+ describe('auth', function () {
+ it('sends the auth information as configured', async function () {
+ return testAuth(
+ StackExchangeMonthlyQuestions,
+ 'QueryStringAuth',
+ {
+ total: 8,
+ },
+ { queryPassKey: 'key' },
+ )
+ })
+ })
+})
diff --git a/services/stackexchange/stackexchange-monthlyquestions.tester.js b/services/stackexchange/stackexchange-monthlyquestions.tester.js
index 4341b282fd2c8..766703a967908 100644
--- a/services/stackexchange/stackexchange-monthlyquestions.tester.js
+++ b/services/stackexchange/stackexchange-monthlyquestions.tester.js
@@ -2,10 +2,10 @@ import { isMetricOverTimePeriod } from '../test-validators.js'
import { createServiceTester } from '../tester.js'
export const t = await createServiceTester()
-t.create('Monthly Questions for StackOverflow Momentjs')
- .get('/stackoverflow/qm/momentjs.json')
+t.create('Monthly Questions for StackOverflow javascript')
+ .get('/stackoverflow/qm/javascript.json')
.expectBadge({
- label: 'stackoverflow momentjs questions',
+ label: 'stackoverflow javascript questions',
message: isMetricOverTimePeriod,
})
diff --git a/services/stackexchange/stackexchange-reputation.service.js b/services/stackexchange/stackexchange-reputation.service.js
index ea8b7b36bbac2..2ee06a3d3a607 100644
--- a/services/stackexchange/stackexchange-reputation.service.js
+++ b/services/stackexchange/stackexchange-reputation.service.js
@@ -1,7 +1,8 @@
import Joi from 'joi'
+import { pathParams } from '../index.js'
import { metric } from '../text-formatters.js'
import { floorCount as floorCountColor } from '../color-formatters.js'
-import { BaseJsonService } from '../index.js'
+import { StackExchangeBase } from './stackexchange-base.js'
const reputationSchema = Joi.object({
items: Joi.array()
@@ -9,33 +10,33 @@ const reputationSchema = Joi.object({
.items(
Joi.object({
reputation: Joi.number().min(0).required(),
- })
+ }),
)
.required(),
}).required()
-export default class StackExchangeReputation extends BaseJsonService {
- static category = 'chat'
-
+export default class StackExchangeReputation extends StackExchangeBase {
static route = {
base: 'stackexchange',
pattern: ':stackexchangesite/r/:query',
}
- static examples = [
- {
- title: 'Stack Exchange reputation',
- namedParams: { stackexchangesite: 'stackoverflow', query: '123' },
- staticPreview: this.render({
- stackexchangesite: 'stackoverflow',
- numValue: 10,
- }),
- keywords: ['stackexchange', 'stackoverflow'],
+ static openApi = {
+ '/stackexchange/{stackexchangesite}/r/{query}': {
+ get: {
+ summary: 'Stack Exchange reputation',
+ parameters: pathParams(
+ {
+ name: 'stackexchangesite',
+ example: 'stackoverflow',
+ },
+ {
+ name: 'query',
+ example: '123',
+ },
+ ),
+ },
},
- ]
-
- static defaultBadgeData = {
- label: 'stackoverflow',
}
static render({ stackexchangesite, numValue }) {
@@ -51,11 +52,11 @@ export default class StackExchangeReputation extends BaseJsonService {
async handle({ stackexchangesite, query }) {
const path = `users/${query}`
- const parsedData = await this._requestJson({
+ const parsedData = await this.fetch({
schema: reputationSchema,
- options: { gzip: true, qs: { site: stackexchangesite } },
+ options: { decompress: true, searchParams: { site: stackexchangesite } },
url: `https://api.stackexchange.com/2.2/${path}`,
- errorMessages: {
+ httpErrors: {
400: 'invalid parameters',
},
})
diff --git a/services/stackexchange/stackexchange-reputation.spec.js b/services/stackexchange/stackexchange-reputation.spec.js
new file mode 100644
index 0000000000000..b0bcd9be17732
--- /dev/null
+++ b/services/stackexchange/stackexchange-reputation.spec.js
@@ -0,0 +1,17 @@
+import { testAuth } from '../test-helpers.js'
+import StackExchangeReputation from './stackexchange-reputation.service.js'
+
+describe('StackExchangeReputation', function () {
+ describe('auth', function () {
+ it('sends the auth information as configured', async function () {
+ return testAuth(
+ StackExchangeReputation,
+ 'QueryStringAuth',
+ {
+ items: [{ reputation: 8 }],
+ },
+ { queryPassKey: 'key' },
+ )
+ })
+ })
+})
diff --git a/services/stackexchange/stackexchange-taginfo.service.js b/services/stackexchange/stackexchange-taginfo.service.js
index af3d5d2557e46..dacb79eafa65e 100644
--- a/services/stackexchange/stackexchange-taginfo.service.js
+++ b/services/stackexchange/stackexchange-taginfo.service.js
@@ -1,6 +1,9 @@
import Joi from 'joi'
-import { BaseJsonService } from '../index.js'
-import renderQuestionsBadge from './stackexchange-helpers.js'
+import { pathParams } from '../index.js'
+import {
+ renderQuestionsBadge,
+ StackExchangeBase,
+} from './stackexchange-base.js'
const tagSchema = Joi.object({
items: Joi.array()
@@ -8,34 +11,33 @@ const tagSchema = Joi.object({
.items(
Joi.object({
count: Joi.number().min(0).required(),
- })
+ }),
)
.required(),
}).required()
-export default class StackExchangeQuestions extends BaseJsonService {
- static category = 'chat'
-
+export default class StackExchangeQuestions extends StackExchangeBase {
static route = {
base: 'stackexchange',
pattern: ':stackexchangesite/t/:query',
}
- static examples = [
- {
- title: 'Stack Exchange questions',
- namedParams: { stackexchangesite: 'stackoverflow', query: 'gson' },
- staticPreview: this.render({
- stackexchangesite: 'stackoverflow',
- query: 'gson',
- numValue: 10,
- }),
- keywords: ['stackexchange', 'stackoverflow'],
+ static openApi = {
+ '/stackexchange/{stackexchangesite}/t/{query}': {
+ get: {
+ summary: 'Stack Exchange questions',
+ parameters: pathParams(
+ {
+ name: 'stackexchangesite',
+ example: 'stackoverflow',
+ },
+ {
+ name: 'query',
+ example: 'gson',
+ },
+ ),
+ },
},
- ]
-
- static defaultBadgeData = {
- label: 'stackoverflow',
}
static render(props) {
@@ -48,9 +50,9 @@ export default class StackExchangeQuestions extends BaseJsonService {
async handle({ stackexchangesite, query }) {
const path = `tags/${query}/info`
- const parsedData = await this._requestJson({
+ const parsedData = await this.fetch({
schema: tagSchema,
- options: { gzip: true, qs: { site: stackexchangesite } },
+ options: { decompress: true, searchParams: { site: stackexchangesite } },
url: `https://api.stackexchange.com/2.2/${path}`,
})
diff --git a/services/stackexchange/stackexchange-taginfo.spec.js b/services/stackexchange/stackexchange-taginfo.spec.js
new file mode 100644
index 0000000000000..46977c1ced29b
--- /dev/null
+++ b/services/stackexchange/stackexchange-taginfo.spec.js
@@ -0,0 +1,17 @@
+import { testAuth } from '../test-helpers.js'
+import StackExchangeQuestions from './stackexchange-taginfo.service.js'
+
+describe('StackExchangeQuestions', function () {
+ describe('auth', function () {
+ it('sends the auth information as configured', async function () {
+ return testAuth(
+ StackExchangeQuestions,
+ 'QueryStringAuth',
+ {
+ items: [{ count: 8 }],
+ },
+ { queryPassKey: 'key' },
+ )
+ })
+ })
+})
diff --git a/services/static-badge/static-badge.service.js b/services/static-badge/static-badge.service.js
index 0bcbe488e3b5f..910a79f962b8d 100644
--- a/services/static-badge/static-badge.service.js
+++ b/services/static-badge/static-badge.service.js
@@ -1,15 +1,90 @@
import { escapeFormat } from '../../core/badge-urls/path-helpers.js'
import { BaseStaticService } from '../index.js'
+const description = `
+The static badge accepts a single required path parameter which encodes either:
+
+
+
+ Label, message and color separated by a dash -. For example:
+ -
+ https://img.shields.io/badge/any_text-you_like-blue
+
+
+ Message and color only, separated by a dash -. For example:
+ -
+ https://img.shields.io/badge/just%20the%20message-8A2BE2
+
+
+
+
+
+
+ URL input
+ Badge output
+
+
+ Underscore _ or %20
+ Space
+
+
+ Double underscore __
+ Underscore _
+
+
+ Double dash --
+ Dash -
+
+
+
+
+Examples of additional options:
+
+
+ Style: -
+ https://img.shields.io/badge/build-passing-brightgreen?style=for-the-badge
+
+
+ Color (named): -
+ https://img.shields.io/badge/coverage-95%25-orange
+
+
+ Logo: -
+ https://img.shields.io/badge/github-repo-blue?logo=github
+
+
+
+Hex, rgb, rgba, hsl, hsla and css named colors may be used. Note: Some named colors may differ from CSS color values. For a list of named colors, see the badge-maker readme .
+`
+
export default class StaticBadge extends BaseStaticService {
static category = 'static'
-
static route = {
base: '',
format: '(?::|badge/)((?:[^-]|--)*?)-?((?:[^-]|--)*)-((?:[^-.]|--)+)',
capture: ['label', 'message', 'color'],
}
+ static openApi = {
+ '/badge/{badgeContent}': {
+ get: {
+ summary: 'Static Badge',
+ description,
+ parameters: [
+ {
+ name: 'badgeContent',
+ description:
+ 'Label, (optional) message, and color. Separated by dashes',
+ in: 'path',
+ required: true,
+ schema: { type: 'string' },
+ example: 'build-passing-brightgreen',
+ },
+ ],
+ },
+ },
+ }
+
handle({ label, message, color }) {
return { label: escapeFormat(label), message: escapeFormat(message), color }
}
diff --git a/services/steam/steam-base.js b/services/steam/steam-base.js
index 9ea5cae989cdc..459c6d2a7ac0f 100644
--- a/services/steam/steam-base.js
+++ b/services/steam/steam-base.js
@@ -45,7 +45,7 @@ class BaseSteamAPI extends BaseJsonService {
return this._requestJson({
url,
schema,
- errorMessages: {
+ httpErrors: {
400: 'bad request',
},
options,
diff --git a/services/steam/steam-workshop.service.js b/services/steam/steam-workshop.service.js
index 23207eb80d9cf..a1024f81e7363 100644
--- a/services/steam/steam-workshop.service.js
+++ b/services/steam/steam-workshop.service.js
@@ -1,23 +1,20 @@
import Joi from 'joi'
-import prettyBytes from 'pretty-bytes'
-import { metric, formatDate } from '../text-formatters.js'
-import { age as ageColor, downloadCount } from '../color-formatters.js'
-import { NotFound } from '../index.js'
+import { renderDateBadge } from '../date.js'
+import { renderSizeBadge } from '../size.js'
+import { renderDownloadsBadge } from '../downloads.js'
+import { metric } from '../text-formatters.js'
+import { NotFound, pathParams } from '../index.js'
import BaseSteamAPI from './steam-base.js'
-const documentation = `
-
- Using a web browser, you can find the ID in the url here:
-
+const description = `
+Using a web browser, you can find the ID in the url here:
-
- In the steam client you can simply just Right-Click and 'Copy Page URL' and follow the above step
-
+In the steam client you can simply just Right-Click and 'Copy Page URL' and follow the above step
+ alt="Right-Click and 'Copy Page URL'" />
`
const steamCollectionSchema = Joi.object({
@@ -27,7 +24,7 @@ const steamCollectionSchema = Joi.object({
.items(
Joi.object({
children: Joi.array().required(),
- }).required()
+ }).required(),
)
.required(),
})
@@ -41,7 +38,7 @@ const steamCollectionNotFoundSchema = Joi.object({
.items(
Joi.object({
result: Joi.number().integer().min(9).max(9).required(),
- }).required()
+ }).required(),
)
.required(),
})
@@ -50,7 +47,7 @@ const steamCollectionNotFoundSchema = Joi.object({
const collectionFoundOrNotSchema = Joi.alternatives(
steamCollectionSchema,
- steamCollectionNotFoundSchema
+ steamCollectionNotFoundSchema,
)
const steamFileSchema = Joi.object({
@@ -67,7 +64,7 @@ const steamFileSchema = Joi.object({
lifetime_subscriptions: Joi.number().integer().required(),
lifetime_favorited: Joi.number().integer().required(),
views: Joi.number().integer().required(),
- })
+ }),
)
.min(1)
.max(1)
@@ -83,7 +80,7 @@ const steamFileNotFoundSchema = Joi.object({
.items(
Joi.object({
result: Joi.number().integer().min(9).max(9).required(),
- }).required()
+ }).required(),
)
.min(1)
.max(1)
@@ -94,7 +91,7 @@ const steamFileNotFoundSchema = Joi.object({
const fileFoundOrNotSchema = Joi.alternatives(
steamFileSchema,
- steamFileNotFoundSchema
+ steamFileNotFoundSchema,
)
class SteamCollectionSize extends BaseSteamAPI {
@@ -105,14 +102,18 @@ class SteamCollectionSize extends BaseSteamAPI {
pattern: ':collectionId',
}
- static examples = [
- {
- title: 'Steam Collection Files',
- namedParams: { collectionId: '180077636' },
- staticPreview: this.render({ size: 32 }),
- documentation,
+ static openApi = {
+ '/steam/collection-files/{collectionId}': {
+ get: {
+ summary: 'Steam Collection Files',
+ description,
+ parameters: pathParams({
+ name: 'collectionId',
+ example: '180077636',
+ }),
+ },
},
- ]
+ }
static defaultBadgeData = {
label: 'files',
@@ -190,25 +191,25 @@ class SteamFileSize extends SteamFileService {
pattern: ':fileId',
}
- static examples = [
- {
- title: 'Steam File Size',
- namedParams: { fileId: '100' },
- staticPreview: this.render({ fileSize: 20000 }),
- documentation,
+ static openApi = {
+ '/steam/size/{fileId}': {
+ get: {
+ summary: 'Steam File Size',
+ description,
+ parameters: pathParams({
+ name: 'fileId',
+ example: '100',
+ }),
+ },
},
- ]
+ }
static defaultBadgeData = {
label: 'size',
}
- static render({ fileSize }) {
- return { message: prettyBytes(fileSize), color: 'brightgreen' }
- }
-
async onRequest({ response }) {
- return this.constructor.render({ fileSize: response.file_size })
+ return renderSizeBadge(response.file_size, 'metric')
}
}
@@ -220,28 +221,26 @@ class SteamFileReleaseDate extends SteamFileService {
pattern: ':fileId',
}
- static examples = [
- {
- title: 'Steam Release Date',
- namedParams: { fileId: '100' },
- staticPreview: this.render({
- releaseDate: new Date(0).setUTCSeconds(1538288239),
- }),
- documentation,
+ static openApi = {
+ '/steam/release-date/{fileId}': {
+ get: {
+ summary: 'Steam Release Date',
+ description,
+ parameters: pathParams({
+ name: 'fileId',
+ example: '100',
+ }),
+ },
},
- ]
+ }
static defaultBadgeData = {
label: 'release date',
}
- static render({ releaseDate }) {
- return { message: formatDate(releaseDate), color: ageColor(releaseDate) }
- }
-
async onRequest({ response }) {
const releaseDate = new Date(0).setUTCSeconds(response.time_created)
- return this.constructor.render({ releaseDate })
+ return renderDateBadge(releaseDate)
}
}
@@ -253,28 +252,26 @@ class SteamFileUpdateDate extends SteamFileService {
pattern: ':fileId',
}
- static examples = [
- {
- title: 'Steam Update Date',
- namedParams: { fileId: '100' },
- staticPreview: this.render({
- updateDate: new Date(0).setUTCSeconds(1538288239),
- }),
- documentation,
+ static openApi = {
+ '/steam/update-date/{fileId}': {
+ get: {
+ summary: 'Steam Update Date',
+ description,
+ parameters: pathParams({
+ name: 'fileId',
+ example: '100',
+ }),
+ },
},
- ]
+ }
static defaultBadgeData = {
label: 'update date',
}
- static render({ updateDate }) {
- return { message: formatDate(updateDate), color: ageColor(updateDate) }
- }
-
async onRequest({ response }) {
const updateDate = new Date(0).setUTCSeconds(response.time_updated)
- return this.constructor.render({ updateDate })
+ return renderDateBadge(updateDate)
}
}
@@ -286,14 +283,18 @@ class SteamFileSubscriptions extends SteamFileService {
pattern: ':fileId',
}
- static examples = [
- {
- title: 'Steam Subscriptions',
- namedParams: { fileId: '100' },
- staticPreview: this.render({ subscriptions: 20124 }),
- documentation,
+ static openApi = {
+ '/steam/subscriptions/{fileId}': {
+ get: {
+ summary: 'Steam Subscriptions',
+ description,
+ parameters: pathParams({
+ name: 'fileId',
+ example: '100',
+ }),
+ },
},
- ]
+ }
static defaultBadgeData = {
label: 'subscriptions',
@@ -316,14 +317,18 @@ class SteamFileFavorites extends SteamFileService {
pattern: ':fileId',
}
- static examples = [
- {
- title: 'Steam Favorites',
- namedParams: { fileId: '100' },
- staticPreview: this.render({ favorites: 20000 }),
- documentation,
+ static openApi = {
+ '/steam/favorites/{fileId}': {
+ get: {
+ summary: 'Steam Favorites',
+ description,
+ parameters: pathParams({
+ name: 'fileId',
+ example: '100',
+ }),
+ },
},
- ]
+ }
static defaultBadgeData = {
label: 'favorites',
@@ -346,27 +351,23 @@ class SteamFileDownloads extends SteamFileService {
pattern: ':fileId',
}
- static examples = [
- {
- title: 'Steam Downloads',
- namedParams: { fileId: '100' },
- staticPreview: this.render({ downloads: 20124 }),
- documentation,
+ static openApi = {
+ '/steam/downloads/{fileId}': {
+ get: {
+ summary: 'Steam Downloads',
+ description,
+ parameters: pathParams({
+ name: 'fileId',
+ example: '100',
+ }),
+ },
},
- ]
-
- static defaultBadgeData = {
- label: 'downloads',
}
- static render({ downloads }) {
- return { message: metric(downloads), color: downloadCount(downloads) }
- }
+ static defaultBadgeData = { label: 'downloads' }
- async onRequest({ response }) {
- return this.constructor.render({
- downloads: response.lifetime_subscriptions,
- })
+ async onRequest({ response: { lifetime_subscriptions: downloads } }) {
+ return renderDownloadsBadge({ downloads })
}
}
@@ -378,14 +379,18 @@ class SteamFileViews extends SteamFileService {
pattern: ':fileId',
}
- static examples = [
- {
- title: 'Steam Views',
- namedParams: { fileId: '100' },
- staticPreview: this.render({ views: 20000 }),
- documentation,
+ static openApi = {
+ '/steam/views/{fileId}': {
+ get: {
+ summary: 'Steam Views',
+ description,
+ parameters: pathParams({
+ name: 'fileId',
+ example: '100',
+ }),
+ },
},
- ]
+ }
static defaultBadgeData = {
label: 'views',
diff --git a/services/steam/steam-workshop.tester.js b/services/steam/steam-workshop.tester.js
index 05b424ac9d658..578551e222990 100644
--- a/services/steam/steam-workshop.tester.js
+++ b/services/steam/steam-workshop.tester.js
@@ -1,5 +1,9 @@
import { ServiceTester } from '../tester.js'
-import { isMetric, isFileSize, isFormattedDate } from '../test-validators.js'
+import {
+ isMetric,
+ isMetricFileSize,
+ isFormattedDate,
+} from '../test-validators.js'
export const t = new ServiceTester({
id: 'steam',
@@ -12,7 +16,7 @@ t.create('Collection Files')
t.create('File Size')
.get('/size/1523924535.json')
- .expectBadge({ label: 'size', message: isFileSize })
+ .expectBadge({ label: 'size', message: isMetricFileSize })
t.create('Release Date')
.get('/release-date/1523924535.json')
diff --git a/services/suggest.integration.js b/services/suggest.integration.js
deleted file mode 100644
index 68efaafcaae4f..0000000000000
--- a/services/suggest.integration.js
+++ /dev/null
@@ -1,270 +0,0 @@
-import { expect } from 'chai'
-import Camp from '@shields_io/camp'
-import portfinder from 'portfinder'
-import config from 'config'
-import got from '../core/got-test-client.js'
-import { setRoutes } from './suggest.js'
-import GithubApiProvider from './github/github-api-provider.js'
-
-describe('Badge suggestions for', function () {
- const githubApiBaseUrl = process.env.GITHUB_URL || 'https://api.github.com'
-
- let token, apiProvider
- before(function () {
- token = config.util.toObject().private.gh_token
- if (!token) {
- throw Error('The integration tests require a gh_token to be set')
- }
- apiProvider = new GithubApiProvider({
- baseUrl: githubApiBaseUrl,
- globalToken: token,
- withPooling: false,
- })
- })
-
- 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
- }
- })
-
- const origin = 'https://example.test'
- before(function () {
- setRoutes([origin], apiProvider, camp)
- })
- describe('GitHub', function () {
- context('with an existing project', function () {
- it('returns the expected suggestions', async function () {
- const { statusCode, body } = await got(
- `${baseUrl}/$suggest/v1?url=${encodeURIComponent(
- 'https://github.com/atom/atom'
- )}`,
- {
- responseType: 'json',
- }
- )
- expect(statusCode).to.equal(200)
- expect(body).to.deep.equal({
- suggestions: [
- {
- title: 'GitHub issues',
- link: 'https://github.com/atom/atom/issues',
- example: {
- pattern: '/github/issues/:user/:repo',
- namedParams: { user: 'atom', repo: 'atom' },
- queryParams: {},
- },
- },
- {
- title: 'GitHub forks',
- link: 'https://github.com/atom/atom/network',
- example: {
- pattern: '/github/forks/:user/:repo',
- namedParams: { user: 'atom', repo: 'atom' },
- queryParams: {},
- },
- },
- {
- title: 'GitHub stars',
- link: 'https://github.com/atom/atom/stargazers',
- example: {
- pattern: '/github/stars/:user/:repo',
- namedParams: { user: 'atom', repo: 'atom' },
- queryParams: {},
- },
- },
- {
- title: 'GitHub license',
- link: 'https://github.com/atom/atom/blob/master/LICENSE.md',
- example: {
- pattern: '/github/license/:user/:repo',
- namedParams: { user: 'atom', repo: 'atom' },
- queryParams: {},
- },
- },
- {
- title: 'Twitter',
- link: 'https://twitter.com/intent/tweet?text=Wow:&url=https%3A%2F%2Fgithub.com%2Fatom%2Fatom',
- example: {
- pattern: '/twitter/url',
- namedParams: {},
- queryParams: {
- url: 'https://github.com/atom/atom',
- },
- },
- preview: {
- style: 'social',
- },
- },
- ],
- })
- })
- })
-
- context('with a non-existent project', function () {
- it('returns the expected suggestions', async function () {
- this.timeout(5000)
-
- const { statusCode, body } = await got(
- `${baseUrl}/$suggest/v1?url=${encodeURIComponent(
- 'https://github.com/badges/not-a-real-project'
- )}`,
- {
- responseType: 'json',
- }
- )
- expect(statusCode).to.equal(200)
- expect(body).to.deep.equal({
- suggestions: [
- {
- title: 'GitHub issues',
- link: 'https://github.com/badges/not-a-real-project/issues',
- example: {
- pattern: '/github/issues/:user/:repo',
- namedParams: { user: 'badges', repo: 'not-a-real-project' },
- queryParams: {},
- },
- },
- {
- title: 'GitHub forks',
- link: 'https://github.com/badges/not-a-real-project/network',
- example: {
- pattern: '/github/forks/:user/:repo',
- namedParams: { user: 'badges', repo: 'not-a-real-project' },
- queryParams: {},
- },
- },
- {
- title: 'GitHub stars',
- link: 'https://github.com/badges/not-a-real-project/stargazers',
- example: {
- pattern: '/github/stars/:user/:repo',
- namedParams: { user: 'badges', repo: 'not-a-real-project' },
- queryParams: {},
- },
- },
- {
- title: 'GitHub license',
- link: 'https://github.com/badges/not-a-real-project',
- example: {
- pattern: '/github/license/:user/:repo',
- namedParams: { user: 'badges', repo: 'not-a-real-project' },
- queryParams: {},
- },
- },
- {
- title: 'Twitter',
- link: 'https://twitter.com/intent/tweet?text=Wow:&url=https%3A%2F%2Fgithub.com%2Fbadges%2Fnot-a-real-project',
- example: {
- pattern: '/twitter/url',
- namedParams: {},
- queryParams: {
- url: 'https://github.com/badges/not-a-real-project',
- },
- },
- preview: {
- style: 'social',
- },
- },
- ],
- })
- })
- })
- })
-
- describe('GitLab', function () {
- context('with an existing project', function () {
- it('returns the expected suggestions', async function () {
- const { statusCode, body } = await got(
- `${baseUrl}/$suggest/v1?url=${encodeURIComponent(
- 'https://gitlab.com/gitlab-org/gitlab'
- )}`,
- {
- responseType: 'json',
- }
- )
- expect(statusCode).to.equal(200)
- expect(body).to.deep.equal({
- suggestions: [
- {
- title: 'GitLab pipeline',
- link: 'https://gitlab.com/gitlab-org/gitlab/builds',
- example: {
- pattern: '/gitlab/pipeline/:user/:repo',
- namedParams: { user: 'gitlab-org', repo: 'gitlab' },
- queryParams: {},
- },
- },
- {
- title: 'Twitter',
- link: 'https://twitter.com/intent/tweet?text=Wow:&url=https%3A%2F%2Fgitlab.com%2Fgitlab-org%2Fgitlab',
- example: {
- pattern: '/twitter/url',
- namedParams: {},
- queryParams: {
- url: 'https://gitlab.com/gitlab-org/gitlab',
- },
- },
- preview: {
- style: 'social',
- },
- },
- ],
- })
- })
- })
-
- context('with an nonexisting project', function () {
- it('returns the expected suggestions', async function () {
- const { statusCode, body } = await got(
- `${baseUrl}/$suggest/v1?url=${encodeURIComponent(
- 'https://gitlab.com/gitlab-org/not-gitlab'
- )}`,
- {
- responseType: 'json',
- }
- )
- expect(statusCode).to.equal(200)
- expect(body).to.deep.equal({
- suggestions: [
- {
- title: 'GitLab pipeline',
- link: 'https://gitlab.com/gitlab-org/not-gitlab/builds',
- example: {
- pattern: '/gitlab/pipeline/:user/:repo',
- namedParams: { user: 'gitlab-org', repo: 'not-gitlab' },
- queryParams: {},
- },
- },
- {
- title: 'Twitter',
- link: 'https://twitter.com/intent/tweet?text=Wow:&url=https%3A%2F%2Fgitlab.com%2Fgitlab-org%2Fnot-gitlab',
- example: {
- pattern: '/twitter/url',
- namedParams: {},
- queryParams: {
- url: 'https://gitlab.com/gitlab-org/not-gitlab',
- },
- },
- preview: {
- style: 'social',
- },
- },
- ],
- })
- })
- })
- })
-})
diff --git a/services/suggest.js b/services/suggest.js
deleted file mode 100644
index ce23724f9a5d3..0000000000000
--- a/services/suggest.js
+++ /dev/null
@@ -1,201 +0,0 @@
-// Suggestion API
-//
-// eg. /$suggest/v1?url=https://github.com/badges/shields
-//
-// This endpoint is called from frontend/components/suggestion-and-search.js.
-
-import { URL } from 'url'
-import request from 'request'
-
-function twitterPage(url) {
- if (url.protocol === null) {
- return null
- }
-
- const schema = url.protocol.slice(0, -1)
- const host = url.host
- const path = url.pathname
- return {
- title: 'Twitter',
- link: `https://twitter.com/intent/tweet?text=Wow:&url=${encodeURIComponent(
- url.href
- )}`,
- example: {
- pattern: '/twitter/url',
- namedParams: {},
- queryParams: { url: `${schema}://${host}${path}` },
- },
- preview: {
- style: 'social',
- },
- }
-}
-
-function githubIssues(user, repo) {
- const repoSlug = `${user}/${repo}`
- return {
- title: 'GitHub issues',
- link: `https://github.com/${repoSlug}/issues`,
- example: {
- pattern: '/github/issues/:user/:repo',
- namedParams: { user, repo },
- queryParams: {},
- },
- }
-}
-
-function githubForks(user, repo) {
- const repoSlug = `${user}/${repo}`
- return {
- title: 'GitHub forks',
- link: `https://github.com/${repoSlug}/network`,
- example: {
- pattern: '/github/forks/:user/:repo',
- namedParams: { user, repo },
- queryParams: {},
- },
- }
-}
-
-function githubStars(user, repo) {
- const repoSlug = `${user}/${repo}`
- return {
- title: 'GitHub stars',
- link: `https://github.com/${repoSlug}/stargazers`,
- example: {
- pattern: '/github/stars/:user/:repo',
- namedParams: { user, repo },
- queryParams: {},
- },
- }
-}
-
-async function githubLicense(githubApiProvider, user, repo) {
- const repoSlug = `${user}/${repo}`
-
- let link = `https://github.com/${repoSlug}`
-
- const { buffer } = await githubApiProvider.requestAsPromise(
- request,
- `/repos/${repoSlug}/license`
- )
- try {
- const data = JSON.parse(buffer)
- if ('html_url' in data) {
- link = data.html_url
- }
- } catch (e) {}
-
- return {
- title: 'GitHub license',
- link,
- example: {
- pattern: '/github/license/:user/:repo',
- namedParams: { user, repo },
- queryParams: {},
- },
- }
-}
-
-function gitlabPipeline(user, repo) {
- const repoSlug = `${user}/${repo}`
- return {
- title: 'GitLab pipeline',
- link: `https://gitlab.com/${repoSlug}/builds`,
- example: {
- pattern: '/gitlab/pipeline/:user/:repo',
- namedParams: { user, repo },
- queryParams: {},
- },
- }
-}
-
-async function findSuggestions(githubApiProvider, url) {
- let promises = []
- if (url.hostname === 'github.com' || url.hostname === 'gitlab.com') {
- const userRepo = url.pathname.slice(1).split('/')
- const user = userRepo[0]
- const repo = userRepo[1]
- if (url.hostname === 'github.com') {
- promises = promises.concat([
- githubIssues(user, repo),
- githubForks(user, repo),
- githubStars(user, repo),
- githubLicense(githubApiProvider, user, repo),
- ])
- } else {
- promises = promises.concat([gitlabPipeline(user, repo)])
- }
- }
- promises.push(twitterPage(url))
-
- const suggestions = await Promise.all(promises)
-
- return suggestions.filter(b => b != null)
-}
-
-// data: {url}, JSON-serializable object.
-// end: function(json), with json of the form:
-// - suggestions: list of objects of the form:
-// - title: string
-// - link: target as a string URL
-// - example: object
-// - pattern: string
-// - namedParams: object
-// - queryParams: object (optional)
-// - link: target as a string URL
-// - preview: object (optional)
-// - style: string
-function setRoutes(allowedOrigin, githubApiProvider, server) {
- server.ajax.on('suggest/v1', (data, end, ask) => {
- // The typical dev and production setups are cross-origin. However, in
- // Heroku deploys and some self-hosted deploys these requests may come from
- // the same host. Chrome does not send an Origin header on same-origin
- // requests, but Firefox does.
- //
- // It would be better to solve this problem using some well-tested
- // middleware.
- const origin = ask.req.headers.origin
- if (origin) {
- let host
- try {
- host = new URL(origin).hostname
- } catch (e) {
- ask.res.setHeader('Access-Control-Allow-Origin', 'null')
- end({ err: 'Disallowed' })
- return
- }
-
- if (host !== ask.req.headers.host) {
- if (allowedOrigin.includes(origin)) {
- ask.res.setHeader('Access-Control-Allow-Origin', origin)
- } else {
- ask.res.setHeader('Access-Control-Allow-Origin', 'null')
- end({ err: 'Disallowed' })
- return
- }
- }
- }
-
- let url
- try {
- url = new URL(data.url)
- } catch (e) {
- end({ err: `${e}` })
- return
- }
-
- findSuggestions(githubApiProvider, url)
- // This interacts with callback code and can't use async/await.
- // eslint-disable-next-line promise/prefer-await-to-then
- .then(suggestions => {
- end({ suggestions })
- })
- // eslint-disable-next-line promise/prefer-await-to-then
- .catch(err => {
- end({ suggestions: [], err })
- })
- })
-}
-
-export { findSuggestions, githubLicense, setRoutes }
diff --git a/services/suggest.spec.js b/services/suggest.spec.js
deleted file mode 100644
index 3e2a9c8f495c1..0000000000000
--- a/services/suggest.spec.js
+++ /dev/null
@@ -1,177 +0,0 @@
-import Camp from '@shields_io/camp'
-import { expect } from 'chai'
-import nock from 'nock'
-import portfinder from 'portfinder'
-import got from '../core/got-test-client.js'
-import { setRoutes, githubLicense } from './suggest.js'
-import GithubApiProvider from './github/github-api-provider.js'
-
-describe('Badge suggestions', function () {
- const githubApiBaseUrl = 'https://api.github.test'
- const apiProvider = new GithubApiProvider({
- baseUrl: githubApiBaseUrl,
- globalToken: 'fake-token',
- withPooling: false,
- })
-
- describe('GitHub license', function () {
- context('When html_url included in response', function () {
- it('Should link to it', async function () {
- const scope = nock(githubApiBaseUrl)
- .get('/repos/atom/atom/license')
- .reply(200, {
- html_url: 'https://github.com/atom/atom/blob/master/LICENSE.md',
- license: {
- key: 'mit',
- name: 'MIT License',
- spdx_id: 'MIT',
- url: 'https://api.github.com/licenses/mit',
- node_id: 'MDc6TGljZW5zZTEz',
- },
- })
-
- expect(await githubLicense(apiProvider, 'atom', 'atom')).to.deep.equal({
- title: 'GitHub license',
- link: 'https://github.com/atom/atom/blob/master/LICENSE.md',
- example: {
- pattern: '/github/license/:user/:repo',
- namedParams: { user: 'atom', repo: 'atom' },
- queryParams: {},
- },
- })
-
- scope.done()
- })
- })
-
- context('When html_url not included in response', function () {
- it('Should link to the repo', async function () {
- const scope = nock(githubApiBaseUrl)
- .get('/repos/atom/atom/license')
- .reply(200, {
- license: { key: 'mit' },
- })
-
- expect(await githubLicense(apiProvider, 'atom', 'atom')).to.deep.equal({
- title: 'GitHub license',
- link: 'https://github.com/atom/atom',
- example: {
- pattern: '/github/license/:user/:repo',
- namedParams: { user: 'atom', repo: 'atom' },
- queryParams: {},
- },
- })
-
- scope.done()
- })
- })
- })
-
- describe('Scoutcamp integration', function () {
- 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
- }
- })
-
- const origin = 'https://example.test'
- before(function () {
- setRoutes([origin], apiProvider, camp)
- })
-
- context('without an origin header', function () {
- it('returns the expected suggestions', async function () {
- const scope = nock(githubApiBaseUrl)
- .get('/repos/atom/atom/license')
- .reply(200, {
- html_url: 'https://github.com/atom/atom/blob/master/LICENSE.md',
- license: {
- key: 'mit',
- name: 'MIT License',
- spdx_id: 'MIT',
- url: 'https://api.github.com/licenses/mit',
- node_id: 'MDc6TGljZW5zZTEz',
- },
- })
-
- const { statusCode, body } = await got(
- `${baseUrl}/$suggest/v1?url=${encodeURIComponent(
- 'https://github.com/atom/atom'
- )}`,
- {
- responseType: 'json',
- }
- )
- expect(statusCode).to.equal(200)
- expect(body).to.deep.equal({
- suggestions: [
- {
- title: 'GitHub issues',
- link: 'https://github.com/atom/atom/issues',
- example: {
- pattern: '/github/issues/:user/:repo',
- namedParams: { user: 'atom', repo: 'atom' },
- queryParams: {},
- },
- },
- {
- title: 'GitHub forks',
- link: 'https://github.com/atom/atom/network',
- example: {
- pattern: '/github/forks/:user/:repo',
- namedParams: { user: 'atom', repo: 'atom' },
- queryParams: {},
- },
- },
- {
- title: 'GitHub stars',
- link: 'https://github.com/atom/atom/stargazers',
- example: {
- pattern: '/github/stars/:user/:repo',
- namedParams: { user: 'atom', repo: 'atom' },
- queryParams: {},
- },
- },
- {
- title: 'GitHub license',
- link: 'https://github.com/atom/atom/blob/master/LICENSE.md',
- example: {
- pattern: '/github/license/:user/:repo',
- namedParams: { user: 'atom', repo: 'atom' },
- queryParams: {},
- },
- },
- {
- title: 'Twitter',
- link: 'https://twitter.com/intent/tweet?text=Wow:&url=https%3A%2F%2Fgithub.com%2Fatom%2Fatom',
- example: {
- pattern: '/twitter/url',
- namedParams: {},
- queryParams: {
- url: 'https://github.com/atom/atom',
- },
- },
- preview: {
- style: 'social',
- },
- },
- ],
- })
-
- scope.done()
- })
- })
- })
-})
diff --git a/services/swagger/swagger-redirect.service.js b/services/swagger/swagger-redirect.service.js
index 091acf5c08c6f..5ddeb4d22403f 100644
--- a/services/swagger/swagger-redirect.service.js
+++ b/services/swagger/swagger-redirect.service.js
@@ -1,18 +1,15 @@
-import { redirector } from '../index.js'
+import { deprecatedService } from '../index.js'
export default [
- redirector({
+ deprecatedService({
category: 'other',
+ label: 'swagger',
name: 'SwaggerRedirect',
route: {
base: 'swagger/valid/2.0',
pattern: ':scheme(http|https)/:url*',
},
- transformPath: () => `/swagger/valid/3.0`,
- transformQueryParams: ({ scheme, url }) => {
- const suffix = /(yaml|yml|json)$/.test(url) ? '' : '.json'
- return { specUrl: `${scheme}://${url}${suffix}` }
- },
- dateAdded: new Date('2019-11-03'),
+ dateAdded: new Date('2025-12-20'),
+ issueUrl: 'https://github.com/badges/shields/pull/11583',
}),
]
diff --git a/services/swagger/swagger-redirect.tester.js b/services/swagger/swagger-redirect.tester.js
index c4658882236c1..f135928490bec 100644
--- a/services/swagger/swagger-redirect.tester.js
+++ b/services/swagger/swagger-redirect.tester.js
@@ -6,26 +6,17 @@ export const t = new ServiceTester({
pathPrefix: '/swagger/valid/2.0',
})
-t.create('swagger json')
- .get('/https/example.com/example.svg')
- .expectRedirect(
- `/swagger/valid/3.0.svg?specUrl=${encodeURIComponent(
- 'https://example.com/example.json'
- )}`
- )
+t.create('swagger json').get('/https/example.com/example.json').expectBadge({
+ label: 'swagger',
+ message: 'https://github.com/badges/shields/pull/11583',
+})
-t.create('swagger yml')
- .get('/https/example.com/example.yml')
- .expectRedirect(
- `/swagger/valid/3.0.svg?specUrl=${encodeURIComponent(
- 'https://example.com/example.yml'
- )}`
- )
+t.create('swagger yml').get('/https/example.com/example.json').expectBadge({
+ label: 'swagger',
+ message: 'https://github.com/badges/shields/pull/11583',
+})
-t.create('swagger yaml')
- .get('/https/example.com/example.yaml')
- .expectRedirect(
- `/swagger/valid/3.0.svg?specUrl=${encodeURIComponent(
- 'https://example.com/example.yaml'
- )}`
- )
+t.create('swagger yaml').get('/https/example.com/example.json').expectBadge({
+ label: 'swagger',
+ message: 'https://github.com/badges/shields/pull/11583',
+})
diff --git a/services/swagger/swagger.service.js b/services/swagger/swagger.service.js
index be4127345553b..87bc1e70d725e 100644
--- a/services/swagger/swagger.service.js
+++ b/services/swagger/swagger.service.js
@@ -1,6 +1,6 @@
import Joi from 'joi'
-import { optionalUrl } from '../validators.js'
-import { BaseJsonService, NotFound } from '../index.js'
+import { url } from '../validators.js'
+import { BaseJsonService, NotFound, queryParams } from '../index.js'
const schema = Joi.object()
.keys({
@@ -8,13 +8,13 @@ const schema = Joi.object()
Joi.object({
level: Joi.string().required(),
message: Joi.string().required(),
- }).required()
+ }),
),
})
.required()
const queryParamSchema = Joi.object({
- specUrl: optionalUrl.required(),
+ specUrl: url,
}).required()
export default class SwaggerValidatorService extends BaseJsonService {
@@ -26,17 +26,19 @@ export default class SwaggerValidatorService extends BaseJsonService {
queryParamSchema,
}
- static examples = [
- {
- title: 'Swagger Validator',
- staticPreview: this.render({ status: 'valid' }),
- namedParams: {},
- queryParams: {
- specUrl:
- 'https://raw.githubusercontent.com/OAI/OpenAPI-Specification/master/examples/v2.0/json/petstore-expanded.json',
+ static openApi = {
+ '/swagger/valid/3.0': {
+ get: {
+ summary: 'Swagger Validator',
+ parameters: queryParams({
+ name: 'specUrl',
+ required: true,
+ example:
+ 'https://raw.githubusercontent.com/OAI/OpenAPI-Specification/c442afe06ec28443df0c69d01dc38c54968b246f/examples/v2.0/json/petstore-expanded.json',
+ }),
},
},
- ]
+ }
static defaultBadgeData = {
label: 'swagger',
@@ -52,10 +54,10 @@ export default class SwaggerValidatorService extends BaseJsonService {
async fetch({ specUrl }) {
return this._requestJson({
- url: 'http://validator.swagger.io/validator/debug',
+ url: 'https://validator.swagger.io/validator/debug',
schema,
options: {
- qs: {
+ searchParams: {
url: specUrl,
},
},
@@ -69,7 +71,7 @@ export default class SwaggerValidatorService extends BaseJsonService {
} else if (valMessages.length === 1) {
const { message, level } = valMessages[0]
if (level === 'error' && message === `Can't read from file ${specUrl}`) {
- throw new NotFound({ prettyMessage: 'spec not found or unreadable ' })
+ throw new NotFound({ prettyMessage: 'spec not found or unreadable' })
}
}
if (valMessages.every(msg => msg.level === 'warning')) {
diff --git a/services/swagger/swagger.tester.js b/services/swagger/swagger.tester.js
index 6c154b6354223..3f61f55306851 100644
--- a/services/swagger/swagger.tester.js
+++ b/services/swagger/swagger.tester.js
@@ -2,7 +2,7 @@ import { createServiceTester } from '../tester.js'
const getURL = '/3.0.json?specUrl=https://example.com/example.json'
const getURLBase = '/3.0.json?specUrl='
-const apiURL = 'http://validator.swagger.io'
+const apiURL = 'https://validator.swagger.io'
const apiGetURL = '/validator/debug'
const apiGetQueryParams = {
url: 'https://example.com/example.json',
@@ -22,7 +22,7 @@ t.create('Invalid')
message: 'error',
},
],
- })
+ }),
)
.expectBadge({
label: 'swagger',
@@ -32,7 +32,7 @@ t.create('Invalid')
t.create('Valid json 2.0')
.get(
- `${getURLBase}https://raw.githubusercontent.com/OAI/OpenAPI-Specification/master/examples/v2.0/json/petstore-expanded.json`
+ `${getURLBase}https://raw.githubusercontent.com/OAI/OpenAPI-Specification/c442afe06ec28443df0c69d01dc38c54968b246f/examples/v2.0/json/petstore-expanded.json`,
)
.expectBadge({
label: 'swagger',
@@ -42,7 +42,7 @@ t.create('Valid json 2.0')
t.create('Valid yaml 3.0')
.get(
- `${getURLBase}https://raw.githubusercontent.com/OAI/OpenAPI-Specification/master/examples/v3.0/petstore.yaml`
+ `${getURLBase}https://raw.githubusercontent.com/OAI/OpenAPI-Specification/c442afe06ec28443df0c69d01dc38c54968b246f/examples/v3.0/petstore.yaml`,
)
.expectBadge({
label: 'swagger',
@@ -61,7 +61,7 @@ t.create('Valid with warnings')
// Isn't a spec, but valid json
t.create('Invalid')
.get(
- `${getURLBase}https://raw.githubusercontent.com/OAI/OpenAPI-Specification/master/schemas/v3.0/schema.json`
+ `${getURLBase}https://raw.githubusercontent.com/OAI/OpenAPI-Specification/c442afe06ec28443df0c69d01dc38c54968b246f/schemas/v3.0/schema.json`,
)
.expectBadge({
label: 'swagger',
@@ -71,7 +71,7 @@ t.create('Invalid')
t.create('Not found')
.get(
- `${getURLBase}https://raw.githubusercontent.com/OAI/OpenAPI-Specification/master/examples/v3.0/notFound.yaml`
+ `${getURLBase}https://raw.githubusercontent.com/OAI/OpenAPI-Specification/c442afe06ec28443df0c69d01dc38c54968b246f/examples/v3.0/notFound.yaml`,
)
.expectBadge({
label: 'swagger',
diff --git a/services/symfony/symfony-insight-base.js b/services/symfony/symfony-insight-base.js
index 8cace75a89741..3bd1a3cbc0d27 100644
--- a/services/symfony/symfony-insight-base.js
+++ b/services/symfony/symfony-insight-base.js
@@ -13,24 +13,29 @@ const schema = Joi.object({
'running',
'measured',
'analyzed',
- 'finished'
+ 'finished',
)
.allow('')
.required(),
grade: Joi.equal('platinum', 'gold', 'silver', 'bronze', 'none'),
- violations: Joi.object({
- // RE: https://github.com/NaturalIntelligence/fast-xml-parser/issues/68
- // The BaseXmlService uses the fast-xml-parser which doesn't support forcing
- // the xml nodes to always be parsed as an array. Currently, if the response
- // only contains a single violation then it will be parsed as an object,
- // otherwise it will be parsed as an array.
- violation: Joi.array().items(violationSchema).single().required(),
- }),
+ violations: Joi.alternatives().try(
+ Joi.object({
+ // RE: https://github.com/NaturalIntelligence/fast-xml-parser/issues/68
+ // The BaseXmlService uses the fast-xml-parser which doesn't support forcing
+ // the xml nodes to always be parsed as an array. Currently, if the response
+ // only contains a single violation then it will be parsed as an object,
+ // otherwise it will be parsed as an array.
+ violation: Joi.array().items(violationSchema).single().required(),
+ }),
+ // If no violations are found, the response will be an empty string.
+ Joi.string().allow(''),
+ ),
}),
}).required(),
}).required()
-const keywords = ['sensiolabs', 'sensio']
+const description =
+ 'SymfonyInsight (formerly SensioLabs) is a code analysis service'
const gradeColors = {
none: 'red',
@@ -62,7 +67,7 @@ class SymfonyInsightBase extends BaseXmlService {
options: {
headers: { Accept: 'application/vnd.com.sensiolabs.insight+xml' },
},
- errorMessages: {
+ httpErrors: {
401: 'not authorized to access project',
404: 'project not found',
},
@@ -70,7 +75,7 @@ class SymfonyInsightBase extends BaseXmlService {
attributeNamePrefix: '',
ignoreAttributes: false,
},
- })
+ }),
)
}
@@ -124,4 +129,4 @@ class SymfonyInsightBase extends BaseXmlService {
}
}
-export { SymfonyInsightBase, keywords, gradeColors }
+export { SymfonyInsightBase, description, gradeColors }
diff --git a/services/symfony/symfony-insight-grade.service.js b/services/symfony/symfony-insight-grade.service.js
index 4e220915373a3..fe72feb567a9f 100644
--- a/services/symfony/symfony-insight-grade.service.js
+++ b/services/symfony/symfony-insight-grade.service.js
@@ -1,6 +1,7 @@
+import { pathParams } from '../index.js'
import {
SymfonyInsightBase,
- keywords,
+ description,
gradeColors,
} from './symfony-insight-base.js'
@@ -10,19 +11,18 @@ export default class SymfonyInsightGrade extends SymfonyInsightBase {
pattern: ':projectUuid',
}
- static examples = [
- {
- title: 'SymfonyInsight Grade',
- namedParams: {
- projectUuid: '825be328-29f8-44f7-a750-f82818ae9111',
+ static openApi = {
+ '/symfony/i/grade/{projectUuid}': {
+ get: {
+ summary: 'SymfonyInsight Grade',
+ description,
+ parameters: pathParams({
+ name: 'projectUuid',
+ example: '825be328-29f8-44f7-a750-f82818ae9111',
+ }),
},
- staticPreview: this.render({
- grade: 'bronze',
- status: 'finished',
- }),
- keywords,
},
- ]
+ }
static render({ status, grade = 'none' }) {
const label = 'grade'
diff --git a/services/symfony/symfony-insight-grade.spec.js b/services/symfony/symfony-insight-grade.spec.js
new file mode 100644
index 0000000000000..28bd368c062a6
--- /dev/null
+++ b/services/symfony/symfony-insight-grade.spec.js
@@ -0,0 +1,17 @@
+import { testAuth } from '../test-helpers.js'
+import SymfonyInsightGrade from './symfony-insight-grade.service.js'
+
+describe('SymfonyInsightGrade', function () {
+ describe('auth', function () {
+ it('sends the auth information as configured', async function () {
+ return testAuth(
+ SymfonyInsightGrade,
+ 'BasicAuth',
+ `
+ gold
+ finished
+ `,
+ )
+ })
+ })
+})
diff --git a/services/symfony/symfony-insight-grade.tester.js b/services/symfony/symfony-insight-grade.tester.js
index 85d4be697c0bc..8b69c22633dbe 100644
--- a/services/symfony/symfony-insight-grade.tester.js
+++ b/services/symfony/symfony-insight-grade.tester.js
@@ -14,7 +14,7 @@ t.create('valid project grade')
'gold',
'silver',
'bronze',
- 'no medal'
+ 'no medal',
).required(),
})
diff --git a/services/symfony/symfony-insight-stars.service.js b/services/symfony/symfony-insight-stars.service.js
index 1665c255b8d6d..c5cae327e8dd1 100644
--- a/services/symfony/symfony-insight-stars.service.js
+++ b/services/symfony/symfony-insight-stars.service.js
@@ -1,7 +1,8 @@
+import { pathParams } from '../index.js'
import { starRating } from '../text-formatters.js'
import {
SymfonyInsightBase,
- keywords,
+ description,
gradeColors,
} from './symfony-insight-base.js'
@@ -19,19 +20,18 @@ export default class SymfonyInsightStars extends SymfonyInsightBase {
pattern: ':projectUuid',
}
- static examples = [
- {
- title: 'SymfonyInsight Stars',
- namedParams: {
- projectUuid: '825be328-29f8-44f7-a750-f82818ae9111',
+ static openApi = {
+ '/symfony/i/stars/{projectUuid}': {
+ get: {
+ summary: 'SymfonyInsight Stars',
+ description,
+ parameters: pathParams({
+ name: 'projectUuid',
+ example: '825be328-29f8-44f7-a750-f82818ae9111',
+ }),
},
- staticPreview: this.render({
- grade: 'silver',
- status: 'finished',
- }),
- keywords,
},
- ]
+ }
static render({ status, grade }) {
const label = 'stars'
diff --git a/services/symfony/symfony-insight-stars.spec.js b/services/symfony/symfony-insight-stars.spec.js
new file mode 100644
index 0000000000000..e1a3deffb7502
--- /dev/null
+++ b/services/symfony/symfony-insight-stars.spec.js
@@ -0,0 +1,17 @@
+import { testAuth } from '../test-helpers.js'
+import SymfonyInsightStars from './symfony-insight-stars.service.js'
+
+describe('SymfonyInsightStars', function () {
+ describe('auth', function () {
+ it('sends the auth information as configured', async function () {
+ return testAuth(
+ SymfonyInsightStars,
+ 'BasicAuth',
+ `
+ gold
+ finished
+ `,
+ )
+ })
+ })
+})
diff --git a/services/symfony/symfony-insight-stars.tester.js b/services/symfony/symfony-insight-stars.tester.js
index 0370fb01f2055..0500bf49871e6 100644
--- a/services/symfony/symfony-insight-stars.tester.js
+++ b/services/symfony/symfony-insight-stars.tester.js
@@ -10,7 +10,7 @@ t.create('valid project stars')
.expectBadge({
label: 'stars',
message: withRegex(
- /^(?=.{4}$)(\u2605{0,4}[\u00BC\u00BD\u00BE]?\u2606{0,4})$/
+ /^(?=.{4}$)(\u2605{0,4}[\u00BC\u00BD\u00BE]?\u2606{0,4})$/,
),
})
diff --git a/services/symfony/symfony-insight-violations.service.js b/services/symfony/symfony-insight-violations.service.js
index b620286b254c4..d1201857eb543 100644
--- a/services/symfony/symfony-insight-violations.service.js
+++ b/services/symfony/symfony-insight-violations.service.js
@@ -1,4 +1,5 @@
-import { SymfonyInsightBase, keywords } from './symfony-insight-base.js'
+import { pathParams } from '../index.js'
+import { SymfonyInsightBase, description } from './symfony-insight-base.js'
export default class SymfonyInsightViolations extends SymfonyInsightBase {
static route = {
@@ -6,19 +7,18 @@ export default class SymfonyInsightViolations extends SymfonyInsightBase {
pattern: ':projectUuid',
}
- static examples = [
- {
- title: 'SymfonyInsight Violations',
- namedParams: {
- projectUuid: '825be328-29f8-44f7-a750-f82818ae9111',
+ static openApi = {
+ '/symfony/i/violations/{projectUuid}': {
+ get: {
+ summary: 'SymfonyInsight Violations',
+ description,
+ parameters: pathParams({
+ name: 'projectUuid',
+ example: '825be328-29f8-44f7-a750-f82818ae9111',
+ }),
},
- staticPreview: this.render({
- numViolations: 0,
- status: 'finished',
- }),
- keywords,
},
- ]
+ }
static render({
status,
diff --git a/services/symfony/symfony-insight-violations.spec.js b/services/symfony/symfony-insight-violations.spec.js
new file mode 100644
index 0000000000000..0c20a3838a785
--- /dev/null
+++ b/services/symfony/symfony-insight-violations.spec.js
@@ -0,0 +1,16 @@
+import { testAuth } from '../test-helpers.js'
+import SymfonyInsightViolations from './symfony-insight-violations.service.js'
+
+describe('SymfonyInsightViolations', function () {
+ describe('auth', function () {
+ it('sends the auth information as configured', async function () {
+ return testAuth(
+ SymfonyInsightViolations,
+ 'BasicAuth',
+ `
+ finished
+ `,
+ )
+ })
+ })
+})
diff --git a/services/symfony/symfony-insight-violations.tester.js b/services/symfony/symfony-insight-violations.tester.js
index f35a9bbc63762..c0b4795188e95 100644
--- a/services/symfony/symfony-insight-violations.tester.js
+++ b/services/symfony/symfony-insight-violations.tester.js
@@ -10,6 +10,6 @@ t.create('valid project violations')
.expectBadge({
label: 'violations',
message: withRegex(
- /0|\d* critical|\d* critical, \d* major|\d* critical, \d* major, \d* minor|\d* critical, \d* major, \d* minor, \d* info|\d* critical, \d* minor|\d* critical, \d* info|\d* major|\d* major, \d* minor|\d* major, \d* minor, \d* info|\d* major, \d* info|\d* minor|\d* minor, \d* info|\d* info/
+ /0|\d* critical|\d* critical, \d* major|\d* critical, \d* major, \d* minor|\d* critical, \d* major, \d* minor, \d* info|\d* critical, \d* minor|\d* critical, \d* info|\d* major|\d* major, \d* minor|\d* major, \d* minor, \d* info|\d* major, \d* info|\d* minor|\d* minor, \d* info|\d* info/,
),
})
diff --git a/services/symfony/symfony-insight.spec.js b/services/symfony/symfony-insight.spec.js
index ea9855d96878b..46ddb28e49846 100644
--- a/services/symfony/symfony-insight.spec.js
+++ b/services/symfony/symfony-insight.spec.js
@@ -42,7 +42,7 @@ describe('SymfonyInsight[Grade|Stars|Violation]', function () {
it('401 not authorized grade', async function () {
const scope = createMock().reply(401)
expect(
- await SymfonyInsightGrade.invoke(defaultContext, config, { projectUuid })
+ await SymfonyInsightGrade.invoke(defaultContext, config, { projectUuid }),
).to.deep.equal({
message: 'not authorized to access project',
color: 'lightgray',
@@ -70,7 +70,7 @@ describe('SymfonyInsight[Grade|Stars|Violation]', function () {
expect(
await SymfonyInsightGrade.invoke(defaultContext, config, {
projectUuid,
- })
+ }),
).to.deep.equal(expectedGradeBadge)
scope.done()
})
@@ -82,7 +82,7 @@ describe('SymfonyInsight[Grade|Stars|Violation]', function () {
expect(
await SymfonyInsightStars.invoke(defaultContext, config, {
projectUuid,
- })
+ }),
).to.deep.equal(expectedStarsBadge)
scope.done()
})
@@ -94,7 +94,7 @@ describe('SymfonyInsight[Grade|Stars|Violation]', function () {
expect(
await SymfonyInsightViolations.invoke(defaultContext, config, {
projectUuid,
- })
+ }),
).to.deep.equal(expectedViolationsBadge)
scope.done()
})
diff --git a/services/teamcity/teamcity-base.js b/services/teamcity/teamcity-base.js
index 1e811a7aef5c6..d8abbf48c0051 100644
--- a/services/teamcity/teamcity-base.js
+++ b/services/teamcity/teamcity-base.js
@@ -7,11 +7,11 @@ export default class TeamCityBase extends BaseJsonService {
serviceKey: 'teamcity',
}
- async fetch({ url, schema, qs = {}, errorMessages = {} }) {
+ async fetch({ url, schema, searchParams = {}, httpErrors = {} }) {
// JetBrains API Auth Docs: https://confluence.jetbrains.com/display/TCD18/REST+API#RESTAPI-RESTAuthentication
- const options = { qs }
+ const options = { searchParams }
if (!this.authHelper.isConfigured) {
- qs.guest = 1
+ searchParams.guest = 1
}
return this._requestJson(
@@ -19,8 +19,8 @@ export default class TeamCityBase extends BaseJsonService {
url,
schema,
options,
- errorMessages: { 404: 'build not found', ...errorMessages },
- })
+ httpErrors: { 404: 'build not found', ...httpErrors },
+ }),
)
}
}
diff --git a/services/teamcity/teamcity-build-redirect.service.js b/services/teamcity/teamcity-build-redirect.service.js
index bb63122228c5c..ea7998f59b2ac 100644
--- a/services/teamcity/teamcity-build-redirect.service.js
+++ b/services/teamcity/teamcity-build-redirect.service.js
@@ -1,26 +1,20 @@
-import { redirector } from '../index.js'
-
-const commonAttrs = {
- dateAdded: new Date('2019-09-15'),
- category: 'build',
-}
+import { deprecatedService, redirector } from '../index.js'
export default [
- redirector({
- ...commonAttrs,
+ deprecatedService({
name: 'TeamCityBuildLegacyCodeBetterRedirect',
+ category: 'build',
+ label: 'teamcity',
route: {
base: 'teamcity/codebetter',
pattern: ':buildId',
},
- transformPath: ({ buildId }) => `/teamcity/build/s/${buildId}`,
- transformQueryParams: _params => ({
- server: 'https://teamcity.jetbrains.com',
- }),
+ dateAdded: new Date('2025-12-20'),
+ issueUrl: 'https://github.com/badges/shields/pull/11583',
}),
redirector({
- ...commonAttrs,
name: 'TeamCityBuildRedirect',
+ category: 'build',
route: {
base: 'teamcity',
pattern:
@@ -31,5 +25,6 @@ export default [
transformQueryParams: ({ protocol, hostAndPath }) => ({
server: `${protocol}://${hostAndPath}`,
}),
+ dateAdded: new Date('2019-09-15'),
}),
]
diff --git a/services/teamcity/teamcity-build-redirect.tester.js b/services/teamcity/teamcity-build-redirect.tester.js
index c3f20425bbf24..37eab2d3e3e62 100644
--- a/services/teamcity/teamcity-build-redirect.tester.js
+++ b/services/teamcity/teamcity-build-redirect.tester.js
@@ -7,25 +7,24 @@ export const t = new ServiceTester({
})
t.create('codebetter')
- .get('/codebetter/IntelliJIdeaCe_JavaDecompilerEngineTests.svg')
- .expectRedirect(
- `/teamcity/build/s/IntelliJIdeaCe_JavaDecompilerEngineTests.svg?server=${encodeURIComponent(
- 'https://teamcity.jetbrains.com'
- )}`
- )
+ .get('/codebetter/IntelliJIdeaCe_JavaDecompilerEngineTests.json')
+ .expectBadge({
+ label: 'teamcity',
+ message: 'https://github.com/badges/shields/pull/11583',
+ })
t.create('hostAndPath simple build')
.get('/https/teamcity.jetbrains.com/s/bt345.svg')
.expectRedirect(
`/teamcity/build/s/bt345.svg?server=${encodeURIComponent(
- 'https://teamcity.jetbrains.com'
- )}`
+ 'https://teamcity.jetbrains.com',
+ )}`,
)
t.create('hostAndPath full build')
.get('/https/teamcity.jetbrains.com/e/bt345.svg')
.expectRedirect(
`/teamcity/build/e/bt345.svg?server=${encodeURIComponent(
- 'https://teamcity.jetbrains.com'
- )}`
+ 'https://teamcity.jetbrains.com',
+ )}`,
)
diff --git a/services/teamcity/teamcity-build.service.js b/services/teamcity/teamcity-build.service.js
index bf90c10979a8f..3eaa918968610 100644
--- a/services/teamcity/teamcity-build.service.js
+++ b/services/teamcity/teamcity-build.service.js
@@ -1,5 +1,6 @@
import Joi from 'joi'
import { optionalUrl } from '../validators.js'
+import { pathParam, queryParam } from '../index.js'
import TeamCityBase from './teamcity-base.js'
const buildStatusSchema = Joi.object({
@@ -20,37 +21,35 @@ export default class TeamCityBuild extends TeamCityBase {
queryParamSchema,
}
- static examples = [
- {
- title: 'TeamCity Simple Build Status',
- namedParams: {
- verbosity: 's',
- buildId: 'IntelliJIdeaCe_JavaDecompilerEngineTests',
+ static openApi = {
+ '/teamcity/build/s/{buildId}': {
+ get: {
+ summary: 'TeamCity Simple Build Status',
+ parameters: [
+ pathParam({
+ name: 'buildId',
+ example: 'IntelliJIdeaCe_JavaDecompilerEngineTests',
+ }),
+ queryParam({
+ name: 'server',
+ example: 'https://teamcity.jetbrains.com',
+ }),
+ ],
},
- queryParams: {
- server: 'https://teamcity.jetbrains.com',
- },
- staticPreview: this.render({
- status: 'SUCCESS',
- }),
},
- {
- title: 'TeamCity Full Build Status',
- namedParams: {
- verbosity: 'e',
- buildId: 'bt345',
- },
- queryParams: {
- server: 'https://teamcity.jetbrains.com',
+ '/teamcity/build/e/{buildId}': {
+ get: {
+ summary: 'TeamCity Full Build Status',
+ parameters: [
+ pathParam({ name: 'buildId', example: 'bt345' }),
+ queryParam({
+ name: 'server',
+ example: 'https://teamcity.jetbrains.com',
+ }),
+ ],
},
- staticPreview: this.render({
- status: 'FAILURE',
- statusText: 'Tests failed: 4, passed: 1103, ignored: 2',
- useVerbose: true,
- }),
- keywords: ['test', 'test results'],
},
- ]
+ }
static defaultBadgeData = {
label: 'build',
@@ -77,7 +76,7 @@ export default class TeamCityBuild extends TeamCityBase {
async handle(
{ verbosity, buildId },
- { server = 'https://teamcity.jetbrains.com' }
+ { server = 'https://teamcity.jetbrains.com' },
) {
// JetBrains Docs: https://confluence.jetbrains.com/display/TCD18/REST+API#RESTAPI-BuildStatusIcon
const buildLocator = `buildType:(id:${buildId})`
diff --git a/services/teamcity/teamcity-build.spec.js b/services/teamcity/teamcity-build.spec.js
index f81599a62e3b7..91ba4c6a85253 100644
--- a/services/teamcity/teamcity-build.spec.js
+++ b/services/teamcity/teamcity-build.spec.js
@@ -1,39 +1,16 @@
-import { expect } from 'chai'
-import nock from 'nock'
-import { cleanUpNockAfterEach, defaultContext } from '../test-helpers.js'
+import { testAuth } from '../test-helpers.js'
import TeamCityBuild from './teamcity-build.service.js'
-import { user, pass, host, config } from './teamcity-test-helpers.js'
+import { config } from './teamcity-test-helpers.js'
describe('TeamCityBuild', function () {
- cleanUpNockAfterEach()
-
- it('sends the auth information as configured', async function () {
- const scope = nock(`https://${host}`)
- .get(`/app/rest/builds/${encodeURIComponent('buildType:(id:bt678)')}`)
- // 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.
- .basicAuth({ user, pass })
- .reply(200, {
- status: 'FAILURE',
- statusText:
- 'Tests failed: 1 (1 new), passed: 50246, ignored: 1, muted: 12',
- })
-
- expect(
- await TeamCityBuild.invoke(
- defaultContext,
- config,
- {
- verbosity: 'e',
- buildId: 'bt678',
- },
- { server: `https://${host}` }
+ describe('auth', function () {
+ it('sends the auth information as configured', async function () {
+ return testAuth(
+ TeamCityBuild,
+ 'BasicAuth',
+ { status: 'SUCCESS', statusText: 'Success' },
+ { configOverride: config },
)
- ).to.deep.equal({
- message: 'tests failed: 1 (1 new), passed: 50246, ignored: 1, muted: 12',
- color: 'red',
})
-
- scope.done()
})
})
diff --git a/services/teamcity/teamcity-build.tester.js b/services/teamcity/teamcity-build.tester.js
index 3fcbb217e8b1e..85900fd401063 100644
--- a/services/teamcity/teamcity-build.tester.js
+++ b/services/teamcity/teamcity-build.tester.js
@@ -34,7 +34,7 @@ t.create('codebetter success build')
.reply(200, {
status: 'SUCCESS',
statusText: 'Success',
- })
+ }),
)
.expectBadge({
label: 'build',
@@ -51,7 +51,7 @@ t.create('codebetter failure build')
.reply(200, {
status: 'FAILURE',
statusText: 'Tests failed: 2',
- })
+ }),
)
.expectBadge({
label: 'build',
@@ -68,7 +68,7 @@ t.create('simple build status with passed build')
.reply(200, {
status: 'SUCCESS',
statusText: 'Tests passed: 100',
- })
+ }),
)
.expectBadge({
label: 'build',
@@ -85,7 +85,7 @@ t.create('simple build status with failed build')
.reply(200, {
status: 'FAILURE',
statusText: 'Tests failed: 10 (2 new)',
- })
+ }),
)
.expectBadge({
label: 'build',
@@ -102,7 +102,7 @@ t.create('full build status with passed build')
.reply(200, {
status: 'SUCCESS',
statusText: 'Tests passed: 100, ignored: 3',
- })
+ }),
)
.expectBadge({
label: 'build',
@@ -119,7 +119,7 @@ t.create('full build status with failed build')
.reply(200, {
status: 'FAILURE',
statusText: 'Tests failed: 10 (2 new), passed: 99',
- })
+ }),
)
.expectBadge({
label: 'build',
@@ -136,7 +136,7 @@ t.create('full build status with passed build chain')
.reply(200, {
status: 'SUCCESS',
statusText: 'Build chain finished (success: 9)',
- })
+ }),
)
.expectBadge({
label: 'build',
diff --git a/services/teamcity/teamcity-coverage-redirect.service.js b/services/teamcity/teamcity-coverage-redirect.service.js
deleted file mode 100644
index 5c989dea9e647..0000000000000
--- a/services/teamcity/teamcity-coverage-redirect.service.js
+++ /dev/null
@@ -1,16 +0,0 @@
-import { redirector } from '../index.js'
-
-export default [
- redirector({
- category: 'coverage',
- route: {
- base: 'teamcity/coverage',
- pattern: ':protocol(http|https)/:hostAndPath(.+)/:buildId',
- },
- transformPath: ({ buildId }) => `/teamcity/coverage/${buildId}`,
- transformQueryParams: ({ protocol, hostAndPath }) => ({
- server: `${protocol}://${hostAndPath}`,
- }),
- dateAdded: new Date('2019-09-15'),
- }),
-]
diff --git a/services/teamcity/teamcity-coverage-redirect.tester.js b/services/teamcity/teamcity-coverage-redirect.tester.js
deleted file mode 100644
index aeb6ce0068c27..0000000000000
--- a/services/teamcity/teamcity-coverage-redirect.tester.js
+++ /dev/null
@@ -1,15 +0,0 @@
-import { ServiceTester } from '../tester.js'
-
-export const t = new ServiceTester({
- id: 'TeamCityCoverageRedirect',
- title: 'TeamCityCoverageRedirect',
- pathPrefix: '/teamcity/coverage',
-})
-
-t.create('coverage')
- .get('/https/teamcity.jetbrains.com/ReactJSNet_PullRequests.svg')
- .expectRedirect(
- `/teamcity/coverage/ReactJSNet_PullRequests.svg?server=${encodeURIComponent(
- 'https://teamcity.jetbrains.com'
- )}`
- )
diff --git a/services/teamcity/teamcity-coverage.service.js b/services/teamcity/teamcity-coverage.service.js
index 8737da79e91c5..726b49e61069a 100644
--- a/services/teamcity/teamcity-coverage.service.js
+++ b/services/teamcity/teamcity-coverage.service.js
@@ -1,7 +1,7 @@
import Joi from 'joi'
import { coveragePercentage } from '../color-formatters.js'
import { optionalUrl } from '../validators.js'
-import { InvalidResponse } from '../index.js'
+import { InvalidResponse, pathParam, queryParam } from '../index.js'
import TeamCityBase from './teamcity-base.js'
const buildStatisticsSchema = Joi.object({
@@ -10,7 +10,7 @@ const buildStatisticsSchema = Joi.object({
Joi.object({
name: Joi.string().required(),
value: Joi.string().required(),
- })
+ }),
)
.required(),
}).required()
@@ -28,20 +28,20 @@ export default class TeamCityCoverage extends TeamCityBase {
queryParamSchema,
}
- static examples = [
- {
- title: 'TeamCity Coverage',
- namedParams: {
- buildId: 'ReactJSNet_PullRequests',
- },
- queryParams: {
- server: 'https://teamcity.jetbrains.com',
+ static openApi = {
+ '/teamcity/coverage/{buildId}': {
+ get: {
+ summary: 'TeamCity Coverage',
+ parameters: [
+ pathParam({ name: 'buildId', example: 'FileHelpersStable' }),
+ queryParam({
+ name: 'server',
+ example: 'https://teamcity.jetbrains.com',
+ }),
+ ],
},
- staticPreview: this.render({
- coverage: 82,
- }),
},
- ]
+ }
static defaultBadgeData = {
label: 'coverage',
@@ -77,7 +77,7 @@ export default class TeamCityCoverage extends TeamCityBase {
// JetBrains Docs: https://confluence.jetbrains.com/display/TCD18/REST+API#RESTAPI-Statistics
const buildLocator = `buildType:(id:${buildId})`
const apiPath = `app/rest/builds/${encodeURIComponent(
- buildLocator
+ buildLocator,
)}/statistics`
const data = await this.fetch({
url: `${server}/${apiPath}`,
diff --git a/services/teamcity/teamcity-coverage.spec.js b/services/teamcity/teamcity-coverage.spec.js
index f0fc578371af8..7bec5ef4c774e 100644
--- a/services/teamcity/teamcity-coverage.spec.js
+++ b/services/teamcity/teamcity-coverage.spec.js
@@ -1,44 +1,21 @@
-import { expect } from 'chai'
-import nock from 'nock'
-import { cleanUpNockAfterEach, defaultContext } from '../test-helpers.js'
+import { testAuth } from '../test-helpers.js'
import TeamCityCoverage from './teamcity-coverage.service.js'
-import { user, pass, host, config } from './teamcity-test-helpers.js'
+import { config } from './teamcity-test-helpers.js'
describe('TeamCityCoverage', function () {
- cleanUpNockAfterEach()
-
- it('sends the auth information as configured', async function () {
- const scope = nock(`https://${host}`)
- .get(
- `/app/rest/builds/${encodeURIComponent(
- 'buildType:(id:bt678)'
- )}/statistics`
- )
- .query({})
- // 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.
- .basicAuth({ user, pass })
- .reply(200, {
- property: [
- { name: 'CodeCoverageAbsSCovered', value: '82' },
- { name: 'CodeCoverageAbsSTotal', value: '100' },
- ],
- })
-
- expect(
- await TeamCityCoverage.invoke(
- defaultContext,
- config,
+ describe('auth', function () {
+ it('sends the auth information as configured', async function () {
+ return testAuth(
+ TeamCityCoverage,
+ 'BasicAuth',
{
- buildId: 'bt678',
+ property: [
+ { name: 'CodeCoverageAbsSCovered', value: '93' },
+ { name: 'CodeCoverageAbsSTotal', value: '95' },
+ ],
},
- { server: 'https://mycompany.teamcity.com' }
+ { configOverride: config },
)
- ).to.deep.equal({
- message: '82%',
- color: 'yellowgreen',
})
-
- scope.done()
})
})
diff --git a/services/teamcity/teamcity-coverage.tester.js b/services/teamcity/teamcity-coverage.tester.js
index 7a0c0387baab7..a6515d243d87e 100644
--- a/services/teamcity/teamcity-coverage.tester.js
+++ b/services/teamcity/teamcity-coverage.tester.js
@@ -6,13 +6,12 @@ t.create('invalid buildId')
.get('/btABC999.json')
.expectBadge({ label: 'coverage', message: 'build not found' })
-t.create('valid buildId').get('/ReactJSNet_PullRequests.json').expectBadge({
- label: 'coverage',
- message: isIntegerPercentage,
-})
+t.create('valid buildId')
+ .get('/FileHelpersStable.json')
+ .expectBadge({ label: 'coverage', message: isIntegerPercentage })
t.create('specified instance valid buildId')
- .get('/ReactJSNet_PullRequests.json?server=https://teamcity.jetbrains.com')
+ .get('/FileHelpersStable.json?server=https://teamcity.jetbrains.com')
.expectBadge({
label: 'coverage',
message: isIntegerPercentage,
@@ -24,7 +23,7 @@ t.create('no coverage data for build')
nock('https://teamcity.jetbrains.com/app/rest/builds')
.get(`/${encodeURIComponent('buildType:(id:bt234)')}/statistics`)
.query({ guest: 1 })
- .reply(200, { property: [] })
+ .reply(200, { property: [] }),
)
.expectBadge({ label: 'coverage', message: 'no coverage data available' })
@@ -45,7 +44,7 @@ t.create('zero lines covered')
value: '345',
},
],
- })
+ }),
)
.expectBadge({
label: 'coverage',
diff --git a/services/teamcity/teamcity-test-helpers.js b/services/teamcity/teamcity-test-helpers.js
index 655a16cbd94fb..a3051df624a82 100644
--- a/services/teamcity/teamcity-test-helpers.js
+++ b/services/teamcity/teamcity-test-helpers.js
@@ -1,18 +1,13 @@
-const user = 'admin'
-const pass = 'password'
-const host = 'mycompany.teamcity.com'
+import TeamCityBase from './teamcity-base.js'
+
const config = {
public: {
services: {
- teamcity: {
- authorizedOrigins: [`https://${host}`],
+ [TeamCityBase.auth.serviceKey]: {
+ authorizedOrigins: ['https://teamcity.jetbrains.com'],
},
},
},
- private: {
- teamcity_user: user,
- teamcity_pass: pass,
- },
}
-export { user, pass, host, config }
+export { config }
diff --git a/services/terraform/terraform-base.js b/services/terraform/terraform-base.js
new file mode 100644
index 0000000000000..b5c2a61d0b277
--- /dev/null
+++ b/services/terraform/terraform-base.js
@@ -0,0 +1,50 @@
+import Joi from 'joi'
+import { nonNegativeInteger } from '../validators.js'
+import { BaseJsonService } from '../index.js'
+
+const description =
+ '[Terraform Registry](https://registry.terraform.io) is an interactive resource for discovering a wide selection of integrations (providers), configuration packages (modules), and security rules (policies) for use with Terraform.'
+
+const schema = Joi.object({
+ data: Joi.object({
+ attributes: Joi.object({
+ month: nonNegativeInteger,
+ total: nonNegativeInteger,
+ week: nonNegativeInteger,
+ year: nonNegativeInteger,
+ }).required(),
+ }),
+})
+
+const intervalMap = {
+ dw: {
+ transform: json => json.data.attributes.week,
+ interval: 'week',
+ },
+ dm: {
+ transform: json => json.data.attributes.month,
+ interval: 'month',
+ },
+ dy: {
+ transform: json => json.data.attributes.year,
+ interval: 'year',
+ },
+ dt: {
+ transform: json => json.data.attributes.total,
+ interval: '',
+ },
+}
+
+class BaseTerraformService extends BaseJsonService {
+ static _cacheLength = 3600
+
+ async fetch({ kind, object }) {
+ const url = `https://registry.terraform.io/v2/${kind}/${object}/downloads/summary`
+ return this._requestJson({
+ schema,
+ url,
+ })
+ }
+}
+
+export { BaseTerraformService, intervalMap, description }
diff --git a/services/terraform/terraform-module-downloads.service.js b/services/terraform/terraform-module-downloads.service.js
new file mode 100644
index 0000000000000..7f944c3ce9b50
--- /dev/null
+++ b/services/terraform/terraform-module-downloads.service.js
@@ -0,0 +1,60 @@
+import { renderDownloadsBadge } from '../downloads.js'
+import { pathParams } from '../index.js'
+import {
+ BaseTerraformService,
+ description,
+ intervalMap,
+} from './terraform-base.js'
+
+export default class TerraformModuleDownloads extends BaseTerraformService {
+ static category = 'downloads'
+
+ static route = {
+ base: 'terraform/module',
+ pattern: ':interval(dw|dm|dy|dt)/:namespace/:name/:provider',
+ }
+
+ static openApi = {
+ '/terraform/module/{interval}/{namespace}/{name}/{provider}': {
+ get: {
+ summary: 'Terraform Module Downloads',
+ description,
+ parameters: pathParams(
+ {
+ name: 'interval',
+ example: 'dy',
+ schema: { type: 'string', enum: this.getEnum('interval') },
+ description: 'Weekly, Monthly, Yearly or Total downloads',
+ },
+ {
+ name: 'namespace',
+ example: 'hashicorp',
+ },
+ {
+ name: 'name',
+ example: 'consul',
+ },
+ {
+ name: 'provider',
+ example: 'aws',
+ },
+ ),
+ },
+ },
+ }
+
+ static defaultBadgeData = { label: 'downloads' }
+
+ async handle({ interval, namespace, name, provider }) {
+ const { transform } = intervalMap[interval]
+ const json = await this.fetch({
+ kind: 'modules',
+ object: `${namespace}/${name}/${provider}`,
+ })
+
+ return renderDownloadsBadge({
+ downloads: transform(json),
+ interval: intervalMap[interval].interval,
+ })
+ }
+}
diff --git a/services/terraform/terraform-module-downloads.tester.js b/services/terraform/terraform-module-downloads.tester.js
new file mode 100644
index 0000000000000..990e9509fd9e7
--- /dev/null
+++ b/services/terraform/terraform-module-downloads.tester.js
@@ -0,0 +1,36 @@
+import { createServiceTester } from '../tester.js'
+import { isMetric, isMetricOverTimePeriod } from '../test-validators.js'
+
+export const t = await createServiceTester()
+
+t.create('weekly downloads (valid)')
+ .get('/dw/hashicorp/consul/aws.json')
+ .expectBadge({ label: 'downloads', message: isMetricOverTimePeriod })
+
+t.create('monthly downloads (valid)')
+ .get('/dm/hashicorp/consul/aws.json')
+ .expectBadge({ label: 'downloads', message: isMetricOverTimePeriod })
+
+t.create('yearly downloads (valid)')
+ .get('/dy/hashicorp/consul/aws.json')
+ .expectBadge({ label: 'downloads', message: isMetricOverTimePeriod })
+
+t.create('total downloads (valid)')
+ .get('/dt/hashicorp/consul/aws.json')
+ .expectBadge({ label: 'downloads', message: isMetric })
+
+t.create('weekly downloads (not found)')
+ .get('/dw/not/real/module.json')
+ .expectBadge({ label: 'downloads', message: 'not found' })
+
+t.create('monthly downloads (not found)')
+ .get('/dm/not/real/module.json')
+ .expectBadge({ label: 'downloads', message: 'not found' })
+
+t.create('yearly downloads (not found)')
+ .get('/dy/not/real/module.json')
+ .expectBadge({ label: 'downloads', message: 'not found' })
+
+t.create('total downloads (not found)')
+ .get('/dt/not/real/module.json')
+ .expectBadge({ label: 'downloads', message: 'not found' })
diff --git a/services/terraform/terraform-provider-downloads.service.js b/services/terraform/terraform-provider-downloads.service.js
new file mode 100644
index 0000000000000..a0d7225c09b38
--- /dev/null
+++ b/services/terraform/terraform-provider-downloads.service.js
@@ -0,0 +1,54 @@
+import { renderDownloadsBadge } from '../downloads.js'
+import { pathParams } from '../index.js'
+import {
+ BaseTerraformService,
+ description,
+ intervalMap,
+} from './terraform-base.js'
+
+export default class TerraformProviderDownloads extends BaseTerraformService {
+ static category = 'downloads'
+
+ static route = {
+ base: 'terraform/provider',
+ pattern: ':interval(dw|dm|dy|dt)/:providerId',
+ }
+
+ static openApi = {
+ '/terraform/provider/{interval}/{providerId}': {
+ get: {
+ summary: 'Terraform Provider Downloads',
+ description,
+ parameters: pathParams(
+ {
+ name: 'interval',
+ example: 'dy',
+ schema: { type: 'string', enum: this.getEnum('interval') },
+ description: 'Weekly, Monthly, Yearly or Total downloads',
+ },
+ {
+ name: 'providerId',
+ example: '323',
+ description:
+ 'The provider ID can be found using `https://registry.terraform.io/v2/providers/{namespace}/{name}`',
+ },
+ ),
+ },
+ },
+ }
+
+ static defaultBadgeData = { label: 'downloads' }
+
+ async handle({ interval, providerId }) {
+ const { transform } = intervalMap[interval]
+ const json = await this.fetch({
+ kind: 'providers',
+ object: providerId,
+ })
+
+ return renderDownloadsBadge({
+ downloads: transform(json),
+ interval: intervalMap[interval].interval,
+ })
+ }
+}
diff --git a/services/terraform/terraform-provider-downloads.tester.js b/services/terraform/terraform-provider-downloads.tester.js
new file mode 100644
index 0000000000000..83c3c2147f681
--- /dev/null
+++ b/services/terraform/terraform-provider-downloads.tester.js
@@ -0,0 +1,36 @@
+import { createServiceTester } from '../tester.js'
+import { isMetric, isMetricOverTimePeriod } from '../test-validators.js'
+
+export const t = await createServiceTester()
+
+t.create('weekly downloads (valid)')
+ .get('/dw/323.json')
+ .expectBadge({ label: 'downloads', message: isMetricOverTimePeriod })
+
+t.create('monthly downloads (valid)')
+ .get('/dm/323.json')
+ .expectBadge({ label: 'downloads', message: isMetricOverTimePeriod })
+
+t.create('yearly downloads (valid)')
+ .get('/dy/323.json')
+ .expectBadge({ label: 'downloads', message: isMetricOverTimePeriod })
+
+t.create('total downloads (valid)')
+ .get('/dt/323.json')
+ .expectBadge({ label: 'downloads', message: isMetric })
+
+t.create('weekly downloads (not found)')
+ .get('/dw/not-valid.json')
+ .expectBadge({ label: 'downloads', message: 'not found' })
+
+t.create('monthly downloads (not found)')
+ .get('/dm/not-valid.json')
+ .expectBadge({ label: 'downloads', message: 'not found' })
+
+t.create('yearly downloads (not found)')
+ .get('/dy/not-valid.json')
+ .expectBadge({ label: 'downloads', message: 'not found' })
+
+t.create('total downloads (not found)')
+ .get('/dt/not-valid.json')
+ .expectBadge({ label: 'downloads', message: 'not found' })
diff --git a/services/test-helpers.js b/services/test-helpers.js
index 876467ebed614..02173f6bf0feb 100644
--- a/services/test-helpers.js
+++ b/services/test-helpers.js
@@ -1,7 +1,10 @@
-import bytes from 'bytes'
+import _ from 'lodash'
+import dayjs from 'dayjs'
+import { expect } from 'chai'
import nock from 'nock'
import config from 'config'
-import { fetchFactory } from '../core/base-service/got.js'
+import { fetch } from '../core/base-service/got.js'
+import BaseService from '../core/base-service/base.js'
const runnerConfig = config.util.toObject()
function cleanUpNockAfterEach() {
@@ -23,7 +26,7 @@ function noToken(serviceClass) {
(passKey && !runnerConfig.private[passKey])
if (noToken && !hasLogged) {
console.warn(
- `${serviceClass.name}: no credentials configured, tests for this service will be skipped. Add credentials in local.yml to run them.`
+ `${serviceClass.name}: no credentials configured, tests for this service will be skipped. Add credentials in local.yml to run them.`,
)
hasLogged = true
}
@@ -31,8 +34,407 @@ function noToken(serviceClass) {
}
}
-const sendAndCacheRequest = fetchFactory(bytes(runnerConfig.public.fetchLimit))
+/**
+ * Retrieves an example set of parameters for invoking a service class using OpenAPI example of that class.
+ *
+ * @param {BaseService} serviceClass The service class containing OpenAPI specifications.
+ * @param {'path'|'query'} paramType The type of params to extract, may be path params or query params.
+ * @returns {object} An object with call params to use with a service invoke of the first OpenAPI example.
+ * @throws {TypeError} - Throws a TypeError if the input `serviceClass` is not an instance of BaseService,
+ * or if it lacks the expected structure.
+ *
+ * @example
+ * // Example usage:
+ * const example = getBadgeExampleCall(StackExchangeReputation)
+ * console.log(example)
+ * // Output: { stackexchangesite: 'stackoverflow', query: '123' }
+ * StackExchangeReputation.invoke(defaultContext, config, example)
+ */
+function getBadgeExampleCall(serviceClass, paramType) {
+ if (!(serviceClass.prototype instanceof BaseService)) {
+ throw new TypeError(
+ 'Invalid serviceClass: Must be an instance of BaseService.',
+ )
+ }
+
+ if (Object.keys(serviceClass.openApi).length === 0) {
+ console.warn(
+ `Missing OpenAPI in service class ${serviceClass.name}. Make sure to use exampleOverride in testAuth.`,
+ )
+ return {}
+ }
+ if (!['path', 'query'].includes(paramType)) {
+ throw new TypeError('Invalid paramType: Must be path or query.')
+ }
+
+ const firstOpenapiPath = Object.keys(serviceClass.openApi)[0]
+
+ const firstOpenapiExampleParams =
+ serviceClass.openApi[firstOpenapiPath].get.parameters
+ if (!Array.isArray(firstOpenapiExampleParams)) {
+ throw new TypeError(
+ `Missing or invalid OpenAPI examples in ${serviceClass.name}.`,
+ )
+ }
+
+ // reformat structure for serviceClass.invoke
+ const exampleInvokeParams = firstOpenapiExampleParams.reduce((acc, obj) => {
+ if (obj.in === paramType) {
+ let example = obj.example
+ if (obj?.schema?.type === 'boolean') {
+ example = example || ''
+ }
+ acc[obj.name] = example
+ }
+ return acc
+ }, {})
+
+ return exampleInvokeParams
+}
+
+/**
+ * Generates a configuration object with a fake key based on the provided class.
+ * For use in auth tests where a config with a test key is required.
+ *
+ * @param {BaseService} serviceClass - The class to generate configuration for.
+ * @param {string} fakeKey - The fake key to be used in the configuration.
+ * @param {string} fakeUser - Optional, The fake user to be used in the configuration.
+ * @param {string[]} fakeauthorizedOrigins - authorizedOrigins to add to config.
+ * @param {object} authOverride Return result with overrid params.
+ * @returns {object} - The configuration object.
+ * @throws {TypeError} - Throws an error if the input is not a class.
+ */
+function generateFakeConfig(
+ serviceClass,
+ fakeKey,
+ fakeUser,
+ fakeauthorizedOrigins,
+ authOverride,
+) {
+ if (
+ !serviceClass ||
+ !serviceClass.prototype ||
+ !(serviceClass.prototype instanceof BaseService)
+ ) {
+ throw new TypeError(
+ 'Invalid serviceClass: Must be an instance of BaseService.',
+ )
+ }
+ if (!fakeKey && !fakeUser) {
+ throw new TypeError('Must provide at least one: fakeKey or fakeUser.')
+ }
+ if (!fakeauthorizedOrigins || !Array.isArray(fakeauthorizedOrigins)) {
+ throw new TypeError('Invalid fakeauthorizedOrigins: Must be an array.')
+ }
+
+ const auth = { ...serviceClass.auth, ...authOverride }
+ if (Object.keys(auth).length === 0) {
+ throw new Error(`Auth empty for ${serviceClass.name}.`)
+ }
+ if (fakeKey && typeof fakeKey !== 'string') {
+ throw new Error('Invalid fakeKey: Must be a String.')
+ }
+ if (fakeKey && !auth.passKey) {
+ throw new Error(`Missing auth.passKey for ${serviceClass.name}.`)
+ }
+ // Extract the passKey property from auth, or use a default if not present
+ const passKeyProperty = auth.passKey ? auth.passKey : undefined
+ if (fakeUser && typeof fakeUser !== 'string') {
+ throw new TypeError('Invalid fakeUser: Must be a String.')
+ }
+ if (fakeUser && !auth.userKey) {
+ throw new Error(`Missing auth.userKey for ${serviceClass.name}.`)
+ }
+ const passUserProperty = auth.userKey ? auth.userKey : undefined
+
+ // Build and return the configuration object with the fake key
+ return {
+ public: {
+ services: {
+ [auth.serviceKey]: {
+ authorizedOrigins: fakeauthorizedOrigins,
+ },
+ },
+ },
+ private: {
+ [passKeyProperty]: fakeKey,
+ [passUserProperty]: fakeUser,
+ },
+ }
+}
-const defaultContext = { sendAndCacheRequest }
+/**
+ * Returns the first auth origin found for a provided service class.
+ *
+ * @param {BaseService} serviceClass The service class to find the authorized origins.
+ * @param {object} authOverride Return result with overridden params.
+ * @param {object} configOverride - Override the config.
+ * @throws {TypeError} - Throws a TypeError if the input `serviceClass` is not an instance of BaseService.
+ * @returns {string[]} First auth origin found.
+ *
+ * @example
+ * // Example usage:
+ * getServiceClassAuthOrigin(Obs)
+ * // outputs ['https://api.opensuse.org']
+ */
+function getServiceClassAuthOrigin(serviceClass, authOverride, configOverride) {
+ if (
+ !serviceClass ||
+ !serviceClass.prototype ||
+ !(serviceClass.prototype instanceof BaseService)
+ ) {
+ throw new TypeError(
+ `Invalid serviceClass ${serviceClass}: Must be an instance of BaseService.`,
+ )
+ }
+ const auth = { ...serviceClass.auth, ...authOverride }
+ if (auth.authorizedOrigins) {
+ return auth.authorizedOrigins
+ } else {
+ const mergedConfig = _.merge(runnerConfig, configOverride)
+ if (!mergedConfig.public.services[auth.serviceKey]) {
+ throw new TypeError(
+ `Missing service key definition for ${auth.serviceKey}: Use an override if applicable.`,
+ )
+ }
+ return [mergedConfig.public.services[auth.serviceKey].authorizedOrigins]
+ }
+}
+
+/**
+ * Generate a fake JWT Token valid for 1 hour for use in testing.
+ *
+ * @returns {string} Fake JWT Token valid for 1 hour.
+ */
+function fakeJwtToken() {
+ const fakeJwtPayload = { exp: dayjs().add(1, 'hours').unix() }
+ const fakeJwtPayloadJsonString = JSON.stringify(fakeJwtPayload)
+ const fakeJwtPayloadBase64 = Buffer.from(fakeJwtPayloadJsonString).toString(
+ 'base64',
+ )
+ const jwtToken = `FakeHeader.${fakeJwtPayloadBase64}.fakeSignature`
+ return jwtToken
+}
-export { cleanUpNockAfterEach, noToken, sendAndCacheRequest, defaultContext }
+/**
+ * Test authentication of a badge for it's first OpenAPI example using a provided dummyResponse and authentication method.
+ *
+ * @param {BaseService} serviceClass The service class tested.
+ * @param {'BasicAuth'|'ApiKeyHeader'|'BearerAuthHeader'|'QueryStringAuth'|'JwtAuth'} authMethod The auth method of the tested service class.
+ * @param {object} dummyResponse An object containing the dummy response by the server.
+ * @param {object} options - Additional options for non default keys and content-type of the dummy response.
+ * @param {string} options.apiHeaderKey - Non default header for ApiKeyHeader auth.
+ * @param {string} options.bearerHeaderKey - Non default bearer header prefix for BearerAuthHeader.
+ * @param {string} options.queryUserKey - QueryStringAuth user key.
+ * @param {string} options.queryPassKey - QueryStringAuth pass key.
+ * @param {string} options.jwtLoginEndpoint - jwtAuth Login endpoint.
+ * @param {object} options.exampleOverride - Override example params in test.
+ * @param {object} options.authOverride - Override class auth params.
+ * @param {object} options.configOverride - Override the config for this test.
+ * @param {boolean} options.multipleRequests - For classes that require multiple requests to complete the test.
+ * @throws {TypeError} - Throws a TypeError if the input `serviceClass` is not an instance of BaseService,
+ * or if `serviceClass` is missing authorizedOrigins.
+ *
+ * @example
+ * // Example usage:
+ * testAuth(StackExchangeReputation, QueryStringAuth, { items: [{ reputation: 8 }] })
+ */
+async function testAuth(serviceClass, authMethod, dummyResponse, options = {}) {
+ if (!(serviceClass.prototype instanceof BaseService)) {
+ throw new TypeError(
+ 'Invalid serviceClass: Must be an instance of BaseService.',
+ )
+ }
+
+ const {
+ apiHeaderKey = 'x-api-key',
+ bearerHeaderKey = 'Bearer',
+ queryUserKey,
+ queryPassKey,
+ jwtLoginEndpoint,
+ exampleOverride = {},
+ authOverride,
+ configOverride,
+ multipleRequests = false,
+ } = options
+ const header = serviceClass.headers
+ ? { 'Content-Type': serviceClass.headers.Accept.split(', ')[0] }
+ : undefined
+ if (!apiHeaderKey || typeof apiHeaderKey !== 'string') {
+ throw new TypeError('Invalid apiHeaderKey: Must be a String.')
+ }
+ if (!bearerHeaderKey || typeof bearerHeaderKey !== 'string') {
+ throw new TypeError('Invalid bearerHeaderKey: Must be a String.')
+ }
+ if (typeof exampleOverride !== 'object') {
+ throw new TypeError('Invalid exampleOverride: Must be an Object.')
+ }
+ if (authOverride && typeof authOverride !== 'object') {
+ throw new TypeError('Invalid authOverride: Must be an Object.')
+ }
+ if (configOverride && typeof configOverride !== 'object') {
+ throw new TypeError('Invalid configOverride: Must be an Object.')
+ }
+ if (multipleRequests && typeof multipleRequests !== 'boolean') {
+ throw new TypeError('Invalid multipleRequests: Must be a boolean.')
+ }
+
+ if (!multipleRequests) {
+ cleanUpNockAfterEach()
+ }
+
+ const auth = { ...serviceClass.auth, ...authOverride }
+ const fakeUser = auth.userKey
+ ? 'fake-user'
+ : auth.defaultToEmptyStringForUser
+ ? ''
+ : undefined
+ const fakeSecret = auth.passKey ? 'fake-secret' : undefined
+ if (!fakeUser && !fakeSecret) {
+ throw new TypeError(
+ `Missing auth pass/user for ${serviceClass.name}. At least one is required.`,
+ )
+ }
+ const authOrigins = getServiceClassAuthOrigin(
+ serviceClass,
+ authOverride,
+ configOverride,
+ )
+ const config = generateFakeConfig(
+ serviceClass,
+ fakeSecret,
+ fakeUser,
+ authOrigins,
+ authOverride,
+ )
+ const exampleInvokePathParams = getBadgeExampleCall(serviceClass, 'path')
+ const exampleInvokeQueryParams = getBadgeExampleCall(serviceClass, 'query')
+ if (options && typeof options !== 'object') {
+ throw new TypeError('Invalid options: Must be an object.')
+ }
+
+ if (!authOrigins) {
+ throw new TypeError(`Missing authorizedOrigins for ${serviceClass.name}.`)
+ }
+ const jwtToken = authMethod === 'JwtAuth' ? fakeJwtToken() : undefined
+
+ const scopeArr = []
+ authOrigins.forEach(authOrigin => {
+ const scope = nock(authOrigin)
+ if (multipleRequests) {
+ scope.persist()
+ }
+ scopeArr.push(scope)
+ switch (authMethod) {
+ case 'BasicAuth':
+ scope
+ .get(/.*/)
+ .basicAuth({ user: fakeUser, pass: fakeSecret })
+ .reply(200, dummyResponse, header)
+ break
+ case 'ApiKeyHeader':
+ scope
+ .get(/.*/)
+ .matchHeader(apiHeaderKey, fakeSecret)
+ .reply(200, dummyResponse, header)
+ break
+ case 'BearerAuthHeader':
+ scope
+ .get(/.*/)
+ .matchHeader('Authorization', `${bearerHeaderKey} ${fakeSecret}`)
+ .reply(200, dummyResponse, header)
+ break
+ case 'QueryStringAuth':
+ if (!queryPassKey || typeof queryPassKey !== 'string') {
+ throw new TypeError('Invalid queryPassKey: Must be a String.')
+ }
+ scope
+ .get(/.*/)
+ .query(queryObject => {
+ if (queryObject[queryPassKey] !== fakeSecret) {
+ return false
+ }
+ if (queryUserKey) {
+ if (typeof queryUserKey !== 'string') {
+ throw new TypeError('Invalid queryUserKey: Must be a String.')
+ }
+ if (queryObject[queryUserKey] !== fakeUser) {
+ return false
+ }
+ }
+ return true
+ })
+ .reply(200, dummyResponse, header)
+ break
+ case 'JwtAuth': {
+ if (!jwtLoginEndpoint || typeof jwtLoginEndpoint !== 'string') {
+ throw new TypeError('Invalid jwtLoginEndpoint: Must be a String.')
+ }
+ if (jwtLoginEndpoint.startsWith(authOrigin)) {
+ scope
+ .post(/.*/, body => {
+ if (typeof body === 'object') {
+ return (
+ body.username === fakeUser && body.password === fakeSecret
+ )
+ }
+ if (typeof body === 'string') {
+ return (
+ body.includes(`username=${encodeURIComponent(fakeUser)}`) &&
+ body.includes(`password=${encodeURIComponent(fakeSecret)}`)
+ )
+ }
+ return false
+ })
+ .reply(200, { token: jwtToken })
+ } else {
+ scope
+ .get(/.*/)
+ .matchHeader('Authorization', `Bearer ${jwtToken}`)
+ .reply(200, dummyResponse, header)
+ }
+ break
+ }
+
+ default:
+ throw new TypeError(`Unkown auth method for ${serviceClass.name}.`)
+ }
+ })
+
+ expect(
+ await serviceClass.invoke(
+ defaultContext,
+ _.merge(config, configOverride),
+ {
+ ...exampleInvokePathParams,
+ ...exampleOverride,
+ },
+ {
+ ...exampleInvokeQueryParams,
+ ...exampleOverride,
+ },
+ ),
+ ).to.not.have.property('isError')
+
+ // clean up persistance if we have multiple requests
+ if (multipleRequests) {
+ scopeArr.forEach(scope => scope.persist(false))
+ nock.restore()
+ nock.cleanAll()
+ nock.enableNetConnect()
+ nock.activate()
+ }
+
+ // if we get 'Mocks not yet satisfied' we have redundent authOrigins or we are missing a critical request
+ scopeArr.forEach(scope => scope.done())
+}
+
+const defaultContext = { requestFetcher: fetch }
+
+export {
+ cleanUpNockAfterEach,
+ noToken,
+ getBadgeExampleCall,
+ testAuth,
+ defaultContext,
+}
diff --git a/services/test-results.js b/services/test-results.js
index 84f6831762087..6615fec840461 100644
--- a/services/test-results.js
+++ b/services/test-results.js
@@ -1,4 +1,5 @@
import Joi from 'joi'
+import { queryParams } from './index.js'
const testResultQueryParamSchema = Joi.object({
compact_message: Joi.equal(''),
@@ -7,6 +8,17 @@ const testResultQueryParamSchema = Joi.object({
skipped_label: Joi.string(),
}).required()
+const testResultOpenApiQueryParams = queryParams(
+ {
+ name: 'compact_message',
+ example: null,
+ schema: { type: 'boolean' },
+ },
+ { name: 'passed_label', example: 'good' },
+ { name: 'failed_label', example: 'bad' },
+ { name: 'skipped_label', example: 'n/a' },
+)
+
function renderTestResultMessage({
passed,
failed,
@@ -83,29 +95,22 @@ function renderTestResultBadge({
}
const documentation = `
-
- You may change the "passed", "failed" and "skipped" text on this badge by supplying query parameters &passed_label=, &failed_label= and &skipped_label= respectively.
-
+You may change the "passed", "failed" and "skipped" text on this badge by supplying query parameters &passed_label=, &failed_label= and &skipped_label= respectively.
+
+For example, if you want to use a different terminology:
+
+\`?passed_label=good&failed_label=bad&skipped_label=n%2Fa\`
-
- For example, if you want to use a different terminology:
-
- ?passed_label=good&failed_label=bad&skipped_label=n%2Fa
-
+Or symbols:
-
- Or symbols:
-
- ?compact_message&passed_label=💃&failed_label=🤦♀️&skipped_label=🤷
-
+\`?compact_message&passed_label=💃&failed_label=🤦♀️&skipped_label=🤷\`
-
- There is also a &compact_message query parameter, which will default to displaying ✔, ✘ and ➟, separated by a horizontal bar |.
-
+There is also a &compact_message query parameter, which will default to displaying ✔, ✘ and ➟, separated by a horizontal bar |.
`
export {
testResultQueryParamSchema,
+ testResultOpenApiQueryParams,
renderTestResultMessage,
renderTestResultBadge,
documentation,
diff --git a/services/test-validators.js b/services/test-validators.js
index 8110c1b208ae4..3f8fb50178856 100644
--- a/services/test-validators.js
+++ b/services/test-validators.js
@@ -21,13 +21,13 @@ const isVPlusDottedVersionNClauses = withRegex(/^v\d+(\.\d+)*$/)
// and an optional text suffix
// e.g: -beta, -preview1, -release-candidate, +beta, ~pre9-12 etc
const isVPlusDottedVersionNClausesWithOptionalSuffix = withRegex(
- /^v\d+(\.\d+)*([-+~].*)?$/
+ /^v\d+(\.\d+)*([-+~].*)?$/,
)
// same as above, but also accepts an optional 'epoch' prefix that can be
// found e.g. in distro package versions, like 4:6.3.0-4
const isVPlusDottedVersionNClausesWithOptionalSuffixAndEpoch = withRegex(
- /^v(\d+:)?\d+(\.\d+)*([-+~].*)?$/
+ /^v(\d+:)?\d+(\.\d+)*([-+~].*)?$/,
)
// Simple regex for test Composer versions rule
@@ -43,7 +43,7 @@ const isVPlusDottedVersionNClausesWithOptionalSuffixAndEpoch = withRegex(
// https://getcomposer.org/doc/04-schema.md#package-links
// https://getcomposer.org/doc/04-schema.md#minimum-stability
const isComposerVersion = withRegex(
- /^\s*(>=|>|<|<=|!=|\^|~)?\d+(\.(\*|(\d+(\.(\d+|\*))?)))?((\s*\|\|)?\s*(>=|>|<|<=|!=|\^|~)?\d+(\.(\*|(\d+(\.(\d+|\*))?)))?)*\s*$/
+ /^\*|(\s*(>=|>|<|<=|!=|\^|~)?\d+(\.(\*|(\d+(\.(\d+|\*))?)))?((\s*\|*)?\s*(>=|>|<|<=|!=|\^|~)?\d+(\.(\*|(\d+(\.(\d+|\*))?)))?)*\s*)$/,
)
// Regex for validate php-version.versionReduction()
@@ -52,16 +52,23 @@ const isComposerVersion = withRegex(
// 5.4, 5.6, 7.2
// 5.4 - 7.1, HHVM
const isPhpVersionReduction = withRegex(
- /^((>= \d+(\.\d+)?)|(\d+\.\d+(, \d+\.\d+)*)|(\d+\.\d+ - \d+\.\d+))(, HHVM)?$/
+ /^((>= \d+(\.\d+)?)|(\d+\.\d+(, \d+\.\d+)*)|(\d+\.\d+ - \d+\.\d+))(, HHVM)?$/,
)
+const isCommitHash = withRegex(/^[a-f0-9]{7,40}$/)
+
const isStarRating = withRegex(
- /^(?=.{5}$)(\u2605{0,5}[\u00BC\u00BD\u00BE]?\u2606{0,5})$/
+ /^(?=.{5}$)(\u2605{0,5}[\u00BC\u00BD\u00BE]?\u2606{0,5})$/,
)
// Required to be > 0, because accepting zero masks many problems.
const isMetric = withRegex(/^([1-9][0-9]*[kMGTPEZY]?|[1-9]\.[1-9][kMGTPEZY])$/)
+// Same as isMetric, but tests for negative numbers also.
+const isMetricAllowNegative = withRegex(
+ /^(0|-?[1-9][0-9]*[kMGTPEZY]?|-?[0-9]\.[0-9][kMGTPEZY])$/,
+)
+
/**
* @param {RegExp} nestedRegexp Pattern that must appear after the metric.
* @returns {Function} A function that returns a RegExp that matches a metric followed by another pattern.
@@ -74,57 +81,66 @@ const isMetricWithPattern = nestedRegexp => {
const isMetricOpenIssues = isMetricWithPattern(/ open/)
+const isMetricClosedIssues = isMetricWithPattern(/ closed/)
+
const isMetricOverMetric = isMetricWithPattern(
- /\/([1-9][0-9]*[kMGTPEZY]?|[1-9]\.[1-9][kMGTPEZY])/
+ /\/([1-9][0-9]*[kMGTPEZY]?|[1-9]\.[1-9][kMGTPEZY])/,
)
const isMetricOverTimePeriod = isMetricWithPattern(
- /\/(year|month|four weeks|quarter|week|day)/
+ /\/(year|month|four weeks|quarter|week|day)/,
)
const isZeroOverTimePeriod = withRegex(
- /^0\/(year|month|four weeks|quarter|week|day)$/
+ /^0\/(year|month|four weeks|quarter|week|day)$/,
)
const isIntegerPercentage = withRegex(/^[1-9][0-9]?%|^100%|^0%$/)
+const isIntegerPercentageNegative = withRegex(/^-?[1-9][0-9]?%|^100%|^0%$/)
const isDecimalPercentage = withRegex(/^[0-9]+\.[0-9]*%$/)
+const isDecimalPercentageNegative = withRegex(/^-?[0-9]+\.[0-9]*%$/)
const isPercentage = Joi.alternatives().try(
isIntegerPercentage,
- isDecimalPercentage
+ isDecimalPercentage,
+ isIntegerPercentageNegative,
+ isDecimalPercentageNegative,
)
-const isFileSize = withRegex(
- /^[0-9]*[.]?[0-9]+\s(B|kB|KB|MB|GB|TB|PB|EB|ZB|YB)$/
+const isMetricFileSize = withRegex(
+ /^[0-9]*[.]?[0-9]+\s(B|kB|KB|MB|GB|TB|PB|EB|ZB|YB)$/,
+)
+const isIecFileSize = withRegex(
+ /^[0-9]*[.]?[0-9]+\s(B|KiB|MiB|GiB|TiB|PiB|EiB|ZiB|YiB)$/,
)
const isFormattedDate = Joi.alternatives().try(
Joi.equal('today', 'yesterday'),
Joi.string().regex(/^last (sun|mon|tues|wednes|thurs|fri|satur)day$/),
Joi.string().regex(
- /^(january|february|march|april|may|june|july|august|september|october|november|december)( \d{4})?$/
- )
+ /^(january|february|march|april|may|june|july|august|september|october|november|december)( \d{4})?$/,
+ ),
)
const isRelativeFormattedDate = Joi.alternatives().try(
Joi.string().regex(
- /^(in |)([0-9]+|a few|a|an|)(| )(second|minute|hour|day|month|year)(s|)( ago|)$/
- )
+ /^(in |)([0-9]+|a few|a|an|)(| )(second|minute|hour|day|month|year)(s|)( ago|)$/,
+ ),
)
const isDependencyState = withRegex(
- /^(\d+ out of date|\d+ deprecated|up to date)$/
+ /^(\d+ out of date|\d+ deprecated|up to date)$/,
)
const makeTestTotalsValidator = ({ passed, failed, skipped }) =>
withRegex(
- new RegExp(`^[0-9]+ ${passed}(, [0-9]+ ${failed})?(, [0-9]+ ${skipped})?$`)
+ new RegExp(`^[0-9]+ ${passed}(, [0-9]+ ${failed})?(, [0-9]+ ${skipped})?$`),
)
const makeCompactTestTotalsValidator = ({ passed, failed, skipped }) =>
withRegex(
new RegExp(
- `^${passed} [0-9]+( \\| ${failed} [0-9]+)?( \\| ${skipped} [0-9]+)?$`
- )
+ `^${passed} [0-9]+( \\| ${failed} [0-9]+)?( \\| ${skipped} [0-9]+)?$`,
+ ),
)
const isDefaultTestTotals = makeTestTotalsValidator({
@@ -148,6 +164,25 @@ const isCustomCompactTestTotals = makeCompactTestTotalsValidator({
skipped: '🤷',
})
+const isOrdinalNumber = Joi.string().regex(/^[1-9][0-9]*(ᵗʰ|ˢᵗ|ⁿᵈ|ʳᵈ)$/)
+const isOrdinalNumberDaily = Joi.string().regex(
+ /^[1-9][0-9]*(ᵗʰ|ˢᵗ|ⁿᵈ|ʳᵈ) daily$/,
+)
+
+const isHumanized = Joi.string().regex(
+ /[0-9a-z]+ (second|seconds|minute|minutes|hour|hours|day|days|month|months|year|years)/,
+)
+
+// $1,530,602.24 // true
+// 1,530,602.24 // true
+// $1,666.24$ // false
+// ,1,666,88, // false
+// 1.6.66,6 // false
+// .1555. // false
+const isCurrency = withRegex(
+ /(?=.*\d)^\$?(([1-9]\d{0,2}(,\d{3})*)|0)?(\.\d{1,2})?$/,
+)
+
export {
isSemver,
isVPlusTripleDottedVersion,
@@ -157,17 +192,21 @@ export {
isVPlusDottedVersionNClausesWithOptionalSuffixAndEpoch,
isComposerVersion,
isPhpVersionReduction,
+ isCommitHash,
isStarRating,
isMetric,
+ isMetricAllowNegative,
isMetricWithPattern,
isMetricOpenIssues,
+ isMetricClosedIssues,
isMetricOverMetric,
isMetricOverTimePeriod,
isZeroOverTimePeriod,
isPercentage,
isIntegerPercentage,
isDecimalPercentage,
- isFileSize,
+ isMetricFileSize,
+ isIecFileSize,
isFormattedDate,
isRelativeFormattedDate,
isDependencyState,
@@ -178,4 +217,8 @@ export {
isCustomCompactTestTotals,
makeTestTotalsValidator,
makeCompactTestTotalsValidator,
+ isOrdinalNumber,
+ isOrdinalNumberDaily,
+ isHumanized,
+ isCurrency,
}
diff --git a/services/testspace/testspace-base.js b/services/testspace/testspace-base.js
index 1bb0d209a7353..e1fd3f300945f 100644
--- a/services/testspace/testspace-base.js
+++ b/services/testspace/testspace-base.js
@@ -2,8 +2,8 @@ import Joi from 'joi'
import { nonNegativeInteger } from '../validators.js'
import { BaseJsonService, NotFound } from '../index.js'
-// https://help.testspace.com/docs/reference/web-api#list-results
-// case_counts|array|The contained cases [passed, failed, na, errored]|counters of result
+// https://help.testspace.com/reference/web-api#list-results
+// case_counts|array|The contained cases [passed, failed, na, errored, untested]|counters of result
// session_* fields are for manual runs
// There are instances where the api returns a 200 status code with an empty array
// notably in cases where a space id is used
@@ -12,33 +12,36 @@ const schema = Joi.array()
Joi.object({
case_counts: Joi.array()
.items(nonNegativeInteger)
- .min(4)
- .max(4)
+ .min(5)
+ .max(5)
.required(),
- })
+ }),
)
.required()
-// https://help.testspace.com/docs/dashboard/overview-navigate
+// https://help.testspace.com/dashboard/overview#navigate
// Org is owner/account
// Project is generally a repository
// Space is a container, often a branch
export default class TestspaceBase extends BaseJsonService {
- static category = 'build'
+ static category = 'test-results'
static defaultBadgeData = { label: 'tests' }
async fetch({ org, project, space }) {
// https://help.testspace.com/docs/reference/web-api#list-results
const url = `https://${org}.testspace.com/api/projects/${encodeURIComponent(
- project
+ project,
)}/spaces/${space}/results`
return this._requestJson({
schema,
url,
- errorMessages: {
+ httpErrors: {
403: 'org not found or not authorized',
404: 'org, project, or space not found',
},
+ options: {
+ dnsLookupIpVersion: 4,
+ },
})
}
@@ -49,11 +52,11 @@ export default class TestspaceBase extends BaseJsonService {
const [
{
- case_counts: [passed, failed, skipped, errored],
+ case_counts: [passed, failed, skipped, errored, untested],
},
] = json
- const total = passed + failed + skipped + errored
+ const total = passed + failed + skipped + errored + untested
- return { passed, failed, skipped, errored, total }
+ return { passed, failed, skipped, errored, untested, total }
}
}
diff --git a/services/testspace/testspace-test-count.service.js b/services/testspace/testspace-test-count.service.js
index 0a217254357b8..8594a88b97e86 100644
--- a/services/testspace/testspace-test-count.service.js
+++ b/services/testspace/testspace-test-count.service.js
@@ -1,3 +1,4 @@
+import { pathParams } from '../index.js'
import { metric as metricCount } from '../text-formatters.js'
import TestspaceBase from './testspace-base.js'
@@ -5,24 +6,35 @@ export default class TestspaceTestCount extends TestspaceBase {
static route = {
base: 'testspace',
pattern:
- ':metric(total|passed|failed|skipped|errored)/:org/:project/:space+',
+ ':metric(total|passed|failed|skipped|errored|untested)/:org/:project/:space+',
}
- static examples = [
- {
- title: 'Testspace tests',
- namedParams: {
- metric: 'passed',
- org: 'swellaby',
- project: 'swellaby:testspace-sample',
- space: 'main',
+ static openApi = {
+ '/testspace/{metric}/{org}/{project}/{space}': {
+ get: {
+ summary: 'Testspace tests count',
+ parameters: pathParams(
+ {
+ name: 'metric',
+ example: 'passed',
+ schema: { type: 'string', enum: this.getEnum('metric') },
+ },
+ {
+ name: 'org',
+ example: 'swellaby',
+ },
+ {
+ name: 'project',
+ example: 'swellaby:testspace-sample',
+ },
+ {
+ name: 'space',
+ example: 'main',
+ },
+ ),
},
- staticPreview: this.render({
- metric: 'passed',
- value: 31,
- }),
},
- ]
+ }
static render({ value, metric }) {
let color = 'informational'
@@ -39,7 +51,7 @@ export default class TestspaceTestCount extends TestspaceBase {
}
transform({ json, metric }) {
- const { passed, failed, skipped, errored, total } =
+ const { passed, failed, skipped, errored, untested, total } =
this.transformCaseCounts(json)
if (metric === 'total') {
return { value: total }
@@ -49,6 +61,8 @@ export default class TestspaceTestCount extends TestspaceBase {
return { value: failed }
} else if (metric === 'skipped') {
return { value: skipped }
+ } else if (metric === 'untested') {
+ return { value: untested }
} else {
return { value: errored }
}
diff --git a/services/testspace/testspace-test-count.spec.js b/services/testspace/testspace-test-count.spec.js
index cb6b59b06e0fc..5876106e311d7 100644
--- a/services/testspace/testspace-test-count.spec.js
+++ b/services/testspace/testspace-test-count.spec.js
@@ -38,5 +38,10 @@ describe('TestspaceTestCount', function () {
message: '0',
color: 'informational',
})
+ given({ metric: 'untested', value: 0 }).expect({
+ label: 'untested tests',
+ message: '0',
+ color: 'informational',
+ })
})
})
diff --git a/services/testspace/testspace-test-count.tester.js b/services/testspace/testspace-test-count.tester.js
index 2ca45d6486096..ce5c03d56ac84 100644
--- a/services/testspace/testspace-test-count.tester.js
+++ b/services/testspace/testspace-test-count.tester.js
@@ -4,7 +4,7 @@ import { isMetric } from '../test-validators.js'
export const t = await createServiceTester()
const isMetricAllowZero = Joi.alternatives(
isMetric,
- Joi.number().valid(0).required()
+ Joi.number().valid(0).required(),
)
t.create('Total')
@@ -41,3 +41,10 @@ t.create('Errored')
label: 'errored tests',
message: isMetricAllowZero,
})
+
+t.create('Untested')
+ .get('/untested/swellaby/swellaby:testspace-sample/main.json')
+ .expectBadge({
+ label: 'untested tests',
+ message: isMetricAllowZero,
+ })
diff --git a/services/testspace/testspace-test-pass-ratio.service.js b/services/testspace/testspace-test-pass-ratio.service.js
index ea78e7eaec6c9..ade08aee21b1f 100644
--- a/services/testspace/testspace-test-pass-ratio.service.js
+++ b/services/testspace/testspace-test-pass-ratio.service.js
@@ -1,3 +1,4 @@
+import { pathParams } from '../index.js'
import TestspaceBase from './testspace-base.js'
export default class TestspacePassRatio extends TestspaceBase {
@@ -6,20 +7,27 @@ export default class TestspacePassRatio extends TestspaceBase {
pattern: ':org/:project/:space+',
}
- static examples = [
- {
- title: 'Testspace pass ratio',
- namedParams: {
- org: 'swellaby',
- project: 'swellaby:testspace-sample',
- space: 'main',
+ static openApi = {
+ '/testspace/pass-ratio/{org}/{project}/{space}': {
+ get: {
+ summary: 'Testspace pass ratio',
+ parameters: pathParams(
+ {
+ name: 'org',
+ example: 'swellaby',
+ },
+ {
+ name: 'project',
+ example: 'swellaby:testspace-sample',
+ },
+ {
+ name: 'space',
+ example: 'main',
+ },
+ ),
},
- staticPreview: this.render({
- passed: 2,
- total: 3,
- }),
},
- ]
+ }
static render({ passed, total }) {
const ratio = ((passed / total) * 100).toFixed(0)
diff --git a/services/testspace/testspace-test-summary.service.js b/services/testspace/testspace-test-summary.service.js
index f5176012a815b..ee19863702bf0 100644
--- a/services/testspace/testspace-test-summary.service.js
+++ b/services/testspace/testspace-test-summary.service.js
@@ -1,6 +1,8 @@
+import { pathParams } from '../index.js'
import {
- documentation,
+ documentation as description,
testResultQueryParamSchema,
+ testResultOpenApiQueryParams,
renderTestResultBadge,
} from '../test-results.js'
import TestspaceBase from './testspace-base.js'
@@ -12,69 +14,22 @@ export default class TestspaceTests extends TestspaceBase {
queryParamSchema: testResultQueryParamSchema,
}
- static examples = [
- {
- title: 'Testspace tests',
- namedParams: {
- org: 'swellaby',
- project: 'swellaby:testspace-sample',
- space: 'main',
- },
- queryParams: {
- passed_label: 'passed',
- failed_label: 'failed',
- skipped_label: 'skipped',
- },
- staticPreview: renderTestResultBadge({
- passed: 477,
- failed: 2,
- skipped: 0,
- total: 479,
- isCompact: false,
- }),
- documentation,
- },
- {
- title: 'Testspace tests (compact)',
- namedParams: {
- org: 'swellaby',
- project: 'swellaby:testspace-sample',
- space: 'main',
- },
- queryParams: {
- compact_message: null,
+ static openApi = {
+ '/testspace/tests/{org}/{project}/{space}': {
+ get: {
+ summary: 'Testspace tests',
+ description,
+ parameters: [
+ ...pathParams(
+ { name: 'org', example: 'swellaby' },
+ { name: 'project', example: 'swellaby:testspace-sample' },
+ { name: 'space', example: 'main' },
+ ),
+ ...testResultOpenApiQueryParams,
+ ],
},
- staticPreview: renderTestResultBadge({
- passed: 20,
- failed: 1,
- skipped: 1,
- total: 22,
- isCompact: true,
- }),
},
- {
- title: 'Testspace tests with custom labels',
- namedParams: {
- org: 'swellaby',
- project: 'swellaby:testspace-sample',
- space: 'main',
- },
- queryParams: {
- passed_label: 'good',
- failed_label: 'bad',
- skipped_label: 'n/a',
- },
- staticPreview: renderTestResultBadge({
- passed: 20,
- failed: 1,
- skipped: 1,
- total: 22,
- passedLabel: 'good',
- failedLabel: 'bad',
- skippedLabel: 'n/a',
- }),
- },
- ]
+ }
async handle(
{ org, project, space },
@@ -83,7 +38,7 @@ export default class TestspaceTests extends TestspaceBase {
passed_label: passedLabel,
failed_label: failedLabel,
skipped_label: skippedLabel,
- }
+ },
) {
const json = await this.fetch({ org, project, space })
const { passed, failed, skipped, total } = this.transformCaseCounts(json)
diff --git a/services/text-formatters.js b/services/text-formatters.js
index 50525fd6debda..95521a3b29dde 100644
--- a/services/text-formatters.js
+++ b/services/text-formatters.js
@@ -1,10 +1,20 @@
/**
* Commonly-used functions for formatting text in badge labels. Includes
* ordinal numbers, currency codes, star ratings, versions, etc.
+ *
+ * @module
*/
-import moment from 'moment'
-moment().format()
+/**
+ * Creates a string of stars and empty stars based on the rating.
+ * The number of stars is determined by the integer part of the rating.
+ * An additional star or a three-quarter star or a half star or a quarter star is added based on the decimal part of the rating.
+ * The remaining stars are empty stars until the maximum number of stars is reached.
+ *
+ * @param {number} rating - Current rating
+ * @param {number} [max] - Maximum rating
+ * @returns {string} A string of stars and empty stars
+ */
function starRating(rating, max = 5) {
const flooredRating = Math.floor(rating)
let stars = ''
@@ -28,7 +38,13 @@ function starRating(rating, max = 5) {
return stars
}
-// Convert ISO 4217 code to unicode string.
+/**
+ * Converts the ISO 4217 code to the corresponding currency symbol.
+ * If the the symbol for the code is not found, then the code itself is returned.
+ *
+ * @param {string} code - ISO 4217 code
+ * @returns {string} Currency symbol for the code
+ */
function currencyFromCode(code) {
return (
{
@@ -40,40 +56,64 @@ function currencyFromCode(code) {
)
}
+/**
+ * Calculates the ordinal number of the given number.
+ * For example, if the input is 1, the output is “1ˢᵗ”.
+ *
+ * @param {number} n - Input number
+ * @returns {string} Ordinal number of the input number
+ */
function ordinalNumber(n) {
const s = ['ᵗʰ', 'ˢᵗ', 'ⁿᵈ', 'ʳᵈ']
const v = n % 100
return n + (s[(v - 20) % 10] || s[v] || s[0])
}
-// Given a number, string with appropriate unit in the metric system, SI.
-// Note: numbers beyond the peta- cannot be represented as integers in JS.
const metricPrefix = ['k', 'M', 'G', 'T', 'P', 'E', 'Z', 'Y']
const metricPower = metricPrefix.map((a, i) => Math.pow(1000, i + 1))
+
+/**
+ * Given a number (positive or negative), returns a string with appropriate unit in the metric system, SI.
+ * Note: numbers beyond the peta- cannot be represented as integers in JS.
+ * For example, if you call metric(1000), it will return "1k", which means one kilo or one thousand.
+ *
+ * @param {number} n - Input number
+ * @returns {string} String with appropriate unit in the metric system, SI
+ */
function metric(n) {
for (let i = metricPrefix.length - 1; i >= 0; i--) {
const limit = metricPower[i]
- if (n >= limit) {
- const scaledN = n / limit
+ const absN = Math.abs(n)
+ if (absN >= limit) {
+ const scaledN = absN / limit
if (scaledN < 10) {
// For "small" numbers, display one decimal digit unless it is 0.
const oneDecimalN = scaledN.toFixed(1)
if (oneDecimalN.charAt(oneDecimalN.length - 1) !== '0') {
- return `${oneDecimalN}${metricPrefix[i]}`
+ const res = `${oneDecimalN}${metricPrefix[i]}`
+ return n > 0 ? res : `-${res}`
}
}
const roundedN = Math.round(scaledN)
if (roundedN < 1000) {
- return `${roundedN}${metricPrefix[i]}`
+ const res = `${roundedN}${metricPrefix[i]}`
+ return n > 0 ? res : `-${res}`
} else {
- return `1${metricPrefix[i + 1]}`
+ const res = `1${metricPrefix[i + 1]}`
+ return n > 0 ? res : `-${res}`
}
}
}
return `${n}`
}
-// Remove the starting v in a string.
+/**
+ * Remove the starting v in a string if it exists.
+ * For example, omitv("v1.2.3") returns "1.2.3", but omitv("hello") returns "hello".
+ *
+ * @param {string} version - Version string
+ * @returns {string} Version string without the starting v
+ */
function omitv(version) {
if (version.charCodeAt(0) === 118) {
return version.slice(1)
@@ -81,10 +121,16 @@ function omitv(version) {
return version
}
-// Add a starting v to the version unless:
-// - it does not start with a digit
-// - it is a date (yyyy-mm-dd)
-const ignoredVersionPatterns = /^[^0-9]|[0-9]{4}-[0-9]{2}-[0-9]{2}/
+const ignoredVersionPatterns =
+ /^[^0-9]|[0-9]{4}-[0-9]{2}-[0-9]{2}|^[a-f0-9]{7,40}$/
+
+/**
+ * Add a starting v to the version unless it doesn't starts with a digit, is a date (yyyy-mm-dd), or is a commit hash.
+ * For example, addv("1.2.3") returns "v1.2.3", but addv("hello"), addv("2021-10-31"), addv("abcdef1"), returns "hello", "2021-10-31", and "abcdef1" respectively.
+ *
+ * @param {string} version - Version string
+ * @returns {string} Version string with the starting v
+ */
function addv(version) {
version = `${version}`
if (version.startsWith('v') || ignoredVersionPatterns.test(version)) {
@@ -94,6 +140,15 @@ function addv(version) {
}
}
+/**
+ * Returns a string that is either the singular or the plural form of a word,
+ * depending on the length of the countable parameter.
+ *
+ * @param {string} singular - Singular form of the word
+ * @param {string[]} countable - Array of values you want to count
+ * @param {string} plural - Plural form of the word
+ * @returns {string} Singular or plural form of the word
+ */
function maybePluralize(singular, countable, plural) {
plural = plural || `${singular}s`
@@ -104,24 +159,6 @@ function maybePluralize(singular, countable, plural) {
}
}
-function formatDate(d) {
- const date = moment(d)
- const dateString = date.calendar(null, {
- lastDay: '[yesterday]',
- sameDay: '[today]',
- lastWeek: '[last] dddd',
- sameElse: 'MMMM YYYY',
- })
- // Trim current year from date string
- return dateString.replace(` ${moment().year()}`, '').toLowerCase()
-}
-
-function formatRelativeDate(timestamp) {
- return moment()
- .to(moment.unix(parseInt(timestamp, 10)))
- .toLowerCase()
-}
-
export {
starRating,
currencyFromCode,
@@ -130,6 +167,4 @@ export {
omitv,
addv,
maybePluralize,
- formatDate,
- formatRelativeDate,
}
diff --git a/services/text-formatters.spec.js b/services/text-formatters.spec.js
index efcfae4c15b96..9bdbd843ecbef 100644
--- a/services/text-formatters.spec.js
+++ b/services/text-formatters.spec.js
@@ -1,5 +1,4 @@
import { test, given } from 'sazerac'
-import sinon from 'sinon'
import {
starRating,
currencyFromCode,
@@ -8,8 +7,6 @@ import {
omitv,
addv,
maybePluralize,
- formatDate,
- formatRelativeDate,
} from './text-formatters.js'
describe('Text formatters', function () {
@@ -39,6 +36,7 @@ describe('Text formatters', function () {
test(metric, () => {
/* eslint-disable no-loss-of-precision */
+ given(0).expect('0')
given(999).expect('999')
given(1000).expect('1k')
given(1100).expect('1.1k')
@@ -59,6 +57,27 @@ describe('Text formatters', function () {
given(1100000000000000000000).expect('1.1Z')
given(2222222222222222222222222).expect('2.2Y')
given(22222222222222222222222222).expect('22Y')
+ given(-999).expect('-999')
+ given(-999).expect('-999')
+ given(-1000).expect('-1k')
+ given(-1100).expect('-1.1k')
+ given(-10100).expect('-10k')
+ given(-999499).expect('-999k')
+ given(-999500).expect('-1M')
+ given(-1100000).expect('-1.1M')
+ given(-1578896212).expect('-1.6G')
+ given(-20000000000).expect('-20G')
+ given(-15788962120).expect('-16G')
+ given(-9949999999999).expect('-9.9T')
+ given(-9950000000001).expect('-10T')
+ given(-4000000000000001).expect('-4P')
+ given(-4200000000000001).expect('-4.2P')
+ given(-7100700010058000200).expect('-7.1E')
+ given(-71007000100580002000).expect('-71E')
+ given(-1000000000000000000000).expect('-1Z')
+ given(-1100000000000000000000).expect('-1.1Z')
+ given(-2222222222222222222222222).expect('-2.2Y')
+ given(-22222222222222222222222222).expect('-22Y')
/* eslint-enable */
})
@@ -74,6 +93,10 @@ describe('Text formatters', function () {
given('v0.6').expect('v0.6')
given('hello').expect('hello')
given('2017-05-05-Release-2.3.17').expect('2017-05-05-Release-2.3.17')
+ given('5aa272da7924fa76581fd5ea83b24cfbb3528b8a').expect(
+ '5aa272da7924fa76581fd5ea83b24cfbb3528b8a',
+ )
+ given('5aa272da79').expect('5aa272da79')
})
test(maybePluralize, () => {
@@ -87,48 +110,4 @@ describe('Text formatters', function () {
given('box', [123, 456], 'boxes').expect('boxes')
given('box', undefined, 'boxes').expect('boxes')
})
-
- test(formatDate, () => {
- given(1465513200000)
- .describe('when given a timestamp in june 2016')
- .expect('june 2016')
- })
-
- context('in october', function () {
- let clock
- beforeEach(function () {
- clock = sinon.useFakeTimers(new Date(2017, 9, 15).getTime())
- })
- afterEach(function () {
- clock.restore()
- })
-
- test(formatDate, () => {
- given(new Date(2017, 0, 1).getTime())
- .describe('when given the beginning of this year')
- .expect('january')
- })
- })
-
- context('in october', function () {
- let clock
- beforeEach(function () {
- clock = sinon.useFakeTimers(new Date(2018, 9, 29).getTime())
- })
- afterEach(function () {
- clock.restore()
- })
-
- test(formatRelativeDate, () => {
- given(new Date(2018, 9, 31).getTime() / 1000)
- .describe('when given the end of october')
- .expect('in 2 days')
- })
-
- test(formatRelativeDate, () => {
- given(new Date(2018, 9, 1).getTime() / 1000)
- .describe('when given the beginning of october')
- .expect('a month ago')
- })
- })
})
diff --git a/services/thunderstore/thunderstore-base.js b/services/thunderstore/thunderstore-base.js
new file mode 100644
index 0000000000000..a913935aec203
--- /dev/null
+++ b/services/thunderstore/thunderstore-base.js
@@ -0,0 +1,60 @@
+import Joi from 'joi'
+import { BaseJsonService } from '../index.js'
+import { nonNegativeInteger } from '../validators.js'
+
+const packageMetricsSchema = Joi.object({
+ downloads: nonNegativeInteger,
+ rating_score: nonNegativeInteger,
+ latest_version: Joi.string().required(),
+})
+
+const description = `
+The Thunderstore badges require a package's namespace and name.
+
+Everything can be discerned from your package's URL. Thunderstore package URLs have a mostly consistent format:
+
+https://thunderstore.io/c/[community]/p/[namespace]/[packageName]
+
+For example: https://thunderstore.io/c/lethal-company/p/notnotnotswipez/MoreCompany/.
+
+ namespace = "notnotnotswipez"
+ packageName = "MoreCompany"
+
+
+:::info[Risk Of Rain 2]
+The 'default community', Risk of Rain 2, has an alternative URL:
+
+https://thunderstore.io/package/[namespace]/[packageName]
+:::
+
+:::info[Subdomain Communities]
+Some communities use a 'subdomain' alternative URL, for example, Valheim:
+
+https://valheim.thunderstore.io/package/[namespace]/[packageName]
+:::
+`
+
+/**
+ * Services which query Thunderstore endpoints should extend BaseThunderstoreService
+ *
+ * @abstract
+ */
+class BaseThunderstoreService extends BaseJsonService {
+ static thunderstoreGreen = '23FFB0'
+ /**
+ * Fetches package metrics from the Thunderstore API.
+ *
+ * @param {object} pkg - Package specifier
+ * @param {string} pkg.namespace - the package namespace
+ * @param {string} pkg.packageName - the package name
+ * @returns {Promise} - Promise containing validated package metrics
+ */
+ async fetchPackageMetrics({ namespace, packageName }) {
+ return this._requestJson({
+ schema: packageMetricsSchema,
+ url: `https://thunderstore.io/api/v1/package-metrics/${namespace}/${packageName}`,
+ })
+ }
+}
+
+export { BaseThunderstoreService, description }
diff --git a/services/thunderstore/thunderstore-downloads.service.js b/services/thunderstore/thunderstore-downloads.service.js
new file mode 100644
index 0000000000000..7dd4ae317ca6c
--- /dev/null
+++ b/services/thunderstore/thunderstore-downloads.service.js
@@ -0,0 +1,43 @@
+import { renderDownloadsBadge } from '../downloads.js'
+import { pathParams } from '../index.js'
+import { BaseThunderstoreService, description } from './thunderstore-base.js'
+
+export default class ThunderstoreDownloads extends BaseThunderstoreService {
+ static category = 'downloads'
+
+ static route = {
+ base: 'thunderstore/dt',
+ pattern: ':namespace/:packageName',
+ }
+
+ static openApi = {
+ '/thunderstore/dt/{namespace}/{packageName}': {
+ get: {
+ summary: 'Thunderstore Downloads',
+ description,
+ parameters: pathParams(
+ { name: 'namespace', example: 'notnotnotswipez' },
+ { name: 'packageName', example: 'MoreCompany' },
+ ),
+ },
+ },
+ }
+
+ static defaultBadgeData = {
+ label: 'downloads',
+ }
+
+ /**
+ * @param {object} pkg - Package specifier
+ * @param {string} pkg.namespace - the package namespace
+ * @param {string} pkg.packageName - the package name
+ * @returns {Promise} - Promise containing the rendered badge payload
+ */
+ async handle({ namespace, packageName }) {
+ const { downloads } = await this.fetchPackageMetrics({
+ namespace,
+ packageName,
+ })
+ return renderDownloadsBadge({ downloads })
+ }
+}
diff --git a/services/thunderstore/thunderstore-downloads.tester.js b/services/thunderstore/thunderstore-downloads.tester.js
new file mode 100644
index 0000000000000..7649798a4aef2
--- /dev/null
+++ b/services/thunderstore/thunderstore-downloads.tester.js
@@ -0,0 +1,12 @@
+import { createServiceTester } from '../tester.js'
+import { isMetric } from '../test-validators.js'
+
+export const t = await createServiceTester()
+
+t.create('Downloads')
+ .get('/ebkr/r2modman.json')
+ .expectBadge({ label: 'downloads', message: isMetric })
+
+t.create('Downloads (not found)')
+ .get('/not-a-namespace/not-a-package-name.json')
+ .expectBadge({ label: 'downloads', message: 'not found', color: 'red' })
diff --git a/services/thunderstore/thunderstore-likes.service.js b/services/thunderstore/thunderstore-likes.service.js
new file mode 100644
index 0000000000000..f8970a2d82fa2
--- /dev/null
+++ b/services/thunderstore/thunderstore-likes.service.js
@@ -0,0 +1,52 @@
+import { metric } from '../text-formatters.js'
+import { pathParams } from '../index.js'
+import { BaseThunderstoreService, description } from './thunderstore-base.js'
+
+export default class ThunderstoreLikes extends BaseThunderstoreService {
+ static category = 'social'
+
+ static route = {
+ base: 'thunderstore/likes',
+ pattern: ':namespace/:packageName',
+ }
+
+ static openApi = {
+ '/thunderstore/likes/{namespace}/{packageName}': {
+ get: {
+ summary: 'Thunderstore Likes',
+ description,
+ parameters: pathParams(
+ { name: 'namespace', example: 'notnotnotswipez' },
+ { name: 'packageName', example: 'MoreCompany' },
+ ),
+ },
+ },
+ }
+
+ static defaultBadgeData = {
+ label: 'likes',
+ namedLogo: 'thunderstore',
+ }
+
+ static render({ likes }) {
+ return {
+ message: metric(likes),
+ style: 'social',
+ color: `#${this.thunderstoreGreen}`,
+ }
+ }
+
+ /**
+ * @param {object} pkg - Package specifier
+ * @param {string} pkg.namespace - the package namespace
+ * @param {string} pkg.packageName - the package name
+ * @returns {Promise} - Promise containing the rendered badge payload
+ */
+ async handle({ namespace, packageName }) {
+ const { rating_score: likes } = await this.fetchPackageMetrics({
+ namespace,
+ packageName,
+ })
+ return this.constructor.render({ likes })
+ }
+}
diff --git a/services/thunderstore/thunderstore-likes.tester.js b/services/thunderstore/thunderstore-likes.tester.js
new file mode 100644
index 0000000000000..65af0a50144fb
--- /dev/null
+++ b/services/thunderstore/thunderstore-likes.tester.js
@@ -0,0 +1,12 @@
+import { createServiceTester } from '../tester.js'
+import { isMetric } from '../test-validators.js'
+
+export const t = await createServiceTester()
+
+t.create('Likes')
+ .get('/ebkr/r2modman.json')
+ .expectBadge({ label: 'likes', message: isMetric })
+
+t.create('Likes (not found)')
+ .get('/not-a-namespace/not-a-package-name.json')
+ .expectBadge({ label: 'likes', message: 'not found', color: 'red' })
diff --git a/services/thunderstore/thunderstore-version.service.js b/services/thunderstore/thunderstore-version.service.js
new file mode 100644
index 0000000000000..25639928b85a8
--- /dev/null
+++ b/services/thunderstore/thunderstore-version.service.js
@@ -0,0 +1,43 @@
+import { renderVersionBadge } from '../version.js'
+import { pathParams } from '../index.js'
+import { BaseThunderstoreService, description } from './thunderstore-base.js'
+
+export default class ThunderstoreVersion extends BaseThunderstoreService {
+ static category = 'version'
+
+ static route = {
+ base: 'thunderstore/v',
+ pattern: ':namespace/:packageName',
+ }
+
+ static openApi = {
+ '/thunderstore/v/{namespace}/{packageName}': {
+ get: {
+ summary: 'Thunderstore Version',
+ description,
+ parameters: pathParams(
+ { name: 'namespace', example: 'notnotnotswipez' },
+ { name: 'packageName', example: 'MoreCompany' },
+ ),
+ },
+ },
+ }
+
+ static defaultBadgeData = {
+ label: 'thunderstore',
+ }
+
+ /**
+ * @param {object} pkg - Package specifier
+ * @param {string} pkg.namespace - the package namespace
+ * @param {string} pkg.packageName - the package name
+ * @returns {Promise} - Promise containing the rendered badge payload
+ */
+ async handle({ namespace, packageName }) {
+ const { latest_version: version } = await this.fetchPackageMetrics({
+ namespace,
+ packageName,
+ })
+ return renderVersionBadge({ version })
+ }
+}
diff --git a/services/thunderstore/thunderstore-version.tester.js b/services/thunderstore/thunderstore-version.tester.js
new file mode 100644
index 0000000000000..d3afc05fbc32f
--- /dev/null
+++ b/services/thunderstore/thunderstore-version.tester.js
@@ -0,0 +1,12 @@
+import { createServiceTester } from '../tester.js'
+import { isSemver } from '../test-validators.js'
+
+export const t = await createServiceTester()
+
+t.create('Version')
+ .get('/ebkr/r2modman.json')
+ .expectBadge({ label: 'thunderstore', message: isSemver })
+
+t.create('Version (not found)')
+ .get('/not-a-namespace/not-a-package-name.json')
+ .expectBadge({ label: 'thunderstore', message: 'not found', color: 'red' })
diff --git a/services/tokei/tokei.service.js b/services/tokei/tokei.service.js
deleted file mode 100644
index dd01918b8d404..0000000000000
--- a/services/tokei/tokei.service.js
+++ /dev/null
@@ -1,78 +0,0 @@
-import Joi from 'joi'
-import { metric } from '../text-formatters.js'
-import { nonNegativeInteger } from '../validators.js'
-import { BaseJsonService } from '../index.js'
-
-const schema = Joi.object({
- lines: nonNegativeInteger,
-}).required()
-
-const documentation = `
-
- The provider is the domain name of git host.
- If no TLD is provided, .com will be added.
- For example, setting gitlab or bitbucket.org as the
- provider also works.
-
- Tokei will automatically count all files with a recognized extension. It will
- automatically ignore files and folders in .ignore files. If you
- want to ignore files or folders specifically for tokei, add them to the
- .tokeignore in the root of your repository.
- See
- https://github.com/XAMPPRocky/tokei#excluding-folders
- for more info.
-
-`
-
-export default class Tokei extends BaseJsonService {
- static category = 'size'
-
- static route = { base: 'tokei/lines', pattern: ':provider/:user/:repo' }
-
- static examples = [
- {
- title: 'Lines of code',
- namedParams: {
- provider: 'github',
- user: 'badges',
- repo: 'shields',
- },
- staticPreview: this.render({ lines: 119500 }),
- keywords: ['loc', 'tokei'],
- documentation,
- },
- ]
-
- static defaultBadgeData = {
- label: 'total lines',
- color: 'blue',
- }
-
- static render({ lines }) {
- return { message: metric(lines) }
- }
-
- async fetch({ provider, user, repo }) {
- // This request uses the tokei-rs (https://github.com/XAMPPRocky/tokei_rs) API.
- //
- // By default, the API returns an svg, but when the Accept HTTP header is set to
- // `application/json`, it sends json data. The `_requestJson` method
- // automatically sets the Accept Header to what we need, so we don't need to
- // specify it here.
- //
- // This behaviour of the API is "documented" here:
- // https://github.com/XAMPPRocky/tokei_rs/issues/8#issuecomment-475071147
- return this._requestJson({
- schema,
- url: `https://tokei.rs/b1/${provider}/${user}/${repo}`,
- errorMessages: {
- 400: 'repo not found',
- },
- })
- }
-
- async handle({ provider, user, repo }) {
- const { lines } = await this.fetch({ provider, user, repo })
- return this.constructor.render({ lines })
- }
-}
diff --git a/services/tokei/tokei.tester.js b/services/tokei/tokei.tester.js
deleted file mode 100644
index 9671c7b58848b..0000000000000
--- a/services/tokei/tokei.tester.js
+++ /dev/null
@@ -1,28 +0,0 @@
-import { ServiceTester } from '../tester.js'
-import { isMetric } from '../test-validators.js'
-
-export const t = new ServiceTester({ id: 'tokei', title: 'Tokei LOC Tests' })
-
-t.create('GitHub LOC')
- .get('/lines/github/badges/shields.json')
- .expectBadge({ label: 'total lines', message: isMetric })
-
-t.create('GitLab LOC')
- .get('/lines/gitlab/tezos/tezos.json')
- .expectBadge({ label: 'total lines', message: isMetric })
-
-t.create('GitHub LOC (with .com)')
- .get('/lines/github.com/badges/shields.json')
- .expectBadge({ label: 'total lines', message: isMetric })
-
-t.create('GitLab LOC (with .com)')
- .get('/lines/gitlab.com/tezos/tezos.json')
- .expectBadge({ label: 'total lines', message: isMetric })
-
-t.create('BitBucket LOC')
- .get('/lines/bitbucket.org/MonliH/tokei-shields-test.json')
- .expectBadge({ label: 'total lines', message: isMetric })
-
-t.create('Invalid Provider')
- .get('/lines/example/tezos/tezos.json')
- .expectBadge({ label: 'total lines', message: 'repo not found' })
diff --git a/services/travis/travis-build.service.js b/services/travis/travis-build.service.js
index 2c52799588a4c..eb18f228fb433 100644
--- a/services/travis/travis-build.service.js
+++ b/services/travis/travis-build.service.js
@@ -1,6 +1,6 @@
import Joi from 'joi'
import { isBuildStatus, renderBuildStatusBadge } from '../build-status.js'
-import { BaseSvgScrapingService } from '../index.js'
+import { BaseSvgScrapingService, pathParams } from '../index.js'
const schema = Joi.object({
message: Joi.alternatives()
@@ -8,61 +8,50 @@ const schema = Joi.object({
.required(),
}).required()
-export default class TravisBuild extends BaseSvgScrapingService {
+export class TravisComBuild extends BaseSvgScrapingService {
static category = 'build'
static route = {
base: 'travis',
- format: '(?:(com)/)?(?!php-v)([^/]+/[^/]+?)(?:/(.+?))?',
- capture: ['comDomain', 'userRepo', 'branch'],
+ format: 'com/(?!php-v)([^/]+/[^/]+?)(?:/(.+?))?',
+ capture: ['userRepo', 'branch'],
}
- static examples = [
- {
- title: 'Travis (.org)',
- pattern: ':user/:repo',
- namedParams: { user: 'rust-lang', repo: 'rust' },
- staticPreview: {
- message: 'passing',
- color: 'brightgreen',
+ static openApi = {
+ '/travis/com/{user}/{repo}': {
+ get: {
+ summary: 'Travis (.com)',
+ parameters: pathParams(
+ {
+ name: 'user',
+ example: 'ivandelabeldad',
+ },
+ {
+ name: 'repo',
+ example: 'rackian-gateway',
+ },
+ ),
},
},
- {
- title: 'Travis (.org) branch',
- pattern: ':user/:repo/:branch',
- namedParams: { user: 'rust-lang', repo: 'rust', branch: 'master' },
- staticPreview: {
- message: 'passing',
- color: 'brightgreen',
+ '/travis/com/{user}/{repo}/{branch}': {
+ get: {
+ summary: 'Travis (.com) branch',
+ parameters: pathParams(
+ {
+ name: 'user',
+ example: 'ivandelabeldad',
+ },
+ {
+ name: 'repo',
+ example: 'rackian-gateway',
+ },
+ {
+ name: 'branch',
+ example: 'master',
+ },
+ ),
},
},
- {
- title: 'Travis (.com)',
- pattern: 'com/:user/:repo',
- namedParams: { user: 'ivandelabeldad', repo: 'rackian-gateway' },
- staticPreview: {
- message: 'passing',
- color: 'brightgreen',
- },
- },
- {
- title: 'Travis (.com) branch',
- pattern: 'com/:user/:repo/:branch',
- namedParams: {
- user: 'ivandelabeldad',
- repo: 'rackian-gateway',
- branch: 'master',
- },
- staticPreview: {
- message: 'passing',
- color: 'brightgreen',
- },
- },
- ]
-
- static staticPreview = {
- message: 'passing',
- color: 'brightgreen',
}
static defaultBadgeData = {
@@ -73,12 +62,11 @@ export default class TravisBuild extends BaseSvgScrapingService {
return renderBuildStatusBadge({ status })
}
- async handle({ comDomain, userRepo, branch }) {
- const domain = comDomain || 'org'
+ async handle({ userRepo, branch }) {
const { message: status } = await this._requestSvg({
schema,
- url: `https://api.travis-ci.${domain}/${userRepo}.svg`,
- options: { qs: { branch } },
+ url: `https://api.travis-ci.com/${userRepo}.svg`,
+ options: { searchParams: { branch } },
valueMatcher: />([^<>]+)<\/text><\/g>/,
})
diff --git a/services/travis/travis-build.tester.js b/services/travis/travis-build.tester.js
index f6bd4df392f79..faf301095de4c 100644
--- a/services/travis/travis-build.tester.js
+++ b/services/travis/travis-build.tester.js
@@ -3,35 +3,6 @@ import { isBuildStatus } from '../build-status.js'
import { createServiceTester } from '../tester.js'
export const t = await createServiceTester()
-// Travis (.org) CI
-
-t.create('build status on default branch')
- .get('/rust-lang/rust.json')
- .expectBadge({
- label: 'build',
- message: Joi.alternatives().try(isBuildStatus, Joi.equal('unknown')),
- })
-
-t.create('build status on named branch')
- .get('/rust-lang/rust/stable.json')
- .expectBadge({
- label: 'build',
- message: Joi.alternatives().try(isBuildStatus, Joi.equal('unknown')),
- })
-
-t.create('unknown repo')
- .get('/this-repo/does-not-exist.json')
- .expectBadge({ label: 'build', message: 'unknown' })
-
-t.create('invalid svg response')
- .get('/foo/bar.json')
- .intercept(nock =>
- nock('https://api.travis-ci.org').get('/foo/bar.svg').reply(200)
- )
- .expectBadge({ label: 'build', message: 'unparseable svg response' })
-
-// Travis (.com) CI
-
t.create('build status on default branch')
.get('/com/ivandelabeldad/rackian-gateway.json')
.expectBadge({
@@ -53,6 +24,6 @@ t.create('unknown repo')
t.create('invalid svg response')
.get('/com/foo/bar.json')
.intercept(nock =>
- nock('https://api.travis-ci.com').get('/foo/bar.svg').reply(200)
+ nock('https://api.travis-ci.com').get('/foo/bar.svg').reply(200),
)
.expectBadge({ label: 'build', message: 'unparseable svg response' })
diff --git a/services/travis/travis-php-version-redirect.service.js b/services/travis/travis-php-version-redirect.service.js
deleted file mode 100644
index a61b4467fcff9..0000000000000
--- a/services/travis/travis-php-version-redirect.service.js
+++ /dev/null
@@ -1,26 +0,0 @@
-import { redirector } from '../index.js'
-
-const ciRedirect = redirector({
- category: 'platform-support',
- route: {
- base: 'travis-ci/php-v',
- pattern: ':user/:repo/:branch*',
- },
- transformPath: ({ user, repo, branch }) =>
- branch
- ? `/travis/php-v/${user}/${repo}/${branch}`
- : `/travis/php-v/${user}/${repo}/master`,
- dateAdded: new Date('2019-04-22'),
-})
-
-const branchRedirect = redirector({
- category: 'platform-support',
- route: {
- base: 'travis/php-v',
- pattern: ':user/:repo',
- },
- transformPath: ({ user, repo }) => `/travis/php-v/${user}/${repo}/master`,
- dateAdded: new Date('2020-07-12'),
-})
-
-export { ciRedirect, branchRedirect }
diff --git a/services/travis/travis-php-version-redirect.tester.js b/services/travis/travis-php-version-redirect.tester.js
deleted file mode 100644
index 4326a1175d3dc..0000000000000
--- a/services/travis/travis-php-version-redirect.tester.js
+++ /dev/null
@@ -1,19 +0,0 @@
-import { ServiceTester } from '../tester.js'
-
-export const t = new ServiceTester({
- id: 'TravisPhpVersionRedirect',
- title: 'TravisPhpVersionRedirect',
- pathPrefix: '/',
-})
-
-t.create('travis-ci no branch')
- .get('travis-ci/php-v/symfony/symfony.svg')
- .expectRedirect('/travis/php-v/symfony/symfony/master.svg')
-
-t.create('travis-ci branch')
- .get('travis-ci/php-v/symfony/symfony/2.8.svg')
- .expectRedirect('/travis/php-v/symfony/symfony/2.8.svg')
-
-t.create('travis no branch')
- .get('travis/php-v/symfony/symfony.svg')
- .expectRedirect('/travis/php-v/symfony/symfony/master.svg')
diff --git a/services/travis/travis-php-version.service.js b/services/travis/travis-php-version.service.js
deleted file mode 100644
index f8a29a39cd76d..0000000000000
--- a/services/travis/travis-php-version.service.js
+++ /dev/null
@@ -1,100 +0,0 @@
-import Joi from 'joi'
-import {
- minorVersion,
- versionReduction,
- getPhpReleases,
-} from '../php-version.js'
-import { BaseJsonService } from '../index.js'
-
-const optionalNumberOrString = Joi.alternatives(Joi.string(), Joi.number())
-const schema = Joi.object({
- branch: Joi.object({
- config: Joi.object({
- php: Joi.array().items(optionalNumberOrString),
- matrix: Joi.object({
- include: Joi.array().items(Joi.object({ php: optionalNumberOrString })),
- }),
- jobs: Joi.object({
- include: Joi.array().items(Joi.object({ php: optionalNumberOrString })),
- }),
- }).required(),
- }).required(),
-}).required()
-
-export default class TravisPhpVersion extends BaseJsonService {
- static category = 'platform-support'
-
- static route = {
- base: 'travis/php-v',
- pattern: ':user/:repo/:branch+',
- }
-
- static examples = [
- {
- title: 'PHP version from Travis config',
- namedParams: { user: 'yiisoft', repo: 'yii', branch: 'master' },
- staticPreview: this.render({ reduction: ['5.3 - 7.4'] }),
- },
- ]
-
- static defaultBadgeData = {
- label: 'php',
- }
-
- static render({ reduction, hasHhvm }) {
- return {
- message: reduction.concat(hasHhvm ? ['HHVM'] : []).join(', '),
- color: 'blue',
- }
- }
-
- constructor(context, config) {
- super(context, config)
- this._githubApiProvider = context.githubApiProvider
- }
-
- async transform({ branch: { config } }) {
- let travisVersions = []
-
- // from php
- if (config.php) {
- travisVersions = travisVersions.concat(config.php.map(v => v.toString()))
- }
- // from matrix
- if (config.matrix && config.matrix.include) {
- travisVersions = travisVersions.concat(
- config.matrix.include.filter(v => 'php' in v).map(v => v.php.toString())
- )
- }
- // from jobs
- if (config.jobs && config.jobs.include) {
- travisVersions = travisVersions.concat(
- config.jobs.include.filter(v => 'php' in v).map(v => v.php.toString())
- )
- }
-
- const versions = travisVersions
- .map(v => minorVersion(v))
- .filter(v => v.includes('.'))
-
- return {
- reduction: versionReduction(
- versions,
- await getPhpReleases(this._githubApiProvider)
- ),
- hasHhvm: travisVersions.find(v => v.startsWith('hhvm')),
- }
- }
-
- async handle({ user, repo, branch }) {
- const travisConfig = await this._requestJson({
- schema,
- url: `https://api.travis-ci.org/repos/${user}/${repo}/branches/${branch}`,
- errorMessages: {
- 404: 'repo not found',
- },
- })
- const { reduction, hasHhvm } = await this.transform(travisConfig)
- return this.constructor.render({ reduction, hasHhvm })
- }
-}
diff --git a/services/travis/travis-php-version.tester.js b/services/travis/travis-php-version.tester.js
deleted file mode 100644
index 16a23bd0c5183..0000000000000
--- a/services/travis/travis-php-version.tester.js
+++ /dev/null
@@ -1,19 +0,0 @@
-import { isPhpVersionReduction } from '../test-validators.js'
-import { createServiceTester } from '../tester.js'
-export const t = await createServiceTester()
-
-t.create('gets the package version of symfony 5.1')
- .get('/symfony/symfony/5.1.json')
- .expectBadge({ label: 'php', message: isPhpVersionReduction })
-
-t.create('gets the package version of yii')
- .get('/yiisoft/yii/master.json')
- .expectBadge({ label: 'php', message: isPhpVersionReduction })
-
-t.create('gets the package version of pagination-bundle')
- .get('/gpslab/pagination-bundle/master.json')
- .expectBadge({ label: 'php', message: isPhpVersionReduction })
-
-t.create('invalid package name')
- .get('/frodo/is-not-a-package/master.json')
- .expectBadge({ label: 'php', message: 'repo not found' })
diff --git a/services/treeware/treeware-trees.service.js b/services/treeware/treeware-trees.service.js
index 1fcbc10125cfb..e3a367cff44c9 100644
--- a/services/treeware/treeware-trees.service.js
+++ b/services/treeware/treeware-trees.service.js
@@ -2,7 +2,7 @@ import crypto from 'crypto'
import Joi from 'joi'
import { metric } from '../text-formatters.js'
import { floorCount } from '../color-formatters.js'
-import { BaseJsonService } from '../index.js'
+import { BaseJsonService, pathParams } from '../index.js'
const apiSchema = Joi.object({
total: Joi.number().required(),
@@ -16,13 +16,23 @@ export default class TreewareTrees extends BaseJsonService {
pattern: ':owner/:packageName',
}
- static examples = [
- {
- title: 'Treeware (Trees)',
- namedParams: { owner: 'stoplightio', packageName: 'spectral' },
- staticPreview: this.render({ count: 250 }),
+ static openApi = {
+ '/treeware/trees/{owner}/{packageName}': {
+ get: {
+ summary: 'Treeware (Trees)',
+ parameters: pathParams(
+ {
+ name: 'owner',
+ example: 'stoplightio',
+ },
+ {
+ name: 'packageName',
+ example: 'spectral',
+ },
+ ),
+ },
},
- ]
+ }
static defaultBadgeData = {
label: 'trees',
@@ -33,12 +43,12 @@ export default class TreewareTrees extends BaseJsonService {
}
async fetch({ reference }) {
- const url = `https://public.offset.earth/users/treeware/trees`
+ const url = 'https://public.ecologi.com/users/treeware/trees'
return this._requestJson({
url,
schema: apiSchema,
options: {
- qs: { ref: reference },
+ searchParams: { ref: reference },
},
})
}
diff --git a/services/treeware/treeware-trees.tester.js b/services/treeware/treeware-trees.tester.js
index 07ada0cf8de39..df55ddeec41e5 100644
--- a/services/treeware/treeware-trees.tester.js
+++ b/services/treeware/treeware-trees.tester.js
@@ -12,9 +12,9 @@ t.create('request for existing package')
t.create('request for existing package (mock)')
.get('/stoplightio/spectral.json')
.intercept(nock =>
- nock('https://public.offset.earth')
+ nock('https://public.ecologi.com')
.get('/users/treeware/trees?ref=65c6e3e942e7464b4591e0c8b70d11d5')
- .reply(200, { total: 50 })
+ .reply(200, { total: 50 }),
)
.expectBadge({
label: 'trees',
diff --git a/services/twitch/twitch-base.js b/services/twitch/twitch-base.js
index 85d4e6b08303f..2c34ef075957a 100644
--- a/services/twitch/twitch-base.js
+++ b/services/twitch/twitch-base.js
@@ -39,26 +39,26 @@ export default class TwitchBase extends BaseJsonService {
{ userKey: 'client_id', passKey: 'client_secret' },
{
schema: tokenSchema,
- url: `https://id.twitch.tv/oauth2/token`,
+ url: 'https://id.twitch.tv/oauth2/token',
options: {
method: 'POST',
- qs: {
+ searchParams: {
grant_type: 'client_credentials',
},
},
- errorMessages: {
+ httpErrors: {
401: 'invalid token',
404: 'node not found',
},
- }
- )
+ },
+ ),
)
// replace the token when we are 80% near the expire time
// 2147483647 is the max 32-bit value that is accepted by setTimeout(), it's about 24.9 days
const replaceTokenMs = Math.min(
tokenRes.expires_in * 1000 * 0.8,
- 2147483647
+ 2147483647,
)
const timeout = setTimeout(() => {
TwitchBase.__twitchToken = this._getNewToken()
diff --git a/services/twitch/twitch-extension-version.service.js b/services/twitch/twitch-extension-version.service.js
index ff4d2295e22e2..9026e068b36c2 100644
--- a/services/twitch/twitch-extension-version.service.js
+++ b/services/twitch/twitch-extension-version.service.js
@@ -1,4 +1,5 @@
import Joi from 'joi'
+import { pathParams } from '../index.js'
import { renderVersionBadge } from '../version.js'
import TwitchBase from './twitch-base.js'
@@ -17,15 +18,17 @@ export default class TwitchExtensionVersion extends TwitchBase {
pattern: ':extensionId',
}
- static examples = [
- {
- title: 'Twitch Extension Version',
- namedParams: {
- extensionId: '2nq5cu1nc9f4p75b791w8d3yo9d195',
+ static openApi = {
+ '/twitch/extension/v/{extensionId}': {
+ get: {
+ summary: 'Twitch Extension Version',
+ parameters: pathParams({
+ name: 'extensionId',
+ example: '2nq5cu1nc9f4p75b791w8d3yo9d195',
+ }),
},
- staticPreview: renderVersionBadge({ version: '1.0.0' }),
},
- ]
+ }
static defaultBadgeData = {
label: 'twitch extension',
@@ -34,9 +37,9 @@ export default class TwitchExtensionVersion extends TwitchBase {
async fetch({ extensionId }) {
const data = this._requestJson({
schema: helixSchema,
- url: `https://api.twitch.tv/helix/extensions/released`,
+ url: 'https://api.twitch.tv/helix/extensions/released',
options: {
- qs: { extension_id: extensionId },
+ searchParams: { extension_id: extensionId },
},
})
diff --git a/services/twitch/twitch.service.js b/services/twitch/twitch.service.js
index 402f7e52cc459..ec76e76a0cf80 100644
--- a/services/twitch/twitch.service.js
+++ b/services/twitch/twitch.service.js
@@ -1,4 +1,5 @@
import Joi from 'joi'
+import { pathParams } from '../index.js'
import TwitchBase from './twitch-base.js'
const helixSchema = Joi.object({
@@ -13,20 +14,17 @@ export default class TwitchStatus extends TwitchBase {
pattern: ':user',
}
- static examples = [
- {
- title: 'Twitch Status',
- namedParams: {
- user: 'andyonthewings',
- },
- queryParams: { style: 'social' },
- staticPreview: {
- message: 'live',
- color: 'red',
- style: 'social',
+ static openApi = {
+ '/twitch/status/{user}': {
+ get: {
+ summary: 'Twitch Status',
+ parameters: pathParams({
+ name: 'user',
+ example: 'andyonthewings',
+ }),
},
},
- ]
+ }
static _cacheLength = 30
@@ -38,6 +36,7 @@ export default class TwitchStatus extends TwitchBase {
static render({ user, isLive }) {
return {
message: isLive ? 'live' : 'offline',
+ style: 'social',
color: isLive ? 'red' : 'lightgrey',
link: `https://www.twitch.tv/${user}`,
}
@@ -51,9 +50,9 @@ export default class TwitchStatus extends TwitchBase {
// which we consider not worth it.
const streams = this._requestJson({
schema: helixSchema,
- url: `https://api.twitch.tv/helix/streams`,
+ url: 'https://api.twitch.tv/helix/streams',
options: {
- qs: { user_login: user },
+ searchParams: { user_login: user },
},
})
diff --git a/services/twitch/twitch.spec.js b/services/twitch/twitch.spec.js
index c3ec31522eb66..ab418bcd48c5a 100644
--- a/services/twitch/twitch.spec.js
+++ b/services/twitch/twitch.spec.js
@@ -43,9 +43,10 @@ describe('TwitchStatus', function () {
expect(
await TwitchStatus.invoke(defaultContext, config, {
status: 'andyonthewings',
- })
+ }),
).to.deep.equal({
message: 'offline',
+ style: 'social',
link: 'https://www.twitch.tv/undefined',
color: 'lightgrey',
})
diff --git a/services/twitter/twitter-redirect.service.js b/services/twitter/twitter-redirect.service.js
index 5291115dbdf43..ee802e17e0510 100644
--- a/services/twitter/twitter-redirect.service.js
+++ b/services/twitter/twitter-redirect.service.js
@@ -8,7 +8,7 @@ export default [
base: 'twitter/url',
pattern: ':protocol(https|http)/:hostAndPath+',
},
- transformPath: () => `/twitter/url`,
+ transformPath: () => '/twitter/url',
transformQueryParams: ({ protocol, hostAndPath }) => ({
url: `${protocol}://${hostAndPath}`,
}),
diff --git a/services/twitter/twitter-redirect.tester.js b/services/twitter/twitter-redirect.tester.js
index e777d9a5bee72..4ecefd8e0fa91 100644
--- a/services/twitter/twitter-redirect.tester.js
+++ b/services/twitter/twitter-redirect.tester.js
@@ -9,5 +9,5 @@ export const t = new ServiceTester({
t.create('twitter')
.get('/https/shields.io.svg')
.expectRedirect(
- `/twitter/url.svg?url=${encodeURIComponent('https://shields.io')}`
+ `/twitter/url.svg?url=${encodeURIComponent('https://shields.io')}`,
)
diff --git a/services/twitter/twitter.service.js b/services/twitter/twitter.service.js
index 2da76486a0846..f613e2e39cbf0 100644
--- a/services/twitter/twitter.service.js
+++ b/services/twitter/twitter.service.js
@@ -1,10 +1,9 @@
import Joi from 'joi'
-import { metric } from '../text-formatters.js'
-import { optionalUrl } from '../validators.js'
-import { BaseService, BaseJsonService, NotFound } from '../index.js'
+import { url } from '../validators.js'
+import { BaseService, pathParams, queryParams } from '../index.js'
const queryParamSchema = Joi.object({
- url: optionalUrl.required(),
+ url,
}).required()
class TwitterUrl extends BaseService {
@@ -16,25 +15,23 @@ class TwitterUrl extends BaseService {
queryParamSchema,
}
- static examples = [
- {
- title: 'Twitter URL',
- namedParams: {},
- queryParams: {
- url: 'https://shields.io',
- },
- // hard code the static preview
- // because link[] is not allowed in examples
- staticPreview: {
- label: 'Tweet',
- message: '',
- style: 'social',
+ static openApi = {
+ '/twitter/url': {
+ get: {
+ summary: 'X (formerly Twitter) URL',
+ parameters: queryParams({
+ name: 'url',
+ example: 'https://shields.io',
+ required: true,
+ }),
},
},
- ]
+ }
+
+ static _cacheLength = 86400
static defaultBadgeData = {
- namedLogo: 'twitter',
+ namedLogo: 'x',
}
async handle(_routeParams, { url }) {
@@ -44,16 +41,27 @@ class TwitterUrl extends BaseService {
message: '',
style: 'social',
link: [
- `https://twitter.com/intent/tweet?text=Wow:&url=${page}`,
- `https://twitter.com/search?q=${page}`,
+ `https://x.com/intent/tweet?text=Wow:&url=${page}`,
+ `https://x.com/search?q=${page}`,
],
}
}
}
-const schema = Joi.any()
+/*
+This badge is unusual.
-class TwitterFollow extends BaseJsonService {
+We don't usually host badges that don't show any dynamic information.
+Also when an upstream API is removed, we usually deprecate/remove badges
+according to the process in
+https://github.com/badges/shields/blob/master/doc/deprecating-badges.md
+
+In the case of twitter, we decided to provide a static fallback instead
+due to how widely used the badge was. See
+https://github.com/badges/shields/issues/8837
+for related discussion.
+*/
+class TwitterFollow extends BaseService {
static category = 'social'
static route = {
@@ -61,55 +69,34 @@ class TwitterFollow extends BaseJsonService {
pattern: ':user',
}
- static examples = [
- {
- title: 'Twitter Follow',
- namedParams: {
- user: 'espadrine',
- },
- queryParams: { label: 'Follow' },
- // hard code the static preview
- // because link[] is not allowed in examples
- staticPreview: {
- label: 'Follow',
- message: '393',
- style: 'social',
+ static openApi = {
+ '/twitter/follow/{user}': {
+ get: {
+ summary: 'X (formerly Twitter) Follow',
+ parameters: pathParams({ name: 'user', example: 'shields_io' }),
},
},
- ]
+ }
+
+ static _cacheLength = 86400
static defaultBadgeData = {
- namedLogo: 'twitter',
+ namedLogo: 'x',
}
- static render({ user, followers }) {
+ static render({ user }) {
return {
label: `follow @${user}`,
- message: metric(followers),
+ message: '',
style: 'social',
link: [
- `https://twitter.com/intent/follow?screen_name=${encodeURIComponent(
- user
- )}`,
- `https://twitter.com/${encodeURIComponent(user)}/followers`,
+ `https://x.com/intent/follow?screen_name=${encodeURIComponent(user)}`,
],
}
}
- async fetch({ user }) {
- return this._requestJson({
- schema,
- url: `http://cdn.syndication.twimg.com/widgets/followbutton/info.json`,
- options: { qs: { screen_names: user } },
- })
- }
-
async handle({ user }) {
- const data = await this.fetch({ user })
- if (!Array.isArray(data) || data.length === 0) {
- throw new NotFound({ prettyMessage: 'invalid user' })
- }
- return this.constructor.render({ user, followers: data[0].followers_count })
+ return this.constructor.render({ user })
}
}
diff --git a/services/twitter/twitter.tester.js b/services/twitter/twitter.tester.js
index e15b79f08f4dd..4449f735d9dca 100644
--- a/services/twitter/twitter.tester.js
+++ b/services/twitter/twitter.tester.js
@@ -1,4 +1,3 @@
-import { isMetric } from '../test-validators.js'
import { ServiceTester } from '../tester.js'
export const t = new ServiceTester({
@@ -10,25 +9,8 @@ t.create('Followers')
.get('/follow/shields_io.json')
.expectBadge({
label: 'follow @shields_io',
- message: isMetric,
- link: [
- 'https://twitter.com/intent/follow?screen_name=shields_io',
- 'https://twitter.com/shields_io/followers',
- ],
- })
-
-t.create('Invalid Username Specified (non-existent user)')
- .get('/follow/invalidusernamethatshouldnotexist.json?label=Follow')
- .expectBadge({
- label: 'Follow',
- message: 'invalid user',
- })
-
-t.create('Invalid Username Specified (only spaces)')
- .get('/follow/%20%20.json?label=Follow')
- .expectBadge({
- label: 'Follow',
- message: 'invalid user',
+ message: '',
+ link: ['https://x.com/intent/follow?screen_name=shields_io'],
})
t.create('URL')
@@ -37,7 +19,7 @@ t.create('URL')
label: 'tweet',
message: '',
link: [
- 'https://twitter.com/intent/tweet?text=Wow:&url=https%3A%2F%2Fshields.io',
- 'https://twitter.com/search?q=https%3A%2F%2Fshields.io',
+ 'https://x.com/intent/tweet?text=Wow:&url=https%3A%2F%2Fshields.io',
+ 'https://x.com/search?q=https%3A%2F%2Fshields.io',
],
})
diff --git a/services/ubuntu/ubuntu.service.js b/services/ubuntu/ubuntu.service.js
index c584c95503edc..0ae4d3c4dbd60 100644
--- a/services/ubuntu/ubuntu.service.js
+++ b/services/ubuntu/ubuntu.service.js
@@ -1,13 +1,13 @@
import Joi from 'joi'
import { renderVersionBadge } from '../version.js'
-import { BaseJsonService, NotFound } from '../index.js'
+import { BaseJsonService, NotFound, pathParams } from '../index.js'
const schema = Joi.object({
entries: Joi.array()
.items(
Joi.object({
source_package_version: Joi.string().required(),
- })
+ }),
)
.required(),
}).required()
@@ -20,13 +20,32 @@ export default class Ubuntu extends BaseJsonService {
pattern: ':packageName/:series?',
}
- static examples = [
- {
- title: 'Ubuntu package',
- namedParams: { series: 'bionic', packageName: 'ubuntu-wallpapers' },
- staticPreview: renderVersionBadge({ version: '18.04.1-0ubuntu1' }),
+ static openApi = {
+ '/ubuntu/v/{packageName}/{series}': {
+ get: {
+ summary: 'Ubuntu Package Version (for series)',
+ parameters: pathParams(
+ {
+ name: 'packageName',
+ example: 'ubuntu-wallpapers',
+ },
+ {
+ name: 'series',
+ example: 'bionic',
+ },
+ ),
+ },
+ },
+ '/ubuntu/v/{packageName}': {
+ get: {
+ summary: 'Ubuntu Package Version',
+ parameters: pathParams({
+ name: 'packageName',
+ example: 'ubuntu-wallpapers',
+ }),
+ },
},
- ]
+ }
static defaultBadgeData = {
label: 'ubuntu',
@@ -36,7 +55,7 @@ export default class Ubuntu extends BaseJsonService {
const seriesParam = series
? {
distro_series: `https://api.launchpad.net/1.0/ubuntu/${encodeURIComponent(
- series
+ series,
)}`,
}
: {}
@@ -44,7 +63,7 @@ export default class Ubuntu extends BaseJsonService {
schema,
url: 'https://api.launchpad.net/1.0/ubuntu/+archive/primary',
options: {
- qs: {
+ searchParams: {
'ws.op': 'getPublishedSources',
exact_match: 'true',
order_by_date: 'true',
@@ -53,7 +72,7 @@ export default class Ubuntu extends BaseJsonService {
...seriesParam,
},
},
- errorMessages: {
+ httpErrors: {
400: 'series not found',
},
})
diff --git a/services/ubuntu/ubuntu.tester.js b/services/ubuntu/ubuntu.tester.js
index c622493075b08..16f5228f5f67f 100644
--- a/services/ubuntu/ubuntu.tester.js
+++ b/services/ubuntu/ubuntu.tester.js
@@ -3,7 +3,7 @@ import { createServiceTester } from '../tester.js'
export const t = await createServiceTester()
const isUbuntuVersion = Joi.string().regex(
- /^v(\d+:)?\d+(\.\d+)*([\w\\.]*)?([-+~].*)?$/
+ /^v(\d+:)?\d+(\.\d+)*([\w\\.]*)?([-+~].*)?$/,
)
t.create('Ubuntu package (default distribution, valid)')
@@ -18,7 +18,7 @@ t.create('Ubuntu package (valid)')
.intercept(nock =>
nock('https://api.launchpad.net')
.get(
- '/1.0/ubuntu/+archive/primary?ws.op=getPublishedSources&exact_match=true&order_by_date=true&status=Published&source_name=ubuntu-wallpapers&distro_series=https%3A%2F%2Fapi.launchpad.net%2F1.0%2Fubuntu%2Fbionic'
+ '/1.0/ubuntu/+archive/primary?ws.op=getPublishedSources&exact_match=true&order_by_date=true&status=Published&source_name=ubuntu-wallpapers&distro_series=https%3A%2F%2Fapi.launchpad.net%2F1.0%2Fubuntu%2Fbionic',
)
.reply(200, {
entries: [
@@ -27,7 +27,7 @@ t.create('Ubuntu package (valid)')
source_package_version: '18.04.1-0ubuntu1',
},
],
- })
+ }),
)
.expectBadge({ label: 'ubuntu', message: 'v18.04.1-0ubuntu1' })
diff --git a/services/uptimeobserver/uptimeobserver-base.js b/services/uptimeobserver/uptimeobserver-base.js
new file mode 100644
index 0000000000000..fa1676661073e
--- /dev/null
+++ b/services/uptimeobserver/uptimeobserver-base.js
@@ -0,0 +1,56 @@
+import Joi from 'joi'
+import { BaseJsonService, InvalidParameter, InvalidResponse } from '../index.js'
+
+const errorResponse = Joi.object({
+ error: Joi.string().required(),
+}).required()
+
+const monitorResponse = Joi.object({
+ status: Joi.string().required(),
+ uptime24h: Joi.number().min(0).max(100).required(),
+ uptime7d: Joi.number().min(0).max(100).required(),
+ uptime30d: Joi.number().min(0).max(100).required(),
+}).required()
+
+const singleMonitorResponse = Joi.alternatives(monitorResponse, errorResponse)
+
+export default class UptimeObserverBase extends BaseJsonService {
+ static category = 'monitoring'
+
+ static ensureIsMonitorApiKey(value) {
+ if (!value) {
+ throw new InvalidParameter({
+ prettyMessage: 'monitor API key is required',
+ })
+ } else if (value.length <= 32) {
+ throw new InvalidParameter({
+ prettyMessage: 'monitor API key is unvalid',
+ })
+ }
+ }
+
+ async fetch({ monitorKey }) {
+ this.constructor.ensureIsMonitorApiKey(monitorKey)
+
+ // Docs for API: https://support.uptimeobserver.com/shields-api.yaml
+ const url = `https://app.uptimeobserver.com/api/monitor/status/${monitorKey}`
+
+ const response = await this._requestJson({
+ schema: singleMonitorResponse,
+ url,
+ options: {
+ method: 'GET',
+ },
+ logErrors: [],
+ })
+
+ // Handle error responses
+ if (response.error) {
+ throw new InvalidResponse({
+ prettyMessage: response.error || 'service error',
+ })
+ }
+
+ return response
+ }
+}
diff --git a/services/uptimeobserver/uptimeobserver-ratio.service.js b/services/uptimeobserver/uptimeobserver-ratio.service.js
new file mode 100644
index 0000000000000..edc16a165bbaf
--- /dev/null
+++ b/services/uptimeobserver/uptimeobserver-ratio.service.js
@@ -0,0 +1,80 @@
+import { pathParams } from '../index.js'
+import { colorScale } from '../color-formatters.js'
+import UptimeObserverBase from './uptimeobserver-base.js'
+
+const ratioColor = colorScale([10, 30, 50, 70])
+
+export default class UptimeObserverRatio extends UptimeObserverBase {
+ static route = {
+ base: 'uptimeobserver/ratio',
+ pattern: ':period(\\d+)?/:monitorKey',
+ }
+
+ static openApi = {
+ '/uptimeobserver/ratio/1/{monitorKey}': {
+ get: {
+ summary: 'UptimeObserver uptime ratio (1 day)',
+ parameters: pathParams({
+ name: 'monitorKey',
+ example: '33Zw1rnH6veb4OLcskqvj6g9Lj4tnyxZ41',
+ }),
+ },
+ },
+ '/uptimeobserver/ratio/7/{monitorKey}': {
+ get: {
+ summary: 'UptimeObserver uptime ratio (7 days)',
+ parameters: pathParams({
+ name: 'monitorKey',
+ example: '33Zw1rnH6veb4OLcskqvj6g9Lj4tnyxZ41',
+ }),
+ },
+ },
+ '/uptimeobserver/ratio/30/{monitorKey}': {
+ get: {
+ summary: 'UptimeObserver uptime ratio (30 days)',
+ parameters: pathParams({
+ name: 'monitorKey',
+ example: '33Zw1rnH6veb4OLcskqvj6g9Lj4tnyxZ41',
+ }),
+ },
+ },
+ }
+
+ static defaultBadgeData = {
+ label: 'uptime',
+ }
+
+ static render({ ratio }) {
+ // Remove trailing zeros, show up to 3 decimal places
+ const formatted = Number(ratio).toLocaleString(undefined, {
+ minimumFractionDigits: 0,
+ maximumFractionDigits: 3,
+ })
+ return {
+ message: `${formatted}%`,
+ color: ratioColor(ratio),
+ }
+ }
+
+ async handle({ period = '30', monitorKey }) {
+ const response = await this.fetch({ monitorKey })
+
+ let uptimeField
+ switch (period) {
+ case '1':
+ uptimeField = 'uptime24h'
+ break
+ case '7':
+ uptimeField = 'uptime7d'
+ break
+ case '30':
+ default:
+ uptimeField = 'uptime30d'
+ break
+ }
+
+ const ratio = response[uptimeField]
+
+ return this.constructor.render({ ratio })
+ }
+}
diff --git a/services/uptimeobserver/uptimeobserver-ratio.tester.js b/services/uptimeobserver/uptimeobserver-ratio.tester.js
new file mode 100644
index 0000000000000..e3dfc8f13b3fa
--- /dev/null
+++ b/services/uptimeobserver/uptimeobserver-ratio.tester.js
@@ -0,0 +1,113 @@
+import { createServiceTester } from '../tester.js'
+import { isPercentage } from '../test-validators.js'
+import { invalidJSON } from '../response-fixtures.js'
+
+export const t = await createServiceTester()
+
+t.create('UptimeObserver uptime ratio 30 days (mock)')
+ .get('/30/94e1d60553b1425ab2a276128f3bca7466.json')
+ .intercept(nock =>
+ nock('https://app.uptimeobserver.com')
+ .get('/api/monitor/status/94e1d60553b1425ab2a276128f3bca7466')
+ .reply(200, {
+ status: 'UP',
+ friendlyName: 'Test Monitor',
+ lastExecution: '2025-06-04T18:17:06.171106Z',
+ uptime24h: 99.5,
+ uptime7d: 98.2,
+ uptime30d: 97.8,
+ }),
+ )
+ .expectBadge({
+ label: 'uptime',
+ message: '97.8%',
+ color: 'brightgreen',
+ })
+
+t.create('UptimeObserver uptime ratio 30 days')
+ .get('/30/33Zw1rnH6veb4OLcskqvj6g9Lj4tnyxZ41.json')
+ .expectBadge({
+ label: 'uptime',
+ message: isPercentage,
+ })
+
+t.create('UptimeObserver uptime ratio 7 days (mock)')
+ .get('/7/94e1d60553b1425ab2a276128f3bca7466.json')
+ .intercept(nock =>
+ nock('https://app.uptimeobserver.com')
+ .get('/api/monitor/status/94e1d60553b1425ab2a276128f3bca7466')
+ .reply(200, {
+ status: 'UP',
+ friendlyName: 'Test Monitor',
+ lastExecution: '2025-06-04T18:17:06.171106Z',
+ uptime24h: 99.5,
+ uptime7d: 98.2,
+ uptime30d: 97.8,
+ }),
+ )
+ .expectBadge({
+ label: 'uptime',
+ message: '98.2%',
+ color: 'brightgreen',
+ })
+
+t.create('UptimeObserver uptime ratio 7 days')
+ .get('/7/33Zw1rnH6veb4OLcskqvj6g9Lj4tnyxZ41.json')
+ .expectBadge({
+ label: 'uptime',
+ message: isPercentage,
+ })
+
+t.create('UptimeObserver uptime ratio 24h (mock)')
+ .get('/1/94e1d60553b1425ab2a276128f3bca7466.json')
+ .intercept(nock =>
+ nock('https://app.uptimeobserver.com')
+ .get('/api/monitor/status/94e1d60553b1425ab2a276128f3bca7466')
+ .reply(200, {
+ status: 'UP',
+ friendlyName: 'Test Monitor',
+ lastExecution: '2025-06-04T18:17:06.171106Z',
+ uptime24h: 99.5,
+ uptime7d: 98.2,
+ uptime30d: 97.8,
+ }),
+ )
+ .expectBadge({
+ label: 'uptime',
+ message: '99.5%',
+ color: 'brightgreen',
+ })
+
+t.create('UptimeObserver uptime ratio 24h')
+ .get('/1/33Zw1rnH6veb4OLcskqvj6g9Lj4tnyxZ41.json')
+ .expectBadge({
+ label: 'uptime',
+ message: isPercentage,
+ })
+
+t.create('UptimeObserver: Percentage (service unavailable)')
+ .get('/1/22Zw1rnH6veb4OLcskqvj6g9Lj4tnyx741.json')
+ .intercept(nock =>
+ nock('https://app.uptimeobserver.com')
+ .get('/api/monitor/status/22Zw1rnH6veb4OLcskqvj6g9Lj4tnyx741')
+ .reply(503, '{"error": "oh noes!!"}'),
+ )
+ .expectBadge({ label: 'uptime', message: 'inaccessible' })
+
+t.create('UptimeObserver: Percentage (unexpected response, valid json)')
+ .get('/1/22Zw1rnH6veb4OLcskqvj6g9Lj4tnyx741.json')
+ .intercept(nock =>
+ nock('https://app.uptimeobserver.com')
+ .get('/api/monitor/status/22Zw1rnH6veb4OLcskqvj6g9Lj4tnyx741')
+ .reply(200, '[]'),
+ )
+ .expectBadge({ label: 'uptime', message: 'invalid response data' })
+
+t.create('UptimeObserver: Percentage (unexpected response, valid json)')
+ .get('/1/22Zw1rnH6veb4OLcskqvj6g9Lj4tnyx741.json')
+ .intercept(nock =>
+ nock('https://app.uptimeobserver.com')
+ .get('/api/monitor/status/22Zw1rnH6veb4OLcskqvj6g9Lj4tnyx741')
+ .reply(invalidJSON),
+ )
+ .expectBadge({ label: 'uptime', message: 'unparseable json response' })
diff --git a/services/uptimeobserver/uptimeobserver-status.service.js b/services/uptimeobserver/uptimeobserver-status.service.js
new file mode 100644
index 0000000000000..1ffba7bf7d461
--- /dev/null
+++ b/services/uptimeobserver/uptimeobserver-status.service.js
@@ -0,0 +1,71 @@
+import { pathParam } from '../index.js'
+import { queryParamSchema, queryParams } from '../website-status.js'
+import UptimeObserverBase from './uptimeobserver-base.js'
+
+export default class UptimeObserverStatus extends UptimeObserverBase {
+ static route = {
+ base: 'uptimeobserver/status',
+ pattern: ':monitorKey',
+ queryParamSchema,
+ }
+
+ static openApi = {
+ '/uptimeobserver/status/{monitorKey}': {
+ get: {
+ summary: 'UptimeObserver status',
+ parameters: [
+ pathParam({
+ name: 'monitorKey',
+ example: '33Zw1rnH6veb4OLcskqvj6g9Lj4tnyxZ41',
+ }),
+ ...queryParams,
+ ],
+ },
+ },
+ }
+
+ static defaultBadgeData = {
+ label: 'status',
+ }
+
+ static render({
+ status,
+ upMessage = 'up',
+ downMessage = 'down',
+ upColor = 'brightgreen',
+ downColor = 'red',
+ }) {
+ // Based on the API response, status values are strings like "UP", "DOWN", etc.
+ switch (status.toUpperCase()) {
+ case 'UP':
+ return { message: upMessage, color: upColor }
+ case 'DOWN':
+ return { message: downMessage, color: downColor }
+ case 'PAUSED':
+ return { message: 'paused', color: 'lightgrey' }
+ default:
+ return { message: status.toLowerCase(), color: 'lightgrey' }
+ }
+ }
+
+ async handle(
+ { monitorKey },
+ {
+ up_message: upMessage,
+ down_message: downMessage,
+ up_color: upColor,
+ down_color: downColor,
+ },
+ ) {
+ const response = await this.fetch({ monitorKey })
+ const { status } = response
+
+ return this.constructor.render({
+ status,
+ upMessage,
+ downMessage,
+ upColor,
+ downColor,
+ })
+ }
+}
diff --git a/services/uptimeobserver/uptimeobserver-status.tester.js b/services/uptimeobserver/uptimeobserver-status.tester.js
new file mode 100644
index 0000000000000..d3c9b43cee9ff
--- /dev/null
+++ b/services/uptimeobserver/uptimeobserver-status.tester.js
@@ -0,0 +1,74 @@
+import Joi from 'joi'
+import { createServiceTester } from '../tester.js'
+
+export const t = await createServiceTester()
+
+const isUptimeStatus = Joi.string().valid('up', 'down', 'paused')
+
+t.create('UptimeObserver status (mock)')
+ .get('/94e1d60553b1425ab2a276128f3bca7466.json')
+ .intercept(nock =>
+ nock('https://app.uptimeobserver.com')
+ .get('/api/monitor/status/94e1d60553b1425ab2a276128f3bca7466')
+ .reply(200, {
+ status: 'UP',
+ friendlyName: 'Test Monitor',
+ lastExecution: '2025-06-04T18:17:06.171106Z',
+ uptime24h: 99.5,
+ uptime7d: 98.2,
+ uptime30d: 97.8,
+ }),
+ )
+ .expectBadge({
+ label: 'status',
+ message: 'up',
+ color: 'brightgreen',
+ })
+
+t.create('UptimeObserver uptime status (known monitor)')
+ .get('/33Zw1rnH6veb4OLcskqvj6g9Lj4tnyxZ41.json')
+ .expectBadge({
+ label: 'status',
+ message: isUptimeStatus,
+ })
+
+t.create('UptimeObserver uptime status (paused monitor)')
+ .get('/d48f3768a01a4f36972efebb096cb6c01933.json')
+ .expectBadge({
+ label: 'status',
+ message: isUptimeStatus,
+ })
+
+t.create('UptimeObserver uptime status (not found)')
+ .get('/aae1d60553b1425ab2a276128f3bca7466.json')
+ .expectBadge({
+ label: 'status',
+ message: 'not found',
+ })
+
+t.create('UptimeObserver uptime status (unvalid key)')
+ .get('/aae1d60553b1425ab2a.json')
+ .expectBadge({
+ label: 'status',
+ message: 'monitor API key is unvalid',
+ })
+
+t.create('UptimeObserver status DOWN (mock)')
+ .get('/94e1d60553b1425ab2a276128f3bca7466.json')
+ .intercept(nock =>
+ nock('https://app.uptimeobserver.com')
+ .get('/api/monitor/status/94e1d60553b1425ab2a276128f3bca7466')
+ .reply(200, {
+ status: 'DOWN',
+ friendlyName: 'Test Monitor Down',
+ lastExecution: '2025-06-04T18:17:06.171106Z',
+ uptime24h: 85.0,
+ uptime7d: 88.2,
+ uptime30d: 92.1,
+ }),
+ )
+ .expectBadge({
+ label: 'status',
+ message: 'down',
+ color: 'red',
+ })
diff --git a/services/uptimerobot/uptimerobot-base.js b/services/uptimerobot/uptimerobot-base.js
index 2afa607b9fa70..0d5b1506986b0 100644
--- a/services/uptimerobot/uptimerobot-base.js
+++ b/services/uptimerobot/uptimerobot-base.js
@@ -25,7 +25,7 @@ const singleMonitorResponse = Joi.alternatives(
Joi.object({
stat: Joi.equal('ok').required(),
monitors: Joi.array().length(1).items(monitor).required(),
- }).required()
+ }).required(),
)
const singleMonitorResponseWithUptime = Joi.alternatives(
@@ -33,7 +33,7 @@ const singleMonitorResponseWithUptime = Joi.alternatives(
Joi.object({
stat: Joi.equal('ok').required(),
monitors: Joi.array().length(1).items(monitorWithUptime).required(),
- }).required()
+ }).required(),
)
export default class UptimeRobotBase extends BaseJsonService {
@@ -74,6 +74,7 @@ export default class UptimeRobotBase extends BaseJsonService {
...opts,
},
},
+ logErrors: [],
})
if (stat === 'fail') {
diff --git a/services/uptimerobot/uptimerobot-ratio.service.js b/services/uptimerobot/uptimerobot-ratio.service.js
index fa98e8ed1fd05..5b1d36eaf8ed8 100644
--- a/services/uptimerobot/uptimerobot-ratio.service.js
+++ b/services/uptimerobot/uptimerobot-ratio.service.js
@@ -1,3 +1,4 @@
+import { pathParams } from '../index.js'
import { colorScale } from '../color-formatters.js'
import UptimeRobotBase from './uptimerobot-base.js'
@@ -9,24 +10,26 @@ export default class UptimeRobotRatio extends UptimeRobotBase {
pattern: ':numberOfDays(\\d+)?/:monitorSpecificKey',
}
- static examples = [
- {
- title: 'Uptime Robot ratio (30 days)',
- pattern: ':monitorSpecificKey',
- namedParams: {
- monitorSpecificKey: 'm778918918-3e92c097147760ee39d02d36',
+ static openApi = {
+ '/uptimerobot/ratio/{monitorSpecificKey}': {
+ get: {
+ summary: 'Uptime Robot ratio (30 days)',
+ parameters: pathParams({
+ name: 'monitorSpecificKey',
+ example: 'm778918918-3e92c097147760ee39d02d36',
+ }),
},
- staticPreview: this.render({ ratio: 100 }),
},
- {
- title: 'Uptime Robot ratio (7 days)',
- pattern: '7/:monitorSpecificKey',
- namedParams: {
- monitorSpecificKey: 'm778918918-3e92c097147760ee39d02d36',
+ '/uptimerobot/ratio/7/{monitorSpecificKey}': {
+ get: {
+ summary: 'Uptime Robot ratio (7 days)',
+ parameters: pathParams({
+ name: 'monitorSpecificKey',
+ example: 'm778918918-3e92c097147760ee39d02d36',
+ }),
},
- staticPreview: this.render({ ratio: 100 }),
},
- ]
+ }
static defaultBadgeData = {
label: 'uptime',
diff --git a/services/uptimerobot/uptimerobot-ratio.tester.js b/services/uptimerobot/uptimerobot-ratio.tester.js
index db6b55c9da933..d50c077df141d 100644
--- a/services/uptimerobot/uptimerobot-ratio.tester.js
+++ b/services/uptimerobot/uptimerobot-ratio.tester.js
@@ -33,7 +33,7 @@ t.create('Uptime Robot: Percentage (unspecified error)')
.intercept(nock =>
nock('https://api.uptimerobot.com')
.post('/v2/getMonitors')
- .reply(200, '{"stat": "fail"}')
+ .reply(200, '{"stat": "fail"}'),
)
.expectBadge({ label: 'uptime', message: 'service error' })
@@ -42,14 +42,16 @@ t.create('Uptime Robot: Percentage (service unavailable)')
.intercept(nock =>
nock('https://api.uptimerobot.com')
.post('/v2/getMonitors')
- .reply(503, '{"error": "oh noes!!"}')
+ .reply(503, '{"error": "oh noes!!"}'),
)
.expectBadge({ label: 'uptime', message: 'inaccessible' })
t.create('Uptime Robot: Percentage (unexpected response, valid json)')
.get('/m778918918-3e92c097147760ee39d02d36.json')
.intercept(nock =>
- nock('https://api.uptimerobot.com').post('/v2/getMonitors').reply(200, '[]')
+ nock('https://api.uptimerobot.com')
+ .post('/v2/getMonitors')
+ .reply(200, '[]'),
)
.expectBadge({ label: 'uptime', message: 'invalid response data' })
@@ -58,6 +60,6 @@ t.create('Uptime Robot: Percentage (unexpected response, invalid json)')
.intercept(nock =>
nock('https://api.uptimerobot.com')
.post('/v2/getMonitors')
- .reply(invalidJSON)
+ .reply(invalidJSON),
)
.expectBadge({ label: 'uptime', message: 'unparseable json response' })
diff --git a/services/uptimerobot/uptimerobot-status.service.js b/services/uptimerobot/uptimerobot-status.service.js
index a468175c42b62..bfc881f763982 100644
--- a/services/uptimerobot/uptimerobot-status.service.js
+++ b/services/uptimerobot/uptimerobot-status.service.js
@@ -1,45 +1,73 @@
+import { pathParam } from '../index.js'
+import { queryParamSchema, queryParams } from '../website-status.js'
import UptimeRobotBase from './uptimerobot-base.js'
export default class UptimeRobotStatus extends UptimeRobotBase {
static route = {
base: 'uptimerobot/status',
pattern: ':monitorSpecificKey',
+ queryParamSchema,
}
- static examples = [
- {
- title: 'Uptime Robot status',
- namedParams: {
- monitorSpecificKey: 'm778918918-3e92c097147760ee39d02d36',
+ static openApi = {
+ '/uptimerobot/status/{monitorSpecificKey}': {
+ get: {
+ summary: 'Uptime Robot status',
+ parameters: [
+ pathParam({
+ name: 'monitorSpecificKey',
+ example: 'm778918918-3e92c097147760ee39d02d36',
+ }),
+ ...queryParams,
+ ],
},
- staticPreview: this.render({ status: 2 }),
},
- ]
+ }
static defaultBadgeData = {
label: 'status',
}
- static render({ status }) {
+ static render({
+ status,
+ upMessage = 'up',
+ downMessage = 'down',
+ upColor = 'brightgreen',
+ downColor = 'red',
+ }) {
switch (status) {
case 0:
return { message: 'paused', color: 'yellow' }
case 1:
return { message: 'not checked yet', color: 'yellowgreen' }
case 2:
- return { message: 'up', color: 'brightgreen' }
+ return { message: upMessage, color: upColor }
case 8:
return { message: 'seems down', color: 'orange' }
case 9:
- return { message: 'down', color: 'red' }
+ return { message: downMessage, color: downColor }
default:
throw Error('Should not get here due to validation')
}
}
- async handle({ monitorSpecificKey }) {
+ async handle(
+ { monitorSpecificKey },
+ {
+ up_message: upMessage,
+ down_message: downMessage,
+ up_color: upColor,
+ down_color: downColor,
+ },
+ ) {
const { monitors } = await this.fetch({ monitorSpecificKey })
const { status } = monitors[0]
- return this.constructor.render({ status })
+ return this.constructor.render({
+ status,
+ upMessage,
+ downMessage,
+ upColor,
+ downColor,
+ })
}
}
diff --git a/services/uptimerobot/uptimerobot-status.tester.js b/services/uptimerobot/uptimerobot-status.tester.js
index dca7e92554e3d..1af85f668dc10 100644
--- a/services/uptimerobot/uptimerobot-status.tester.js
+++ b/services/uptimerobot/uptimerobot-status.tester.js
@@ -8,7 +8,7 @@ const isUptimeStatus = Joi.string().valid(
'not checked yet',
'up',
'seems down',
- 'down'
+ 'down',
)
t.create('Uptime Robot: Status (valid)')
@@ -34,7 +34,7 @@ t.create('Uptime Robot: Status (unspecified error)')
.intercept(nock =>
nock('https://api.uptimerobot.com')
.post('/v2/getMonitors')
- .reply(200, '{"stat": "fail"}')
+ .reply(200, '{"stat": "fail"}'),
)
.expectBadge({ label: 'status', message: 'service error' })
@@ -43,14 +43,16 @@ t.create('Uptime Robot: Status (service unavailable)')
.intercept(nock =>
nock('https://api.uptimerobot.com')
.post('/v2/getMonitors')
- .reply(503, '{"error": "oh noes!!"}')
+ .reply(503, '{"error": "oh noes!!"}'),
)
.expectBadge({ label: 'status', message: 'inaccessible' })
t.create('Uptime Robot: Status (unexpected response, valid json)')
.get('/m778918918-3e92c097147760ee39d02d36.json')
.intercept(nock =>
- nock('https://api.uptimerobot.com').post('/v2/getMonitors').reply(200, '[]')
+ nock('https://api.uptimerobot.com')
+ .post('/v2/getMonitors')
+ .reply(200, '[]'),
)
.expectBadge({ label: 'status', message: 'invalid response data' })
@@ -59,6 +61,6 @@ t.create('Uptime Robot: Status (unexpected response, invalid json)')
.intercept(nock =>
nock('https://api.uptimerobot.com')
.post('/v2/getMonitors')
- .reply(invalidJSON)
+ .reply(invalidJSON),
)
.expectBadge({ label: 'status', message: 'unparseable json response' })
diff --git a/services/vaadin-directory/vaadin-directory-base.js b/services/vaadin-directory/vaadin-directory-base.js
index b567df2b3177e..3bffe42a6f473 100644
--- a/services/vaadin-directory/vaadin-directory-base.js
+++ b/services/vaadin-directory/vaadin-directory-base.js
@@ -16,9 +16,9 @@ class BaseVaadinDirectoryService extends BaseJsonService {
async fetch({ packageName }) {
return this._requestJson({
schema,
- url: `https://vaadin.com/vaadincom/directory-service/components/search/findByUrlIdentifier`,
+ url: 'https://vaadin.com/vaadincom/directory-service/components/search/findByUrlIdentifier',
options: {
- qs: {
+ searchParams: {
projection: 'summary',
urlIdentifier: packageName,
},
diff --git a/services/vaadin-directory/vaadin-directory-rating-count.service.js b/services/vaadin-directory/vaadin-directory-rating-count.service.js
index e8af652c66d28..ffd450030ee13 100644
--- a/services/vaadin-directory/vaadin-directory-rating-count.service.js
+++ b/services/vaadin-directory/vaadin-directory-rating-count.service.js
@@ -1,3 +1,4 @@
+import { pathParams } from '../index.js'
import { metric } from '../text-formatters.js'
import { floorCount as floorCountColor } from '../color-formatters.js'
import { BaseVaadinDirectoryService } from './vaadin-directory-base.js'
@@ -10,15 +11,17 @@ export default class VaadinDirectoryRatingCount extends BaseVaadinDirectoryServi
pattern: ':alias(rc|rating-count)/:packageName',
}
- static examples = [
- {
- title: 'Vaadin Directory',
- pattern: 'rating-count/:packageName',
- namedParams: { packageName: 'vaadinvaadin-grid' },
- staticPreview: this.render({ ratingCount: 6 }),
- keywords: ['vaadin-directory', 'rating-count'],
+ static openApi = {
+ '/vaadin-directory/rating-count/{packageName}': {
+ get: {
+ summary: 'Vaadin Directory Rating Count',
+ parameters: pathParams({
+ name: 'packageName',
+ example: 'vaadinvaadin-grid',
+ }),
+ },
},
- ]
+ }
static defaultBadgeData = {
label: 'rating count',
diff --git a/services/vaadin-directory/vaadin-directory-rating.service.js b/services/vaadin-directory/vaadin-directory-rating.service.js
index c07ddf091b099..846e7416558bd 100644
--- a/services/vaadin-directory/vaadin-directory-rating.service.js
+++ b/services/vaadin-directory/vaadin-directory-rating.service.js
@@ -1,3 +1,4 @@
+import { pathParams } from '../index.js'
import { starRating } from '../text-formatters.js'
import { floorCount as floorCountColor } from '../color-formatters.js'
import { BaseVaadinDirectoryService } from './vaadin-directory-base.js'
@@ -10,15 +11,24 @@ export default class VaadinDirectoryRating extends BaseVaadinDirectoryService {
pattern: ':format(star|stars|rating)/:packageName',
}
- static examples = [
- {
- title: 'Vaadin Directory',
- pattern: ':format(stars|rating)/:packageName',
- namedParams: { format: 'rating', packageName: 'vaadinvaadin-grid' },
- staticPreview: this.render({ format: 'rating', score: 4.75 }),
- keywords: ['vaadin-directory'],
+ static openApi = {
+ '/vaadin-directory/{format}/{packageName}': {
+ get: {
+ summary: 'Vaadin Directory Rating',
+ parameters: pathParams(
+ {
+ name: 'format',
+ example: 'rating',
+ schema: { type: 'string', enum: this.getEnum('format') },
+ },
+ {
+ name: 'packageName',
+ example: 'vaadinvaadin-grid',
+ },
+ ),
+ },
},
- ]
+ }
static defaultBadgeData = {
label: 'rating',
diff --git a/services/vaadin-directory/vaadin-directory-release-date.service.js b/services/vaadin-directory/vaadin-directory-release-date.service.js
index d9069ca4c9897..6a435d19b25ba 100644
--- a/services/vaadin-directory/vaadin-directory-release-date.service.js
+++ b/services/vaadin-directory/vaadin-directory-release-date.service.js
@@ -1,5 +1,5 @@
-import { formatDate } from '../text-formatters.js'
-import { age as ageColor } from '../color-formatters.js'
+import { pathParams } from '../index.js'
+import { renderDateBadge } from '../date.js'
import { BaseVaadinDirectoryService } from './vaadin-directory-base.js'
export default class VaadinDirectoryReleaseDate extends BaseVaadinDirectoryService {
@@ -10,28 +10,24 @@ export default class VaadinDirectoryReleaseDate extends BaseVaadinDirectoryServi
pattern: ':alias(rd|release-date)/:packageName',
}
- static examples = [
- {
- title: 'Vaadin Directory',
- pattern: 'release-date/:packageName',
- namedParams: { packageName: 'vaadinvaadin-grid' },
- staticPreview: this.render({ date: '2018-12-12' }),
- keywords: ['vaadin-directory', 'date', 'latest release date'],
+ static openApi = {
+ '/vaadin-directory/release-date/{packageName}': {
+ get: {
+ summary: 'Vaadin Directory Release Date',
+ parameters: pathParams({
+ name: 'packageName',
+ example: 'vaadinvaadin-grid',
+ }),
+ },
},
- ]
+ }
static defaultBadgeData = {
label: 'latest release date',
}
- static render({ date }) {
- return { message: formatDate(date), color: ageColor(date) }
- }
-
async handle({ alias, packageName }) {
const data = await this.fetch({ packageName })
- return this.constructor.render({
- date: data.latestAvailableRelease.publicationDate,
- })
+ return renderDateBadge(data.latestAvailableRelease.publicationDate)
}
}
diff --git a/services/vaadin-directory/vaadin-directory-status.service.js b/services/vaadin-directory/vaadin-directory-status.service.js
index 0ad9186faad5b..2ada00c6a1ff1 100644
--- a/services/vaadin-directory/vaadin-directory-status.service.js
+++ b/services/vaadin-directory/vaadin-directory-status.service.js
@@ -1,3 +1,4 @@
+import { pathParams } from '../index.js'
import { BaseVaadinDirectoryService } from './vaadin-directory-base.js'
export default class VaadinDirectoryStatus extends BaseVaadinDirectoryService {
@@ -8,14 +9,17 @@ export default class VaadinDirectoryStatus extends BaseVaadinDirectoryService {
pattern: ':packageName',
}
- static examples = [
- {
- title: 'Vaadin Directory',
- namedParams: { packageName: 'vaadinvaadin-grid' },
- staticPreview: this.render({ status: 'published' }),
- keywords: ['vaadin-directory', 'status'],
+ static openApi = {
+ '/vaadin-directory/status/{packageName}': {
+ get: {
+ summary: 'Vaadin Directory Status',
+ parameters: pathParams({
+ name: 'packageName',
+ example: 'vaadinvaadin-grid',
+ }),
+ },
},
- ]
+ }
static defaultBadgeData = {
label: 'vaadin directory',
diff --git a/services/vaadin-directory/vaadin-directory-version.service.js b/services/vaadin-directory/vaadin-directory-version.service.js
index 53af932836ecc..814e1a61a4b4b 100644
--- a/services/vaadin-directory/vaadin-directory-version.service.js
+++ b/services/vaadin-directory/vaadin-directory-version.service.js
@@ -1,3 +1,4 @@
+import { pathParams } from '../index.js'
import { renderVersionBadge } from '../version.js'
import { BaseVaadinDirectoryService } from './vaadin-directory-base.js'
@@ -9,15 +10,17 @@ export default class VaadinDirectoryVersion extends BaseVaadinDirectoryService {
pattern: ':alias(v|version)/:packageName',
}
- static examples = [
- {
- title: 'Vaadin Directory',
- pattern: 'v/:packageName',
- namedParams: { packageName: 'vaadinvaadin-grid' },
- staticPreview: renderVersionBadge({ version: 'v5.3.0-alpha4' }),
- keywords: ['vaadin-directory', 'latest'],
+ static openApi = {
+ '/vaadin-directory/v/{packageName}': {
+ get: {
+ summary: 'Vaadin Directory Version',
+ parameters: pathParams({
+ name: 'packageName',
+ example: 'vaadinvaadin-grid',
+ }),
+ },
},
- ]
+ }
static defaultBadgeData = {
label: 'vaadin directory',
diff --git a/services/validators.js b/services/validators.js
index 42b89cd1d10e2..4a195ff642407 100644
--- a/services/validators.js
+++ b/services/validators.js
@@ -1,3 +1,9 @@
+/**
+ * This module contains commonly used validators.
+ *
+ * @module
+ */
+
import {
semver as joiSemver,
semverRange as joiSemverRange,
@@ -5,15 +11,82 @@ import {
import joi from 'joi'
const Joi = joi.extend(joiSemver).extend(joiSemverRange)
+/**
+ * Joi validator that checks if a value is a number, an integer, and greater than or equal to zero.
+ *
+ * @type {Joi}
+ */
const optionalNonNegativeInteger = Joi.number().integer().min(0)
export { optionalNonNegativeInteger }
+
+/**
+ * Joi validator that checks if a value is a number, an integer, greater than or equal to zero and the value must be present.
+ *
+ * @type {Joi}
+ */
export const nonNegativeInteger = optionalNonNegativeInteger.required()
+
+/**
+ * Joi validator that checks if a value is a number, an integer and the value must be present.
+ *
+ * @type {Joi}
+ */
export const anyInteger = Joi.number().integer().required()
+
+/**
+ * Joi validator that checks if a value is a valid semantic versioning string and the value must be present.
+ *
+ * @type {Joi}
+ */
export const semver = Joi.semver().valid().required()
+
+/**
+ * Joi validator that checks if a value is a valid semantic versioning range and the value must be present.
+ *
+ * @type {Joi}
+ */
export const semverRange = Joi.semverRange().valid().required()
+
+/**
+ * Joi validator that checks if a value is a string that matches a regular expression.
+ * The regular expression matches strings that start with one or more digits, followed by zero or more groups of a dot and one or more digits,
+ * followed by an optional suffix that starts with a dash or a plus sign and can contain any characters.
+ * This validator can be used to validate properties that can be version strings with an optional suffix or absent.
+ * For example, some valid values for this validator are: 1, 1.0, 1.0.0, 1.0.0-beta
+ * Some invalid values for this validator are: abc, 1.a, 1.0-, .1
+ *
+ * @type {Joi}
+ */
export const optionalDottedVersionNClausesWithOptionalSuffix =
Joi.string().regex(/^\d+(\.\d+)*([-+].*)?$/)
-// TODO This accepts URLs with query strings and fragments, which for some
-// purposes should be rejected.
+
+/**
+ * Joi validator that checks if a value is a URL
+ *
+ * TODO: This accepts URLs with query strings and fragments, which for some purposes should be rejected.
+ *
+ * @type {Joi}
+ */
export const optionalUrl = Joi.string().uri({ scheme: ['http', 'https'] })
+
+/**
+ * Joi validator that checks if a value is a URL and the value must be present.
+ *
+ * @type {Joi}
+ */
+export const url = optionalUrl.required()
+
+/**
+ * Joi validator for a file size in bytes (direct numeric value)
+ *
+ * @type {Joi}
+ */
+export const fileSizeBytes = Joi.number().integer().positive().required()
+
+/**
+ * Joi validator that checks if a value is a relative-only URI
+ *
+ * @type {Joi}
+ */
+export const relativeUri = Joi.string().uri({ relativeOnly: true })
diff --git a/services/vcpkg/vcpkg-version-helpers.js b/services/vcpkg/vcpkg-version-helpers.js
new file mode 100644
index 0000000000000..23cc2d7f4bb3c
--- /dev/null
+++ b/services/vcpkg/vcpkg-version-helpers.js
@@ -0,0 +1,17 @@
+import { InvalidResponse } from '../index.js'
+
+export function parseVersionFromVcpkgManifest(manifest) {
+ if (manifest['version-date']) {
+ return manifest['version-date']
+ }
+ if (manifest['version-semver']) {
+ return manifest['version-semver']
+ }
+ if (manifest['version-string']) {
+ return manifest['version-string']
+ }
+ if (manifest.version) {
+ return manifest.version
+ }
+ throw new InvalidResponse({ prettyMessage: 'missing version' })
+}
diff --git a/services/vcpkg/vcpkg-version-helpers.spec.js b/services/vcpkg/vcpkg-version-helpers.spec.js
new file mode 100644
index 0000000000000..c73e804b2ca1a
--- /dev/null
+++ b/services/vcpkg/vcpkg-version-helpers.spec.js
@@ -0,0 +1,41 @@
+import { expect } from 'chai'
+import { InvalidResponse } from '../index.js'
+import { parseVersionFromVcpkgManifest } from './vcpkg-version-helpers.js'
+
+describe('parseVersionFromVcpkgManifest', function () {
+ it('returns a version when `version` field is detected', function () {
+ expect(
+ parseVersionFromVcpkgManifest({
+ version: '2.12.1',
+ }),
+ ).to.equal('2.12.1')
+ })
+
+ it('returns a version when `version-date` field is detected', function () {
+ expect(
+ parseVersionFromVcpkgManifest({
+ 'version-date': '2022-12-04',
+ }),
+ ).to.equal('2022-12-04')
+ })
+
+ it('returns a version when `version-semver` field is detected', function () {
+ expect(
+ parseVersionFromVcpkgManifest({
+ 'version-semver': '3.11.2',
+ }),
+ ).to.equal('3.11.2')
+ })
+
+ it('returns a version when `version-date` field is detected', function () {
+ expect(
+ parseVersionFromVcpkgManifest({
+ 'version-string': '22.01',
+ }),
+ ).to.equal('22.01')
+ })
+
+ it('rejects when no version field variant is detected', function () {
+ expect(() => parseVersionFromVcpkgManifest('{}')).to.throw(InvalidResponse)
+ })
+})
diff --git a/services/vcpkg/vcpkg-version.service.js b/services/vcpkg/vcpkg-version.service.js
new file mode 100644
index 0000000000000..001c1b0a41839
--- /dev/null
+++ b/services/vcpkg/vcpkg-version.service.js
@@ -0,0 +1,70 @@
+import Joi from 'joi'
+import { ConditionalGithubAuthV3Service } from '../github/github-auth-service.js'
+import { fetchJsonFromRepo } from '../github/github-common-fetch.js'
+import { renderVersionBadge } from '../version.js'
+import { NotFound, pathParams } from '../index.js'
+import { parseVersionFromVcpkgManifest } from './vcpkg-version-helpers.js'
+
+// Handle the different version fields available in Vcpkg manifests
+// https://learn.microsoft.com/en-us/vcpkg/reference/vcpkg-json?source=recommendations#version
+const vcpkgManifestSchema = Joi.alternatives()
+ .match('one')
+ .try(
+ Joi.object({
+ version: Joi.string().required(),
+ }).required(),
+ Joi.object({
+ 'version-date': Joi.string().required(),
+ }).required(),
+ Joi.object({
+ 'version-semver': Joi.string().required(),
+ }).required(),
+ Joi.object({
+ 'version-string': Joi.string().required(),
+ }).required(),
+ )
+
+export default class VcpkgVersion extends ConditionalGithubAuthV3Service {
+ static category = 'version'
+
+ static route = { base: 'vcpkg/v', pattern: ':portName' }
+
+ static openApi = {
+ '/vcpkg/v/{portName}': {
+ get: {
+ summary: 'Vcpkg Version',
+ parameters: pathParams({
+ name: 'portName',
+ example: 'entt',
+ }),
+ },
+ },
+ }
+
+ static defaultBadgeData = { label: 'vcpkg' }
+
+ static render({ version }) {
+ return renderVersionBadge({ version })
+ }
+
+ async handle({ portName }) {
+ try {
+ const manifest = await fetchJsonFromRepo(this, {
+ schema: vcpkgManifestSchema,
+ user: 'microsoft',
+ repo: 'vcpkg',
+ branch: 'master',
+ filename: `ports/${portName}/vcpkg.json`,
+ })
+ const version = parseVersionFromVcpkgManifest(manifest)
+ return this.constructor.render({ version })
+ } catch (error) {
+ if (error instanceof NotFound) {
+ throw new NotFound({
+ prettyMessage: 'port not found',
+ })
+ }
+ throw error
+ }
+ }
+}
diff --git a/services/vcpkg/vcpkg-version.tester.js b/services/vcpkg/vcpkg-version.tester.js
new file mode 100644
index 0000000000000..b7ad6afc3e0a7
--- /dev/null
+++ b/services/vcpkg/vcpkg-version.tester.js
@@ -0,0 +1,16 @@
+import { isSemver } from '../test-validators.js'
+import { createServiceTester } from '../tester.js'
+
+export const t = await createServiceTester()
+
+t.create('gets nlohmann-json port version')
+ .get('/nlohmann-json.json')
+ .expectBadge({ label: 'vcpkg', color: 'blue', message: isSemver })
+
+t.create('gets not found error for invalid port')
+ .get('/this-port-does-not-exist.json')
+ .expectBadge({
+ label: 'vcpkg',
+ color: 'red',
+ message: 'port not found',
+ })
diff --git a/services/version.js b/services/version.js
index 934b4d8de71d5..5534b7a7a662c 100644
--- a/services/version.js
+++ b/services/version.js
@@ -2,13 +2,24 @@
* Utilities relating to generating badges relating to version numbers. Includes
* comparing versions to determine the latest, and determining the color to use
* for the badge based on whether the version is a stable release.
- *
* For utilities specific to PHP version ranges, see php-version.js.
+ *
+ * @module
*/
+
import semver from 'semver'
import { addv } from './text-formatters.js'
import { version as versionColor } from './color-formatters.js'
+/**
+ * Compares two arrays of numbers lexicographically and returns an integer value.
+ *
+ * @param {number[]} a - The first array to compare
+ * @param {number[]} b - The second array to compare
+ * @returns {number} -1 if a is smaller than b, 1 if a is larger than b, 0 if a and b are equal
+ * @example
+ * listCompare([1, 2, 3], [1, 2, 4]) // returns -1 because the third element of the first array is smaller than the third element of the second array.
+ */
function listCompare(a, b) {
const alen = a.length
const blen = b.length
@@ -22,8 +33,15 @@ function listCompare(a, b) {
return alen - blen
}
-// Take string versions.
-// -1 if v1 < v2, 1 if v1 > v2, 0 otherwise.
+/**
+ * Compares two strings representing version numbers lexicographically and returns an integer value.
+ *
+ * @param {string} v1 - The first version to compare
+ * @param {string} v2 - The second version to compare
+ * @returns {number} -1 if v1 is smaller than v2, 1 if v1 is larger than v2, 0 if v1 and v2 are equal
+ * @example
+ * compareDottedVersion('1.2.3', '1.2.4') // returns -1 because numeric part of first version is smaller than the numeric part of second version.
+ */
function compareDottedVersion(v1, v2) {
const parts1 = /([0-9.]+)(.*)$/.exec(v1)
const parts2 = /([0-9.]+)(.*)$/.exec(v2)
@@ -41,15 +59,22 @@ function compareDottedVersion(v1, v2) {
return distinguisher1 < distinguisher2
? -1
: distinguisher1 > distinguisher2
- ? 1
- : 0
+ ? 1
+ : 0
}
}
return v1 < v2 ? -1 : v1 > v2 ? 1 : 0
}
-// Take a list of string versions.
-// Return the latest, or undefined, if there are none.
+/**
+ * Finds the largest version number lexicographically from an array of strings representing version numbers and returns it as a string.
+ *
+ * @param {string[]} versions - The array of version numbers to compare
+ * @returns {string|undefined} The largest version number as a string, or undefined if the array is empty
+ * @example
+ * latestDottedVersion(['1.2.3', '1.2.4', '1.3', '2.0']) // returns '2.0' because it is the largest version number in the array.
+ * latestDottedVersion([]) // returns undefined because the array is empty.
+ */
function latestDottedVersion(versions) {
const len = versions.length
if (len === 0) {
@@ -64,6 +89,19 @@ function latestDottedVersion(versions) {
return version
}
+/**
+ * Finds the largest version number lexicographically or semantically from an array of strings representing version numbers and returns it as a string.
+ * latestMaybeSemVer() is used for versions that match some kind of dotted version pattern.
+ *
+ * @param {string[]} versions - The array of version numbers to compare
+ * @param {boolean} pre - Whether to include pre-release versions or not
+ * @returns {string|undefined} The largest version number as a string, or undefined if the array is empty
+ * @example
+ * latestMaybeSemVer(['1.2.3', '1.2.4', '1.3', '2.0'], false) // returns '2.0' because it is the largest version number and pre-release versions are excluded.
+ * latestMaybeSemVer(['1.2.3', '1.2.4', '1.3', '2.0'], true) // returns '2.0' because pre-release versions are included but none of them are present in the array.
+ * latestMaybeSemVer(['1.2.3', '1.2.4', '1.3-alpha', '2.0-beta'], false) // returns '1.2.4' because pre-release versions are excluded and it is the largest version number among the remaining ones.
+ * latestMaybeSemVer(['1.2.3', '1.2.4', '1.3-alpha', '2.0-beta'], true) // returns '2.0-beta' because pre-release versions are included and it is the largest version number.
+ */
function latestMaybeSemVer(versions, pre) {
let version = ''
@@ -74,20 +112,33 @@ function latestMaybeSemVer(versions, pre) {
try {
// coerce to string then lowercase otherwise alpha > RC
version = versions.sort((a, b) =>
- semver.rcompare(
+ semver.compareBuild(
`${a}`.toLowerCase(),
`${b}`.toLowerCase(),
- /* loose */ true
- )
- )[0]
+ /* loose */ true,
+ ),
+ )[versions.length - 1]
} catch (e) {
version = latestDottedVersion(versions)
}
return version
}
-// Given a list of versions (as strings), return the latest version.
-// Return undefined if no version could be found.
+/**
+ * Finds the largest version number lexicographically or semantically from an array of strings representing version numbers and returns it as a string.
+ * latest() is looser than latestMaybeSemVer() as it will attempt to make sense of anything, falling back to alphabetic sorting.
+ * We should ideally prefer latest() over latestMaybeSemVer() when adding version badges.
+ *
+ * @param {string[]} versions - The array of version numbers to compare
+ * @param {object} [options] - An optional object that contains additional options
+ * @param {boolean} [options.pre=false] - Whether to include pre-release versions or not, defaults to false
+ * @returns {string|undefined} The largest version number as a string, or undefined if the array is empty
+ * @example
+ * latest(['1.2.3', '1.2.4', '1.3', '2.0'], { pre: false }) // returns '2.0' because it is the largest version number and pre-release versions are excluded.
+ * latest(['1.2.3', '1.2.4', '1.3', '2.0'], { pre: true }) // returns '2.0' because pre-release versions are included but none of them are present in the array.
+ * latest(['1.2.3', '1.2.4', '1.3-alpha', '2.0-beta'], { pre: false }) // returns '1.2.4' because pre-release versions are excluded and it is the largest version number among the remaining ones.
+ * latest(['1.2.3', '1.2.4', '1.3-alpha', '2.0-beta'], { pre: true }) // returns '2.0-beta' because pre-release versions are included and it is the largest version number.
+ */
function latest(versions, { pre = false } = {}) {
let version = ''
let origVersions = versions
@@ -109,7 +160,7 @@ function latest(versions, { pre = false } = {}) {
// fall back to a case-insensitive string comparison
if (version == null) {
origVersions = origVersions.sort((a, b) =>
- a.toLowerCase().localeCompare(b.toLowerCase())
+ a.toLowerCase().localeCompare(b.toLowerCase()),
)
version = origVersions[origVersions.length - 1]
}
@@ -117,8 +168,17 @@ function latest(versions, { pre = false } = {}) {
return version
}
-// Slice the specified number of dotted parts from the given semver version.
-// e.g. slice('2.4.7', 'minor') -> '2.4'
+/**
+ * Slices the specified number of dotted parts from the given semver version.
+ *
+ * @param {string} v - The semver version to slice
+ * @param {string} releaseType - The release type to slice up to. Can be one of "major", "minor", or "patch"
+ * @returns {string|null} The sliced version as a string, or null if the version is not valid
+ * @example
+ * slice('2.4.7', 'minor') // returns '2.4' because it slices the version string up to the minor component.
+ * slice('2.4.7-alpha', 'patch') // returns '2.4.7-alpha' because it slices the version string up to the patch component and preserves the prerelease component.
+ * slice('2.4', 'patch') // returns null because the version string is not valid according to semver rules.
+ */
function slice(v, releaseType) {
if (!semver.valid(v, /* loose */ true)) {
return null
@@ -147,16 +207,58 @@ function slice(v, releaseType) {
}
}
+/**
+ * Returns the start of the range that matches a given version string.
+ *
+ * @param {string} v - A version string that follows the Semantic Versioning specification. The function will accept and coerce invalid versions into valid ones.
+ * @returns {string} The start of the range that matches the given version string, or null if no match is found.
+ * @throws {TypeError} If v is an invalid semver range
+ * @example
+ * rangeStart('^1.2.3') // returns '1.2.3'
+ * rangeStart('>=2.0.0') // returns '2.0.0'
+ * rangeStart('1.x || >=2.5.0 || 5.0.0 - 7.2.3') // returns '1.0.0'
+ * rangeStart('1.2.x') // returns '1.2.0'
+ * rangeStart('1.2.*') // returns '1.2.0-0'
+ * rangeStart(null) // throws TypeError: Invalid Version: null
+ * rangeStart('') // throws TypeError: Invalid Version:
+ */
function rangeStart(v) {
const range = new semver.Range(v, /* loose */ true)
return range.set[0][0].semver.version
}
-function renderVersionBadge({ version, tag, defaultLabel }) {
+/**
+ * Creates a badge object that displays information about a version number. It should usually be used to output a version badge.
+ *
+ * @param {object} options - An object that contains the options for the badge
+ * @param {string} options.version - The version number to display on the badge
+ * @param {string} [options.tag] - The tag to display on the badge, such as "alpha" or "beta"
+ * @param {string} [options.defaultLabel] - The default label to display on the badge, such as "npm" or "github"
+ * @param {string} [options.prefix] - The prefix to display on the message, such as ">=", "v", overrides the default behavior of using addv
+ * @param {string} [options.suffix] - The suffix to display on the message, such as "tested"
+ * @param {Function} [options.versionFormatter=versionColor] - The function to use to format the color of the badge based on the version number
+ * @param {boolean} [options.isPrerelease] - Whether the version is explicitly marked as a prerelease by upstream API
+ * @returns {object} A badge object that has three properties: label, message, and color
+ * @example
+ * renderVersionBadge({version: '1.2.3', tag: 'alpha', defaultLabel: 'npm'}) // returns {label: 'npm@alpha', message: 'v1.2.3', color: 'orange'} because
+ * it uses the tag and the defaultLabel to create the label, the addv function to add a 'v' prefix to the version in message,
+ * and the versionColor function to assign an orange color based on the version.
+ */
+function renderVersionBadge({
+ version,
+ tag,
+ defaultLabel,
+ prefix,
+ suffix,
+ versionFormatter = versionColor,
+ isPrerelease,
+}) {
return {
- label: tag ? `${defaultLabel}@${tag}` : undefined,
- message: addv(version),
- color: versionColor(version),
+ label: tag ? `${defaultLabel}@${tag}` : defaultLabel,
+ message:
+ (prefix ? `${prefix}${version}` : addv(version)) +
+ (suffix ? ` ${suffix}` : ''),
+ color: versionFormatter(isPrerelease ? 'pre' : version),
}
}
diff --git a/services/version.spec.js b/services/version.spec.js
index 92b040cfa3090..90f87140bf824 100644
--- a/services/version.spec.js
+++ b/services/version.spec.js
@@ -54,7 +54,7 @@ describe('Version helpers', function () {
'v1.0.1-RC.2',
'v1.0.0',
],
- { pre: includePre }
+ { pre: includePre },
).expect('v1.0.1-RC.2')
given(
[
@@ -66,7 +66,7 @@ describe('Version helpers', function () {
'v1.0.1-RC.2',
'v1.0.1',
],
- { pre: includePre }
+ { pre: includePre },
).expect('v1.0.1')
given(
[
@@ -76,7 +76,7 @@ describe('Version helpers', function () {
'v1.0.1-beta.1',
'v1.0.1-RC.1',
],
- { pre: includePre }
+ { pre: includePre },
).expect('v1.0.1-RC.1')
// Exclude pre-releases
@@ -116,8 +116,11 @@ describe('Version helpers', function () {
// Semver mixed with non semver versions
given(['1.0.0', '1.0.2', '1.1', '1.0', 'notaversion2', '12bcde4']).expect(
- '1.1'
+ '1.1',
)
+
+ // build qualifiers - https://github.com/badges/shields/issues/4172
+ given(['0.3.9', '0.4.0+1', '0.4.0+9']).expect('0.4.0+9')
})
test(slice, () => {
@@ -147,5 +150,69 @@ describe('Version helpers', function () {
message: 'v1.2.3',
color: 'blue',
})
+ given({ version: '1.2.3', defaultLabel: 'npm' }).expect({
+ label: 'npm',
+ message: 'v1.2.3',
+ color: 'blue',
+ })
+ given({ version: '1.2.3', suffix: 'tested' }).expect({
+ label: undefined,
+ message: 'v1.2.3 tested',
+ color: 'blue',
+ })
+ given({
+ version: '1.2.3',
+ tag: 'beta',
+ defaultLabel: 'github',
+ suffix: 'tested',
+ }).expect({
+ label: 'github@beta',
+ message: 'v1.2.3 tested',
+ color: 'blue',
+ })
+ given({ version: '1.2.3', prefix: '^' }).expect({
+ label: undefined,
+ message: '^1.2.3',
+ color: 'blue',
+ })
+ given({
+ version: '1.2.3',
+ tag: 'alpha',
+ defaultLabel: 'npm',
+ prefix: '^',
+ }).expect({
+ label: 'npm@alpha',
+ message: '^1.2.3',
+ color: 'blue',
+ })
+ given({
+ version: '1.2.3',
+ defaultLabel: 'npm',
+ prefix: '^',
+ }).expect({
+ label: 'npm',
+ message: '^1.2.3',
+ color: 'blue',
+ })
+ given({
+ version: '1.2.3',
+ prefix: '^',
+ suffix: 'tested',
+ }).expect({
+ label: undefined,
+ message: '^1.2.3 tested',
+ color: 'blue',
+ })
+ given({
+ version: '1.2.3',
+ tag: 'beta',
+ defaultLabel: 'github',
+ prefix: '^',
+ suffix: 'tested',
+ }).expect({
+ label: 'github@beta',
+ message: '^1.2.3 tested',
+ color: 'blue',
+ })
})
})
diff --git a/services/visual-studio-app-center/visual-studio-app-center-base.js b/services/visual-studio-app-center/visual-studio-app-center-base.js
deleted file mode 100644
index e90a6ee2f368b..0000000000000
--- a/services/visual-studio-app-center/visual-studio-app-center-base.js
+++ /dev/null
@@ -1,38 +0,0 @@
-import { BaseJsonService } from '../index.js'
-
-const keywords = [
- 'visual-studio',
- 'vsac',
- 'visual-studio-app-center',
- 'app-center',
-]
-
-const documentation =
- "You will need to create a read-only API token here ."
-
-class BaseVisualStudioAppCenterService extends BaseJsonService {
- async fetch({
- owner,
- app,
- token,
- schema,
- url = `https://api.appcenter.ms/v0.1/apps/${owner}/${app}/releases/latest`,
- }) {
- return this._requestJson({
- schema,
- options: {
- headers: {
- 'X-API-Token': token,
- },
- },
- errorMessages: {
- 401: 'invalid token',
- 403: 'project not found',
- 404: 'project not found',
- },
- url,
- })
- }
-}
-
-export { keywords, documentation, BaseVisualStudioAppCenterService }
diff --git a/services/visual-studio-app-center/visual-studio-app-center-builds.service.js b/services/visual-studio-app-center/visual-studio-app-center-builds.service.js
index 6b70a31a6f7dd..447901b071c5b 100644
--- a/services/visual-studio-app-center/visual-studio-app-center-builds.service.js
+++ b/services/visual-studio-app-center/visual-studio-app-center-builds.service.js
@@ -1,52 +1,14 @@
-import Joi from 'joi'
-import { isBuildStatus, renderBuildStatusBadge } from '../build-status.js'
-import { NotFound } from '../index.js'
-import {
- BaseVisualStudioAppCenterService,
- keywords,
- documentation,
-} from './visual-studio-app-center-base.js'
+import { deprecatedService } from '../index.js'
-const schema = Joi.array().items({
- result: isBuildStatus.required(),
-})
-
-export default class VisualStudioAppCenterBuilds extends BaseVisualStudioAppCenterService {
- static category = 'build'
-
- static route = {
+// Visual Studio App Center was retired. See: https://learn.microsoft.com/en-us/appcenter/retirement
+const VisualStudioAppCenterBuilds = deprecatedService({
+ category: 'build',
+ route: {
base: 'visual-studio-app-center/builds',
pattern: ':owner/:app/:branch/:token',
- }
-
- static examples = [
- {
- title: 'Visual Studio App Center Builds',
- namedParams: {
- owner: 'jct',
- app: 'my-amazing-app',
- branch: 'master',
- token: 'ac70cv...',
- },
- staticPreview: renderBuildStatusBadge({ status: 'succeeded' }),
- keywords,
- documentation,
- },
- ]
-
- static defaultBadgeData = {
- label: 'build',
- }
+ },
+ label: 'visualstudioappcenter',
+ dateAdded: new Date('2025-08-30'),
+})
- async handle({ owner, app, branch, token }) {
- const json = await this.fetch({
- token,
- schema,
- url: `https://api.appcenter.ms/v0.1/apps/${owner}/${app}/branches/${branch}/builds`,
- })
- if (json[0] === undefined)
- // Fetch will return a 200 with no data if no builds were found.
- throw new NotFound({ prettyMessage: 'no builds found' })
- return renderBuildStatusBadge({ status: json[0].result })
- }
-}
+export default VisualStudioAppCenterBuilds
diff --git a/services/visual-studio-app-center/visual-studio-app-center-builds.tester.js b/services/visual-studio-app-center/visual-studio-app-center-builds.tester.js
index 0a70d24e994d5..089f6e382f714 100644
--- a/services/visual-studio-app-center/visual-studio-app-center-builds.tester.js
+++ b/services/visual-studio-app-center/visual-studio-app-center-builds.tester.js
@@ -1,33 +1,26 @@
-import { createServiceTester } from '../tester.js'
-export const t = await createServiceTester()
+import { ServiceTester } from '../tester.js'
-// Mocked response rather than real data as old builds are deleted after some time.
-t.create('Valid Build')
- .get('/nock/nock/master/token.json')
- .intercept(nock =>
- nock('https://api.appcenter.ms/v0.1/apps/')
- .get('/nock/nock/branches/master/builds')
- .reply(200, [
- {
- result: 'succeeded',
- },
- ])
- )
- .expectBadge({
- label: 'build',
- message: 'passing',
- })
+export const t = new ServiceTester({
+ id: 'VisualStudioAppCenterBuilds',
+ title: 'VisualStudioAppCenterBuilds',
+ pathPrefix: '/visual-studio-app-center/builds',
+})
+
+t.create('Valid Build').get('/nock/nock/master/token.json').expectBadge({
+ label: 'visualstudioappcenter',
+ message: 'no longer available',
+})
t.create('Invalid Branch')
.get('/jct/test-1/invalid/8c9b519a0750095b9fea3d40b2645d8a0c24a2f3.json')
.expectBadge({
- label: 'build',
- message: 'no builds found',
+ label: 'visualstudioappcenter',
+ message: 'no longer available',
})
t.create('Invalid API Token')
.get('/jct/test-1/master/invalid.json')
.expectBadge({
- label: 'build',
- message: 'invalid token',
+ label: 'visualstudioappcenter',
+ message: 'no longer available',
})
diff --git a/services/visual-studio-app-center/visual-studio-app-center-releases-osversion.service.js b/services/visual-studio-app-center/visual-studio-app-center-releases-osversion.service.js
index 9f51181597afa..c04500b441e82 100644
--- a/services/visual-studio-app-center/visual-studio-app-center-releases-osversion.service.js
+++ b/services/visual-studio-app-center/visual-studio-app-center-releases-osversion.service.js
@@ -1,56 +1,14 @@
-import Joi from 'joi'
-import {
- BaseVisualStudioAppCenterService,
- keywords,
- documentation,
-} from './visual-studio-app-center-base.js'
+import { deprecatedService } from '../index.js'
-const schema = Joi.object({
- app_os: Joi.string().required(),
- min_os: Joi.string().required(),
-}).required()
-
-export default class VisualStudioAppCenterReleasesOSVersion extends BaseVisualStudioAppCenterService {
- static category = 'version'
-
- static route = {
+// Visual Studio App Center was retired. See: https://learn.microsoft.com/en-us/appcenter/retirement
+const VisualStudioAppCenterReleasesOSVersion = deprecatedService({
+ category: 'version',
+ route: {
base: 'visual-studio-app-center/releases/osver',
pattern: ':owner/:app/:token',
- }
-
- static examples = [
- {
- title: 'Visual Studio App Center (Minimum) OS Version',
- namedParams: {
- owner: 'jct',
- app: 'my-amazing-app',
- token: 'ac70cv...',
- },
- staticPreview: this.render({ minOS: '4.1', appOS: 'Android' }),
- keywords,
- documentation,
- },
- ]
-
- static defaultBadgeData = {
- label: 'min version',
- color: 'blue',
- }
-
- static render({ appOS, minOS }) {
- return {
- label: `${appOS.toLowerCase()}`,
- message: `${minOS}+`,
- }
- }
+ },
+ label: 'visualstudioappcenter',
+ dateAdded: new Date('2025-08-30'),
+})
- async handle({ owner, app, token }) {
- const { app_os: appOS, min_os: minOS } = await this.fetch({
- owner,
- app,
- token,
- schema,
- })
- return this.constructor.render({ appOS, minOS })
- }
-}
+export default VisualStudioAppCenterReleasesOSVersion
diff --git a/services/visual-studio-app-center/visual-studio-app-center-releases-osversion.tester.js b/services/visual-studio-app-center/visual-studio-app-center-releases-osversion.tester.js
index 826bcf1ad863b..ffef71607ddf5 100644
--- a/services/visual-studio-app-center/visual-studio-app-center-releases-osversion.tester.js
+++ b/services/visual-studio-app-center/visual-studio-app-center-releases-osversion.tester.js
@@ -1,35 +1,35 @@
-import { createServiceTester } from '../tester.js'
-export const t = await createServiceTester()
+import { ServiceTester } from '../tester.js'
-// Note:
-// Unfortunately an Invalid user, invalid project, valid API token test is not possible due to the way Microsoft cache their responses.
-// For this reason 404 and 403 will instead both display 'project not found'
+export const t = new ServiceTester({
+ id: 'VisualStudioAppCenterReleasesOSVersion',
+ title: 'VisualStudioAppCenterReleasesOSVersion',
+ pathPrefix: '/visual-studio-app-center/releases/osver',
+})
t.create('[fixed] Example Release')
- // This application will never have a new release created.
.get(
- '/jct/test-fixed-android-react/8c9b519a0750095b9fea3d40b2645d8a0c24a2f3.json'
+ '/jct/test-fixed-android-react/8c9b519a0750095b9fea3d40b2645d8a0c24a2f3.json',
)
.expectBadge({
- label: 'android',
- message: '4.1+',
+ label: 'visualstudioappcenter',
+ message: 'no longer available',
})
t.create('Valid user, invalid project, valid API token')
.get('/jcx/invalid/8c9b519a0750095b9fea3d40b2645d8a0c24a2f3.json')
.expectBadge({
- label: 'min version',
- message: 'project not found',
+ label: 'visualstudioappcenter',
+ message: 'no longer available',
})
t.create('Invalid user, invalid project, valid API token')
.get('/invalid/invalid/8c9b519a0750095b9fea3d40b2645d8a0c24a2f3.json')
.expectBadge({
- label: 'min version',
- message: 'project not found',
+ label: 'visualstudioappcenter',
+ message: 'no longer available',
})
t.create('Invalid API Token').get('/invalid/invalid/invalid.json').expectBadge({
- label: 'min version',
- message: 'invalid token',
+ label: 'visualstudioappcenter',
+ message: 'no longer available',
})
diff --git a/services/visual-studio-app-center/visual-studio-app-center-releases-size.service.js b/services/visual-studio-app-center/visual-studio-app-center-releases-size.service.js
index 76dd16ceb6f34..57e9de1574c11 100644
--- a/services/visual-studio-app-center/visual-studio-app-center-releases-size.service.js
+++ b/services/visual-studio-app-center/visual-studio-app-center-releases-size.service.js
@@ -1,51 +1,14 @@
-import Joi from 'joi'
-import prettyBytes from 'pretty-bytes'
-import { nonNegativeInteger } from '../validators.js'
-import {
- BaseVisualStudioAppCenterService,
- keywords,
- documentation,
-} from './visual-studio-app-center-base.js'
+import { deprecatedService } from '../index.js'
-const schema = Joi.object({
- size: nonNegativeInteger,
-}).required()
-
-export default class VisualStudioAppCenterReleasesSize extends BaseVisualStudioAppCenterService {
- static category = 'size'
-
- static route = {
+// Visual Studio App Center was retired. See: https://learn.microsoft.com/en-us/appcenter/retirement
+const VisualStudioAppCenterReleasesSize = deprecatedService({
+ category: 'size',
+ route: {
base: 'visual-studio-app-center/releases/size',
pattern: ':owner/:app/:token',
- }
-
- static examples = [
- {
- title: 'Visual Studio App Center Size',
- namedParams: {
- owner: 'jct',
- app: 'my-amazing-app',
- token: 'ac70cv...',
- },
- staticPreview: this.render({ size: 8368844 }),
- keywords,
- documentation,
- },
- ]
-
- static defaultBadgeData = {
- label: 'size',
- color: 'blue',
- }
-
- static render({ size }) {
- return {
- message: prettyBytes(size),
- }
- }
+ },
+ label: 'visualstudioappcenter',
+ dateAdded: new Date('2025-08-30'),
+})
- async handle({ owner, app, token }) {
- const { size } = await this.fetch({ owner, app, token, schema })
- return this.constructor.render({ size })
- }
-}
+export default VisualStudioAppCenterReleasesSize
diff --git a/services/visual-studio-app-center/visual-studio-app-center-releases-size.tester.js b/services/visual-studio-app-center/visual-studio-app-center-releases-size.tester.js
index 5a1681a79eab0..f7473bb98350a 100644
--- a/services/visual-studio-app-center/visual-studio-app-center-releases-size.tester.js
+++ b/services/visual-studio-app-center/visual-studio-app-center-releases-size.tester.js
@@ -1,45 +1,42 @@
-import { createServiceTester } from '../tester.js'
-import { isFileSize } from '../test-validators.js'
-export const t = await createServiceTester()
+import { ServiceTester } from '../tester.js'
+
+export const t = new ServiceTester({
+ id: 'VisualStudioAppCenterReleasesSize',
+ title: 'VisualStudioAppCenterReleasesSize',
+ pathPrefix: '/visual-studio-app-center/releases/size',
+})
t.create('8368844 bytes to 8.37 megabytes')
.get('/nock/nock/nock.json')
- .intercept(nock =>
- nock('https://api.appcenter.ms/v0.1/apps/')
- .get('/nock/nock/releases/latest')
- .reply(200, {
- size: 8368844,
- })
- )
.expectBadge({
- label: 'size',
- message: '8.37 MB',
+ label: 'visualstudioappcenter',
+ message: 'no longer available',
})
t.create('Valid Release')
.get(
- '/jct/test-fixed-android-react/8c9b519a0750095b9fea3d40b2645d8a0c24a2f3.json'
+ '/jct/test-fixed-android-react/8c9b519a0750095b9fea3d40b2645d8a0c24a2f3.json',
)
.expectBadge({
- label: 'size',
- message: isFileSize,
+ label: 'visualstudioappcenter',
+ message: 'no longer available',
})
t.create('Valid user, invalid project, valid API token')
.get('/jcx/invalid/8c9b519a0750095b9fea3d40b2645d8a0c24a2f3.json')
.expectBadge({
- label: 'size',
- message: 'project not found',
+ label: 'visualstudioappcenter',
+ message: 'no longer available',
})
t.create('Invalid user, invalid project, valid API token')
.get('/invalid/invalid/8c9b519a0750095b9fea3d40b2645d8a0c24a2f3.json')
.expectBadge({
- label: 'size',
- message: 'project not found',
+ label: 'visualstudioappcenter',
+ message: 'no longer available',
})
t.create('Invalid API Token').get('/invalid/invalid/invalid.json').expectBadge({
- label: 'size',
- message: 'invalid token',
+ label: 'visualstudioappcenter',
+ message: 'no longer available',
})
diff --git a/services/visual-studio-app-center/visual-studio-app-center-releases-version.service.js b/services/visual-studio-app-center/visual-studio-app-center-releases-version.service.js
index a4f90d3096a93..fd3181ab46192 100644
--- a/services/visual-studio-app-center/visual-studio-app-center-releases-version.service.js
+++ b/services/visual-studio-app-center/visual-studio-app-center-releases-version.service.js
@@ -1,51 +1,14 @@
-import Joi from 'joi'
-import { renderVersionBadge } from '../version.js'
-import {
- BaseVisualStudioAppCenterService,
- keywords,
- documentation,
-} from './visual-studio-app-center-base.js'
+import { deprecatedService } from '../index.js'
-const schema = Joi.object({
- version: Joi.string().required(),
- short_version: Joi.string().required(),
-}).required()
-
-export default class VisualStudioAppCenterReleasesVersion extends BaseVisualStudioAppCenterService {
- static category = 'version'
-
- static route = {
+// Visual Studio App Center was retired. See: https://learn.microsoft.com/en-us/appcenter/retirement
+const VisualStudioAppCenterReleasesVersion = deprecatedService({
+ category: 'version',
+ route: {
base: 'visual-studio-app-center/releases/version',
pattern: ':owner/:app/:token',
- }
-
- static examples = [
- {
- title: 'Visual Studio App Center Releases',
- namedParams: {
- owner: 'jct',
- app: 'my-amazing-app',
- token: 'ac70cv...',
- },
- staticPreview: renderVersionBadge({ version: '1.0 (4)' }),
- keywords,
- documentation,
- },
- ]
-
- static defaultBadgeData = {
- label: 'release',
- }
+ },
+ label: 'visualstudioappcenter',
+ dateAdded: new Date('2025-08-30'),
+})
- async handle({ owner, app, token }) {
- const { version, short_version: shortVersion } = await this.fetch({
- owner,
- app,
- token,
- schema,
- })
- return renderVersionBadge({
- version: `${shortVersion} (${version})`,
- })
- }
-}
+export default VisualStudioAppCenterReleasesVersion
diff --git a/services/visual-studio-app-center/visual-studio-app-center-releases-version.tester.js b/services/visual-studio-app-center/visual-studio-app-center-releases-version.tester.js
index 49f528a0bfbe8..dd92e4a630134 100644
--- a/services/visual-studio-app-center/visual-studio-app-center-releases-version.tester.js
+++ b/services/visual-studio-app-center/visual-studio-app-center-releases-version.tester.js
@@ -1,35 +1,40 @@
-import { createServiceTester } from '../tester.js'
-export const t = await createServiceTester()
+import { ServiceTester } from '../tester.js'
-// Note:
-// Unfortunately an Invalid user, invalid project, valid API token test is not possible due to the way Microsoft cache their responses.
-// For this reason 404 and 403 will instead both display 'project not found'
+export const t = new ServiceTester({
+ id: 'VisualStudioAppCenterReleasesVersion',
+ title: 'VisualStudioAppCenterReleasesVersion',
+ pathPrefix: '/visual-studio-app-center/releases/version',
+})
t.create('[fixed] Example Release')
- // This application will never have a new release created.
.get(
- '/jct/test-fixed-android-react/8c9b519a0750095b9fea3d40b2645d8a0c24a2f3.json'
+ '/jct/test-fixed-android-react/8c9b519a0750095b9fea3d40b2645d8a0c24a2f3.json',
)
.expectBadge({
- label: 'release',
- message: 'v1.0 (1)',
+ label: 'visualstudioappcenter',
+ message: 'no longer available',
})
t.create('Valid user, invalid project, valid API token')
.get('/jcx/invalid/8c9b519a0750095b9fea3d40b2645d8a0c24a2f3.json')
.expectBadge({
- label: 'release',
- message: 'project not found',
+ label: 'visualstudioappcenter',
+ message: 'no longer available',
})
t.create('Invalid user, invalid project, valid API token')
.get('/invalid/invalid/8c9b519a0750095b9fea3d40b2645d8a0c24a2f3.json')
.expectBadge({
- label: 'release',
- message: 'project not found',
+ label: 'visualstudioappcenter',
+ message: 'no longer available',
})
+t.create('Missing Short Version').get('/nock/nock/nock.json').expectBadge({
+ label: 'visualstudioappcenter',
+ message: 'no longer available',
+})
+
t.create('Invalid API Token').get('/invalid/invalid/invalid.json').expectBadge({
- label: 'release',
- message: 'invalid token',
+ label: 'visualstudioappcenter',
+ message: 'no longer available',
})
diff --git a/services/visual-studio-marketplace/visual-studio-marketplace-azure-devops-installs.service.js b/services/visual-studio-marketplace/visual-studio-marketplace-azure-devops-installs.service.js
index 8cfd950274f29..3820e52838bab 100644
--- a/services/visual-studio-marketplace/visual-studio-marketplace-azure-devops-installs.service.js
+++ b/services/visual-studio-marketplace/visual-studio-marketplace-azure-devops-installs.service.js
@@ -1,12 +1,10 @@
-import { metric } from '../text-formatters.js'
-import { downloadCount } from '../color-formatters.js'
+import { pathParams } from '../index.js'
+import { renderDownloadsBadge } from '../downloads.js'
import VisualStudioMarketplaceBase from './visual-studio-marketplace-base.js'
-const documentation = `
-
- This badge can show total installs, installs for Azure DevOps Services,
- or on-premises installs for Azure DevOps Server.
-
+const description = `
+This badge can show total installs, installs for Azure DevOps Services,
+or on-premises installs for Azure DevOps Server.
`
// This service exists separately from the other Marketplace downloads badges (in ./visual-studio-marketplace-downloads.js)
@@ -20,42 +18,42 @@ export default class VisualStudioMarketplaceAzureDevOpsInstalls extends VisualSt
pattern: ':measure(total|onprem|services)/:extensionId',
}
- static examples = [
- {
- title: 'Visual Studio Marketplace Installs - Azure DevOps Extension',
- namedParams: {
- measure: 'total',
- extensionId: 'swellaby.mirror-git-repository',
+ static openApi = {
+ '/visual-studio-marketplace/azure-devops/installs/{measure}/{extensionId}':
+ {
+ get: {
+ summary:
+ 'Visual Studio Marketplace Installs - Azure DevOps Extension',
+ description,
+ parameters: pathParams(
+ {
+ name: 'measure',
+ example: 'total',
+ schema: { type: 'string', enum: this.getEnum('measure') },
+ },
+ { name: 'extensionId', example: 'swellaby.mirror-git-repository' },
+ ),
+ },
},
- staticPreview: this.render({ count: 651 }),
- keywords: this.keywords,
- documentation,
- },
- ]
-
- static defaultBadgeData = {
- label: 'installs',
}
- static render({ count }) {
- return {
- message: metric(count),
- color: downloadCount(count),
+ static defaultBadgeData = { label: 'installs' }
+
+ transform({ json, measure }) {
+ const { statistics } = this.transformStatistics({ json })
+ const { onpremDownloads, install } = statistics
+ if (measure === 'total') {
+ return { downloads: onpremDownloads + install }
+ }
+ if (measure === 'services') {
+ return { downloads: install }
}
+ return { downloads: onpremDownloads }
}
async handle({ measure, extensionId }) {
const json = await this.fetch({ extensionId })
- const { statistics } = this.transformStatistics({ json })
-
- if (measure === 'total') {
- return this.constructor.render({
- count: statistics.onpremDownloads + statistics.install,
- })
- } else if (measure === 'services') {
- return this.constructor.render({ count: statistics.install })
- } else {
- return this.constructor.render({ count: statistics.onpremDownloads })
- }
+ const { downloads } = this.transform({ json, measure })
+ return renderDownloadsBadge({ downloads })
}
}
diff --git a/services/visual-studio-marketplace/visual-studio-marketplace-azure-devops-installs.tester.js b/services/visual-studio-marketplace/visual-studio-marketplace-azure-devops-installs.tester.js
index f4b209c5460c5..1a320ef539d39 100644
--- a/services/visual-studio-marketplace/visual-studio-marketplace-azure-devops-installs.tester.js
+++ b/services/visual-studio-marketplace/visual-studio-marketplace-azure-devops-installs.tester.js
@@ -62,8 +62,8 @@ t.create('total installs')
.get('/total/swellaby.cobertura-transform.json')
.intercept(nock =>
nock('https://marketplace.visualstudio.com/_apis/public/gallery/')
- .post(`/extensionquery/`)
- .reply(200, mockResponse)
+ .post('/extensionquery/')
+ .reply(200, mockResponse),
)
.expectBadge({
label: 'installs',
@@ -75,8 +75,8 @@ t.create('services installs')
.get('/services/swellaby.cobertura-transform.json')
.intercept(nock =>
nock('https://marketplace.visualstudio.com/_apis/public/gallery/')
- .post(`/extensionquery/`)
- .reply(200, mockResponse)
+ .post('/extensionquery/')
+ .reply(200, mockResponse),
)
.expectBadge({
label: 'installs',
@@ -88,8 +88,8 @@ t.create('onprem installs')
.get('/onprem/swellaby.cobertura-transform.json')
.intercept(nock =>
nock('https://marketplace.visualstudio.com/_apis/public/gallery/')
- .post(`/extensionquery/`)
- .reply(200, mockResponse)
+ .post('/extensionquery/')
+ .reply(200, mockResponse),
)
.expectBadge({
label: 'installs',
@@ -101,7 +101,7 @@ t.create('zero installs')
.get('/total/swellaby.cobertura-transform.json')
.intercept(nock =>
nock('https://marketplace.visualstudio.com/_apis/public/gallery/')
- .post(`/extensionquery/`)
+ .post('/extensionquery/')
.reply(200, {
results: [
{
@@ -119,7 +119,7 @@ t.create('zero installs')
],
},
],
- })
+ }),
)
.expectBadge({
label: 'installs',
diff --git a/services/visual-studio-marketplace/visual-studio-marketplace-base.js b/services/visual-studio-marketplace/visual-studio-marketplace-base.js
index d25e9c11a2fc7..e3adbb7888a57 100644
--- a/services/visual-studio-marketplace/visual-studio-marketplace-base.js
+++ b/services/visual-studio-marketplace/visual-studio-marketplace-base.js
@@ -14,23 +14,31 @@ const extensionQuerySchema = Joi.object({
Joi.object({
statisticName: Joi.string().required(),
value: Joi.number().required(),
- })
+ }),
)
- .required(),
+ .default([]),
versions: Joi.array()
.items(
Joi.object({
version: Joi.string().required(),
- })
+ properties: Joi.array()
+ .items(
+ Joi.object({
+ key: Joi.string().required(),
+ value: Joi.any().required(),
+ }),
+ )
+ .default([]),
+ }),
)
.min(1)
.required(),
releaseDate: Joi.string().required(),
lastUpdated: Joi.string().required(),
- })
+ }),
)
.required(),
- })
+ }),
)
.required(),
}).required()
@@ -44,14 +52,11 @@ const statisticSchema = Joi.object().keys({
})
export default class VisualStudioMarketplaceBase extends BaseJsonService {
- static keywords = [
- 'vscode',
- 'tfs',
- 'vsts',
- 'visual-studio-marketplace',
- 'vs-marketplace',
- 'vscode-marketplace',
- ]
+ static get _cacheLength() {
+ // we reached rate limit, instead of fine tuning for each service
+ // we add a multipler to the default category cache length
+ return Math.floor(super._cacheLength * 1.5)
+ }
static defaultBadgeData = {
label: 'vs marketplace',
@@ -67,7 +72,12 @@ export default class VisualStudioMarketplaceBase extends BaseJsonService {
criteria: [{ filterType: 7, value: extensionId }],
},
],
- flags: 914,
+ // Microsoft does not provide a clear API doc. It seems that the flag value is calculated
+ // as the combined hex values of the requested flags, converted to base 10.
+ // This was found using the vscode repo at:
+ // https://github.com/microsoft/vscode/blob/main/src/vs/platform/extensionManagement/common/extensionGalleryService.ts
+ // This flag value is 0x192.
+ flags: 402,
}
const options = {
method: 'POST',
@@ -82,7 +92,7 @@ export default class VisualStudioMarketplaceBase extends BaseJsonService {
schema: extensionQuerySchema,
url,
options,
- errorMessages: {
+ httpErrors: {
400: 'invalid extension id',
},
})
diff --git a/services/visual-studio-marketplace/visual-studio-marketplace-downloads.service.js b/services/visual-studio-marketplace/visual-studio-marketplace-downloads.service.js
index 1d90a6275cfa9..a17bc6c50a372 100644
--- a/services/visual-studio-marketplace/visual-studio-marketplace-downloads.service.js
+++ b/services/visual-studio-marketplace/visual-studio-marketplace-downloads.service.js
@@ -1,14 +1,11 @@
-import { metric } from '../text-formatters.js'
-import { downloadCount } from '../color-formatters.js'
+import { pathParams } from '../index.js'
+import { renderDownloadsBadge } from '../downloads.js'
import VisualStudioMarketplaceBase from './visual-studio-marketplace-base.js'
-const documentation = `
-
- This is for Visual Studio and Visual Studio Code Extensions.
-
-
- For correct results on Azure DevOps Extensions, use the Azure DevOps Installs badge instead.
-
+const description = `
+This is for Visual Studio and Visual Studio Code Extensions.
+
+For correct results on Azure DevOps Extensions, use the Azure DevOps Installs badge instead.
`
export default class VisualStudioMarketplaceDownloads extends VisualStudioMarketplaceBase {
@@ -20,42 +17,41 @@ export default class VisualStudioMarketplaceDownloads extends VisualStudioMarket
'(visual-studio-marketplace|vscode-marketplace)/:measure(d|i)/:extensionId',
}
- static examples = [
- {
- title: 'Visual Studio Marketplace Installs',
- pattern: 'visual-studio-marketplace/i/:extensionId',
- namedParams: { extensionId: 'ritwickdey.LiveServer' },
- staticPreview: this.render({ measure: 'i', count: 843 }),
- keywords: this.keywords,
- documentation,
+ static openApi = {
+ '/visual-studio-marketplace/i/{extensionId}': {
+ get: {
+ summary: 'Visual Studio Marketplace Installs',
+ description,
+ parameters: pathParams({
+ name: 'extensionId',
+ example: 'ritwickdey.LiveServer',
+ }),
+ },
},
- {
- title: 'Visual Studio Marketplace Downloads',
- pattern: 'visual-studio-marketplace/d/:extensionId',
- namedParams: { extensionId: 'ritwickdey.LiveServer' },
- staticPreview: this.render({ measure: 'd', count: 1239 }),
- keywords: this.keywords,
- documentation,
+ '/visual-studio-marketplace/d/{extensionId}': {
+ get: {
+ summary: 'Visual Studio Marketplace Downloads',
+ description,
+ parameters: pathParams({
+ name: 'extensionId',
+ example: 'ritwickdey.LiveServer',
+ }),
+ },
},
- ]
-
- static render({ measure, count }) {
- const label = measure === 'd' ? 'downloads' : 'installs'
+ }
- return {
- label,
- message: metric(count),
- color: downloadCount(count),
- }
+ static render({ measure, downloads }) {
+ const labelOverride = measure === 'd' ? 'downloads' : 'installs'
+ return renderDownloadsBadge({ downloads, labelOverride })
}
async handle({ measure, extensionId }) {
const json = await this.fetch({ extensionId })
const { statistics } = this.transformStatistics({ json })
- const count =
+ const downloads =
measure === 'i'
? statistics.install
: statistics.install + statistics.updateCount
- return this.constructor.render({ measure, count })
+ return this.constructor.render({ measure, downloads })
}
}
diff --git a/services/visual-studio-marketplace/visual-studio-marketplace-downloads.tester.js b/services/visual-studio-marketplace/visual-studio-marketplace-downloads.tester.js
index d74b78cb87470..a964793362f25 100644
--- a/services/visual-studio-marketplace/visual-studio-marketplace-downloads.tester.js
+++ b/services/visual-studio-marketplace/visual-studio-marketplace-downloads.tester.js
@@ -62,8 +62,8 @@ t.create('installs')
.get('/visual-studio-marketplace/i/swellaby.rust-pack.json')
.intercept(nock =>
nock('https://marketplace.visualstudio.com/_apis/public/gallery/')
- .post(`/extensionquery/`)
- .reply(200, mockResponse)
+ .post('/extensionquery/')
+ .reply(200, mockResponse),
)
.expectBadge({
label: 'installs',
@@ -75,7 +75,7 @@ t.create('zero installs')
.get('/visual-studio-marketplace/i/swellaby.rust-pack.json')
.intercept(nock =>
nock('https://marketplace.visualstudio.com/_apis/public/gallery/')
- .post(`/extensionquery/`)
+ .post('/extensionquery/')
.reply(200, {
results: [
{
@@ -93,7 +93,36 @@ t.create('zero installs')
],
},
],
- })
+ }),
+ )
+ .expectBadge({
+ label: 'installs',
+ message: '0',
+ color: 'red',
+ })
+
+t.create('missing statistics array')
+ .get('/visual-studio-marketplace/i/swellaby.rust-pack.json')
+ .intercept(nock =>
+ nock('https://marketplace.visualstudio.com/_apis/public/gallery/')
+ .post('/extensionquery/')
+ .reply(200, {
+ results: [
+ {
+ extensions: [
+ {
+ versions: [
+ {
+ version: '1.0.0',
+ },
+ ],
+ releaseDate: '2019-04-13T07:50:27.000Z',
+ lastUpdated: '2019-04-13T07:50:27.000Z',
+ },
+ ],
+ },
+ ],
+ }),
)
.expectBadge({
label: 'installs',
@@ -105,8 +134,8 @@ t.create('downloads')
.get('/visual-studio-marketplace/d/swellaby.rust-pack.json')
.intercept(nock =>
nock('https://marketplace.visualstudio.com/_apis/public/gallery/')
- .post(`/extensionquery/`)
- .reply(200, mockResponse)
+ .post('/extensionquery/')
+ .reply(200, mockResponse),
)
.expectBadge({
label: 'downloads',
diff --git a/services/visual-studio-marketplace/visual-studio-marketplace-last-updated.service.js b/services/visual-studio-marketplace/visual-studio-marketplace-last-updated.service.js
index 5d7106fa58151..f205954ab458a 100644
--- a/services/visual-studio-marketplace/visual-studio-marketplace-last-updated.service.js
+++ b/services/visual-studio-marketplace/visual-studio-marketplace-last-updated.service.js
@@ -1,5 +1,5 @@
-import { age } from '../color-formatters.js'
-import { formatDate } from '../text-formatters.js'
+import { pathParams } from '../index.js'
+import { renderDateBadge } from '../date.js'
import VisualStudioMarketplaceBase from './visual-studio-marketplace-base.js'
export default class VisualStudioMarketplaceLastUpdated extends VisualStudioMarketplaceBase {
@@ -11,27 +11,22 @@ export default class VisualStudioMarketplaceLastUpdated extends VisualStudioMark
'(visual-studio-marketplace|vscode-marketplace)/last-updated/:extensionId',
}
- static examples = [
- {
- title: 'Visual Studio Marketplace Last Updated',
- pattern: 'visual-studio-marketplace/last-updated/:extensionId',
- namedParams: { extensionId: 'yasht.terminal-all-in-one' },
- staticPreview: this.render({ lastUpdated: '2019-04-13T07:50:27.000Z' }),
- keywords: this.keywords,
+ static openApi = {
+ '/visual-studio-marketplace/last-updated/{extensionId}': {
+ get: {
+ summary: 'Visual Studio Marketplace Last Updated',
+ parameters: pathParams({
+ name: 'extensionId',
+ example: 'yasht.terminal-all-in-one',
+ }),
+ },
},
- ]
+ }
static defaultBadgeData = {
label: 'last updated',
}
- static render({ lastUpdated }) {
- return {
- message: formatDate(lastUpdated),
- color: age(lastUpdated),
- }
- }
-
transform({ json }) {
const { extension } = this.transformExtension({ json })
const lastUpdated = extension.lastUpdated
@@ -41,6 +36,6 @@ export default class VisualStudioMarketplaceLastUpdated extends VisualStudioMark
async handle({ extensionId }) {
const json = await this.fetch({ extensionId })
const { lastUpdated } = this.transform({ json })
- return this.constructor.render({ lastUpdated })
+ return renderDateBadge(lastUpdated)
}
}
diff --git a/services/visual-studio-marketplace/visual-studio-marketplace-last-updated.tester.js b/services/visual-studio-marketplace/visual-studio-marketplace-last-updated.tester.js
index cd7e40745a4ff..997127064110d 100644
--- a/services/visual-studio-marketplace/visual-studio-marketplace-last-updated.tester.js
+++ b/services/visual-studio-marketplace/visual-studio-marketplace-last-updated.tester.js
@@ -18,7 +18,7 @@ t.create('invalid extension id')
t.create('non existent extension')
.get(
- '/visual-studio-marketplace/last-updated/yasht.terminal-all-in-one-fake.json'
+ '/visual-studio-marketplace/last-updated/yasht.terminal-all-in-one-fake.json',
)
.expectBadge({
label: 'last updated',
diff --git a/services/visual-studio-marketplace/visual-studio-marketplace-rating.service.js b/services/visual-studio-marketplace/visual-studio-marketplace-rating.service.js
index 0c3cb2eda2ccf..01352ee3cf82a 100644
--- a/services/visual-studio-marketplace/visual-studio-marketplace-rating.service.js
+++ b/services/visual-studio-marketplace/visual-studio-marketplace-rating.service.js
@@ -1,3 +1,4 @@
+import { pathParams } from '../index.js'
import { starRating } from '../text-formatters.js'
import { floorCount } from '../color-formatters.js'
import VisualStudioMarketplaceBase from './visual-studio-marketplace-base.js'
@@ -11,29 +12,25 @@ export default class VisualStudioMarketplaceRating extends VisualStudioMarketpla
'(visual-studio-marketplace|vscode-marketplace)/:format(r|stars)/:extensionId',
}
- static examples = [
- {
- title: 'Visual Studio Marketplace Rating',
- pattern: 'visual-studio-marketplace/r/:extensionId',
- namedParams: { extensionId: 'ritwickdey.LiveServer' },
- staticPreview: this.render({
- format: 'r',
- averageRating: 4.79,
- ratingCount: 145,
- }),
- keywords: this.keywords,
+ static openApi = {
+ '/visual-studio-marketplace/{format}/{extensionId}': {
+ get: {
+ summary: 'Visual Studio Marketplace Rating',
+ parameters: pathParams(
+ {
+ name: 'format',
+ example: 'r',
+ description: 'rating (r) or stars',
+ schema: { type: 'string', enum: this.getEnum('format') },
+ },
+ {
+ name: 'extensionId',
+ example: 'ritwickdey.LiveServer',
+ },
+ ),
+ },
},
- {
- title: 'Visual Studio Marketplace Rating (Stars)',
- pattern: 'visual-studio-marketplace/stars/:extensionId',
- namedParams: { extensionId: 'ritwickdey.LiveServer' },
- staticPreview: this.render({
- format: 'stars',
- averageRating: 4.5,
- }),
- keywords: this.keywords,
- },
- ]
+ }
static defaultBadgeData = {
label: 'rating',
diff --git a/services/visual-studio-marketplace/visual-studio-marketplace-rating.tester.js b/services/visual-studio-marketplace/visual-studio-marketplace-rating.tester.js
index 1d23290707ef2..36a66ee735730 100644
--- a/services/visual-studio-marketplace/visual-studio-marketplace-rating.tester.js
+++ b/services/visual-studio-marketplace/visual-studio-marketplace-rating.tester.js
@@ -22,7 +22,7 @@ t.create('rating')
.get('/visual-studio-marketplace/r/ritwickdey.LiveServer.json')
.intercept(nock =>
nock('https://marketplace.visualstudio.com/_apis/public/gallery/')
- .post(`/extensionquery/`)
+ .post('/extensionquery/')
.reply(200, {
results: [
{
@@ -49,7 +49,7 @@ t.create('rating')
],
},
],
- })
+ }),
)
.expectBadge({
label: 'rating',
@@ -61,7 +61,7 @@ t.create('zero rating')
.get('/visual-studio-marketplace/r/ritwickdey.LiveServer.json')
.intercept(nock =>
nock('https://marketplace.visualstudio.com/_apis/public/gallery/')
- .post(`/extensionquery/`)
+ .post('/extensionquery/')
.reply(200, {
results: [
{
@@ -79,7 +79,7 @@ t.create('zero rating')
],
},
],
- })
+ }),
)
.expectBadge({
label: 'rating',
@@ -91,7 +91,7 @@ t.create('stars')
.get('/visual-studio-marketplace/stars/ritwickdey.LiveServer.json')
.intercept(nock =>
nock('https://marketplace.visualstudio.com/_apis/public/gallery/')
- .post(`/extensionquery/`)
+ .post('/extensionquery/')
.reply(200, {
results: [
{
@@ -118,7 +118,7 @@ t.create('stars')
],
},
],
- })
+ }),
)
.expectBadge({
label: 'rating',
diff --git a/services/visual-studio-marketplace/visual-studio-marketplace-release-date.service.js b/services/visual-studio-marketplace/visual-studio-marketplace-release-date.service.js
index d604c425c1a84..010319715a9ee 100644
--- a/services/visual-studio-marketplace/visual-studio-marketplace-release-date.service.js
+++ b/services/visual-studio-marketplace/visual-studio-marketplace-release-date.service.js
@@ -1,5 +1,5 @@
-import { age } from '../color-formatters.js'
-import { formatDate } from '../text-formatters.js'
+import { pathParams } from '../index.js'
+import { renderDateBadge } from '../date.js'
import VisualStudioMarketplaceBase from './visual-studio-marketplace-base.js'
export default class VisualStudioMarketplaceReleaseDate extends VisualStudioMarketplaceBase {
@@ -11,27 +11,22 @@ export default class VisualStudioMarketplaceReleaseDate extends VisualStudioMark
'(visual-studio-marketplace|vscode-marketplace)/release-date/:extensionId',
}
- static examples = [
- {
- title: 'Visual Studio Marketplace Release Date',
- pattern: 'visual-studio-marketplace/release-date/:extensionId',
- namedParams: { extensionId: 'yasht.terminal-all-in-one' },
- staticPreview: this.render({ releaseDate: '2019-04-13T07:50:27.000Z' }),
- keywords: this.keywords,
+ static openApi = {
+ '/visual-studio-marketplace/release-date/{extensionId}': {
+ get: {
+ summary: 'Visual Studio Marketplace Release Date',
+ parameters: pathParams({
+ name: 'extensionId',
+ example: 'yasht.terminal-all-in-one',
+ }),
+ },
},
- ]
+ }
static defaultBadgeData = {
label: 'release date',
}
- static render({ releaseDate }) {
- return {
- message: formatDate(releaseDate),
- color: age(releaseDate),
- }
- }
-
transform({ json }) {
const { extension } = this.transformExtension({ json })
const releaseDate = extension.releaseDate
@@ -41,6 +36,6 @@ export default class VisualStudioMarketplaceReleaseDate extends VisualStudioMark
async handle({ extensionId }) {
const json = await this.fetch({ extensionId })
const { releaseDate } = this.transform({ json })
- return this.constructor.render({ releaseDate })
+ return renderDateBadge(releaseDate)
}
}
diff --git a/services/visual-studio-marketplace/visual-studio-marketplace-release-date.tester.js b/services/visual-studio-marketplace/visual-studio-marketplace-release-date.tester.js
index acf9da220bd8f..47ead2faba84a 100644
--- a/services/visual-studio-marketplace/visual-studio-marketplace-release-date.tester.js
+++ b/services/visual-studio-marketplace/visual-studio-marketplace-release-date.tester.js
@@ -18,7 +18,7 @@ t.create('invalid extension id')
t.create('non existent extension')
.get(
- '/visual-studio-marketplace/release-date/yasht.terminal-all-in-one-fake.json'
+ '/visual-studio-marketplace/release-date/yasht.terminal-all-in-one-fake.json',
)
.expectBadge({
label: 'release date',
diff --git a/services/visual-studio-marketplace/visual-studio-marketplace-version.service.js b/services/visual-studio-marketplace/visual-studio-marketplace-version.service.js
index e0e8e2458b31e..1503c304b9bbe 100644
--- a/services/visual-studio-marketplace/visual-studio-marketplace-version.service.js
+++ b/services/visual-studio-marketplace/visual-studio-marketplace-version.service.js
@@ -1,23 +1,36 @@
+import Joi from 'joi'
+import { pathParam, queryParam } from '../index.js'
import { renderVersionBadge } from '../version.js'
import VisualStudioMarketplaceBase from './visual-studio-marketplace-base.js'
+const queryParamSchema = Joi.object({
+ include_prereleases: Joi.equal(''),
+}).required()
+
export default class VisualStudioMarketplaceVersion extends VisualStudioMarketplaceBase {
static category = 'version'
static route = {
base: '',
pattern: '(visual-studio-marketplace|vscode-marketplace)/v/:extensionId',
+ queryParamSchema,
}
- static examples = [
- {
- title: 'Visual Studio Marketplace Version',
- pattern: 'visual-studio-marketplace/v/:extensionId',
- namedParams: { extensionId: 'swellaby.rust-pack' },
- staticPreview: this.render({ version: '0.2.7' }),
- keywords: this.keywords,
+ static openApi = {
+ '/visual-studio-marketplace/v/{extensionId}': {
+ get: {
+ summary: 'Visual Studio Marketplace Version',
+ parameters: [
+ pathParam({ name: 'extensionId', example: 'swellaby.rust-pack' }),
+ queryParam({
+ name: 'include_prereleases',
+ schema: { type: 'boolean' },
+ example: null,
+ }),
+ ],
+ },
},
- ]
+ }
static defaultBadgeData = {
label: 'version',
@@ -27,15 +40,33 @@ export default class VisualStudioMarketplaceVersion extends VisualStudioMarketpl
return renderVersionBadge({ version })
}
- transform({ json }) {
+ transform({ json }, includePrereleases) {
const { extension } = this.transformExtension({ json })
- const version = extension.versions[0].version
+ const preReleaseKey = 'Microsoft.VisualStudio.Code.PreRelease'
+ let version
+
+ if (!includePrereleases) {
+ version = extension.versions.find(
+ obj =>
+ !obj.properties.find(
+ ({ key, value }) => key === preReleaseKey && value === 'true',
+ ),
+ )?.version
+ }
+
+ // this condition acts as the 'else' clause AND as a fallback,
+ // in case all versions are pre-release
+ if (!version) {
+ version = extension.versions[0].version
+ }
+
return { version }
}
- async handle({ extensionId }) {
+ async handle({ extensionId }, queryParams) {
const json = await this.fetch({ extensionId })
- const { version } = this.transform({ json })
+ const includePrereleases = queryParams.include_prereleases !== undefined
+ const { version } = this.transform({ json }, includePrereleases)
return this.constructor.render({ version })
}
diff --git a/services/visual-studio-marketplace/visual-studio-marketplace-version.tester.js b/services/visual-studio-marketplace/visual-studio-marketplace-version.tester.js
index 1b44b3e0e0e26..cf53176636fd9 100644
--- a/services/visual-studio-marketplace/visual-studio-marketplace-version.tester.js
+++ b/services/visual-studio-marketplace/visual-studio-marketplace-version.tester.js
@@ -5,17 +5,17 @@ export const t = await createServiceTester()
const isMarketplaceVersion = withRegex(/^v(\d+\.\d+\.\d+)(\.\d+)?$/)
t.create('rating')
- .get('/visual-studio-marketplace/v/ritwickdey.LiveServer.json')
+ .get('/visual-studio-marketplace/v/lextudio.restructuredtext.json')
.expectBadge({
label: 'version',
message: isMarketplaceVersion,
})
t.create('version')
- .get('/visual-studio-marketplace/v/ritwickdey.LiveServer.json')
+ .get('/visual-studio-marketplace/v/lextudio.restructuredtext.json')
.intercept(nock =>
nock('https://marketplace.visualstudio.com/_apis/public/gallery/')
- .post(`/extensionquery/`)
+ .post('/extensionquery/')
.reply(200, {
results: [
{
@@ -23,8 +23,27 @@ t.create('version')
{
statistics: [],
versions: [
+ {
+ version: '1.3.8-alpha',
+ properties: [
+ {
+ key: 'Microsoft.VisualStudio.Services.Branding.Theme',
+ value: 'light',
+ },
+ {
+ key: 'Microsoft.VisualStudio.Code.PreRelease',
+ value: 'true',
+ },
+ ],
+ },
{
version: '1.0.0',
+ properties: [
+ {
+ key: 'Microsoft.VisualStudio.Services.Branding.Theme',
+ value: 'light',
+ },
+ ],
},
],
releaseDate: '2019-04-13T07:50:27.000Z',
@@ -33,7 +52,7 @@ t.create('version')
],
},
],
- })
+ }),
)
.expectBadge({
label: 'version',
@@ -41,11 +60,147 @@ t.create('version')
color: 'blue',
})
+t.create(
+ 'version - includePrereleases flag is false and response has pre-release only',
+)
+ .get('/visual-studio-marketplace/v/lextudio.restructuredtext.json')
+ .intercept(nock =>
+ nock('https://marketplace.visualstudio.com/_apis/public/gallery/')
+ .post('/extensionquery/')
+ .reply(200, {
+ results: [
+ {
+ extensions: [
+ {
+ statistics: [],
+ versions: [
+ {
+ version: '1.3.8',
+ properties: [
+ {
+ key: 'Microsoft.VisualStudio.Services.Branding.Theme',
+ value: 'light',
+ },
+ {
+ key: 'Microsoft.VisualStudio.Code.PreRelease',
+ value: 'true',
+ },
+ ],
+ },
+ {
+ version: '1.3.7',
+ properties: [
+ {
+ key: 'Microsoft.VisualStudio.Services.Branding.Theme',
+ value: 'light',
+ },
+ {
+ key: 'Microsoft.VisualStudio.Code.PreRelease',
+ value: 'true',
+ },
+ ],
+ },
+ {
+ version: '1.3.6',
+ properties: [
+ {
+ key: 'Microsoft.VisualStudio.Services.Branding.Theme',
+ value: 'light',
+ },
+ {
+ key: 'Microsoft.VisualStudio.Code.PreRelease',
+ value: 'true',
+ },
+ ],
+ },
+ ],
+ releaseDate: '2019-04-13T07:50:27.000Z',
+ lastUpdated: '2019-04-13T07:50:27.000Z',
+ },
+ ],
+ },
+ ],
+ }),
+ )
+ .expectBadge({
+ label: 'version',
+ message: 'v1.3.8',
+ color: 'blue',
+ })
+
+t.create('version - prerelease key has false value')
+ .get('/visual-studio-marketplace/v/lextudio.restructuredtext.json')
+ .intercept(nock =>
+ nock('https://marketplace.visualstudio.com/_apis/public/gallery/')
+ .post('/extensionquery/')
+ .reply(200, {
+ results: [
+ {
+ extensions: [
+ {
+ statistics: [],
+ versions: [
+ {
+ version: '1.3.8',
+ properties: [
+ {
+ key: 'Microsoft.VisualStudio.Services.Branding.Theme',
+ value: 'light',
+ },
+ {
+ key: 'Microsoft.VisualStudio.Code.PreRelease',
+ value: 'true',
+ },
+ ],
+ },
+ {
+ version: '1.3.7',
+ properties: [
+ {
+ key: 'Microsoft.VisualStudio.Services.Branding.Theme',
+ value: 'light',
+ },
+ {
+ key: 'Microsoft.VisualStudio.Code.PreRelease',
+ value: 'true',
+ },
+ ],
+ },
+ {
+ version: '1.3.6',
+ properties: [
+ {
+ key: 'Microsoft.VisualStudio.Services.Branding.Theme',
+ value: 'light',
+ },
+ {
+ key: 'Microsoft.VisualStudio.Code.PreRelease',
+ value: 'false',
+ },
+ ],
+ },
+ ],
+ releaseDate: '2019-04-13T07:50:27.000Z',
+ lastUpdated: '2019-04-13T07:50:27.000Z',
+ },
+ ],
+ },
+ ],
+ }),
+ )
+ .expectBadge({
+ label: 'version',
+ message: 'v1.3.6',
+ color: 'blue',
+ })
+
t.create('pre-release version')
- .get('/visual-studio-marketplace/v/swellaby.vscode-rust-test-adapter.json')
+ .get(
+ '/visual-studio-marketplace/v/swellaby.vscode-rust-test-adapter.json?include_prereleases',
+ )
.intercept(nock =>
nock('https://marketplace.visualstudio.com/_apis/public/gallery/')
- .post(`/extensionquery/`)
+ .post('/extensionquery/')
.reply(200, {
results: [
{
@@ -54,7 +209,26 @@ t.create('pre-release version')
statistics: [],
versions: [
{
- version: '0.3.8',
+ version: '1.3.8-alpha',
+ properties: [
+ {
+ key: 'Microsoft.VisualStudio.Services.Branding.Theme',
+ value: 'light',
+ },
+ {
+ key: 'Microsoft.VisualStudio.Code.PreRelease',
+ value: 'true',
+ },
+ ],
+ },
+ {
+ version: '1.0.0',
+ properties: [
+ {
+ key: 'Microsoft.VisualStudio.Services.Branding.Theme',
+ value: 'light',
+ },
+ ],
},
],
releaseDate: '2019-04-13T07:50:27.000Z',
@@ -63,11 +237,11 @@ t.create('pre-release version')
],
},
],
- })
+ }),
)
.expectBadge({
label: 'version',
- message: 'v0.3.8',
+ message: 'v1.3.8-alpha',
color: 'orange',
})
diff --git a/services/vpm/vpm-version.service.js b/services/vpm/vpm-version.service.js
new file mode 100644
index 0000000000000..b71bc73a849ca
--- /dev/null
+++ b/services/vpm/vpm-version.service.js
@@ -0,0 +1,80 @@
+import Joi from 'joi'
+import { url } from '../validators.js'
+import { latest, renderVersionBadge } from '../version.js'
+import { BaseJsonService, NotFound, pathParam, queryParam } from '../index.js'
+
+const queryParamSchema = Joi.object({
+ repository_url: url,
+ include_prereleases: Joi.equal(''),
+}).required()
+
+const schema = Joi.object({
+ packages: Joi.object()
+ .pattern(
+ /./,
+ Joi.object({
+ versions: Joi.object().pattern(/./, Joi.object()).min(1).required(),
+ }).required(),
+ )
+ .required(),
+}).required()
+
+export default class VpmVersion extends BaseJsonService {
+ static category = 'version'
+
+ static route = {
+ base: 'vpm/v',
+ pattern: ':packageId',
+ queryParamSchema,
+ }
+
+ static openApi = {
+ '/vpm/v/{packageId}': {
+ get: {
+ summary: 'VPM Package Version',
+ description: 'VPM is the VRChat Package Manager',
+ parameters: [
+ pathParam({
+ name: 'packageId',
+ example: 'com.vrchat.udonsharp',
+ }),
+ queryParam({
+ name: 'repository_url',
+ example: 'https://packages.vrchat.com/curated?download',
+ required: true,
+ }),
+ queryParam({
+ name: 'include_prereleases',
+ schema: { type: 'boolean' },
+ example: null,
+ }),
+ ],
+ },
+ },
+ }
+
+ static defaultBadgeData = {
+ label: 'vpm',
+ }
+
+ async fetch({ repositoryUrl }) {
+ return this._requestJson({
+ schema,
+ url: repositoryUrl,
+ })
+ }
+
+ async handle(
+ { packageId },
+ { repository_url: repositoryUrl, include_prereleases: prereleases },
+ ) {
+ const data = await this.fetch({ repositoryUrl })
+ const pkg = data.packages[packageId]
+ if (pkg === undefined)
+ throw new NotFound({ prettyMessage: 'package not found' })
+ const versions = Object.keys(pkg.versions)
+ const version = latest(versions, { pre: prereleases !== undefined })
+
+ return renderVersionBadge({ version })
+ }
+}
diff --git a/services/vpm/vpm-version.tester.js b/services/vpm/vpm-version.tester.js
new file mode 100644
index 0000000000000..eb9827e67d589
--- /dev/null
+++ b/services/vpm/vpm-version.tester.js
@@ -0,0 +1,51 @@
+import { isSemver } from '../test-validators.js'
+import { createServiceTester } from '../tester.js'
+export const t = await createServiceTester()
+
+t.create('gets the package version of com.vrchat.udonsharp')
+ .get(
+ '/com.vrchat.udonsharp.json?repository_url=https%3A%2F%2Fpackages.vrchat.com%2Fcurated%3Fdownload',
+ )
+ .expectBadge({ label: 'vpm', message: isSemver })
+
+t.create('gets the latest version')
+ .intercept(nock =>
+ nock('https://packages.vrchat.com')
+ .get('/curated?download')
+ .reply(200, {
+ packages: {
+ 'com.vrchat.udonsharp': {
+ versions: {
+ '2.0.0': {},
+ '2.1.0-rc1': {},
+ '1.9.0': {},
+ },
+ },
+ },
+ }),
+ )
+ .get(
+ '/com.vrchat.udonsharp.json?repository_url=https%3A%2F%2Fpackages.vrchat.com%2Fcurated%3Fdownload',
+ )
+ .expectBadge({ label: 'vpm', message: 'v2.0.0' })
+
+t.create('gets the latest version including prerelease')
+ .intercept(nock =>
+ nock('https://packages.vrchat.com')
+ .get('/curated?download')
+ .reply(200, {
+ packages: {
+ 'com.vrchat.udonsharp': {
+ versions: {
+ '2.0.0': {},
+ '2.1.0-rc1': {},
+ '1.9.0': {},
+ },
+ },
+ },
+ }),
+ )
+ .get(
+ '/com.vrchat.udonsharp.json?repository_url=https%3A%2F%2Fpackages.vrchat.com%2Fcurated%3Fdownload&include_prereleases',
+ )
+ .expectBadge({ label: 'vpm', message: 'v2.1.0-rc1' })
diff --git a/services/w3c/w3c-validation-helper.js b/services/w3c/w3c-validation-helper.js
index fef29ef373811..5880ac3faf18c 100644
--- a/services/w3c/w3c-validation-helper.js
+++ b/services/w3c/w3c-validation-helper.js
@@ -8,7 +8,7 @@ const svgExpression =
'^SVG\\s?1\\.1\\s?,\\s?URL\\s?,\\s?XHTML\\s?,\\s?MathML\\s?3\\.0$'
const presetRegex = new RegExp(
`(${html5Expression})|(${html4Expression})|(${xhtmlExpression})|(${svgExpression})`,
- 'i'
+ 'i',
)
const getMessage = messageTypes => {
@@ -20,7 +20,7 @@ const getMessage = messageTypes => {
}
const messages = messageTypeKeys.map(
- key => `${messageTypes[key]} ${key}${messageTypes[key] > 1 ? 's' : ''}`
+ key => `${messageTypes[key]} ${key}${messageTypes[key] > 1 ? 's' : ''}`,
)
return messages.join(', ')
}
@@ -77,72 +77,19 @@ const getSchema = preset => {
return schema.map(url => encodeURI(url)).join(' ')
}
-const documentation = `
-
-
- The W3C validation badge performs validation of the HTML, SVG, MathML, ITS, RDFa Lite, XHTML documents.
- The badge uses the type property of each message found in the messages from the validation results to determine to be an error or warning.
- The rules are as follows:
-
- info: These messages are counted as warnings
- error: These messages are counted as errors
- non-document-error: These messages are counted as errors
-
-
-
- This badge relies on the https://validator.nu/ service to perform the validation. Please refer to https://about.validator.nu/ for the full documentation and Terms of service.
- The following are required from the consumer for the badge to function.
+const description = `
+The W3C validation badge performs validation of the HTML, SVG, MathML, ITS, RDFa Lite, XHTML documents.
+The badge uses the type property of each message found in the messages from the validation results to determine to be an error or warning.
+The rules are as follows:
+
+
+ info: These messages are counted as warnings
+ error: These messages are counted as errors
+ non-document-error: These messages are counted as errors
+
-
-
- Path:
-
-
- parser: The parser that is used for validation. This is a passthru value to the service
-
- default (This will not pass a parser to the API and make the API choose the parser based on the validated content)
- html (HTML)
- xml (XML; don’t load external entities)
- xmldtd (XML; load external entities)
-
-
-
-
-
- Query string:
-
-
- targetUrl (Required): This is the path for the document to be validated
-
-
- preset (Optional can be left as blank): This is used to determine the schema for the document to be valdiated against.
- The following are the allowed values
-
- HTML, SVG 1.1, MathML 3.0
- HTML, SVG 1.1, MathML 3.0, ITS 2.0
- HTML, SVG 1.1, MathML 3.0, RDFa Lite 1.1
- HTML 4.01 Strict, URL / XHTML 1.0 Strict, URL
- HTML 4.01 Transitional, URL / XHTML 1.0 Transitional, URL
- HTML 4.01 Frameset, URL / XHTML 1.0 Frameset, URL
- XHTML, SVG 1.1, MathML 3.0
- XHTML, SVG 1.1, MathML 3.0, RDFa Lite 1.1
- XHTML 1.0 Strict, URL, Ruby, SVG 1.1, MathML 3.0
- SVG 1.1, URL, XHTML, MathML 3.0
-
-
-
-
-
-
+This badge relies on the https://validator.nu/ service to perform the validation.
+Please refer to https://about.validator.nu/ for the full documentation and Terms of service.
`
-export { documentation, presetRegex, getColor, getMessage, getSchema }
+export { description, presetRegex, getColor, getMessage, getSchema }
diff --git a/services/w3c/w3c-validation-helper.spec.js b/services/w3c/w3c-validation-helper.spec.js
index 3c024c2bbc89b..516b5f644a5de 100644
--- a/services/w3c/w3c-validation-helper.spec.js
+++ b/services/w3c/w3c-validation-helper.spec.js
@@ -167,7 +167,7 @@ describe('w3c-validation-helper', function () {
const actualResult = getSchema(preset)
expect(actualResult).to.equal(
- 'http://s.validator.nu/html5.rnc http://s.validator.nu/html5/assertions.sch http://c.validator.nu/all/'
+ 'http://s.validator.nu/html5.rnc http://s.validator.nu/html5/assertions.sch http://c.validator.nu/all/',
)
})
@@ -177,7 +177,7 @@ describe('w3c-validation-helper', function () {
const actualResult = getSchema(preset)
expect(actualResult).to.equal(
- 'http://s.validator.nu/html5-its.rnc http://s.validator.nu/html5/assertions.sch http://c.validator.nu/all/'
+ 'http://s.validator.nu/html5-its.rnc http://s.validator.nu/html5/assertions.sch http://c.validator.nu/all/',
)
})
@@ -187,7 +187,7 @@ describe('w3c-validation-helper', function () {
const actualResult = getSchema(preset)
expect(actualResult).to.equal(
- 'http://s.validator.nu/html5-rdfalite.rnc http://s.validator.nu/html5/assertions.sch http://c.validator.nu/all/'
+ 'http://s.validator.nu/html5-rdfalite.rnc http://s.validator.nu/html5/assertions.sch http://c.validator.nu/all/',
)
})
@@ -197,7 +197,7 @@ describe('w3c-validation-helper', function () {
const actualResult = getSchema(preset)
expect(actualResult).to.equal(
- 'http://s.validator.nu/xhtml10/xhtml-strict.rnc http://c.validator.nu/all-html4/'
+ 'http://s.validator.nu/xhtml10/xhtml-strict.rnc http://c.validator.nu/all-html4/',
)
})
@@ -207,7 +207,7 @@ describe('w3c-validation-helper', function () {
const actualResult = getSchema(preset)
expect(actualResult).to.equal(
- 'http://s.validator.nu/xhtml10/xhtml-transitional.rnc http://c.validator.nu/all-html4/'
+ 'http://s.validator.nu/xhtml10/xhtml-transitional.rnc http://c.validator.nu/all-html4/',
)
})
@@ -217,7 +217,7 @@ describe('w3c-validation-helper', function () {
const actualResult = getSchema(preset)
expect(actualResult).to.equal(
- 'http://s.validator.nu/xhtml10/xhtml-frameset.rnc http://c.validator.nu/all-html4/'
+ 'http://s.validator.nu/xhtml10/xhtml-frameset.rnc http://c.validator.nu/all-html4/',
)
})
@@ -227,7 +227,7 @@ describe('w3c-validation-helper', function () {
const actualResult = getSchema(preset)
expect(actualResult).to.equal(
- 'http://s.validator.nu/xhtml5.rnc http://s.validator.nu/html5/assertions.sch http://c.validator.nu/all/'
+ 'http://s.validator.nu/xhtml5.rnc http://s.validator.nu/html5/assertions.sch http://c.validator.nu/all/',
)
})
@@ -237,7 +237,7 @@ describe('w3c-validation-helper', function () {
const actualResult = getSchema(preset)
expect(actualResult).to.equal(
- 'http://s.validator.nu/xhtml5-rdfalite.rnc http://s.validator.nu/html5/assertions.sch http://c.validator.nu/all/'
+ 'http://s.validator.nu/xhtml5-rdfalite.rnc http://s.validator.nu/html5/assertions.sch http://c.validator.nu/all/',
)
})
@@ -247,7 +247,7 @@ describe('w3c-validation-helper', function () {
const actualResult = getSchema(preset)
expect(actualResult).to.equal(
- 'http://s.validator.nu/xhtml1-ruby-rdf-svg-mathml.rnc http://c.validator.nu/all-html4/'
+ 'http://s.validator.nu/xhtml1-ruby-rdf-svg-mathml.rnc http://c.validator.nu/all-html4/',
)
})
@@ -257,7 +257,7 @@ describe('w3c-validation-helper', function () {
const actualResult = getSchema(preset)
expect(actualResult).to.equal(
- 'http://s.validator.nu/svg-xhtml5-rdf-mathml.rnc http://s.validator.nu/html5/assertions.sch http://c.validator.nu/all/'
+ 'http://s.validator.nu/svg-xhtml5-rdf-mathml.rnc http://s.validator.nu/html5/assertions.sch http://c.validator.nu/all/',
)
})
})
diff --git a/services/w3c/w3c-validation.service.js b/services/w3c/w3c-validation.service.js
index 945fd9a27473c..e781efb4c4d53 100644
--- a/services/w3c/w3c-validation.service.js
+++ b/services/w3c/w3c-validation.service.js
@@ -1,8 +1,8 @@
import Joi from 'joi'
-import { optionalUrl } from '../validators.js'
-import { BaseJsonService, NotFound } from '../index.js'
+import { url } from '../validators.js'
+import { BaseJsonService, NotFound, pathParam, queryParam } from '../index.js'
import {
- documentation,
+ description,
presetRegex,
getColor,
getMessage,
@@ -16,19 +16,26 @@ const schema = Joi.object({
.items(
Joi.object({
type: Joi.string()
- .allow('info', 'error', 'non-document-error')
+ .equal('info', 'error', 'non-document-error')
.required(),
subType: Joi.string().optional(),
message: Joi.string().required(),
- })
+ }),
),
}).required()
const queryParamSchema = Joi.object({
- targetUrl: optionalUrl.required(),
+ targetUrl: url,
preset: Joi.string().regex(presetRegex).allow(''),
}).required()
+const parserDescription = `The parser that is used for validation. This is a passthru value to the service
+- \`default\`: This will not pass a parser to the API and make the API choose the parser based on the validated content
+- \`html\`: HTML
+- \`xml\`: XML (don't load external entities)
+- \`xmldtd\`: XML (load external entities)
+`
+
export default class W3cValidation extends BaseJsonService {
static category = 'analysis'
@@ -38,18 +45,49 @@ export default class W3cValidation extends BaseJsonService {
queryParamSchema,
}
- static examples = [
- {
- title: 'W3C Validation',
- namedParams: { parser: 'html' },
- queryParams: {
- targetUrl: 'https://validator.nu/',
- preset: 'HTML, SVG 1.1, MathML 3.0',
+ static openApi = {
+ '/w3c-validation/{parser}': {
+ get: {
+ summary: 'W3C Validation',
+ description,
+ parameters: [
+ pathParam({
+ name: 'parser',
+ example: 'html',
+ schema: { type: 'string', enum: this.getEnum('parser') },
+ description: parserDescription,
+ }),
+ queryParam({
+ name: 'targetUrl',
+ example: 'https://validator.nu/',
+ required: true,
+ description: 'URL of the document to be validate',
+ }),
+ queryParam({
+ name: 'preset',
+ example: 'HTML, SVG 1.1, MathML 3.0',
+ description:
+ 'This is used to determine the schema for the document to be valdiated against.',
+ schema: {
+ type: 'string',
+ enum: [
+ 'HTML, SVG 1.1, MathML 3.0',
+ 'HTML, SVG 1.1, MathML 3.0, ITS 2.0',
+ 'HTML, SVG 1.1, MathML 3.0, RDFa Lite 1.1',
+ 'HTML 4.01 Strict, URL / XHTML 1.0 Strict, URL',
+ 'HTML 4.01 Transitional, URL / XHTML 1.0 Transitional, URL',
+ 'HTML 4.01 Frameset, URL / XHTML 1.0 Frameset, URL',
+ 'XHTML, SVG 1.1, MathML 3.0',
+ 'XHTML, SVG 1.1, MathML 3.0, RDFa Lite 1.1',
+ 'XHTML 1.0 Strict, URL, Ruby, SVG 1.1, MathML 3.0',
+ 'SVG 1.1, URL, XHTML, MathML 3.0',
+ ],
+ },
+ }),
+ ],
},
- staticPreview: this.render({ messageTypes: {} }),
- documentation,
},
- ]
+ }
static defaultBadgeData = {
label: 'w3c',
@@ -67,7 +105,7 @@ export default class W3cValidation extends BaseJsonService {
url: 'https://validator.nu/',
schema,
options: {
- qs: {
+ searchParams: {
schema: getSchema(preset),
parser: parser === 'default' ? undefined : parser,
doc: encodeURI(targetUrl),
diff --git a/services/w3c/w3c-validation.tester.js b/services/w3c/w3c-validation.tester.js
index 75bbae5787775..f7cc4880c62c8 100644
--- a/services/w3c/w3c-validation.tester.js
+++ b/services/w3c/w3c-validation.tester.js
@@ -7,21 +7,21 @@ const isErrorOnly = Joi.string().regex(/^[0-9]+ errors?$/)
const isWarningOnly = Joi.string().regex(/^[0-9]+ warnings?$/)
const isErrorAndWarning = Joi.string().regex(
- /^[0-9]+ errors?, [0-9]+ warnings?$/
+ /^[0-9]+ errors?, [0-9]+ warnings?$/,
)
const isW3CMessage = Joi.alternatives().try(
'validated',
isErrorOnly,
isWarningOnly,
- isErrorAndWarning
+ isErrorAndWarning,
)
const isW3CColors = Joi.alternatives().try('brightgreen', 'red', 'yellow')
t.create(
- 'W3C Validation page conforms to standards with no preset and parser with brightgreen badge'
+ 'W3C Validation page conforms to standards with no preset and parser with brightgreen badge',
)
.get(
- '/default.json?targetUrl=https://hsivonen.com/test/moz/messages-types/no-message.html'
+ '/default.json?targetUrl=https://hsivonen.com/test/moz/messages-types/no-message.html',
)
.expectBadge({
label: 'w3c',
@@ -30,10 +30,10 @@ t.create(
})
t.create(
- 'W3C Validation page conforms to standards with no HTML4 preset and HTML parser with brightgreen badge'
+ 'W3C Validation page conforms to standards with no HTML4 preset and HTML parser with brightgreen badge',
)
.get(
- '/html.json?targetUrl=https://hsivonen.com/test/moz/messages-types/no-message.html&preset=HTML,%20SVG%201.1,%20MathML%203.0'
+ '/html.json?targetUrl=https://hsivonen.com/test/moz/messages-types/no-message.html&preset=HTML,%20SVG%201.1,%20MathML%203.0',
)
.expectBadge({
label: 'w3c',
@@ -43,7 +43,7 @@ t.create(
t.create('W3C Validation target url not found error')
.get(
- '/default.json?targetUrl=http://hsivonen.com/test/moz/messages-types/404.html'
+ '/default.json?targetUrl=http://hsivonen.com/test/moz/messages-types/404.html',
)
.expectBadge({
label: 'w3c',
@@ -59,7 +59,7 @@ t.create('W3C Validation target url host not found error')
t.create('W3C Validation page has 1 validation error with red badge')
.get(
- '/default.json?targetUrl=http://hsivonen.com/test/moz/messages-types/warning.html'
+ '/default.json?targetUrl=http://hsivonen.com/test/moz/messages-types/warning.html',
)
.expectBadge({
label: 'w3c',
@@ -68,10 +68,10 @@ t.create('W3C Validation page has 1 validation error with red badge')
})
t.create(
- 'W3C Validation page has 3 validation error using HTML 4.01 Frameset preset with red badge'
+ 'W3C Validation page has 3 validation error using HTML 4.01 Frameset preset with red badge',
)
.get(
- '/html.json?targetUrl=http://hsivonen.com/test/moz/messages-types/warning.html&preset=HTML 4.01 Frameset, URL / XHTML 1.0 Frameset, URL'
+ '/html.json?targetUrl=http://hsivonen.com/test/moz/messages-types/warning.html&preset=HTML 4.01 Frameset, URL / XHTML 1.0 Frameset, URL',
)
.expectBadge({
label: 'w3c',
@@ -81,7 +81,7 @@ t.create(
t.create('W3C Validation page has 1 validation warning with yellow badge')
.get(
- '/default.json?targetUrl=http://hsivonen.com/test/moz/messages-types/info.svg'
+ '/default.json?targetUrl=http://hsivonen.com/test/moz/messages-types/info.svg',
)
.expectBadge({
label: 'w3c',
@@ -91,7 +91,7 @@ t.create('W3C Validation page has 1 validation warning with yellow badge')
t.create('W3C Validation page has multiple of validation errors with red badge')
.get(
- '/default.json?targetUrl=http://hsivonen.com/test/moz/messages-types/range-error.html'
+ '/default.json?targetUrl=http://hsivonen.com/test/moz/messages-types/range-error.html',
)
.expectBadge({
label: 'w3c',
diff --git a/services/weblate/weblate-base.js b/services/weblate/weblate-base.js
index 12aed7c901e9d..da9d0cc5d7474 100644
--- a/services/weblate/weblate-base.js
+++ b/services/weblate/weblate-base.js
@@ -2,6 +2,10 @@ import Joi from 'joi'
import { BaseJsonService } from '../index.js'
import { optionalUrl } from '../validators.js'
+export const defaultServer = 'https://hosted.weblate.org'
+export const description =
+ 'Weblate is an web-based tool for translation and internationalization'
+
export default class WeblateBase extends BaseJsonService {
static queryParamSchema = Joi.object({
server: optionalUrl,
@@ -15,7 +19,10 @@ export default class WeblateBase extends BaseJsonService {
async fetch(requestParams) {
return this._requestJson(
- this.authHelper.withBearerAuthHeader(requestParams, 'Token')
+ this.authHelper.withBearerAuthHeader(
+ requestParams,
+ 'Token', // lgtm [js/hardcoded-credentials]
+ ),
)
}
}
diff --git a/services/weblate/weblate-component-license.service.js b/services/weblate/weblate-component-license.service.js
index 1de097624c61d..0771dd96eb1ff 100644
--- a/services/weblate/weblate-component-license.service.js
+++ b/services/weblate/weblate-component-license.service.js
@@ -1,5 +1,6 @@
import Joi from 'joi'
-import WeblateBase from './weblate-base.js'
+import { pathParam, queryParam } from '../index.js'
+import WeblateBase, { defaultServer, description } from './weblate-base.js'
const schema = Joi.object({
license: Joi.string().required(),
@@ -17,15 +18,19 @@ export default class WeblateComponentLicense extends WeblateBase {
queryParamSchema: this.queryParamSchema,
}
- static examples = [
- {
- title: 'Weblate component license',
- namedParams: { project: 'godot-engine', component: 'godot' },
- queryParams: { server: 'https://hosted.weblate.org' },
- staticPreview: this.render({ license: 'MIT' }),
- keywords: ['i18n', 'translation', 'internationalization'],
+ static openApi = {
+ '/weblate/l/{project}/{component}': {
+ get: {
+ summary: 'Weblate component license',
+ description,
+ parameters: [
+ pathParam({ name: 'project', example: 'godot-engine' }),
+ pathParam({ name: 'component', example: 'godot' }),
+ queryParam({ name: 'server', example: defaultServer }),
+ ],
+ },
},
- ]
+ }
static defaultBadgeData = { label: 'license', color: 'informational' }
@@ -33,15 +38,15 @@ export default class WeblateComponentLicense extends WeblateBase {
return { message: `${license}` }
}
- async fetch({ project, component, server = 'https://hosted.weblate.org' }) {
+ async fetch({ project, component, server = defaultServer }) {
return super.fetch({
schema,
url: `${server}/api/components/${project}/${component}/`,
- errorMessages: {
+ httpErrors: {
403: 'access denied by remote server',
404: 'component not found',
- 429: 'rate limited by remote server',
},
+ logErrors: server === defaultServer ? [429] : [],
})
}
diff --git a/services/weblate/weblate-entities.service.js b/services/weblate/weblate-entities.service.js
index df5c53d9ca85a..738f7a41906b4 100644
--- a/services/weblate/weblate-entities.service.js
+++ b/services/weblate/weblate-entities.service.js
@@ -1,7 +1,8 @@
import Joi from 'joi'
+import { pathParam, queryParam } from '../index.js'
import { nonNegativeInteger } from '../validators.js'
import { metric } from '../text-formatters.js'
-import WeblateBase from './weblate-base.js'
+import WeblateBase, { defaultServer, description } from './weblate-base.js'
const schema = Joi.object({
count: nonNegativeInteger,
@@ -16,15 +17,24 @@ export default class WeblateEntities extends WeblateBase {
queryParamSchema: this.queryParamSchema,
}
- static examples = [
- {
- title: `Weblate entities`,
- namedParams: { type: 'projects' },
- queryParams: { server: 'https://hosted.weblate.org' },
- staticPreview: this.render({ type: 'projects', count: 533 }),
- keywords: ['i18n', 'internationalization'],
+ static openApi = {
+ '/weblate/{type}': {
+ get: {
+ summary: 'Weblate entities',
+ description,
+ parameters: [
+ pathParam({
+ name: 'type',
+ example: 'projects',
+ schema: { type: 'string', enum: this.getEnum('type') },
+ }),
+ queryParam({ name: 'server', example: defaultServer }),
+ ],
+ },
},
- ]
+ }
+
+ static _cacheLength = 600
static defaultBadgeData = { color: 'informational' }
@@ -32,14 +42,14 @@ export default class WeblateEntities extends WeblateBase {
return { label: type, message: metric(count) }
}
- async fetch({ type, server = 'https://hosted.weblate.org' }) {
+ async fetch({ type, server = defaultServer }) {
return super.fetch({
schema,
url: `${server}/api/${type}/`,
- errorMessages: {
+ httpErrors: {
403: 'access denied by remote server',
- 429: 'rate limited by remote server',
},
+ logErrors: server === defaultServer ? [429] : [],
})
}
diff --git a/services/weblate/weblate-project-translated-percentage.service.js b/services/weblate/weblate-project-translated-percentage.service.js
index b4a2481ed0dfb..dbeb0a883d6df 100644
--- a/services/weblate/weblate-project-translated-percentage.service.js
+++ b/services/weblate/weblate-project-translated-percentage.service.js
@@ -1,6 +1,7 @@
import Joi from 'joi'
+import { pathParam, queryParam } from '../index.js'
import { colorScale } from '../color-formatters.js'
-import WeblateBase from './weblate-base.js'
+import WeblateBase, { defaultServer, description } from './weblate-base.js'
const schema = Joi.object({
translated_percent: Joi.number().required(),
@@ -19,15 +20,20 @@ export default class WeblateProjectTranslatedPercentage extends WeblateBase {
queryParamSchema: this.queryParamSchema,
}
- static examples = [
- {
- title: 'Weblate project translated',
- namedParams: { project: 'godot-engine' },
- queryParams: { server: 'https://hosted.weblate.org' },
- staticPreview: this.render({ translatedPercent: 20.5 }),
- keywords: ['i18n', 'translation', 'internationalization'],
+ static openApi = {
+ '/weblate/progress/{project}': {
+ get: {
+ summary: 'Weblate project translated',
+ description,
+ parameters: [
+ pathParam({ name: 'project', example: 'godot-engine' }),
+ queryParam({ name: 'server', example: defaultServer }),
+ ],
+ },
},
- ]
+ }
+
+ static _cacheLength = 600
static defaultBadgeData = { label: 'translated' }
@@ -45,20 +51,23 @@ export default class WeblateProjectTranslatedPercentage extends WeblateBase {
return { message: `${translatedPercent.toFixed(0)}%`, color }
}
- async fetch({ project, server = 'https://hosted.weblate.org' }) {
+ async fetch({ project, server = defaultServer }) {
return super.fetch({
schema,
url: `${server}/api/projects/${project}/statistics/`,
- errorMessages: {
+ httpErrors: {
403: 'access denied by remote server',
404: 'project not found',
- 429: 'rate limited by remote server',
},
+ logErrors: server === defaultServer ? [429] : [],
})
}
async handle({ project }, { server }) {
- const { translated_percent } = await this.fetch({ project, server })
- return this.constructor.render({ translatedPercent: translated_percent })
+ const { translated_percent: translatedPercent } = await this.fetch({
+ project,
+ server,
+ })
+ return this.constructor.render({ translatedPercent })
}
}
diff --git a/services/weblate/weblate-user-statistic.service.js b/services/weblate/weblate-user-statistic.service.js
index fa697194e5eb4..c490c048e1401 100644
--- a/services/weblate/weblate-user-statistic.service.js
+++ b/services/weblate/weblate-user-statistic.service.js
@@ -1,7 +1,8 @@
import Joi from 'joi'
+import { pathParam, queryParam } from '../index.js'
import { nonNegativeInteger } from '../validators.js'
import { metric } from '../text-formatters.js'
-import WeblateBase from './weblate-base.js'
+import WeblateBase, { defaultServer, description } from './weblate-base.js'
const schema = Joi.object({
translated: nonNegativeInteger,
@@ -29,15 +30,25 @@ export default class WeblateUserStatistic extends WeblateBase {
queryParamSchema: this.queryParamSchema,
}
- static examples = [
- {
- title: `Weblate user statistic`,
- namedParams: { statistic: 'translations', user: 'nijel' },
- queryParams: { server: 'https://hosted.weblate.org' },
- staticPreview: this.render({ statistic: 'translations', count: 30585 }),
- keywords: ['i18n', 'internationalization'],
+ static openApi = {
+ '/weblate/{statistic}/{user}': {
+ get: {
+ summary: 'Weblate user statistic',
+ description,
+ parameters: [
+ pathParam({
+ name: 'statistic',
+ example: 'translations',
+ schema: { type: 'string', enum: this.getEnum('statistic') },
+ }),
+ pathParam({ name: 'user', example: 'nijel' }),
+ queryParam({ name: 'server', example: defaultServer }),
+ ],
+ },
},
- ]
+ }
+
+ static _cacheLength = 600
static defaultBadgeData = { color: 'informational' }
@@ -45,15 +56,15 @@ export default class WeblateUserStatistic extends WeblateBase {
return { label: statistic, message: metric(count) }
}
- async fetch({ user, server = 'https://hosted.weblate.org' }) {
+ async fetch({ user, server = defaultServer }) {
return super.fetch({
schema,
url: `${server}/api/users/${user}/statistics/`,
- errorMessages: {
+ httpErrors: {
403: 'access denied by remote server',
404: 'user not found',
- 429: 'rate limited by remote server',
},
+ logErrors: server === defaultServer ? [429] : [],
})
}
diff --git a/services/website-status.js b/services/website-status.js
index c0fdb252b07b1..832f56ad78b6a 100644
--- a/services/website-status.js
+++ b/services/website-status.js
@@ -1,5 +1,20 @@
+/**
+ * Commonly used functions and utilities for tasks related to website status.
+ *
+ * @module
+ */
+
import Joi from 'joi'
+import { queryParams as qP } from './index.js'
+
+/** @import { OpenApiParam } from '../core/base-service/openapi.js' */
+/**
+ * Joi schema for validating query params.
+ * Checks if the query params object has valid up_message, down_message, up_color and down_color properties.
+ *
+ * @type {Joi}
+ */
const queryParamSchema = Joi.object({
up_message: Joi.string(),
down_message: Joi.string(),
@@ -7,13 +22,35 @@ const queryParamSchema = Joi.object({
down_color: Joi.alternatives(Joi.string(), Joi.number()),
}).required()
-const exampleQueryParams = {
- up_message: 'online',
- up_color: 'blue',
- down_message: 'offline',
- down_color: 'lightgrey',
-}
+/**
+ * Array of OpenAPI Parameter Objects describing the
+ * up_message, down_message, up_color and down_color
+ * query params
+ *
+ * @type {Array.}
+ * @see {@link module:core/base-service/openapi~OpenApiParam}
+ */
+const queryParams = qP(
+ { name: 'up_message', example: 'online' },
+ { name: 'up_color', example: 'blue' },
+ { name: 'down_message', example: 'offline' },
+ { name: 'down_color', example: 'lightgrey' },
+)
+/**
+ * Creates a badge object that displays information about website status.
+ *
+ * @param {object} options - The options for rendering the status
+ * @param {boolean} options.isUp - Whether the website or service is up or down
+ * @param {string} [options.upMessage='up'] - The message to display when the website or service is up
+ * @param {string} [options.downMessage='down'] - The message to display when the website or service is down
+ * @param {string} [options.upColor='brightgreen'] - The color to use when the website or service is up
+ * @param {string} [options.downColor='red'] - The color to use when the website or service is down
+ * @returns {object} An object with a message and a color property
+ * @example
+ * renderWebsiteStatus({ isUp: true }) // returns { message: 'up', color: 'brightgreen' }
+ * renderWebsiteStatus({ isUp: false }) // returns { message: 'down', color: 'red' }
+ */
function renderWebsiteStatus({
isUp,
upMessage = 'up',
@@ -28,4 +65,4 @@ function renderWebsiteStatus({
}
}
-export { queryParamSchema, exampleQueryParams, renderWebsiteStatus }
+export { queryParamSchema, queryParams, renderWebsiteStatus }
diff --git a/services/website/website-redirect.tester.js b/services/website/website-redirect.tester.js
index 0aeba6b5bc629..f592b4b88f360 100644
--- a/services/website/website-redirect.tester.js
+++ b/services/website/website-redirect.tester.js
@@ -9,25 +9,25 @@ export const t = new ServiceTester({
t.create('Website with custom messages')
.get('/website-up-down/https/www.google.com.svg')
.expectRedirect(
- `/website.svg?down_message=down&up_message=up&url=${encodeURIComponent(
- 'https://www.google.com'
- )}`
+ `/website.svg?up_message=up&down_message=down&url=${encodeURIComponent(
+ 'https://www.google.com',
+ )}`,
)
t.create('Website with custom messages and colors')
.get('/website-up-down-yellow-gray/https/www.google.com.svg')
.expectRedirect(
- `/website.svg?down_color=gray&down_message=down&up_color=yellow&up_message=up&url=${encodeURIComponent(
- 'https://www.google.com'
- )}`
+ `/website.svg?up_message=up&down_message=down&up_color=yellow&down_color=gray&url=${encodeURIComponent(
+ 'https://www.google.com',
+ )}`,
)
t.create('Website to queryParam with custom messages and colors')
.get(
- '/website/https/www.google.com.svg?down_color=gray&down_message=down&up_color=yellow&up_message=up'
+ '/website/https/www.google.com.svg?down_color=gray&down_message=down&up_color=yellow&up_message=up',
)
.expectRedirect(
`/website.svg?down_color=gray&down_message=down&up_color=yellow&up_message=up&url=${encodeURIComponent(
- 'https://www.google.com'
- )}`
+ 'https://www.google.com',
+ )}`,
)
diff --git a/services/website/website.service.js b/services/website/website.service.js
index 5ac7e4536a0c8..fbfefc5e2fe25 100644
--- a/services/website/website.service.js
+++ b/services/website/website.service.js
@@ -1,36 +1,26 @@
-import Joi from 'joi'
import emojic from 'emojic'
-import { optionalUrl } from '../validators.js'
+import Joi from 'joi'
+import trace from '../../core/base-service/trace.js'
+import { BaseService, queryParams } from '../index.js'
+import { url } from '../validators.js'
import {
queryParamSchema,
- exampleQueryParams,
renderWebsiteStatus,
+ queryParams as websiteQueryParams,
} from '../website-status.js'
-import { BaseService } from '../index.js'
-import trace from '../../core/base-service/trace.js'
-const documentation = `
-
- The badge is of the form
- https://img.shields.io/website/PROTOCOL/URLREST.svg.
-
-
- The whole URL is obtained by concatenating the PROTOCOL
- (http or https, for example) with the
- URLREST (separating them with ://).
-
-
- The existence of a specific path on the server can be checked by appending
- a path after the domain name, e.g.
- https://img.shields.io/website/http/www.website.com/path/to/page.html.svg.
-
-
- The messages and colors for the up and down states can also be customized.
-
+const description = `
+The existence of a specific path on the server can be checked by appending
+a path after the domain name, e.g.
+\`https://img.shields.io/website?url=http%3A//www.website.com/path/to/page.html\`.
+
+The messages and colors for the up and down states can also be customized.
+
+A site will be classified as "down" if it fails to respond within 3.5 seconds.
`
const urlQueryParamSchema = Joi.object({
- url: optionalUrl.required(),
+ url,
}).required()
export default class Website extends BaseService {
@@ -42,18 +32,19 @@ export default class Website extends BaseService {
queryParamSchema: queryParamSchema.concat(urlQueryParamSchema),
}
- static examples = [
- {
- title: 'Website',
- namedParams: {},
- queryParams: {
- ...exampleQueryParams,
- ...{ url: 'https://shields.io' },
+ static openApi = {
+ '/website': {
+ get: {
+ summary: 'Website',
+ description,
+ parameters: queryParams({
+ name: 'url',
+ required: true,
+ example: 'https://shields.io',
+ }).concat(websiteQueryParams),
},
- staticPreview: renderWebsiteStatus({ isUp: true }),
- documentation,
},
- ]
+ }
static defaultBadgeData = {
label: 'website',
@@ -76,7 +67,7 @@ export default class Website extends BaseService {
up_color: upColor,
down_color: downColor,
url,
- }
+ },
) {
let isUp
try {
@@ -86,6 +77,9 @@ export default class Website extends BaseService {
url,
options: {
method: 'HEAD',
+ timeout: {
+ response: 3500,
+ },
},
})
// We consider all HTTP status codes below 310 as success.
diff --git a/services/website/website.tester.js b/services/website/website.tester.js
index c70a206e68441..0aec420ab0c90 100644
--- a/services/website/website.tester.js
+++ b/services/website/website.tester.js
@@ -46,16 +46,23 @@ t.create('status is down if response code is 401')
.intercept(nock => nock('http://offline.com').head('/').reply(401))
.expectBadge({ label: 'website', message: 'down' })
+t.create('status is down if it is unresponsive for more than 3500 ms')
+ .get('/website.json?url=http://offline.com')
+ .intercept(nock =>
+ nock('http://offline.com').head('/').delay(4000).reply(200),
+ )
+ .expectBadge({ label: 'website', message: 'down' })
+
t.create('custom online label, online message and online color')
.get(
- '/website.json?url=http://online.com&up_message=up&down_message=down&up_color=green&down_color=grey'
+ '/website.json?url=http://online.com&up_message=up&down_message=down&up_color=green&down_color=grey',
)
.intercept(nock => nock('http://online.com').head('/').reply(200))
.expectBadge({ label: 'website', message: 'up', color: 'green' })
t.create('custom offline message and offline color')
.get(
- '/website.json?url=http://offline.com&up_message=up&down_message=down&up_color=green&down_color=grey'
+ '/website.json?url=http://offline.com&up_message=up&down_message=down&up_color=green&down_color=grey',
)
.intercept(nock => nock('http://offline.com').head('/').reply(500))
.expectBadge({ label: 'website', message: 'down', color: 'grey' })
diff --git a/services/wercker/wercker.service.js b/services/wercker/wercker.service.js
deleted file mode 100644
index 286c24f21a670..0000000000000
--- a/services/wercker/wercker.service.js
+++ /dev/null
@@ -1,130 +0,0 @@
-import Joi from 'joi'
-import { isBuildStatus, renderBuildStatusBadge } from '../build-status.js'
-import { BaseJsonService } from '../index.js'
-
-const werckerSchema = Joi.array()
- .items(
- Joi.object({
- result: isBuildStatus,
- })
- )
- .min(0)
- .max(1)
- .required()
-
-const werckerCIDocumentation = `
-
- Note that Wercker badge Key (used in Wercker's native badge urls) is not the same as
- the Application Id and the badge key will not work.
-
-
- You can use the Wercker API to locate your Application Id:
-
-
- https://app.wercker.com/api/v3/applications/:username/:applicationName
-
- For example: https://app.wercker.com/api/v3/applications/wercker/go-wercker-api
-
-
- Your Application Id will be in the 'id' field in the API response.
-
-`
-
-export default class Wercker extends BaseJsonService {
- static category = 'build'
-
- static route = {
- base: 'wercker',
- format:
- '(?:(?:ci/)([a-fA-F0-9]{24})|(?:build|ci)/([^/]+/[^/]+?))(?:/(.+?))?',
- capture: ['projectId', 'applicationName', 'branch'],
- }
-
- static examples = [
- {
- title: `Wercker CI Run`,
- pattern: 'ci/:applicationId',
- namedParams: { applicationId: '559e33c8e982fc615500b357' },
- staticPreview: this.render({ result: 'passed' }),
- documentation: werckerCIDocumentation,
- },
- {
- title: `Wercker CI Run`,
- pattern: 'ci/:applicationId/:branch',
- namedParams: {
- applicationId: '559e33c8e982fc615500b357',
- branch: 'master',
- },
- staticPreview: this.render({ result: 'passed' }),
- documentation: werckerCIDocumentation,
- },
- {
- title: `Wercker Build`,
- pattern: 'build/:userName/:applicationName',
- namedParams: {
- userName: 'wercker',
- applicationName: 'go-wercker-api',
- },
- staticPreview: this.render({ result: 'passed' }),
- },
- {
- title: `Wercker Build branch`,
- pattern: 'build/:userName/:applicationName/:branch',
- namedParams: {
- userName: 'wercker',
- applicationName: 'go-wercker-api',
- branch: 'master',
- },
- staticPreview: this.render({ result: 'passed' }),
- },
- ]
-
- static render({ result }) {
- return renderBuildStatusBadge({ status: result })
- }
-
- static getBaseUrl({ projectId, applicationName }) {
- if (applicationName) {
- return `https://app.wercker.com/api/v3/applications/${applicationName}/builds`
- } else {
- return `https://app.wercker.com/api/v3/runs?applicationId=${projectId}`
- }
- }
-
- async fetch({ projectId, applicationName, branch }) {
- let url
- const qs = { branch, limit: 1 }
-
- if (applicationName) {
- url = `https://app.wercker.com/api/v3/applications/${applicationName}/builds`
- } else {
- url = 'https://app.wercker.com/api/v3/runs'
- qs.applicationId = projectId
- }
-
- return this._requestJson({
- schema: werckerSchema,
- url,
- options: { qs },
- errorMessages: {
- 401: 'private application not supported',
- 404: 'application not found',
- },
- })
- }
-
- async handle({ projectId, applicationName, branch }) {
- const json = await this.fetch({
- projectId,
- applicationName,
- branch,
- })
- if (json.length === 0) {
- return this.constructor.render({
- result: 'not built',
- })
- }
- const { result } = json[0]
- return this.constructor.render({ result })
- }
-}
diff --git a/services/wercker/wercker.tester.js b/services/wercker/wercker.tester.js
deleted file mode 100644
index bcbd29368db1b..0000000000000
--- a/services/wercker/wercker.tester.js
+++ /dev/null
@@ -1,58 +0,0 @@
-import { isBuildStatus } from '../build-status.js'
-import { createServiceTester } from '../tester.js'
-export const t = await createServiceTester()
-
-t.create('Build status')
- .get('/build/wercker/go-wercker-api.json')
- .expectBadge({ label: 'build', message: isBuildStatus })
-
-t.create('Build status (with branch)')
- .get('/build/wercker/go-wercker-api/master.json')
- .expectBadge({ label: 'build', message: isBuildStatus })
-
-t.create('Build status (application not found)')
- .get('/build/some-project/that-doesnt-exist.json')
- .expectBadge({ label: 'build', message: 'application not found' })
-
-t.create('Build status (private application)')
- .get('/build/wercker/blueprint.json')
- .expectBadge({ label: 'build', message: 'private application not supported' })
-
-t.create('Build passed')
- .get('/build/wercker/go-wercker-api.json')
- .intercept(nock =>
- nock('https://app.wercker.com/api/v3/applications/')
- .get('/wercker/go-wercker-api/builds?limit=1')
- .reply(200, [{ status: 'finished', result: 'passed' }])
- )
- .expectBadge({
- label: 'build',
- message: 'passing',
- color: 'brightgreen',
- })
-
-t.create('Build failed')
- .get('/build/wercker/go-wercker-api.json')
- .intercept(nock =>
- nock('https://app.wercker.com/api/v3/applications/')
- .get('/wercker/go-wercker-api/builds?limit=1')
- .reply(200, [{ status: 'finished', result: 'failed' }])
- )
- .expectBadge({ label: 'build', message: 'failing', color: 'red' })
-
-t.create('CI status by ID')
- .get('/ci/559e33c8e982fc615500b357.json')
- .expectBadge({ label: 'build', message: isBuildStatus })
-
-t.create('CI status by ID (with branch)')
- .get('/ci/559e33c8e982fc615500b357/master.json')
- .expectBadge({ label: 'build', message: isBuildStatus })
-
-t.create('CI status by ID (no runs yet)')
- .get('/ci/559e33c8e982fc615500b357.json')
- .intercept(nock =>
- nock('https://app.wercker.com/api/v3')
- .get('/runs?applicationId=559e33c8e982fc615500b357&limit=1')
- .reply(200, [])
- )
- .expectBadge({ label: 'build', message: 'not built' })
diff --git a/services/whatpulse/whatpulse.service.js b/services/whatpulse/whatpulse.service.js
new file mode 100644
index 0000000000000..dabe0bb1eaf5f
--- /dev/null
+++ b/services/whatpulse/whatpulse.service.js
@@ -0,0 +1,134 @@
+import Joi from 'joi'
+import dayjs from 'dayjs'
+import calendar from 'dayjs/plugin/calendar.js'
+import duration from 'dayjs/plugin/duration.js'
+import relativeTime from 'dayjs/plugin/relativeTime.js'
+import { BaseJsonService, pathParam, queryParam } from '../index.js'
+import { metric as formatMetric, ordinalNumber } from '../text-formatters.js'
+dayjs.extend(calendar)
+dayjs.extend(duration)
+dayjs.extend(relativeTime)
+
+const schema = Joi.object({
+ Keys: Joi.alternatives(Joi.string(), Joi.number()).required(),
+ Clicks: Joi.alternatives(Joi.string(), Joi.number()).required(),
+ UptimeSeconds: Joi.alternatives(Joi.string(), Joi.number()).required(),
+ Download: Joi.string().required(),
+ Upload: Joi.string().required(),
+ Ranks: Joi.object({
+ Keys: Joi.string().required(),
+ Clicks: Joi.string().required(),
+ Download: Joi.string().required(),
+ Upload: Joi.string().required(),
+ Uptime: Joi.string().required(),
+ }),
+}).required()
+
+const queryParamSchema = Joi.object({
+ rank: Joi.equal(''),
+}).required()
+
+export default class WhatPulse extends BaseJsonService {
+ static category = 'activity'
+ static route = {
+ base: 'whatpulse',
+ pattern:
+ ':metric(keys|clicks|uptime|download|upload)/:userType(user|team)/:id',
+ queryParamSchema,
+ }
+
+ static openApi = {
+ '/whatpulse/{metric}/{userType}/{id}': {
+ get: {
+ summary: 'WhatPulse',
+ parameters: [
+ pathParam({
+ name: 'metric',
+ example: 'keys',
+ schema: { type: 'string', enum: this.getEnum('metric') },
+ }),
+ pathParam({
+ name: 'userType',
+ example: 'team',
+ schema: { type: 'string', enum: this.getEnum('userType') },
+ }),
+ pathParam({
+ name: 'id',
+ example: '179734',
+ description:
+ 'Either a user ID (e.g: `179734`) or a group ID (e.g: `dutch power cows`)',
+ }),
+ queryParam({
+ name: 'rank',
+ description: 'show rank instead of value',
+ example: null,
+ schema: { type: 'boolean' },
+ }),
+ ],
+ },
+ },
+ }
+
+ static defaultBadgeData = { label: 'whatpulse' }
+
+ static render({ metric, metricValue }) {
+ return {
+ label: metric,
+ message: metricValue,
+ color: 'informational',
+ }
+ }
+
+ async fetch({ userType, id }) {
+ return await this._requestJson({
+ schema,
+ url: `https://api.whatpulse.org/${userType}.php?${userType}=${id}&format=json`,
+ })
+ }
+
+ toLowerKeys(obj) {
+ return Object.keys(obj).reduce((accumulator, key) => {
+ accumulator[key.toLowerCase()] = obj[key]
+ return accumulator
+ }, {})
+ }
+
+ transform({ json, metric }, { rank }) {
+ // We want to compare with lowercase keys from the WhatPulse's API.
+ const jsonLowercase = this.toLowerKeys(json)
+ jsonLowercase.ranks = this.toLowerKeys(json.Ranks)
+
+ // Just metric, no rank.
+ if (rank === undefined) {
+ if (metric === 'uptime') {
+ return dayjs.duration(jsonLowercase.uptimeseconds, 'seconds').humanize()
+ }
+
+ let metricValue
+
+ metricValue = jsonLowercase[metric]
+
+ if (metric === 'keys' || metric === 'clicks') {
+ metricValue = formatMetric(metricValue)
+ }
+
+ if (metric === 'upload' || metric === 'download') {
+ metricValue = metricValue.replace(/([A-Za-z]+)/, ' $1')
+ }
+
+ return metricValue
+ }
+
+ // Rank achieved by the user/team with the given metric.
+ const rankFromResp = jsonLowercase.ranks[metric]
+
+ return ordinalNumber(rankFromResp)
+ }
+
+ async handle({ metric, userType, id }, { rank }) {
+ const json = await this.fetch({ userType, id, metric })
+ const metricValue = this.transform({ json, metric }, { rank })
+
+ return this.constructor.render({ metric, metricValue })
+ }
+}
diff --git a/services/whatpulse/whatpulse.tester.js b/services/whatpulse/whatpulse.tester.js
new file mode 100644
index 0000000000000..4c0b3e47f118d
--- /dev/null
+++ b/services/whatpulse/whatpulse.tester.js
@@ -0,0 +1,42 @@
+import { createServiceTester } from '../tester.js'
+import {
+ isMetricFileSize,
+ isHumanized,
+ isMetric,
+ isOrdinalNumber,
+} from '../test-validators.js'
+export const t = await createServiceTester()
+
+t.create('WhatPulse user as user id, uptime')
+ .get('/uptime/user/179734.json')
+ .expectBadge({ label: 'uptime', message: isHumanized })
+
+t.create('WhatPulse user as user name, keys')
+ .get('/keys/user/jerone.json')
+ .expectBadge({ label: 'keys', message: isMetric })
+
+t.create('WhatPulse team as team id, clicks')
+ .get('/clicks/team/1295.json')
+ .expectBadge({ label: 'clicks', message: isMetric })
+
+t.create('WhatPulse team as team id, download')
+ .get('/download/team/1295.json')
+ .expectBadge({ label: 'download', message: isMetricFileSize })
+
+t.create('WhatPulse team as team id, upload')
+ .get('/upload/team/1295.json')
+ .expectBadge({ label: 'upload', message: isMetricFileSize })
+
+t.create('WhatPulse team as team name, keys - from Ranks')
+ .get('/keys/team/dutch power cows.json?rank')
+ .expectBadge({ label: 'keys', message: isOrdinalNumber })
+
+t.create(
+ 'WhatPulse invalid metric name (not one of the options from the modal`s dropdown)',
+)
+ .get('/UpTIMe/user/jerone.json')
+ .expectBadge({ label: '404', message: 'badge not found' })
+
+t.create('WhatPulse incorrect user name')
+ .get('/uptime/user/NonExistentUsername.json')
+ .expectBadge({ label: 'whatpulse', message: 'invalid response data' })
diff --git a/services/wheelmap/wheelmap.service.js b/services/wheelmap/wheelmap.service.js
deleted file mode 100644
index c6abf22d8c6cd..0000000000000
--- a/services/wheelmap/wheelmap.service.js
+++ /dev/null
@@ -1,67 +0,0 @@
-import Joi from 'joi'
-import { BaseJsonService } from '../index.js'
-
-const schema = Joi.object({
- node: Joi.object({
- wheelchair: Joi.string().required(),
- }).required(),
-}).required()
-
-export default class Wheelmap extends BaseJsonService {
- static category = 'other'
-
- static route = {
- base: 'wheelmap/a',
- pattern: ':nodeId(-?[0-9]+)',
- }
-
- static auth = {
- passKey: 'wheelmap_token',
- authorizedOrigins: ['https://wheelmap.org'],
- isRequired: true,
- }
-
- static examples = [
- {
- title: 'Wheelmap',
- namedParams: { nodeId: '26699541' },
- staticPreview: this.render({ accessibility: 'yes' }),
- },
- ]
-
- static defaultBadgeData = { label: 'accessibility' }
-
- static render({ accessibility }) {
- let color
- if (accessibility === 'yes') {
- color = 'brightgreen'
- } else if (accessibility === 'limited') {
- color = 'yellow'
- } else if (accessibility === 'no') {
- color = 'red'
- }
- return { message: accessibility, color }
- }
-
- async fetch({ nodeId }) {
- return this._requestJson(
- this.authHelper.withQueryStringAuth(
- { passKey: 'api_key' },
- {
- schema,
- url: `https://wheelmap.org/api/nodes/${nodeId}`,
- errorMessages: {
- 401: 'invalid token',
- 404: 'node not found',
- },
- }
- )
- )
- }
-
- async handle({ nodeId }) {
- const json = await this.fetch({ nodeId })
- const accessibility = json.node.wheelchair
- return this.constructor.render({ accessibility })
- }
-}
diff --git a/services/wheelmap/wheelmap.spec.js b/services/wheelmap/wheelmap.spec.js
deleted file mode 100644
index 80287395c033d..0000000000000
--- a/services/wheelmap/wheelmap.spec.js
+++ /dev/null
@@ -1,59 +0,0 @@
-import { expect } from 'chai'
-import nock from 'nock'
-import { cleanUpNockAfterEach, defaultContext } from '../test-helpers.js'
-import Wheelmap from './wheelmap.service.js'
-
-describe('Wheelmap', function () {
- cleanUpNockAfterEach()
-
- const token = 'abc123'
- const config = { private: { wheelmap_token: token } }
-
- function createMock({ nodeId, wheelchair }) {
- const scope = nock('https://wheelmap.org')
- .get(`/api/nodes/${nodeId}`)
- .query({ api_key: token })
-
- if (wheelchair) {
- return scope.reply(200, { node: { wheelchair } })
- } else {
- return scope.reply(404)
- }
- }
-
- it('node with accessibility', async function () {
- const nodeId = '26699541'
- const scope = createMock({ nodeId, wheelchair: 'yes' })
- expect(
- await Wheelmap.invoke(defaultContext, config, { nodeId })
- ).to.deep.equal({ message: 'yes', color: 'brightgreen' })
- scope.done()
- })
-
- it('node with limited accessibility', async function () {
- const nodeId = '2034868974'
- const scope = createMock({ nodeId, wheelchair: 'limited' })
- expect(
- await Wheelmap.invoke(defaultContext, config, { nodeId })
- ).to.deep.equal({ message: 'limited', color: 'yellow' })
- scope.done()
- })
-
- it('node without accessibility', async function () {
- const nodeId = '-147495158'
- const scope = createMock({ nodeId, wheelchair: 'no' })
- expect(
- await Wheelmap.invoke(defaultContext, config, { nodeId })
- ).to.deep.equal({ message: 'no', color: 'red' })
- scope.done()
- })
-
- it('node not found', async function () {
- const nodeId = '0'
- const scope = createMock({ nodeId })
- expect(
- await Wheelmap.invoke(defaultContext, config, { nodeId })
- ).to.deep.equal({ message: 'node not found', color: 'red', isError: true })
- scope.done()
- })
-})
diff --git a/services/wheelmap/wheelmap.tester.js b/services/wheelmap/wheelmap.tester.js
deleted file mode 100644
index e4d5e6c49b580..0000000000000
--- a/services/wheelmap/wheelmap.tester.js
+++ /dev/null
@@ -1,44 +0,0 @@
-import { createServiceTester } from '../tester.js'
-import { noToken } from '../test-helpers.js'
-import _noWheelmapToken from './wheelmap.service.js'
-export const t = await createServiceTester()
-const noWheelmapToken = noToken(_noWheelmapToken)
-
-t.create('node with accessibility')
- .skipWhen(noWheelmapToken)
- .get('/26699541.json')
- .timeout(7500)
- .expectBadge({
- label: 'accessibility',
- message: 'yes',
- color: 'brightgreen',
- })
-
-t.create('node with limited accessibility')
- .skipWhen(noWheelmapToken)
- .get('/2034868974.json')
- .timeout(7500)
- .expectBadge({
- label: 'accessibility',
- message: 'limited',
- color: 'yellow',
- })
-
-t.create('node without accessibility')
- .skipWhen(noWheelmapToken)
- .get('/-147495158.json')
- .timeout(7500)
- .expectBadge({
- label: 'accessibility',
- message: 'no',
- color: 'red',
- })
-
-t.create('node not found')
- .skipWhen(noWheelmapToken)
- .get('/0.json')
- .timeout(7500)
- .expectBadge({
- label: 'accessibility',
- message: 'node not found',
- })
diff --git a/services/wikiapiary/wikiapiary-installs.service.js b/services/wikiapiary/wikiapiary-installs.service.js
index 222e16c4c875a..952d2f5e86054 100644
--- a/services/wikiapiary/wikiapiary-installs.service.js
+++ b/services/wikiapiary/wikiapiary-installs.service.js
@@ -1,108 +1,11 @@
-import Joi from 'joi'
-import { metric } from '../text-formatters.js'
-import { BaseJsonService, NotFound } from '../index.js'
+import { deprecatedService } from '../index.js'
-const documentation = `
-
- The name of an extension is case-sensitive excluding the first character.
-
-
- For example, in the case of ParserFunctions, the following are
- valid:
-
- ParserFunctions
- parserFunctions
-
-
- However, the following are invalid:
-
- parserfunctions
- Parserfunctions
- pARSERfUNCTIONS
-
-
-`
-
-const schema = Joi.object({
- query: Joi.object({
- results: Joi.alternatives([
- Joi.object()
- .required()
- .pattern(/^\w+:.+$/, {
- printouts: Joi.object({
- 'Has website count': Joi.array()
- .required()
- .items(Joi.number().required()),
- }).required(),
- }),
- Joi.array().required(),
- ]).required(),
- }).required(),
-}).required()
-
-/**
- * This badge displays the total installations of a MediaWiki extensions, skins,
- * etc via Wikiapiary.
- *
- * {@link https://www.mediawiki.org/wiki/Manual:Extensions MediaWiki Extensions Manual}
- */
-export default class WikiapiaryInstalls extends BaseJsonService {
- static category = 'downloads'
- static route = {
+export default deprecatedService({
+ category: 'downloads',
+ route: {
base: 'wikiapiary',
- pattern: ':variant(extension|skin|farm|generator|host)/installs/:name',
- }
-
- static examples = [
- {
- title: 'Wikiapiary installs',
- namedParams: { variant: 'extension', name: 'ParserFunctions' },
- staticPreview: this.render({ usage: 11170 }),
- documentation,
- keywords: ['mediawiki'],
- },
- ]
-
- static defaultBadgeData = { label: 'installs', color: 'informational' }
-
- static render({ usage }) {
- return { message: metric(usage) }
- }
-
- static validate({ results }) {
- if (Array.isArray(results))
- throw new NotFound({ prettyMessage: 'not found' })
- }
-
- async fetch({ variant, name }) {
- return this._requestJson({
- schema,
- url: `https://wikiapiary.com/w/api.php`,
- options: {
- qs: {
- action: 'ask',
- query: `[[${variant}:${name}]]|?Has_website_count`,
- format: 'json',
- },
- },
- })
- }
-
- async handle({ variant, name }) {
- const response = await this.fetch({ variant, name })
- const { results } = response.query
-
- this.constructor.validate({ results })
-
- const keyLowerCase = `${variant}:${name.toLowerCase()}`
- const resultKey = Object.keys(results).find(
- key => keyLowerCase === key.toLowerCase()
- )
-
- if (resultKey === undefined)
- throw new NotFound({ prettyMessage: 'not found' })
-
- const [usage] = results[resultKey].printouts['Has website count']
- return this.constructor.render({ usage })
- }
-}
+ pattern: ':various+',
+ },
+ label: 'wikiapiary',
+ dateAdded: new Date('2025-11-30'),
+})
diff --git a/services/wikiapiary/wikiapiary-installs.tester.js b/services/wikiapiary/wikiapiary-installs.tester.js
index 878ec756dd163..2af4536e3eba2 100644
--- a/services/wikiapiary/wikiapiary-installs.tester.js
+++ b/services/wikiapiary/wikiapiary-installs.tester.js
@@ -1,43 +1,14 @@
-import { createServiceTester } from '../tester.js'
-import { isMetric } from '../test-validators.js'
-export const t = await createServiceTester()
+import { ServiceTester } from '../tester.js'
+
+export const t = new ServiceTester({
+ id: 'wikiapiary',
+ title: 'WikiApiary',
+})
t.create('Extension')
.get('/extension/installs/ParserFunctions.json')
- .expectBadge({ label: 'installs', message: isMetric })
+ .expectBadge({ label: 'wikiapiary', message: 'no longer available' })
t.create('Skins')
.get('/skin/installs/Vector.json')
- .expectBadge({ label: 'installs', message: isMetric })
-
-t.create('Extension Not Found')
- .get('/extension/installs/FakeExtensionThatDoesNotExist.json')
- .expectBadge({ label: 'installs', message: 'not found' })
-
-t.create('Name Lowercase')
- .get('/extension/installs/parserfunctions.json')
- .expectBadge({ label: 'installs', message: 'not found' })
-
-t.create('Name Title Case')
- .get('/extension/installs/parserFunctions.json')
- .expectBadge({ label: 'installs', message: isMetric })
-
-t.create('Malformed API Response')
- .get('/extension/installs/ParserFunctions.json')
- .intercept(nock =>
- nock('https://wikiapiary.com')
- .get('/w/api.php')
- .query({
- action: 'ask',
- query: '[[extension:ParserFunctions]]|?Has_website_count',
- format: 'json',
- })
- .reply(200, {
- query: {
- results: {
- 'Extension:Malformed': { printouts: { 'Has website count': [0] } },
- },
- },
- })
- )
- .expectBadge({ label: 'installs', message: 'not found' })
+ .expectBadge({ label: 'wikiapiary', message: 'no longer available' })
diff --git a/services/winget/version.js b/services/winget/version.js
new file mode 100644
index 0000000000000..9f7c7763463e5
--- /dev/null
+++ b/services/winget/version.js
@@ -0,0 +1,172 @@
+/**
+ * Comparing versions with winget's version comparator.
+ *
+ * See https://github.com/microsoft/winget-cli/blob/ae566c7bf21cfcc75be7ec30e4036a30eede8396/src/AppInstallerSharedLib/Versions.cpp for original implementation.
+ *
+ * @module
+ */
+
+/**
+ * Compares two strings representing version numbers lexicographically and returns an integer value.
+ *
+ * @param {string} v1 - The first version to compare
+ * @param {string} v2 - The second version to compare
+ * @returns {number} -1 if v1 is smaller than v2, 1 if v1 is larger than v2, 0 if v1 and v2 are equal
+ * @example
+ * compareVersion('1.2.3', '1.2.4') // returns -1 because numeric part of first version is smaller than the numeric part of second version.
+ */
+function compareVersion(v1, v2) {
+ // https://github.com/microsoft/winget-cli/blob/ae566c7bf21cfcc75be7ec30e4036a30eede8396/src/AppInstallerSharedLib/Versions.cpp#L109-L173
+ // This implementation does not parse s_Approximate_Greater_Than
+ // and s_Approximate_Less_Than since they won't appear in directory name (package version parsed by shields.io)
+ const v1Trimmed = trimPrefix(v1)
+ const v2Trimmed = trimPrefix(v2)
+
+ const v1Latest = v1Trimmed.trim().toLowerCase() === 'latest'
+ const v2Latest = v2Trimmed.trim().toLowerCase() === 'latest'
+
+ if (v1Latest && v2Latest) {
+ return 0
+ } else if (v1Latest) {
+ return 1
+ } else if (v2Latest) {
+ return -1
+ }
+
+ const v1Unknown = v1Trimmed.trim().toLowerCase() === 'unknown'
+ const v2Unknown = v2Trimmed.trim().toLowerCase() === 'unknown'
+
+ if (v1Unknown && v2Unknown) {
+ return 0
+ } else if (v1Unknown) {
+ return -1
+ } else if (v2Unknown) {
+ return 1
+ }
+
+ const parts1 = v1Trimmed.split('.')
+ const parts2 = v2Trimmed.split('.')
+
+ trimLastZeros(parts1)
+ trimLastZeros(parts2)
+
+ for (let i = 0; i < Math.min(parts1.length, parts2.length); i++) {
+ const part1 = parts1[i]
+ const part2 = parts2[i]
+
+ const compare = compareVersionPart(part1, part2)
+ if (compare !== 0) {
+ return compare
+ }
+ }
+
+ if (parts1.length === parts2.length) {
+ return 0
+ }
+
+ if (parts1.length > parts2.length) {
+ return 1
+ } else if (parts1.length < parts2.length) {
+ return -1
+ }
+
+ return 0
+}
+
+/**
+ * Removes all leading non-digit characters from a version number string
+ * if there is a digit before the split character, or no split characters exist.
+ *
+ * @param {string} version The version number string to trim
+ * @returns {string} The version number string with all leading non-digit characters removed
+ */
+function trimPrefix(version) {
+ // https://github.com/microsoft/winget-cli/blob/ae566c7bf21cfcc75be7ec30e4036a30eede8396/src/AppInstallerSharedLib/Versions.cpp#L66
+ // If there is a digit before the split character, or no split characters exist, trim off all leading non-digit characters
+
+ const digitPos = version.match(/(\d.*)/)
+ const splitPos = version.match(/\./)
+ if (digitPos && (splitPos == null || digitPos.index < splitPos.index)) {
+ // there is digit before the split character so strip off all leading non-digit characters
+ return version.slice(digitPos.index)
+ }
+ return version
+}
+
+/**
+ * Removes all trailing zeros from a version number part array.
+ *
+ * @param {string[]} parts - parts
+ */
+function trimLastZeros(parts) {
+ while (parts.length > 1 && parts[parts.length - 1].trim() === '0') {
+ parts.pop()
+ }
+}
+
+/**
+ * Compares two strings representing version number parts lexicographically and returns an integer value.
+ *
+ * @param {string} part1 - The first version part to compare
+ * @param {string} part2 - The second version part to compare
+ * @returns {number} -1 if part1 is smaller than part2, 1 if part1 is larger than part2, 0 if part1 and part2 are equal
+ * @example
+ * compareVersionPart('3', '4') // returns -1 because numeric part of first part is smaller than the numeric part of second part.
+ */
+function compareVersionPart(part1, part2) {
+ // https://github.com/microsoft/winget-cli/blob/ae566c7bf21cfcc75be7ec30e4036a30eede8396/src/AppInstallerSharedLib/Versions.cpp#L324-L352
+ const [, numericString1, other1] = part1.trim().match(/^(\d*)(.*)$/)
+ const [, numericString2, other2] = part2.trim().match(/^(\d*)(.*)$/)
+ const numeric1 = parseInt(numericString1 || '0', 10)
+ const numeric2 = parseInt(numericString2 || '0', 10)
+
+ if (numeric1 < numeric2) {
+ return -1
+ } else if (numeric1 > numeric2) {
+ return 1
+ }
+ // numeric1 === numeric2
+
+ const otherFolded1 = (other1 ?? '').toLowerCase()
+ const otherFolded2 = (other2 ?? '').toLowerCase()
+
+ if (otherFolded1.length !== 0 && otherFolded2.length === 0) {
+ return -1
+ } else if (otherFolded1.length === 0 && otherFolded2.length !== 0) {
+ return 1
+ }
+
+ if (otherFolded1 < otherFolded2) {
+ return -1
+ } else if (otherFolded1 > otherFolded2) {
+ return 1
+ }
+
+ return 0
+}
+
+/**
+ * Finds the largest version number lexicographically from an array of strings representing version numbers and returns it as a string.
+ *
+ * @param {string[]} versions - The array of version numbers to compare
+ * @returns {string|undefined} The largest version number as a string, or undefined if the array is empty
+ * @example
+ * latest(['1.2.3', '1.2.4', '1.3', '2.0']) // returns '2.0' because it is the largest version number.
+ * latest(['1.2.3', '1.2.4', '1.3-alpha', '2.0-beta']) // returns '2.0-beta'. there is no special handling for pre-release versions.
+ */
+function latest(versions) {
+ const len = versions.length
+ if (len === 0) {
+ return
+ }
+
+ let version = versions[0]
+ for (let i = 1; i < len; i++) {
+ if (compareVersion(version, versions[i]) <= 0) {
+ version = versions[i]
+ }
+ }
+ return version
+}
+
+export { latest, compareVersion }
diff --git a/services/winget/version.spec.js b/services/winget/version.spec.js
new file mode 100644
index 0000000000000..d4449b8767585
--- /dev/null
+++ b/services/winget/version.spec.js
@@ -0,0 +1,57 @@
+import { test, given } from 'sazerac'
+import { compareVersion, latest } from './version.js'
+
+describe('Winget Version helpers', function () {
+ test(compareVersion, () => {
+ // basic compare
+ // https://github.com/microsoft/winget-cli/blob/43425fe97d237e03026fca4530dbc422ab445595/src/AppInstallerCLITests/Versions.cpp#L147
+ given('1', '2').expect(-1)
+ given('1.0.0', '2.0.0').expect(-1)
+ given('0.0.1', '0.0.2').expect(-1)
+ given('0.0.1-alpha', '0.0.2-alpha').expect(-1)
+ given('0.0.1-beta', '0.0.2-alpha').expect(-1)
+ given('0.0.1-beta', '0.0.2-alpha').expect(-1)
+ given('13.9.8', '14.1').expect(-1)
+
+ given('1.0', '1.0.0').expect(0)
+
+ // Ensure whitespace doesn't affect equality
+ given('1.0', '1.0 ').expect(0)
+ given('1.0', '1. 0').expect(0)
+
+ // Ensure versions with preambles are sorted correctly
+ given('1.0', 'Version 1.0').expect(0)
+ given('foo1', 'bar1').expect(0)
+ given('v0.0.1', '0.0.2').expect(-1)
+ given('v0.0.1', 'v0.0.2').expect(-1)
+ given('1.a2', '1.b1').expect(-1)
+ given('alpha', 'beta').expect(-1)
+
+ // latest
+ // https://github.com/microsoft/winget-cli/blob/43425fe97d237e03026fca4530dbc422ab445595/src/AppInstallerCLITests/Versions.cpp#L217
+ given('1.0', 'latest').expect(-1)
+ given('100', 'latest').expect(-1)
+ given('943849587389754876.1', 'latest').expect(-1)
+ given('latest', 'LATEST').expect(0)
+
+ // unknown
+ // https://github.com/microsoft/winget-cli/blob/43425fe97d237e03026fca4530dbc422ab445595/src/AppInstallerCLITests/Versions.cpp#L231
+ given('unknown', '1.0').expect(-1)
+ given('unknown', '1.fork').expect(-1)
+ given('unknown', 'UNKNOWN').expect(0)
+
+ // porting failure tests
+ // https://github.com/badges/shields/pull/10245#discussion_r1817931237
+ // trailing .0 and .0-beta
+ given('1.6.0', '1.6.0-beta.98').expect(-1)
+ })
+
+ test(latest, () => {
+ given(['1.2.3', '1.2.4', '2.0', '1.3.9.1']).expect('2.0')
+ given(['1.2.3', '1.2.4', '2.0-beta', '1.3-alpha']).expect('2.0-beta')
+
+ // compareVersion('3.1.1.0', '3.1.1') == 0, so It's free to choose any of them.
+ // I don't know why but it looks winget registry uses last newest version.
+ given(['3.1.1.0', '3.1.1']).expect('3.1.1')
+ })
+})
diff --git a/services/winget/winget-version.service.js b/services/winget/winget-version.service.js
new file mode 100644
index 0000000000000..15565a76d6059
--- /dev/null
+++ b/services/winget/winget-version.service.js
@@ -0,0 +1,120 @@
+import Joi from 'joi'
+import gql from 'graphql-tag'
+import { renderVersionBadge } from '../version.js'
+import { InvalidParameter, pathParam } from '../index.js'
+import { GithubAuthV4Service } from '../github/github-auth-service.js'
+import { transformErrors } from '../github/github-helpers.js'
+import { latest } from './version.js'
+
+const schema = Joi.object({
+ data: Joi.object({
+ repository: Joi.object({
+ object: Joi.object({
+ entries: Joi.array().items(
+ Joi.object({
+ type: Joi.string().required(),
+ name: Joi.string().required(),
+ object: Joi.object({
+ entries: Joi.array().items(
+ Joi.object({
+ type: Joi.string().required(),
+ name: Joi.string().required(),
+ }),
+ ),
+ }).required(),
+ }),
+ ),
+ })
+ .allow(null)
+ .required(),
+ }).required(),
+ }).required(),
+}).required()
+
+export default class WingetVersion extends GithubAuthV4Service {
+ static category = 'version'
+
+ static route = {
+ base: 'winget/v',
+ pattern: ':name',
+ }
+
+ static openApi = {
+ '/winget/v/{name}': {
+ get: {
+ summary: 'WinGet Package Version',
+ description: 'WinGet Community Repository',
+ parameters: [
+ pathParam({
+ name: 'name',
+ example: 'Microsoft.WSL',
+ }),
+ ],
+ },
+ },
+ }
+
+ static defaultBadgeData = {
+ label: 'winget',
+ }
+
+ async fetch({ name }) {
+ const nameFirstLower = name[0].toLowerCase()
+ const nameSlashed = name.replaceAll('.', '/')
+ const path = `manifests/${nameFirstLower}/${nameSlashed}`
+ const expression = `HEAD:${path}`
+ return this._requestGraphql({
+ query: gql`
+ query RepoFiles($expression: String!) {
+ repository(owner: "microsoft", name: "winget-pkgs") {
+ object(expression: $expression) {
+ ... on Tree {
+ entries {
+ type
+ name
+ object {
+ ... on Tree {
+ entries {
+ type
+ name
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ `,
+ variables: { expression },
+ schema,
+ transformErrors,
+ })
+ }
+
+ async handle({ name }) {
+ const json = await this.fetch({ name })
+ if (json.data.repository.object?.entries == null) {
+ throw new InvalidParameter({
+ prettyMessage: 'package not found',
+ })
+ }
+ const entries = json.data.repository.object.entries
+ const directories = entries.filter(entry => entry.type === 'tree')
+ const versionDirs = directories.filter(dir =>
+ dir.object.entries.some(
+ file => file.type === 'blob' && file.name === `${name}.yaml`,
+ ),
+ )
+ const versions = versionDirs.map(dir => dir.name)
+ const version = latest(versions)
+
+ if (version == null) {
+ throw new InvalidParameter({
+ prettyMessage: 'no versions found',
+ })
+ }
+
+ return renderVersionBadge({ version })
+ }
+}
diff --git a/services/winget/winget-version.tester.js b/services/winget/winget-version.tester.js
new file mode 100644
index 0000000000000..0bc5bf6c6bf1c
--- /dev/null
+++ b/services/winget/winget-version.tester.js
@@ -0,0 +1,343 @@
+import { isVPlusDottedVersionNClauses } from '../test-validators.js'
+import { createServiceTester } from '../tester.js'
+
+export const t = await createServiceTester()
+
+// basic test
+t.create('gets the package version of WSL')
+ .get('/Microsoft.WSL.json')
+ .expectBadge({ label: 'winget', message: isVPlusDottedVersionNClauses })
+
+// test more than one dots
+t.create('gets the package version of .NET 8')
+ .get('/Microsoft.DotNet.SDK.8.json')
+ .expectBadge({ label: 'winget', message: isVPlusDottedVersionNClauses })
+
+// test sort based on dotted version order instead of ASCII
+t.create('gets the latest version')
+ .intercept(nock =>
+ nock('https://api.github.com/')
+ .post('/graphql')
+ .reply(200, {
+ data: {
+ repository: {
+ object: {
+ entries: [
+ {
+ type: 'tree',
+ name: '0.1001.389.0',
+ object: {
+ entries: [
+ {
+ type: 'blob',
+ name: 'Microsoft.DevHome.installer.yaml',
+ },
+ {
+ type: 'blob',
+ name: 'Microsoft.DevHome.locale.en-US.yaml',
+ },
+ {
+ type: 'blob',
+ name: 'Microsoft.DevHome.yaml',
+ },
+ ],
+ },
+ },
+ {
+ type: 'tree',
+ name: '0.1101.416.0',
+ object: {
+ entries: [
+ {
+ type: 'blob',
+ name: 'Microsoft.DevHome.installer.yaml',
+ },
+ {
+ type: 'blob',
+ name: 'Microsoft.DevHome.locale.en-US.yaml',
+ },
+ {
+ type: 'blob',
+ name: 'Microsoft.DevHome.yaml',
+ },
+ ],
+ },
+ },
+ {
+ type: 'tree',
+ name: '0.1201.442.0',
+ object: {
+ entries: [
+ {
+ type: 'blob',
+ name: 'Microsoft.DevHome.installer.yaml',
+ },
+ {
+ type: 'blob',
+ name: 'Microsoft.DevHome.locale.en-US.yaml',
+ },
+ {
+ type: 'blob',
+ name: 'Microsoft.DevHome.yaml',
+ },
+ ],
+ },
+ },
+ {
+ type: 'tree',
+ name: '0.137.141.0',
+ object: {
+ entries: [
+ {
+ type: 'blob',
+ name: 'Microsoft.DevHome.installer.yaml',
+ },
+ {
+ type: 'blob',
+ name: 'Microsoft.DevHome.locale.en-US.yaml',
+ },
+ {
+ type: 'blob',
+ name: 'Microsoft.DevHome.yaml',
+ },
+ ],
+ },
+ },
+ {
+ type: 'tree',
+ name: '0.200.170.0',
+ object: {
+ entries: [
+ {
+ type: 'blob',
+ name: 'Microsoft.DevHome.installer.yaml',
+ },
+ {
+ type: 'blob',
+ name: 'Microsoft.DevHome.locale.en-US.yaml',
+ },
+ {
+ type: 'blob',
+ name: 'Microsoft.DevHome.yaml',
+ },
+ ],
+ },
+ },
+ {
+ type: 'tree',
+ name: '0.503.261.0',
+ object: {
+ entries: [
+ {
+ type: 'blob',
+ name: 'Microsoft.DevHome.installer.yaml',
+ },
+ {
+ type: 'blob',
+ name: 'Microsoft.DevHome.locale.en-US.yaml',
+ },
+ {
+ type: 'blob',
+ name: 'Microsoft.DevHome.yaml',
+ },
+ ],
+ },
+ },
+ {
+ type: 'tree',
+ name: '0.601.285.0',
+ object: {
+ entries: [
+ {
+ type: 'blob',
+ name: 'Microsoft.DevHome.installer.yaml',
+ },
+ {
+ type: 'blob',
+ name: 'Microsoft.DevHome.locale.en-US.yaml',
+ },
+ {
+ type: 'blob',
+ name: 'Microsoft.DevHome.yaml',
+ },
+ ],
+ },
+ },
+ {
+ type: 'tree',
+ name: '0.601.297.0',
+ object: {
+ entries: [
+ {
+ type: 'blob',
+ name: 'Microsoft.DevHome.installer.yaml',
+ },
+ {
+ type: 'blob',
+ name: 'Microsoft.DevHome.locale.en-US.yaml',
+ },
+ {
+ type: 'blob',
+ name: 'Microsoft.DevHome.yaml',
+ },
+ ],
+ },
+ },
+ {
+ type: 'tree',
+ name: '0.701.323.0',
+ object: {
+ entries: [
+ {
+ type: 'blob',
+ name: 'Microsoft.DevHome.installer.yaml',
+ },
+ {
+ type: 'blob',
+ name: 'Microsoft.DevHome.locale.en-US.yaml',
+ },
+ {
+ type: 'blob',
+ name: 'Microsoft.DevHome.yaml',
+ },
+ ],
+ },
+ },
+ {
+ type: 'tree',
+ name: '0.801.344.0',
+ object: {
+ entries: [
+ {
+ type: 'blob',
+ name: 'Microsoft.DevHome.installer.yaml',
+ },
+ {
+ type: 'blob',
+ name: 'Microsoft.DevHome.locale.en-US.yaml',
+ },
+ {
+ type: 'blob',
+ name: 'Microsoft.DevHome.yaml',
+ },
+ ],
+ },
+ },
+ ],
+ },
+ },
+ },
+ }),
+ )
+ .get('/Microsoft.DevHome.json')
+ .expectBadge({ label: 'winget', message: 'v0.1201.442.0' })
+
+// Both 'Some.Package' and 'Some.Package.Sub' are present in the response.
+// We should ignore 'Some.Package.Sub' in response to 'Some.Package' request.
+// In this test case, Canonical.Ubuntu.2404 is present, but it should not be treated as Canonical.Ubuntu version 2404.
+t.create('do not pick sub-package as version')
+ .intercept(nock =>
+ nock('https://api.github.com/')
+ .post('/graphql')
+ .reply(200, {
+ data: {
+ repository: {
+ object: {
+ entries: [
+ {
+ type: 'blob',
+ name: '.validation',
+ object: {},
+ },
+ {
+ type: 'tree',
+ name: '1804',
+ object: {
+ entries: [
+ {
+ type: 'tree',
+ name: '1804.6.4.0',
+ },
+ ],
+ },
+ },
+ {
+ type: 'tree',
+ name: '2004',
+ object: {
+ entries: [
+ {
+ type: 'tree',
+ name: '2004.6.16.0',
+ },
+ ],
+ },
+ },
+ {
+ type: 'tree',
+ name: '2204.1.8.0',
+ object: {
+ entries: [
+ {
+ type: 'blob',
+ name: 'Canonical.Ubuntu.installer.yaml',
+ },
+ {
+ type: 'blob',
+ name: 'Canonical.Ubuntu.locale.en-US.yaml',
+ },
+ {
+ type: 'blob',
+ name: 'Canonical.Ubuntu.locale.zh-CN.yaml',
+ },
+ {
+ type: 'blob',
+ name: 'Canonical.Ubuntu.yaml',
+ },
+ ],
+ },
+ },
+ {
+ type: 'tree',
+ name: '2204',
+ object: {
+ entries: [
+ {
+ type: 'blob',
+ name: '.validation',
+ },
+ {
+ type: 'tree',
+ name: '2204.0.10.0',
+ },
+ {
+ type: 'tree',
+ name: '2204.2.47.0',
+ },
+ ],
+ },
+ },
+ {
+ type: 'tree',
+ name: '2404',
+ object: {
+ entries: [
+ {
+ type: 'blob',
+ name: '.validation',
+ },
+ {
+ type: 'tree',
+ name: '2404.0.5.0',
+ },
+ ],
+ },
+ },
+ ],
+ },
+ },
+ },
+ }),
+ )
+ .get('/Canonical.Ubuntu.json')
+ .expectBadge({ label: 'winget', message: 'v2204.1.8.0' })
diff --git a/services/wordpress/wordpress-base.js b/services/wordpress/wordpress-base.js
index 7f660e72624ad..07d2c45dd9e3d 100644
--- a/services/wordpress/wordpress-base.js
+++ b/services/wordpress/wordpress-base.js
@@ -41,7 +41,7 @@ const notFoundSchema = Joi.object()
const pluginSchemas = Joi.alternatives(pluginSchema, notFoundSchema)
const themeSchemas = Joi.alternatives(themeSchema, notFoundSchema)
-export default class BaseWordpress extends BaseJsonService {
+export class BaseWordpress extends BaseJsonService {
async fetch({ extensionType, slug }) {
const url = `https://api.wordpress.org/${extensionType}s/info/1.2/`
let schemas
@@ -68,14 +68,14 @@ export default class BaseWordpress extends BaseJsonService {
},
},
},
- { encode: false }
+ { encode: false },
)
const json = await this._requestJson({
url,
schema: schemas,
options: {
- qs: queryString,
+ searchParams: queryString,
},
})
if ('error' in json) {
@@ -84,3 +84,10 @@ export default class BaseWordpress extends BaseJsonService {
return json
}
}
+
+export const description = `
+These badges rely on an API that is no longer supported by Wordpress. You are
+still free to use them, simply bear in mind that Shields.io cannot guarantee
+that they'll keep on working in the future. Please also double-check the
+provided slug, as an incorrect value may lead to unexpected results.
+`
diff --git a/services/wordpress/wordpress-downloads.service.js b/services/wordpress/wordpress-downloads.service.js
index 83979488b5fa4..cd01f34575127 100644
--- a/services/wordpress/wordpress-downloads.service.js
+++ b/services/wordpress/wordpress-downloads.service.js
@@ -1,8 +1,7 @@
import Joi from 'joi'
-import { metric } from '../text-formatters.js'
-import { downloadCount } from '../color-formatters.js'
-import { NotFound } from '../index.js'
-import BaseWordpress from './wordpress-base.js'
+import { renderDownloadsBadge } from '../downloads.js'
+import { pathParams } from '../index.js'
+import { description, BaseWordpress } from './wordpress-base.js'
const dateSchema = Joi.object()
.pattern(Joi.date().iso(), Joi.number().integer())
@@ -22,23 +21,22 @@ const extensionData = {
const intervalMap = {
dd: {
limit: 1,
- messageSuffix: '/day',
+ interval: 'day',
},
dw: {
limit: 7,
- messageSuffix: '/week',
+ interval: 'week',
},
dm: {
limit: 30,
- messageSuffix: '/month',
+ interval: 'month',
},
dy: {
limit: 365,
- messageSuffix: '/year',
+ interval: 'year',
},
dt: {
limit: null,
- messageSuffix: '',
},
}
@@ -55,23 +53,37 @@ function DownloadsForExtensionType(extensionType) {
pattern: ':interval(dd|dw|dm|dy|dt)/:slug',
}
- static examples = [
- {
- title: `WordPress ${capt} Downloads`,
- namedParams: { interval: 'dm', slug: exampleSlug },
- staticPreview: this.render({ interval: 'dm', downloads: 200000 }),
- },
- ]
+ static get openApi() {
+ const key = `/wordpress/${extensionType}/{interval}/{slug}`
+ const route = {}
+ route[key] = {
+ get: {
+ summary: `WordPress ${capt} Downloads`,
+ description,
+ parameters: pathParams(
+ {
+ name: 'interval',
+ example: 'dm',
+ schema: { type: 'string', enum: this.getEnum('interval') },
+ description: 'Daily, Weekly, Monthly, Yearly, or Total downloads',
+ },
+ {
+ name: 'slug',
+ example: exampleSlug,
+ },
+ ),
+ },
+ }
+ return route
+ }
static defaultBadgeData = { label: 'downloads' }
static render({ interval, downloads }) {
- const { messageSuffix } = intervalMap[interval]
-
- return {
- message: `${metric(downloads)}${messageSuffix}`,
- color: downloadCount(downloads),
- }
+ return renderDownloadsBadge({
+ downloads,
+ interval: intervalMap[interval].interval,
+ })
}
async handle({ interval, slug }) {
@@ -89,23 +101,15 @@ function DownloadsForExtensionType(extensionType) {
schema: dateSchema,
url: `https://api.wordpress.org/stats/${extType}/1.0/downloads.php`,
options: {
- qs: {
+ searchParams: {
slug,
limit,
},
},
})
- const size = Object.keys(json).length
downloads = Object.values(json).reduce(
- (a, b) => parseInt(a) + parseInt(b)
+ (a, b) => parseInt(a) + parseInt(b),
)
- // This check is for non-existent and brand-new plugins both having new stats.
- // Non-Existent plugins results are the same as a brandspanking new plugin with no downloads.
- if (downloads <= 0 && size <= 1) {
- throw new NotFound({
- prettyMessage: `${extensionType} not found or too new`,
- })
- }
}
return this.constructor.render({ interval, downloads })
@@ -126,29 +130,30 @@ function InstallsForExtensionType(extensionType) {
pattern: ':slug',
}
- static examples = [
- {
- title: `WordPress ${capt} Active Installs`,
- namedParams: { slug: exampleSlug },
- staticPreview: this.render({ installCount: 300000 }),
- },
- ]
-
- static defaultBadgeData = { label: 'active installs' }
-
- static render({ installCount }) {
- return {
- message: metric(installCount),
- color: downloadCount(installCount),
+ static get openApi() {
+ const key = `/wordpress/${extensionType}/installs/{slug}`
+ const route = {}
+ route[key] = {
+ get: {
+ summary: `WordPress ${capt} Active Installs`,
+ description,
+ parameters: pathParams({
+ name: 'slug',
+ example: exampleSlug,
+ }),
+ },
}
+ return route
}
+ static defaultBadgeData = { label: 'active installs' }
+
async handle({ slug }) {
const { active_installs: installCount } = await this.fetch({
extensionType,
slug,
})
- return this.constructor.render({ installCount })
+ return renderDownloadsBadge({ downloads: installCount })
}
}
}
diff --git a/services/wordpress/wordpress-downloads.tester.js b/services/wordpress/wordpress-downloads.tester.js
index 05019f0782733..b82bffc9dc949 100644
--- a/services/wordpress/wordpress-downloads.tester.js
+++ b/services/wordpress/wordpress-downloads.tester.js
@@ -98,34 +98,6 @@ t.create('Plugin Downloads - Active | Not Found')
message: 'not found',
})
-t.create('Plugin Downloads - Day | Not Found')
- .get('/plugin/dd/100.json')
- .expectBadge({
- label: 'downloads',
- message: 'plugin not found or too new',
- })
-
-t.create('Plugin Downloads - Week | Not Found')
- .get('/plugin/dw/100.json')
- .expectBadge({
- label: 'downloads',
- message: 'plugin not found or too new',
- })
-
-t.create('Plugin Downloads - Month | Not Found')
- .get('/plugin/dm/100.json')
- .expectBadge({
- label: 'downloads',
- message: 'plugin not found or too new',
- })
-
-t.create('Plugin Downloads - Year | Not Found')
- .get('/plugin/dy/100.json')
- .expectBadge({
- label: 'downloads',
- message: 'plugin not found or too new',
- })
-
t.create('Theme Downloads - Total | Not Found')
.get('/theme/dt/100.json')
.expectBadge({
@@ -139,31 +111,3 @@ t.create('Theme Downloads - Active | Not Found')
label: 'active installs',
message: 'not found',
})
-
-t.create('Theme Downloads - Day | Not Found')
- .get('/theme/dd/100.json')
- .expectBadge({
- label: 'downloads',
- message: 'theme not found or too new',
- })
-
-t.create('Theme Downloads - Week | Not Found')
- .get('/theme/dw/100.json')
- .expectBadge({
- label: 'downloads',
- message: 'theme not found or too new',
- })
-
-t.create('Theme Downloads - Month | Not Found')
- .get('/theme/dm/100.json')
- .expectBadge({
- label: 'downloads',
- message: 'theme not found or too new',
- })
-
-t.create('Theme Downloads - Year | Not Found')
- .get('/theme/dy/100.json')
- .expectBadge({
- label: 'downloads',
- message: 'theme not found or too new',
- })
diff --git a/services/wordpress/wordpress-last-update.service.js b/services/wordpress/wordpress-last-update.service.js
index 10b48ef92b7e4..9543a3b8d0069 100644
--- a/services/wordpress/wordpress-last-update.service.js
+++ b/services/wordpress/wordpress-last-update.service.js
@@ -1,14 +1,12 @@
-import moment from 'moment'
-import { InvalidResponse } from '../index.js'
-import { formatDate } from '../text-formatters.js'
-import { age as ageColor } from '../color-formatters.js'
-import BaseWordpress from './wordpress-base.js'
+import { pathParams } from '../index.js'
+import { parseDate, renderDateBadge } from '../date.js'
+import { description, BaseWordpress } from './wordpress-base.js'
const extensionData = {
plugin: {
capt: 'Plugin',
exampleSlug: 'bbpress',
- lastUpdateFormat: 'YYYY-MM-DD hh:mma GMT',
+ lastUpdateFormat: 'YYYY-MM-DD h:mma [GMT]',
},
theme: {
capt: 'Theme',
@@ -30,33 +28,23 @@ function LastUpdateForType(extensionType) {
pattern: ':slug',
}
- static examples = [
- {
- title: `WordPress ${capt} Last Updated`,
- namedParams: { slug: exampleSlug },
- staticPreview: this.render({ lastUpdated: '2020-08-11' }),
- },
- ]
-
- static defaultBadgeData = { label: 'last updated' }
-
- static render({ lastUpdated }) {
- return {
- label: 'last updated',
- message: formatDate(lastUpdated),
- color: ageColor(lastUpdated),
+ static get openApi() {
+ const key = `/wordpress/${extensionType}/last-updated/{slug}`
+ const route = {}
+ route[key] = {
+ get: {
+ summary: `WordPress ${capt} Last Updated`,
+ description,
+ parameters: pathParams({
+ name: 'slug',
+ example: exampleSlug,
+ }),
+ },
}
+ return route
}
- transform(lastUpdate) {
- const date = moment(lastUpdate, lastUpdateFormat)
-
- if (date.isValid()) {
- return date.format('YYYY-MM-DD')
- } else {
- throw new InvalidResponse({ prettyMessage: 'invalid date' })
- }
- }
+ static defaultBadgeData = { label: 'last updated' }
async handle({ slug }) {
const { last_updated: lastUpdated } = await this.fetch({
@@ -64,11 +52,9 @@ function LastUpdateForType(extensionType) {
slug,
})
- const newDate = await this.transform(lastUpdated)
+ const date = parseDate(lastUpdated, lastUpdateFormat)
- return this.constructor.render({
- lastUpdated: newDate,
- })
+ return renderDateBadge(date)
}
}
}
diff --git a/services/wordpress/wordpress-platform.service.js b/services/wordpress/wordpress-platform.service.js
index 5c0d1cbd05aa7..d3f859e529cf4 100644
--- a/services/wordpress/wordpress-platform.service.js
+++ b/services/wordpress/wordpress-platform.service.js
@@ -1,7 +1,6 @@
-import { NotFound } from '../index.js'
-import { addv } from '../text-formatters.js'
-import { version as versionColor } from '../color-formatters.js'
-import BaseWordpress from './wordpress-base.js'
+import { NotFound, pathParams } from '../index.js'
+import { renderVersionBadge } from '../version.js'
+import { description, BaseWordpress } from './wordpress-base.js'
import { versionColorForWordpressVersion } from './wordpress-version-color.js'
const extensionData = {
@@ -28,23 +27,24 @@ function WordpressRequiresVersion(extensionType) {
pattern: ':slug',
}
- static examples = [
- {
- title: `WordPress ${capt}: Required WP Version`,
- namedParams: { slug: exampleSlug },
- staticPreview: this.render({ wordpressVersion: '4.8' }),
- },
- ]
-
- static defaultBadgeData = { label: 'wordpress' }
-
- static render({ wordpressVersion }) {
- return {
- message: addv(wordpressVersion),
- color: versionColor(wordpressVersion),
+ static get openApi() {
+ const key = `/wordpress/${extensionType}/wp-version/{slug}`
+ const route = {}
+ route[key] = {
+ get: {
+ summary: `WordPress ${capt}: Required WP Version`,
+ description,
+ parameters: pathParams({
+ name: 'slug',
+ example: exampleSlug,
+ }),
+ },
}
+ return route
}
+ static defaultBadgeData = { label: 'wordpress' }
+
async handle({ slug }) {
const { requires: wordpressVersion } = await this.fetch({
extensionType,
@@ -57,7 +57,7 @@ function WordpressRequiresVersion(extensionType) {
})
}
- return this.constructor.render({ wordpressVersion })
+ return renderVersionBadge({ version: wordpressVersion })
}
}
}
@@ -66,47 +66,37 @@ class WordpressPluginTestedVersion extends BaseWordpress {
static category = 'platform-support'
static route = {
- base: `wordpress/plugin/tested`,
+ base: 'wordpress/plugin/tested',
pattern: ':slug',
}
- static examples = [
- {
- title: 'WordPress Plugin: Tested WP Version',
- namedParams: { slug: 'bbpress' },
- staticPreview: this.renderStaticPreview({
- testedVersion: '4.9.8',
- }),
+ static openApi = {
+ '/wordpress/plugin/tested/{slug}': {
+ get: {
+ summary: 'WordPress Plugin: Tested WP Version',
+ description,
+ parameters: pathParams({
+ name: 'slug',
+ example: 'bbpress',
+ }),
+ },
},
- ]
-
- static defaultBadgeData = { label: 'wordpress' }
-
- static renderStaticPreview({ testedVersion }) {
- // Since this badge has an async `render()` function, but `get examples()` has to
- // be synchronous, this method exists. It should return the same value as the
- // real `render()`.
- return {
- message: `${addv(testedVersion)} tested`,
- color: 'brightgreen',
- }
}
- static async render({ testedVersion }) {
- // Atypically, the `render()` function of this badge is `async` because it needs to pull
- // data from the server.
- return {
- message: `${addv(testedVersion)} tested`,
- color: await versionColorForWordpressVersion(testedVersion),
- }
- }
+ static defaultBadgeData = { label: 'wordpress' }
async handle({ slug }) {
const { tested: testedVersion } = await this.fetch({
extensionType: 'plugin',
slug,
})
- return this.constructor.render({ testedVersion })
+ // Atypically, pulling color data from the server with async operation.
+ const color = await versionColorForWordpressVersion(testedVersion)
+ return renderVersionBadge({
+ version: testedVersion,
+ suffix: 'tested',
+ versionFormatter: () => color,
+ })
}
}
@@ -123,24 +113,24 @@ function RequiresPHPVersionForType(extensionType) {
pattern: ':slug',
}
- static examples = [
- {
- title: `WordPress ${capt} Required PHP Version`,
- namedParams: { slug: exampleSlug },
- staticPreview: this.render({ version: '5.5' }),
- },
- ]
-
- static defaultBadgeData = { label: 'php' }
-
- static render({ version }) {
- return {
- label: 'php',
- message: `>=${version}`,
- color: versionColor(version),
+ static get openApi() {
+ const key = `/wordpress/${extensionType}/required-php/{slug}`
+ const route = {}
+ route[key] = {
+ get: {
+ summary: `WordPress ${capt} Required PHP Version`,
+ description,
+ parameters: pathParams({
+ name: 'slug',
+ example: exampleSlug,
+ }),
+ },
}
+ return route
}
+ static defaultBadgeData = { label: 'php' }
+
async handle({ slug }) {
const { requires_php: requiresPhp } = await this.fetch({
extensionType,
@@ -153,9 +143,7 @@ function RequiresPHPVersionForType(extensionType) {
})
}
- return this.constructor.render({
- version: requiresPhp,
- })
+ return renderVersionBadge({ version: requiresPhp, prefix: '>=' })
}
}
}
diff --git a/services/wordpress/wordpress-platform.tester.js b/services/wordpress/wordpress-platform.tester.js
index ded6f1b6b094b..118f72e736dbd 100644
--- a/services/wordpress/wordpress-platform.tester.js
+++ b/services/wordpress/wordpress-platform.tester.js
@@ -73,7 +73,7 @@ t.create('Plugin Tested WP Version - current')
requires_php: '5.5',
})
.get('/core/version-check/1.7/')
- .reply(200, mockedCoreResponseData)
+ .reply(200, mockedCoreResponseData),
)
.expectBadge({
label: 'wordpress',
@@ -99,7 +99,7 @@ t.create('Plugin Tested WP Version - old')
requires_php: '5.5',
})
.get('/core/version-check/1.7/')
- .reply(200, mockedCoreResponseData)
+ .reply(200, mockedCoreResponseData),
)
.expectBadge({
label: 'wordpress',
@@ -125,7 +125,7 @@ t.create('Plugin Tested WP Version - non-exsistant or unsupported')
requires_php: '5.5',
})
.get('/core/version-check/1.7/')
- .reply(200, mockedCoreResponseData)
+ .reply(200, mockedCoreResponseData),
)
.expectBadge({
label: 'wordpress',
@@ -149,7 +149,7 @@ t.create('Plugin Required WP Version | Missing')
tested: '4.0.0',
last_updated: '2020-01-01 7:21am GMT',
requires_php: '5.5',
- })
+ }),
)
.expectBadge({
label: 'wordpress',
@@ -190,6 +190,22 @@ t.create('Plugin Required PHP Version')
t.create('Plugin Required PHP Version (Not Set)')
.get('/plugin/required-php/akismet.json')
+ .intercept(nock =>
+ nock('https://api.wordpress.org')
+ .get('/plugins/info/1.2/')
+ .query(mockedQuerySelector)
+ .reply(200, {
+ version: '1.2',
+ rating: 80,
+ num_ratings: 100,
+ downloaded: 100,
+ active_installs: 100,
+ requires: false,
+ tested: '4.0.0',
+ last_updated: '2020-01-01 7:21am GMT',
+ requires_php: false,
+ }),
+ )
.expectBadge({
label: 'php',
message: 'not set for this plugin',
@@ -224,7 +240,7 @@ t.create('Theme Required PHP Version (Not Set)')
tested: '4.0.0',
requires_php: false,
last_updated: '2020-01-01',
- })
+ }),
)
.expectBadge({
label: 'php',
diff --git a/services/wordpress/wordpress-rating.service.js b/services/wordpress/wordpress-rating.service.js
index 6489fde9dae90..9c43c00c76351 100644
--- a/services/wordpress/wordpress-rating.service.js
+++ b/services/wordpress/wordpress-rating.service.js
@@ -1,6 +1,7 @@
-import { starRating, metric } from '../text-formatters.js'
import { floorCount } from '../color-formatters.js'
-import BaseWordpress from './wordpress-base.js'
+import { pathParams } from '../index.js'
+import { starRating, metric } from '../text-formatters.js'
+import { description, BaseWordpress } from './wordpress-base.js'
const extensionData = {
plugin: {
@@ -30,16 +31,21 @@ function RatingForExtensionType(extensionType) {
pattern: ':slug',
}
- static examples = [
- {
- title: `WordPress ${capt} Rating`,
- namedParams: { slug: exampleSlug },
- staticPreview: this.render({
- rating: 80,
- numRatings: 100,
- }),
- },
- ]
+ static get openApi() {
+ const key = `/wordpress/${extensionType}/rating/{slug}`
+ const route = {}
+ route[key] = {
+ get: {
+ summary: `WordPress ${capt} Rating`,
+ description,
+ parameters: pathParams({
+ name: 'slug',
+ example: exampleSlug,
+ }),
+ },
+ }
+ return route
+ }
static render({ rating, numRatings }) {
const scaledAndRounded = ((rating / 100) * 5).toFixed(1)
@@ -70,17 +76,21 @@ function StarsForExtensionType(extensionType) {
pattern: '(stars|r)/:slug',
}
- static examples = [
- {
- title: `WordPress ${capt} Rating`,
- pattern: 'stars/:slug',
- namedParams: { slug: exampleSlug },
- staticPreview: this.render({
- rating: 80,
- }),
- documentation: 'There is an alias /r/:slug.svg as well.',
- },
- ]
+ static get openApi() {
+ const key = `/wordpress/${extensionType}/stars/{slug}`
+ const route = {}
+ route[key] = {
+ get: {
+ summary: `WordPress ${capt} Stars`,
+ description,
+ parameters: pathParams({
+ name: 'slug',
+ example: exampleSlug,
+ }),
+ },
+ }
+ return route
+ }
static render({ rating }) {
const scaled = (rating / 100) * 5
diff --git a/services/wordpress/wordpress-version-color.integration.js b/services/wordpress/wordpress-version-color.integration.js
new file mode 100644
index 0000000000000..2bbeef5a349bc
--- /dev/null
+++ b/services/wordpress/wordpress-version-color.integration.js
@@ -0,0 +1,23 @@
+import { expect } from 'chai'
+import { versionColorForWordpressVersion } from './wordpress-version-color.js'
+
+describe('versionColorForWordpressVersion()', function () {
+ it('generates correct colours for given versions', async function () {
+ this.timeout(5e3)
+
+ expect(await versionColorForWordpressVersion('11.2.0')).to.equal(
+ 'brightgreen',
+ )
+ expect(await versionColorForWordpressVersion('11.2')).to.equal(
+ 'brightgreen',
+ )
+ expect(await versionColorForWordpressVersion('3.2.0')).to.equal('yellow')
+ expect(await versionColorForWordpressVersion('3.2')).to.equal('yellow')
+ expect(await versionColorForWordpressVersion('4.7-beta.3')).to.equal(
+ 'yellow',
+ )
+ expect(await versionColorForWordpressVersion('cheese')).to.equal(
+ 'lightgrey',
+ )
+ })
+})
diff --git a/services/wordpress/wordpress-version-color.js b/services/wordpress/wordpress-version-color.js
index 4e15ca3c32653..1e01ded09a779 100644
--- a/services/wordpress/wordpress-version-color.js
+++ b/services/wordpress/wordpress-version-color.js
@@ -1,6 +1,5 @@
-import { promisify } from 'util'
import semver from 'semver'
-import { regularUpdate } from '../../core/legacy/regular-update.js'
+import { getCachedResource } from '../../core/base-service/resource-cache.js'
// TODO: Incorporate this schema.
// const schema = Joi.object()
@@ -19,11 +18,9 @@ import { regularUpdate } from '../../core/legacy/regular-update.js'
// })
// .required()
-function getOfferedVersions() {
- return promisify(regularUpdate)({
+async function getOfferedVersions() {
+ return getCachedResource({
url: 'https://api.wordpress.org/core/version-check/1.7/',
- intervalMillis: 24 * 3600 * 1000,
- json: true,
scraper: json => json.offers.map(v => v.version),
})
}
diff --git a/services/wordpress/wordpress-version-color.spec.js b/services/wordpress/wordpress-version-color.spec.js
index c428886c55347..60ba668b4d8b1 100644
--- a/services/wordpress/wordpress-version-color.spec.js
+++ b/services/wordpress/wordpress-version-color.spec.js
@@ -1,8 +1,5 @@
import { expect } from 'chai'
-import {
- toSemver,
- versionColorForWordpressVersion,
-} from './wordpress-version-color.js'
+import { toSemver } from './wordpress-version-color.js'
describe('toSemver() function', function () {
it('coerces versions', function () {
@@ -13,24 +10,3 @@ describe('toSemver() function', function () {
expect(toSemver('foobar')).to.equal('foobar')
})
})
-
-describe('versionColorForWordpressVersion()', function () {
- it('generates correct colours for given versions', async function () {
- this.timeout(5e3)
-
- expect(await versionColorForWordpressVersion('11.2.0')).to.equal(
- 'brightgreen'
- )
- expect(await versionColorForWordpressVersion('11.2')).to.equal(
- 'brightgreen'
- )
- expect(await versionColorForWordpressVersion('3.2.0')).to.equal('yellow')
- expect(await versionColorForWordpressVersion('3.2')).to.equal('yellow')
- expect(await versionColorForWordpressVersion('4.7-beta.3')).to.equal(
- 'yellow'
- )
- expect(await versionColorForWordpressVersion('cheese')).to.equal(
- 'lightgrey'
- )
- })
-})
diff --git a/services/wordpress/wordpress-version.service.js b/services/wordpress/wordpress-version.service.js
index 9f5d62741391e..3fb9273bbfa58 100644
--- a/services/wordpress/wordpress-version.service.js
+++ b/services/wordpress/wordpress-version.service.js
@@ -1,6 +1,6 @@
-import { addv } from '../text-formatters.js'
-import { version as versionColor } from '../color-formatters.js'
-import BaseWordpress from './wordpress-base.js'
+import { pathParams } from '../index.js'
+import { renderVersionBadge } from '../version.js'
+import { description, BaseWordpress } from './wordpress-base.js'
function VersionForExtensionType(extensionType) {
const { capt, exampleSlug } = {
@@ -24,29 +24,30 @@ function VersionForExtensionType(extensionType) {
pattern: ':slug',
}
- static examples = [
- {
- title: `WordPress ${capt} Version`,
- namedParams: { slug: exampleSlug },
- staticPreview: this.render({ version: 2.5 }),
- },
- ]
-
- static defaultBadgeData = { label: extensionType }
-
- static render({ version }) {
- return {
- message: addv(version),
- color: versionColor(version),
+ static get openApi() {
+ const key = `/wordpress/${extensionType}/v/{slug}`
+ const route = {}
+ route[key] = {
+ get: {
+ summary: `WordPress ${capt} Version`,
+ description,
+ parameters: pathParams({
+ name: 'slug',
+ example: exampleSlug,
+ }),
+ },
}
+ return route
}
+ static defaultBadgeData = { label: extensionType }
+
async handle({ slug }) {
const { version } = await this.fetch({
extensionType,
slug,
})
- return this.constructor.render({ version })
+ return renderVersionBadge({ version })
}
}
}
diff --git a/services/youtube/youtube-base.js b/services/youtube/youtube-base.js
index 94f14ee6018d2..efb7ea5680190 100644
--- a/services/youtube/youtube-base.js
+++ b/services/youtube/youtube-base.js
@@ -3,9 +3,21 @@ import { BaseJsonService, NotFound } from '../index.js'
import { metric } from '../text-formatters.js'
import { nonNegativeInteger } from '../validators.js'
-const documentation = `
-By using the YouTube badges provided by Shields.io, you are agreeing to be bound by the YouTube Terms of Service. These can be found here:
-https://www.youtube.com/t/terms
`
+const description = `
+Shields.io is committed to protecting the privacy of its users. We do not collect, store, or track any personal
+information or data returned by the YouTube API Services. Our service is designed to generate badges based on
+public YouTube data, and we do not retain any user-specific information:
+* Data Collection: we do not collect, store, or process any personal data from users. The information
+retrieved from the YouTube API is used solely to generate badges in real-time and is not stored or saved by us.
+* Cookies and Tracking: Shields.io does not use cookies or any other tracking technologies to collect or store user data.
+* Data Sharing: no information retrieved via the YouTube API is shared with third parties or used beyond generating the
+requested badges.
+* Contact Information: if you have any questions or concerns about our data practices, please contact us at team at shields.io.
+
+By using the YouTube badge, you are:
+* agreeing to be bound by the YouTube Terms of Service, which can be found here: [https://www.youtube.com/t/terms](https://www.youtube.com/t/terms)
+* acknowledging and accepting the Google Privacy Policy, which can be found here: [https://policies.google.com/privacy](https://policies.google.com/privacy)
+`
const schema = Joi.object({
pageInfo: Joi.object({
@@ -18,15 +30,14 @@ const schema = Joi.object({
Joi.object({
viewCount: nonNegativeInteger,
likeCount: nonNegativeInteger,
- dislikeCount: nonNegativeInteger,
commentCount: nonNegativeInteger,
}),
Joi.object({
viewCount: nonNegativeInteger,
subscriberCount: nonNegativeInteger,
- })
+ }),
),
- })
+ }),
),
}).required()
@@ -62,14 +73,17 @@ class YouTubeBase extends BaseJsonService {
schema,
url: `https://www.googleapis.com/youtube/v3/${this.constructor.type}s`,
options: {
- qs: { id, part: 'statistics' },
+ searchParams: { id, part: 'statistics' },
},
- }
- )
+ httpErrors: {
+ 400: `${this.constructor.type} not found`,
+ },
+ },
+ ),
)
}
- async handle({ channelId, videoId }, queryParams) {
+ async handle({ channelId, videoId }) {
const id = channelId || videoId
const json = await this.fetch({ id })
if (json.pageInfo.totalResults === 0) {
@@ -78,7 +92,7 @@ class YouTubeBase extends BaseJsonService {
})
}
const statistics = json.items[0].statistics
- return this.constructor.render({ statistics, id }, queryParams)
+ return this.constructor.render({ statistics, id })
}
}
@@ -90,4 +104,4 @@ class YouTubeChannelBase extends YouTubeBase {
static type = 'channel'
}
-export { documentation, YouTubeVideoBase, YouTubeChannelBase }
+export { description, YouTubeVideoBase, YouTubeChannelBase }
diff --git a/services/youtube/youtube-channel-views.service.js b/services/youtube/youtube-channel-views.service.js
index d3f854a920a6f..ae1681129492d 100644
--- a/services/youtube/youtube-channel-views.service.js
+++ b/services/youtube/youtube-channel-views.service.js
@@ -1,4 +1,5 @@
-import { documentation, YouTubeChannelBase } from './youtube-base.js'
+import { pathParams } from '../index.js'
+import { description, YouTubeChannelBase } from './youtube-base.js'
export default class YouTubeChannelViews extends YouTubeChannelBase {
static route = {
@@ -6,21 +7,17 @@ export default class YouTubeChannelViews extends YouTubeChannelBase {
pattern: ':channelId',
}
- static get examples() {
- const preview = this.render({
- statistics: { viewCount: 30543 },
- id: 'UC8butISFwT-Wl7EV0hUK0BQ',
- })
- // link[] is not allowed in examples
- delete preview.link
- return [
- {
- title: 'YouTube Channel Views',
- namedParams: { channelId: 'UC8butISFwT-Wl7EV0hUK0BQ' },
- staticPreview: preview,
- documentation,
+ static openApi = {
+ '/youtube/channel/views/{channelId}': {
+ get: {
+ summary: 'YouTube Channel Views',
+ description,
+ parameters: pathParams({
+ name: 'channelId',
+ example: 'UC8butISFwT-Wl7EV0hUK0BQ',
+ }),
},
- ]
+ },
}
static render({ statistics, id }) {
diff --git a/services/youtube/youtube-channel-views.tester.js b/services/youtube/youtube-channel-views.tester.js
index 47501da678108..02d316ecd8273 100644
--- a/services/youtube/youtube-channel-views.tester.js
+++ b/services/youtube/youtube-channel-views.tester.js
@@ -21,5 +21,4 @@ t.create('channel not found')
.expectBadge({
label: 'youtube',
message: 'channel not found',
- color: 'red',
})
diff --git a/services/youtube/youtube-comments.service.js b/services/youtube/youtube-comments.service.js
index 0f7faf3c7806b..dc003c472a8fd 100644
--- a/services/youtube/youtube-comments.service.js
+++ b/services/youtube/youtube-comments.service.js
@@ -1,4 +1,5 @@
-import { documentation, YouTubeVideoBase } from './youtube-base.js'
+import { pathParams } from '../index.js'
+import { description, YouTubeVideoBase } from './youtube-base.js'
export default class YouTubeComments extends YouTubeVideoBase {
static route = {
@@ -6,21 +7,17 @@ export default class YouTubeComments extends YouTubeVideoBase {
pattern: ':videoId',
}
- static get examples() {
- const preview = this.render({
- statistics: { commentCount: 209 },
- id: 'wGJHwc5ksMA',
- })
- // link[] is not allowed in examples
- delete preview.link
- return [
- {
- title: 'YouTube Video Comments',
- namedParams: { videoId: 'wGJHwc5ksMA' },
- staticPreview: preview,
- documentation,
+ static openApi = {
+ '/youtube/comments/{videoId}': {
+ get: {
+ summary: 'YouTube Video Comments',
+ description,
+ parameters: pathParams({
+ name: 'videoId',
+ example: 'wGJHwc5ksMA',
+ }),
},
- ]
+ },
}
static render({ statistics, id }) {
diff --git a/services/youtube/youtube-likes.service.js b/services/youtube/youtube-likes.service.js
index 94e2d7ae66264..017dfa2af0aaa 100644
--- a/services/youtube/youtube-likes.service.js
+++ b/services/youtube/youtube-likes.service.js
@@ -1,74 +1,30 @@
-import Joi from 'joi'
-import { metric } from '../text-formatters.js'
-import { documentation, YouTubeVideoBase } from './youtube-base.js'
-
-const documentationWithDislikes = `
- ${documentation}
-
- When enabling the withDislikes option, 👍 corresponds to the number
- of likes of a given video, 👎 corresponds to the number of dislikes.
-
-`
-
-const queryParamSchema = Joi.object({
- withDislikes: Joi.equal(''),
-}).required()
+import { pathParams } from '../index.js'
+import { description, YouTubeVideoBase } from './youtube-base.js'
export default class YouTubeLikes extends YouTubeVideoBase {
static route = {
base: 'youtube/likes',
pattern: ':videoId',
- queryParamSchema,
}
- static get examples() {
- const previewLikes = this.render({
- statistics: { likeCount: 7 },
- id: 'abBdk8bSPKU',
- })
- const previewVotes = this.render(
- {
- statistics: { likeCount: 10236, dislikeCount: 396 },
- id: 'pU9Q6oiQNd0',
- },
- { withDislikes: '' }
- )
- // link[] is not allowed in examples
- delete previewLikes.link
- delete previewVotes.link
- return [
- {
- title: 'YouTube Video Likes',
- namedParams: { videoId: 'abBdk8bSPKU' },
- staticPreview: previewLikes,
- documentation,
- },
- {
- title: 'YouTube Video Likes and Dislikes',
- namedParams: { videoId: 'pU9Q6oiQNd0' },
- staticPreview: previewVotes,
- queryParams: {
- withDislikes: null,
- },
- documentation: documentationWithDislikes,
+ static openApi = {
+ '/youtube/likes/{videoId}': {
+ get: {
+ summary: 'YouTube Video Likes',
+ description,
+ parameters: pathParams({
+ name: 'videoId',
+ example: 'abBdk8bSPKU',
+ }),
},
- ]
+ },
}
- static render({ statistics, id }, queryParams) {
- let renderedBadge = super.renderSingleStat({
+ static render({ statistics, id }) {
+ return super.renderSingleStat({
statistics,
statisticName: 'like',
id,
})
- if (queryParams && typeof queryParams.withDislikes !== 'undefined') {
- renderedBadge = {
- ...renderedBadge,
- message: `${metric(statistics.likeCount)} 👍 ${metric(
- statistics.dislikeCount
- )} 👎`,
- }
- }
- return renderedBadge
}
}
diff --git a/services/youtube/youtube-likes.tester.js b/services/youtube/youtube-likes.tester.js
index 8f6f8dc8fcac6..366690aa03e16 100644
--- a/services/youtube/youtube-likes.tester.js
+++ b/services/youtube/youtube-likes.tester.js
@@ -1,4 +1,3 @@
-import Joi from 'joi'
import { createServiceTester } from '../tester.js'
import { noToken } from '../test-helpers.js'
import { isMetric } from '../test-validators.js'
@@ -16,21 +15,9 @@ t.create('video like count')
link: ['https://www.youtube.com/video/pU9Q6oiQNd0'],
})
-t.create('video vote count')
- .skipWhen(noYouTubeToken)
- .get('/pU9Q6oiQNd0.json?withDislikes')
- .expectBadge({
- label: 'likes',
- message: Joi.string().regex(
- /^([1-9][0-9]*[kMGTPEZY]?|[1-9]\.[1-9][kMGTPEZY]) 👍 ([1-9][0-9]*[kMGTPEZY]?|[1-9]\.[1-9][kMGTPEZY]) 👎$/
- ),
- color: 'red',
- link: ['https://www.youtube.com/video/pU9Q6oiQNd0'],
- })
-
t.create('video not found')
.skipWhen(noYouTubeToken)
- .get('/doesnotexist.json?withDislikes')
+ .get('/doesnotexist.json')
.expectBadge({
label: 'youtube',
message: 'video not found',
diff --git a/services/youtube/youtube-subscribers.service.js b/services/youtube/youtube-subscribers.service.js
index 0c0d1b69aaf70..ffc6f319404c4 100644
--- a/services/youtube/youtube-subscribers.service.js
+++ b/services/youtube/youtube-subscribers.service.js
@@ -1,4 +1,5 @@
-import { documentation, YouTubeChannelBase } from './youtube-base.js'
+import { pathParams } from '../index.js'
+import { description, YouTubeChannelBase } from './youtube-base.js'
export default class YouTubeSubscribes extends YouTubeChannelBase {
static route = {
@@ -6,21 +7,17 @@ export default class YouTubeSubscribes extends YouTubeChannelBase {
pattern: ':channelId',
}
- static get examples() {
- const preview = this.render({
- statistics: { subscriberCount: 14577 },
- id: 'UC8butISFwT-Wl7EV0hUK0BQ',
- })
- // link[] is not allowed in examples
- delete preview.link
- return [
- {
- title: 'YouTube Channel Subscribers',
- namedParams: { channelId: 'UC8butISFwT-Wl7EV0hUK0BQ' },
- staticPreview: preview,
- documentation,
+ static openApi = {
+ '/youtube/channel/subscribers/{channelId}': {
+ get: {
+ summary: 'YouTube Channel Subscribers',
+ description,
+ parameters: pathParams({
+ name: 'channelId',
+ example: 'UC8butISFwT-Wl7EV0hUK0BQ',
+ }),
},
- ]
+ },
}
static render({ statistics, id }) {
diff --git a/services/youtube/youtube-subscribers.tester.js b/services/youtube/youtube-subscribers.tester.js
index d1d1c5175996b..2a35d9daefb34 100644
--- a/services/youtube/youtube-subscribers.tester.js
+++ b/services/youtube/youtube-subscribers.tester.js
@@ -21,5 +21,4 @@ t.create('channel not found')
.expectBadge({
label: 'youtube',
message: 'channel not found',
- color: 'red',
})
diff --git a/services/youtube/youtube-views.service.js b/services/youtube/youtube-views.service.js
index 32c2d6afa92ec..a4728ca90e2aa 100644
--- a/services/youtube/youtube-views.service.js
+++ b/services/youtube/youtube-views.service.js
@@ -1,4 +1,5 @@
-import { documentation, YouTubeVideoBase } from './youtube-base.js'
+import { pathParams } from '../index.js'
+import { description, YouTubeVideoBase } from './youtube-base.js'
export default class YouTubeViews extends YouTubeVideoBase {
static route = {
@@ -6,21 +7,17 @@ export default class YouTubeViews extends YouTubeVideoBase {
pattern: ':videoId',
}
- static get examples() {
- const preview = this.render({
- statistics: { viewCount: 14577 },
- id: 'abBdk8bSPKU',
- })
- // link[] is not allowed in examples
- delete preview.link
- return [
- {
- title: 'YouTube Video Views',
- namedParams: { videoId: 'abBdk8bSPKU' },
- staticPreview: preview,
- documentation,
+ static openApi = {
+ '/youtube/views/{videoId}': {
+ get: {
+ summary: 'YouTube Video Views',
+ description,
+ parameters: pathParams({
+ name: 'videoId',
+ example: 'abBdk8bSPKU',
+ }),
},
- ]
+ },
}
static render({ statistics, id }) {
diff --git a/spec/users.md b/spec/users.md
deleted file mode 100644
index b7d5391cfb16e..0000000000000
--- a/spec/users.md
+++ /dev/null
@@ -1,21 +0,0 @@
-# Services using the Shields standard
-
-- [Badger](https://github.com/badges/badgerbadgerbadger)
-- [badges2svg](https://github.com/bfontaine/badges2svg)
-- [CII Best Practices](https://bestpractices.coreinfrastructure.org/)
-- [Codacy](https://www.codacy.com)
-- [Code Climate](https://codeclimate.com/changelog/510d4fde56b102523a0004bf)
-- [Coveralls](https://coveralls.io/)
-- [docs.rs](https://docs.rs/)
-- [Forkability](http://basicallydan.github.io/forkability/)
-- [Gemnasium](http://support.gemnasium.com/forums/236528-general/suggestions/5518400-use-svg-for-badges-so-they-still-look-sharp-on-r)
-- [GoDoc](https://godoc.org/)
-- [PHPPackages](https://phppackages.org)
-- [Read the Docs](https://readthedocs.org/)
-- [reposs](https://github.com/rexfinn/reposs)
-- [ruby-gem-downloads-badge](https://github.com/bogdanRada/ruby-gem-downloads-badge/)
-- [Scrutinizer](https://scrutinizer-ci.com/)
-- [Semaphore](https://semaphoreci.com)
-- [Travis CI](https://github.com/travis-ci/travis-ci/issues/630#issuecomment-38054967)
-- [Version Badge](http://badge.fury.io/)
-- [VersionEye](https://www.versioneye.com/)
diff --git a/static/images/heroku-logotype-horizontal-purple.svg b/static/images/heroku-logotype-horizontal-purple.svg
deleted file mode 100644
index 3bdfea97ee457..0000000000000
--- a/static/images/heroku-logotype-horizontal-purple.svg
+++ /dev/null
@@ -1,3 +0,0 @@
-
-
-
diff --git a/static/images/nodeping.svg b/static/images/nodeping.svg
deleted file mode 100644
index 47a68ffa7feac..0000000000000
--- a/static/images/nodeping.svg
+++ /dev/null
@@ -1 +0,0 @@
-
\ No newline at end of file
diff --git a/static/images/sentry-logo-black.svg b/static/images/sentry-logo-black.svg
deleted file mode 100644
index 578d18f3a646a..0000000000000
--- a/static/images/sentry-logo-black.svg
+++ /dev/null
@@ -1 +0,0 @@
-
\ No newline at end of file
diff --git a/tsconfig.json b/tsconfig.json
deleted file mode 100644
index 1063d8f664a2e..0000000000000
--- a/tsconfig.json
+++ /dev/null
@@ -1,15 +0,0 @@
-{
- "include": ["frontend/**/*"],
- "exclude": [],
- "compilerOptions": {
- "target": "esnext",
- "module": "commonjs",
- "declaration": true,
- "outDir": "unused_ts_output",
- "strict": true,
- "allowSyntheticDefaultImports": true,
- "esModuleInterop": true,
- "jsx": "react",
- "typeRoots": ["node_modules/@types", "frontend/types"]
- }
-}
diff --git a/vendor/http-deceiver/README.md b/vendor/http-deceiver/README.md
new file mode 100644
index 0000000000000..e26298ad9c900
--- /dev/null
+++ b/vendor/http-deceiver/README.md
@@ -0,0 +1,17 @@
+# vendor/http-deceiver
+
+This is a workaround to bypass the blocker for https://github.com/badges/shields/issues/11071.
+
+Related PR: https://github.com/badges/shields/pull/11279.
+
+## LICENSE
+
+This software is licensed under the MIT License.
+
+Copyright Fedor Indutny, 2015.
+
+Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
diff --git a/vendor/http-deceiver/index.js b/vendor/http-deceiver/index.js
new file mode 100644
index 0000000000000..fbb9dd8107d31
--- /dev/null
+++ b/vendor/http-deceiver/index.js
@@ -0,0 +1,288 @@
+/* eslint-disable */
+/**
+ * The related issue:
+ * https://github.com/badges/shields/issues/11071
+ *
+ * The code below is a modified version of the original http-deceiver library.
+ * It has been adapted to work with Node.js versions that have deprecated
+ * the original implementation.
+ *
+ * The original library can be found at:
+ * https://github.com/spdy-http2/http-deceiver (MIT License)
+ *
+ * This version is specifically tailored to handle the deprecation of
+ * the `http_parser` module in Node.js v12 and later.
+ * https://github.com/beenotung/http-deceiver/blob/node-v12-deprecation-fix/lib/deceiver.js (MIT License)
+ *
+ * It maintains compatibility with older Node.js versions while providing
+ * a consistent interface for HTTP request and response parsing.
+ */
+var assert = require('assert')
+
+var Buffer = require('buffer').Buffer
+
+// Node.js version
+var mode = /^v0\.8\./.test(process.version)
+ ? 'rusty'
+ : /^v0\.(9|10)\./.test(process.version)
+ ? 'old'
+ : /^v0\.12\./.test(process.version)
+ ? 'normal'
+ : 'modern'
+
+var HTTPParser
+
+var methods
+var reverseMethods
+
+// var kOnHeaders
+var kOnHeadersComplete
+var kOnMessageComplete
+var kOnBody
+if (mode === 'normal' || mode === 'modern') {
+ HTTPParser = require('_http_common').HTTPParser
+ methods = require('_http_common').methods
+
+ // <= v11
+ if (!HTTPParser) {
+ HTTPParser = process.binding('http_parser').HTTPParser
+ methods = process.binding('http_parser').methods
+ }
+
+ // <= v5
+ if (!methods) {
+ methods = HTTPParser.methods
+ }
+
+ reverseMethods = {}
+
+ methods.forEach(function (method, index) {
+ reverseMethods[method] = index
+ })
+
+ // kOnHeaders = HTTPParser.kOnHeaders | 0
+ kOnHeadersComplete = HTTPParser.kOnHeadersComplete | 0
+ kOnMessageComplete = HTTPParser.kOnMessageComplete | 0
+ kOnBody = HTTPParser.kOnBody | 0
+} else {
+ // kOnHeaders = 'onHeaders'
+ kOnHeadersComplete = 'onHeadersComplete'
+ kOnMessageComplete = 'onMessageComplete'
+ kOnBody = 'onBody'
+}
+
+function Deceiver(socket, options) {
+ this.socket = socket
+ this.options = options || {}
+ this.isClient = this.options.isClient
+}
+module.exports = Deceiver
+
+Deceiver.create = function create(stream, options) {
+ return new Deceiver(stream, options)
+}
+
+Deceiver.prototype._toHeaderList = function _toHeaderList(object) {
+ var out = []
+ var keys = Object.keys(object)
+
+ for (var i = 0; i < keys.length; i++) {
+ out.push(keys[i], object[keys[i]])
+ }
+
+ return out
+}
+
+Deceiver.prototype._isUpgrade = function _isUpgrade(request) {
+ return (
+ request.method === 'CONNECT' ||
+ request.headers.upgrade ||
+ (request.headers.connection &&
+ /(^|\W)upgrade(\W|$)/i.test(request.headers.connection))
+ )
+}
+
+// TODO(indutny): support CONNECT
+if (mode === 'modern') {
+ /*
+ function parserOnHeadersComplete(versionMajor, versionMinor, headers, method,
+ url, statusCode, statusMessage, upgrade,
+ shouldKeepAlive) {
+ */
+ Deceiver.prototype.emitRequest = function emitRequest(request) {
+ var parser = this.socket.parser
+ assert(parser, 'No parser present')
+
+ parser.execute = null
+
+ var self = this
+ var method = reverseMethods[request.method]
+ parser.execute = function execute() {
+ self._skipExecute(this)
+ this[kOnHeadersComplete](
+ 1,
+ 1,
+ self._toHeaderList(request.headers),
+ method,
+ request.path,
+ 0,
+ '',
+ self._isUpgrade(request),
+ true,
+ )
+ return 0
+ }
+
+ this._emitEmpty()
+ }
+
+ Deceiver.prototype.emitResponse = function emitResponse(response) {
+ var parser = this.socket.parser
+ assert(parser, 'No parser present')
+
+ parser.execute = null
+
+ var self = this
+ parser.execute = function execute() {
+ self._skipExecute(this)
+ this[kOnHeadersComplete](
+ 1,
+ 1,
+ self._toHeaderList(response.headers),
+ response.path,
+ response.code,
+ response.status,
+ response.reason || '',
+ self._isUpgrade(response),
+ true,
+ )
+ return 0
+ }
+
+ this._emitEmpty()
+ }
+} else {
+ /*
+ `function parserOnHeadersComplete(info) {`
+
+ info = { .versionMajor, .versionMinor, .url, .headers, .method,
+ .statusCode, .statusMessage, .upgrade, .shouldKeepAlive }
+ */
+ Deceiver.prototype.emitRequest = function emitRequest(request) {
+ var parser = this.socket.parser
+ assert(parser, 'No parser present')
+
+ var method = request.method
+ if (reverseMethods) {
+ method = reverseMethods[method]
+ }
+
+ var info = {
+ versionMajor: 1,
+ versionMinor: 1,
+ url: request.path,
+ headers: this._toHeaderList(request.headers),
+ method: method,
+ statusCode: 0,
+ statusMessage: '',
+ upgrade: this._isUpgrade(request),
+ shouldKeepAlive: true,
+ }
+
+ var self = this
+ parser.execute = function execute() {
+ self._skipExecute(this)
+ this[kOnHeadersComplete](info)
+ return 0
+ }
+
+ this._emitEmpty()
+ }
+
+ Deceiver.prototype.emitResponse = function emitResponse(response) {
+ var parser = this.socket.parser
+ assert(parser, 'No parser present')
+
+ var info = {
+ versionMajor: 1,
+ versionMinor: 1,
+ url: response.path,
+ headers: this._toHeaderList(response.headers),
+ method: false,
+ statusCode: response.status,
+ statusMessage: response.reason || '',
+ upgrade: this._isUpgrade(response),
+ shouldKeepAlive: true,
+ }
+
+ var self = this
+ parser.execute = function execute() {
+ self._skipExecute(this)
+ this[kOnHeadersComplete](info)
+ return 0
+ }
+
+ this._emitEmpty()
+ }
+}
+
+Deceiver.prototype._skipExecute = function _skipExecute(parser) {
+ var self = this
+ var oldExecute = parser.constructor.prototype.execute
+ var oldFinish = parser.constructor.prototype.finish
+
+ parser.execute = null
+ parser.finish = null
+
+ parser.execute = function execute(buffer, start, len) {
+ // Parser reuse
+ if (this.socket !== self.socket) {
+ this.execute = oldExecute
+ this.finish = oldFinish
+ return this.execute(buffer, start, len)
+ }
+
+ if (start !== undefined) {
+ buffer = buffer.slice(start, start + len)
+ }
+ self.emitBody(buffer)
+ return len
+ }
+
+ parser.finish = function finish() {
+ // Parser reuse
+ if (this.socket !== self.socket) {
+ this.execute = oldExecute
+ this.finish = oldFinish
+ return this.finish()
+ }
+
+ this.execute = oldExecute
+ this.finish = oldFinish
+ self.emitMessageComplete()
+ }
+}
+
+Deceiver.prototype.emitBody = function emitBody(buffer) {
+ var parser = this.socket.parser
+ assert(parser, 'No parser present')
+
+ parser[kOnBody](buffer, 0, buffer.length)
+}
+
+Deceiver.prototype._emitEmpty = function _emitEmpty() {
+ // Emit data to force out handling of UPGRADE
+ var empty = new Buffer(0)
+ if (this.socket.ondata) {
+ this.socket.ondata(empty, 0, 0)
+ } else {
+ this.socket.emit('data', empty)
+ }
+}
+
+Deceiver.prototype.emitMessageComplete = function emitMessageComplete() {
+ var parser = this.socket.parser
+ assert(parser, 'No parser present')
+
+ parser[kOnMessageComplete]()
+}
diff --git a/vendor/http-deceiver/package.json b/vendor/http-deceiver/package.json
new file mode 100644
index 0000000000000..0967ef424bce6
--- /dev/null
+++ b/vendor/http-deceiver/package.json
@@ -0,0 +1 @@
+{}