diff --git a/.nycrc.json b/.c8rc.json
similarity index 87%
rename from .nycrc.json
rename to .c8rc.json
index 5e00c6d1746e1..9413073fbf999 100644
--- a/.nycrc.json
+++ b/.c8rc.json
@@ -9,10 +9,8 @@
"**/test-helpers.js",
"**/*-test-helpers.js",
"**/*-fixtures.js",
- "**/mocha-*.js",
"**/*.test-d.ts",
"dangerfile.js",
- "gatsby-*.js",
"core/service-test-runner",
"core/got-test-client.js",
"services/**/*.tester.js",
@@ -21,8 +19,10 @@
"core/base-service/loader-test-fixtures",
"scripts",
"coverage",
- "build",
".github",
- "**/public/"
+ "**/public/",
+ "cypress",
+ "frontend",
+ "migrations"
]
}
diff --git a/.circleci/config.yml b/.circleci/config.yml
deleted file mode 100644
index 0c4a2f6f8683b..0000000000000
--- a/.circleci/config.yml
+++ /dev/null
@@ -1,359 +0,0 @@
-version: 2
-
-main_steps: &main_steps
- steps:
- - checkout
-
- - run:
- name: Install dependencies
- command: npm ci
- environment:
- # https://docs.cypress.io/guides/getting-started/installing-cypress.html#Skipping-installation
- # We don't need to install the Cypress binary in jobs that aren't actually running Cypress.
- CYPRESS_INSTALL_BINARY: 0
-
- - run:
- name: Linter
- when: always
- command: npm run lint
-
- - run:
- name: Core tests
- when: always
- environment:
- mocha_reporter: mocha-junit-reporter
- MOCHA_FILE: junit/core/results.xml
- command: npm run test:core
-
- - run:
- name: Entrypoint tests
- when: always
- environment:
- mocha_reporter: mocha-junit-reporter
- MOCHA_FILE: junit/entrypoint/results.xml
- command: npm run test:entrypoint
-
- - store_test_results:
- path: junit
-
- - run:
- name: 'Prettier check (quick fix: `npm run prettier`)'
- when: always
- command: npm run prettier:check
-
-integration_steps: &integration_steps
- steps:
- - checkout
-
- - run:
- name: Install dependencies
- command: npm ci
- environment:
- CYPRESS_INSTALL_BINARY: 0
-
- - run:
- name: Integration tests
- when: always
- environment:
- mocha_reporter: mocha-junit-reporter
- MOCHA_FILE: junit/integration/results.xml
- command: npm run test:integration
-
- - store_test_results:
- path: junit
-
-services_steps: &services_steps
- steps:
- - checkout
-
- - run:
- name: Install dependencies
- command: npm ci
- environment:
- CYPRESS_INSTALL_BINARY: 0
-
- - run:
- name: Identify services tagged in the PR title
- command: npm run test:services:pr:prepare
-
- - run:
- name: Run tests for tagged services
- environment:
- mocha_reporter: mocha-junit-reporter
- MOCHA_FILE: junit/services/results.xml
- command: RETRY_COUNT=3 npm run test:services:pr:run
-
- - store_test_results:
- path: junit
-
-package_steps: &package_steps
- steps:
- - checkout
-
- - run:
- name: Install node and npm
- command: |
- set +e
- export NVM_DIR="/opt/circleci/.nvm"
- [ -s "$NVM_DIR/nvm.sh" ] && \. "$NVM_DIR/nvm.sh"
- nvm install v14
- nvm use v14
- npm install -g npm
-
- # Run the package tests on each currently supported node version. See:
- # https://github.com/badges/shields/blob/master/badge-maker/README.md#node-version-support
- # https://nodejs.org/en/about/releases/
-
- - run:
- environment:
- mocha_reporter: mocha-junit-reporter
- MOCHA_FILE: junit/badge-maker/v12/results.xml
- NODE_VERSION: v12
- CYPRESS_INSTALL_BINARY: 0
- name: Run package tests on Node 12
- command: scripts/run_package_tests.sh
-
- - run:
- environment:
- mocha_reporter: mocha-junit-reporter
- MOCHA_FILE: junit/badge-maker/v14/results.xml
- NODE_VERSION: v14
- CYPRESS_INSTALL_BINARY: 0
- name: Run package tests on Node 14
- command: scripts/run_package_tests.sh
-
- - run:
- environment:
- mocha_reporter: mocha-junit-reporter
- MOCHA_FILE: junit/badge-maker/v16/results.xml
- NODE_VERSION: v16
- CYPRESS_INSTALL_BINARY: 0
- name: Run package tests on Node 16
- command: scripts/run_package_tests.sh
-
- - store_test_results:
- path: junit
-
-jobs:
- main:
- docker:
- - image: circleci/node:14
-
- <<: *main_steps
-
- main@node-16:
- docker:
- - image: circleci/node:16
-
- <<: *main_steps
-
- integration:
- docker:
- - image: circleci/node:14
- - image: redis
-
- <<: *integration_steps
-
- integration@node-16:
- docker:
- - image: circleci/node:16
- - image: redis
-
- <<: *integration_steps
-
- danger:
- docker:
- - image: circleci/node:14
- steps:
- - checkout
-
- - run:
- name: Install dependencies
- command: npm ci
- environment:
- CYPRESS_INSTALL_BINARY: 0
-
- - run:
- name: Danger
- when: always
- environment:
- # https://github.com/gatsbyjs/gatsby/pull/11555
- NODE_ENV: test
- command: npm run danger ci
-
- frontend:
- docker:
- - image: circleci/node:14
- steps:
- - checkout
-
- - run:
- name: Install dependencies
- command: npm ci
- environment:
- CYPRESS_INSTALL_BINARY: 0
-
- - run:
- name: Prepare frontend tests
- command: npm run defs && npm run features
-
- - run:
- name: Check types
- command: npm run check-types:frontend
-
- - run:
- name: Frontend unit tests
- environment:
- mocha_reporter: mocha-junit-reporter
- MOCHA_FILE: junit/frontend/results.xml
- when: always
- command: npm run test:frontend
-
- - store_test_results:
- path: junit
-
- - run:
- name: Frontend build completes successfully
- when: always
- command: npm run build
-
- package:
- machine: true
-
- <<: *package_steps
-
- services:
- docker:
- - image: circleci/node:14
-
- <<: *services_steps
-
- services@node-16:
- docker:
- - image: circleci/node:16
-
- <<: *services_steps
-
- e2e:
- docker:
- - image: cypress/base:14.16.0
- steps:
- - checkout
-
- - restore_cache:
- name: Restore Cypress binary
- keys:
- - v2-cypress-dependencies-{{ checksum "package-lock.json" }}
-
- - run:
- name: Install dependencies
- command: npm ci
-
- - run:
- name: Frontend build
- command: GATSBY_BASE_URL=http://localhost:8080 npm run build
-
- - run:
- name: Run tests
- environment:
- CYPRESS_REPORTER: junit
- MOCHA_FILE: junit/e2e/results.xml
- command: npm run e2e-on-build
-
- - store_test_results:
- path: junit
-
- - store_artifacts:
- path: cypress/videos
-
- - store_artifacts:
- path: cypress/screenshots
-
- - save_cache:
- name: Cache Cypress binary
- paths:
- # https://docs.cypress.io/guides/getting-started/installing-cypress.html#Binary-cache
- - ~/.cache/Cypress
- key: v2-cypress-dependencies-{{ checksum "package-lock.json" }}
-
-workflows:
- version: 2
-
- on-commit:
- jobs:
- - main:
- filters:
- branches:
- ignore: gh-pages
- - main@node-16:
- filters:
- branches:
- ignore: gh-pages
- - integration@node-16:
- filters:
- branches:
- ignore: gh-pages
- - frontend:
- filters:
- branches:
- ignore: gh-pages
- - package:
- filters:
- branches:
- ignore: gh-pages
- - services:
- filters:
- branches:
- ignore:
- - master
- - gh-pages
- - services@node-16:
- filters:
- branches:
- ignore:
- - master
- - gh-pages
- - danger:
- filters:
- branches:
- ignore:
- - master
- - gh-pages
- - /dependabot\/.*/
- - e2e:
- filters:
- branches:
- ignore: gh-pages
- # on-commit-with-cache:
- # jobs:
- # - npm-install:
- # filters:
- # branches:
- # ignore: gh-pages
- # - main:
- # requires:
- # - npm-install
- # - main@node-latest:
- # requires:
- # - npm-install
- # - frontend:
- # requires:
- # - npm-install
- # - services:
- # requires:
- # - npm-install
- # filters:
- # branches:
- # ignore: master
- # - services@node-latest:
- # requires:
- # - npm-install
- # filters:
- # branches:
- # ignore: master
- # - danger:
- # requires:
- # - npm-install
- # filters:
- # branches:
- # ignore: /dependabot\/.*/
diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json
new file mode 100644
index 0000000000000..1df72d64c0b4e
--- /dev/null
+++ b/.devcontainer/devcontainer.json
@@ -0,0 +1,14 @@
+{
+ "name": "Node.js",
+ // "Officially" maintained node devcontainer image from Microsoft
+ // https://github.com/devcontainers/templates/tree/main/src/javascript-node
+ "image": "mcr.microsoft.com/devcontainers/javascript-node:1-22-bookworm",
+
+ // Features to add to the dev container. More info: https://containers.dev/features.
+ "features": {
+ "ghcr.io/devcontainers/features/github-cli:1": {}
+ },
+
+ // Use 'postCreateCommand' to run commands after the container is created.
+ "postCreateCommand": "npm ci"
+}
diff --git a/.dockerignore b/.dockerignore
index 17a2a01a4ba70..02e23cdd067a6 100644
--- a/.dockerignore
+++ b/.dockerignore
@@ -1,8 +1,12 @@
node_modules/
-shields.env
.git/
.gitignore
+.github
.vscode/
+fly.toml
+
+*.md
+doc/
# Improve layer cacheability.
Dockerfile
diff --git a/.editorconfig b/.editorconfig
index 27a65a3586c47..4a8c4be5758ae 100644
--- a/.editorconfig
+++ b/.editorconfig
@@ -1,3 +1,5 @@
+# The formatting for most files is handled by Prettier nowadays.
+# See https://github.com/badges/shields/blob/master/CONTRIBUTING.md#prettier for more information.
root = true
@@ -6,7 +8,3 @@ end_of_line = lf
insert_final_newline = true
trim_trailing_whitespace = true
indent_style = space
-
-[*.{js,json,html,css}]
-charset = utf-8
-indent_size = 2
diff --git a/.eslintignore b/.eslintignore
deleted file mode 100644
index 170a29b892244..0000000000000
--- a/.eslintignore
+++ /dev/null
@@ -1,7 +0,0 @@
-/api-docs/
-/build
-/coverage
-/__snapshots__
-public
-badge-maker/node_modules/
-!.github/
diff --git a/.eslintrc.yml b/.eslintrc.yml
deleted file mode 100644
index 0614f9a0134af..0000000000000
--- a/.eslintrc.yml
+++ /dev/null
@@ -1,198 +0,0 @@
-extends:
- - standard
- - standard-jsx
- - standard-react
- - plugin:@typescript-eslint/recommended
- - prettier
- - eslint:recommended
-
-globals:
- JSX: 'readonly'
-
-parserOptions:
- # Override eslint-config-standard, which incorrectly sets this to "module",
- # though that setting is only for ES6 modules, not CommonJS modules.
- sourceType: 'script'
-
-settings:
- react:
- version: '16.8'
- jsdoc:
- mode: jsdoc
-
-plugins:
- - chai-friendly
- - jsdoc
- - mocha
- - no-extension-in-require
- - sort-class-members
- - import
- - react-hooks
- - promise
-
-overrides:
- # For simplicity's sake, when possible prefer to add rules to the top-level
- # list of rules, even if they only apply to certain files. That way the
- # rules listed here are only ones which conflict.
-
- - files:
- - '**/*.js'
- - '!frontend/**/*.js'
- env:
- node: true
- es6: true
- rules:
- no-console: 'off'
- '@typescript-eslint/explicit-module-boundary-types': 'off'
-
- - files:
- - '**/*.@(ts|tsx)'
- parserOptions:
- sourceType: 'module'
- parser: '@typescript-eslint/parser'
- rules:
- # Argh.
- '@typescript-eslint/explicit-function-return-type':
- ['error', { 'allowExpressions': true }]
- '@typescript-eslint/no-empty-function': 'error'
- '@typescript-eslint/no-var-requires': 'error'
- '@typescript-eslint/no-object-literal-type-assertion': 'off'
- '@typescript-eslint/no-explicit-any': 'error'
- '@typescript-eslint/ban-ts-ignore': 'off'
- '@typescript-eslint/explicit-module-boundary-types': 'off'
-
- - files:
- - core/**/*.ts
- parserOptions:
- sourceType: 'module'
- parser: '@typescript-eslint/parser'
- - files:
- - gatsby-browser.js
- - 'frontend/**/*.@(js|ts|tsx)'
- parserOptions:
- sourceType: 'module'
- env:
- browser: true
- rules:
- import/extensions:
- ['error', 'never', { 'json': 'always', 'yml': 'always' }]
-
- - files:
- - 'core/base-service/**/*.js'
- - 'services/**/*.js'
- rules:
- sort-class-members/sort-class-members:
- [
- 'error',
- {
- order:
- [
- 'name',
- 'category',
- 'isDeprecated',
- 'route',
- 'auth',
- 'examples',
- '_cacheLength',
- 'defaultBadgeData',
- 'render',
- 'constructor',
- 'fetch',
- 'transform',
- 'handle',
- ],
- },
- ]
-
- - files:
- - '**/*.spec.@(js|ts|tsx)'
- - '**/*.integration.js'
- - '**/test-helpers.js'
- - 'core/service-test-runner/**/*.js'
- env:
- mocha: true
- rules:
- mocha/no-exclusive-tests: 'error'
- mocha/no-mocha-arrows: 'error'
- mocha/prefer-arrow-callback: 'error'
-
-rules:
- # Disable some rules from eslint:recommended.
- no-empty: ['error', { 'allowEmptyCatch': true }]
-
- # Allow unused parameters. In callbacks, removing them seems to obscure
- # what the functions are doing.
- '@typescript-eslint/no-unused-vars': ['error', { 'args': 'none' }]
- no-unused-vars: 'off'
-
- '@typescript-eslint/no-var-requires': 'off'
-
- '@typescript-eslint/no-use-before-define': 'error'
- no-use-before-define: 'off'
-
- # These should be disabled by eslint-config-prettier, but are not.
- no-extra-semi: 'off'
-
- # Shields additions.
- no-var: 'error'
- prefer-const: 'error'
- arrow-body-style: ['error', 'as-needed']
- no-extension-in-require/main: 'error'
- object-shorthand: ['error', 'properties']
- prefer-template: 'error'
- promise/prefer-await-to-then: 'error'
- func-style: ['error', 'declaration', { 'allowArrowFunctions': true }]
- new-cap: ['error', { 'capIsNew': true }]
- import/order: ['error', { 'newlines-between': 'never' }]
-
- # Account for destructuring responses from upstream services,
- # many of which do not follow camelcase
- # Based on original rule configuration from eslint-config-standard
- camelcase:
- [
- 'error',
- {
- ignoreDestructuring: true,
- properties: 'never',
- ignoreGlobals: true,
- allow: ['^UNSAFE_'],
- },
- ]
-
- # Chai friendly.
- no-unused-expressions: 'off'
- chai-friendly/no-unused-expressions: 'error'
-
- # jsdoc plugin:
- # don't require every class/function to have a docblock
- jsdoc/require-jsdoc: 'off'
-
- # allow Joi as an undefined type
- jsdoc/no-undefined-types: ['error', { definedTypes: ['Joi'] }]
-
- # all the other recommended rules as errors (not warnings)
- jsdoc/check-alignment: 'error'
- jsdoc/check-param-names: 'error'
- jsdoc/check-tag-names: 'error'
- jsdoc/check-types: 'error'
- jsdoc/implements-on-classes: 'error'
- jsdoc/newline-after-description: 'error'
- jsdoc/require-param: 'error'
- jsdoc/require-param-description: 'error'
- jsdoc/require-param-name: 'error'
- jsdoc/require-param-type: 'error'
- jsdoc/require-returns: 'error'
- jsdoc/require-returns-check: 'error'
- jsdoc/require-returns-description: 'error'
- jsdoc/require-returns-type: 'error'
- jsdoc/valid-types: 'error'
-
- # Disable some from TypeScript.
- '@typescript-eslint/camelcase': off
- '@typescript-eslint/explicit-function-return-type': 'off'
- '@typescript-eslint/no-empty-function': 'off'
-
- react/jsx-sort-props: 'error'
- react-hooks/rules-of-hooks: 'error'
- react-hooks/exhaustive-deps: 'error'
- jsx-quotes: ['error', 'prefer-double']
diff --git a/.gitattributes b/.gitattributes
new file mode 100644
index 0000000000000..6313b56c57848
--- /dev/null
+++ b/.gitattributes
@@ -0,0 +1 @@
+* text=auto eol=lf
diff --git a/.github/ISSUE_TEMPLATE/1_Bug_report.yml b/.github/ISSUE_TEMPLATE/1_Bug_report.yml
index b2f126dd9a1fe..417bc782df20b 100644
--- a/.github/ISSUE_TEMPLATE/1_Bug_report.yml
+++ b/.github/ISSUE_TEMPLATE/1_Bug_report.yml
@@ -41,4 +41,4 @@ body:
attributes:
value: |
## :heart: Love Shields?
- Please consider donating $10 to sustain our activities: [https://opencollective.com/shields](https://opencollective.com/shields)
+ Please consider donating to sustain our activities: [https://opencollective.com/shields](https://opencollective.com/shields)
diff --git a/.github/ISSUE_TEMPLATE/2_Failing_service_test.md b/.github/ISSUE_TEMPLATE/2_Failing_service_test.md
index 4de1d140813c6..57919ee852193 100644
--- a/.github/ISSUE_TEMPLATE/2_Failing_service_test.md
+++ b/.github/ISSUE_TEMPLATE/2_Failing_service_test.md
@@ -18,19 +18,15 @@ labels: 'keep-service-tests-green'
-:link: **CircleCI link**
-
-
-
-:beetle: **Stack trace**
+:lady_beetle: **Stack trace**
```
-
+
```
:bulb: **Possible solution**
-
diff --git a/.github/ISSUE_TEMPLATE/3_Badge_request.md b/.github/ISSUE_TEMPLATE/3_Badge_request.md
deleted file mode 100644
index 37936abaa6921..0000000000000
--- a/.github/ISSUE_TEMPLATE/3_Badge_request.md
+++ /dev/null
@@ -1,37 +0,0 @@
----
-name: 💡 Badge Request
-about: Ideas for new badges
-labels: 'service-badge'
----
-
-:clipboard: **Description**
-
-
-
-:link: **Data**
-
-
-
-:microphone: **Motivation**
-
-
-
-
diff --git a/.github/ISSUE_TEMPLATE/3_Badge_request.yml b/.github/ISSUE_TEMPLATE/3_Badge_request.yml
new file mode 100644
index 0000000000000..1f6cc81bc0924
--- /dev/null
+++ b/.github/ISSUE_TEMPLATE/3_Badge_request.yml
@@ -0,0 +1,58 @@
+name: '💡 Badge Request'
+description: Ideas for new badges
+labels: ['service-badge']
+body:
+ - type: markdown
+ attributes:
+ value: >
+ ## Ideas for new badges
+
+
+ This issue template is for suggesting new badges which **fetch and display data from an upstream service**. If your suggestion is for a static badge (which shows the same information every time it is requested), it is [already possible to make these](https://shields.io/docs/static-badges). We don't add specific routes for badges which only show static information.
+
+
+ - type: textarea
+ id: description
+ attributes:
+ label: '📋 Description'
+ description: |
+ A clear and concise description of the new badge.
+
+ - Which service is this badge for e.g: GitHub, Travis CI
+ - What sort of information should this badge show?
+ Provide an example in plain text e.g: "version | v1.01" or as a static badge
+ (static badge generator can be found at https://shields.io/badges/static-badge )
+ validations:
+ required: true
+
+ - type: textarea
+ id: data
+ attributes:
+ label: '🔗 Data'
+ description: |
+ Where can we get the data from?
+
+ Please consider and cover details like:
+ - Is there a public API?
+ - Does the API require authentication or an API key?
+ If so, please review our documentation on [Badges Requiring Authentication](https://github.com/badges/shields/blob/master/doc/authentication.md)
+ - Link to the API documentation.
+ validations:
+ required: true
+
+ - type: textarea
+ id: motivation
+ attributes:
+ label: '🎤 Motivation'
+ description: |
+ Please explain why this feature should be implemented and how it would be used.
+
+ - What is the specific use case?
+ validations:
+ required: true
+
+ - type: markdown
+ attributes:
+ value: |
+ ## :heart: Love Shields?
+ Please consider donating to sustain our activities: [https://opencollective.com/shields](https://opencollective.com/shields)
diff --git a/.github/ISSUE_TEMPLATE/4_Feature_request.md b/.github/ISSUE_TEMPLATE/4_Feature_request.md
index 64f5f734b59a6..d285358ebf665 100644
--- a/.github/ISSUE_TEMPLATE/4_Feature_request.md
+++ b/.github/ISSUE_TEMPLATE/4_Feature_request.md
@@ -7,5 +7,5 @@ about: Ideas for other new features or improvements
-
diff --git a/.github/actions/close-bot/action.yml b/.github/actions/close-bot/action.yml
deleted file mode 100644
index 0c74e5eaa750b..0000000000000
--- a/.github/actions/close-bot/action.yml
+++ /dev/null
@@ -1,12 +0,0 @@
-name: 'Auto Approve'
-description: 'Automatically approve/close selected pull requests for shields.io'
-branding:
- icon: 'check-circle'
- color: 'green'
-inputs:
- github-token:
- description: 'The GITHUB_TOKEN secret'
- required: true
-runs:
- using: 'node12'
- main: 'index.js'
diff --git a/.github/actions/close-bot/helpers.js b/.github/actions/close-bot/helpers.js
deleted file mode 100644
index 13daf44ccb406..0000000000000
--- a/.github/actions/close-bot/helpers.js
+++ /dev/null
@@ -1,62 +0,0 @@
-'use strict'
-
-function findChangelogStart(lines) {
- for (let i = 0; i < lines.length; i++) {
- const line = lines[i]
- if (
- line === '
' - ) { - return i + 3 - } - } - return null -} - -function findChangelogEnd(lines, start) { - for (let i = start; i < lines.length; i++) { - const line = lines[i] - if (line === '') { - return i - } - } - return null -} - -function allChangelogLinesAreVersionBump(changelogLines) { - return ( - changelogLines.length > 0 && - changelogLines.length === - changelogLines.filter(line => - line.includes('Version bump only for package') - ).length - ) -} - -function isPointlessVersionBump(body) { - const pointlessBumpLinks = [ - 'https://github.com/gatsbyjs/gatsby', - 'https://github.com/typescript-eslint/typescript-eslint', - ] - - const lines = body.split(/\r?\n/) - if (!pointlessBumpLinks.some(link => lines[0].includes(link))) { - return false - } - const start = findChangelogStart(lines) - const end = findChangelogEnd(lines, start) - if (!start || !end) { - return false - } - const changelogLines = lines - .slice(start, end) - .filter(line => !line.startsWith('
-
-
-
+
-
+
-
-
-
-
+
+
+
+
-
-
+ alt="Code Coverage">
-
-
-
+
- Previously amo/d provided a “total downloads” badge. However,
- updates to the v3 API only
- give us weekly downloads. The route amo/d redirects to amo/dw.
-
- 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:
-
- /appveyor/tests/NZSmartie/coap-net-iu0to.svg?passed_label=good&failed_label=bad&skipped_label=n%2Fa
-
- Or symbols:
-
- /appveyor/tests/NZSmartie/coap-net-iu0to.svg?compact_message&passed_label=%F0%9F%8E%89&failed_label=%F0%9F%92%A2&skipped_label=%F0%9F%A4%B7
-
- There is also a &compact_message query parameter, which will default to displaying ✔, ✘ and ➟, separated by a horizontal bar |.
-
- A badge requires three pieces of information: ORGANIZATION,
- PROJECT_ID and DEFINITION_ID.
-
- To start, edit your build definition and look at the url: -
+const description = ` +[Azure Devops](https://dev.azure.com/) (formerly VSO, VSTS) is Microsoft Azure's CI/CD platform. + +A badge requires three pieces of information: +\`ORGANIZATION\`, \`PROJECT_ID\` and \`DEFINITION_ID\`. + +To start, edit your build definition and look at the url: +
-
- Then use the Azure DevOps REST API to translate the
- PROJECT_NAME to a PROJECT_ID.
-
- Navigate to https://dev.azure.com/ORGANIZATION/_apis/projects/PROJECT_NAME
-
@@ -40,76 +44,82 @@ export default class AzureDevOpsBuild extends BaseSvgScrapingService {
queryParamSchema,
}
- static examples = [
- {
- title: 'Azure DevOps builds',
- pattern: ':organization/:projectId/:definitionId',
- namedParams: {
- organization: 'totodem',
- projectId: '8cf3ec0e-d0c2-4fcd-8206-ad204f254a96',
- definitionId: '2',
+ static openApi = {
+ '/azure-devops/build/{organization}/{projectId}/{definitionId}': {
+ get: {
+ summary: 'Azure DevOps builds',
+ description,
+ parameters: [
+ pathParam({
+ name: 'organization',
+ example: 'totodem',
+ }),
+ pathParam({
+ name: 'projectId',
+ example: '8cf3ec0e-d0c2-4fcd-8206-ad204f254a96',
+ }),
+ pathParam({
+ name: 'definitionId',
+ example: '2',
+ }),
+ queryParam({
+ name: 'stage',
+ example: 'Successful Stage',
+ }),
+ queryParam({
+ name: 'job',
+ example: 'Successful Job',
+ }),
+ ],
},
- staticPreview: renderBuildStatusBadge({ status: 'succeeded' }),
- keywords,
- documentation,
},
- {
- title: 'Azure DevOps builds (branch)',
- pattern: ':organization/:projectId/:definitionId/:branch',
- namedParams: {
- organization: 'totodem',
- projectId: '8cf3ec0e-d0c2-4fcd-8206-ad204f254a96',
- definitionId: '2',
- branch: 'master',
+ '/azure-devops/build/{organization}/{projectId}/{definitionId}/{branch}': {
+ get: {
+ summary: 'Azure DevOps builds (branch)',
+ description,
+ parameters: [
+ pathParam({
+ name: 'organization',
+ example: 'totodem',
+ }),
+ pathParam({
+ name: 'projectId',
+ example: '8cf3ec0e-d0c2-4fcd-8206-ad204f254a96',
+ }),
+ pathParam({
+ name: 'definitionId',
+ example: '2',
+ }),
+ pathParam({
+ name: 'branch',
+ example: 'master',
+ }),
+ queryParam({
+ name: 'stage',
+ example: 'Successful Stage',
+ }),
+ queryParam({
+ name: 'job',
+ example: 'Successful Job',
+ }),
+ ],
},
- staticPreview: renderBuildStatusBadge({ status: 'succeeded' }),
- keywords,
- documentation,
},
- {
- title: 'Azure DevOps builds (stage)',
- namedParams: {
- organization: 'totodem',
- projectId: '8cf3ec0e-d0c2-4fcd-8206-ad204f254a96',
- definitionId: '5',
- },
- queryParams: {
- stage: 'Successful Stage',
- },
- staticPreview: renderBuildStatusBadge({ status: 'succeeded' }),
- keywords,
- documentation,
- },
- {
- title: 'Azure DevOps builds (job)',
- namedParams: {
- organization: 'totodem',
- projectId: '8cf3ec0e-d0c2-4fcd-8206-ad204f254a96',
- definitionId: '5',
- },
- queryParams: {
- stage: 'Successful Stage',
- job: 'Successful Job',
- },
- staticPreview: renderBuildStatusBadge({ status: 'succeeded' }),
- keywords,
- documentation,
- },
- ]
+ }
async handle(
{ organization, projectId, definitionId, branch },
- { stage, job }
+ { stage, job },
) {
// Microsoft documentation: https://docs.microsoft.com/en-us/rest/api/vsts/build/status/get
const { status } = await fetch(this, {
url: `https://dev.azure.com/${organization}/${projectId}/_apis/build/status/${definitionId}`,
- qs: {
+ searchParams: {
branchName: branch,
stageName: stage,
jobName: job,
},
- errorMessages: {
+ httpErrors: {
404: 'user or project not found',
},
})
diff --git a/services/azure-devops/azure-devops-build.tester.js b/services/azure-devops/azure-devops-build.tester.js
index 73027411170a8..7376a29a9e0f7 100644
--- a/services/azure-devops/azure-devops-build.tester.js
+++ b/services/azure-devops/azure-devops-build.tester.js
@@ -24,7 +24,7 @@ t.create('stage badge')
t.create('job badge')
.get(
- '/totodem/Shields.io/5.json?stage=Successful%20Stage&job=Successful%20Job'
+ '/totodem/Shields.io/5.json?stage=Successful%20Stage&job=Successful%20Job',
)
.expectBadge({
label: 'build',
diff --git a/services/azure-devops/azure-devops-coverage.service.js b/services/azure-devops/azure-devops-coverage.service.js
index 4234b36f48d6a..37f72e6e0811c 100644
--- a/services/azure-devops/azure-devops-coverage.service.js
+++ b/services/azure-devops/azure-devops-coverage.service.js
@@ -1,28 +1,27 @@
import Joi from 'joi'
+import { pathParams } from '../index.js'
import { coveragePercentage as coveragePercentageColor } from '../color-formatters.js'
import AzureDevOpsBase from './azure-devops-base.js'
-import { keywords } from './azure-devops-helpers.js'
-const documentation = `
-
- To obtain your own badge, you need to get 3 pieces of information:
- ORGANIZATION, PROJECT and DEFINITION_ID.
-
- First, you need to select your build definition and look at the url: -
+const description = ` +[Azure Devops](https://dev.azure.com/) (formerly VSO, VSTS) is Microsoft Azure's CI/CD platform. + +To obtain your own badge, you need to get 3 pieces of information: +\`ORGANIZATION\`, \`PROJECT_ID\` and \`DEFINITION_ID\`. + +First, you need to select your build definition and look at the url: +
-
- Your badge will then have the form:
- https://img.shields.io/azure-devops/coverage/ORGANIZATION/PROJECT/DEFINITION_ID.svg.
-
- Optionally, you can specify a named branch:
- https://img.shields.io/azure-devops/coverage/ORGANIZATION/PROJECT/DEFINITION_ID/NAMED_BRANCH.svg.
-
- To obtain your own badge, you need to get 4 pieces of information:
- ORGANIZATION, PROJECT_ID, DEFINITION_ID and ENVIRONMENT_ID.
-
- First, you need to enable badges for each required environments in the options of your release definition. - Once you have save the change, look at badge url: -
+import { BaseSvgScrapingService, pathParams } from '../index.js' +import { fetch } from './azure-devops-helpers.js' + +const description = ` +To obtain your own badge, you need to get 4 pieces of information: +\`ORGANIZATION\`, \`PROJECT_ID\`, \`DEFINITION_ID\` and \`ENVIRONMENT_ID\`. + +First, you need to enable badges for each required environments in the options of your release definition. +Once you have save the change, look at badge url: +
-
- Your badge will then have the form:
- https://img.shields.io/vso/release/ORGANIZATION/PROJECT_ID/DEFINITION_ID/ENVIRONMENT_ID.svg.
-
- To obtain your own badge, you need to get 3 pieces of information:
- ORGANIZATION, PROJECT and DEFINITION_ID.
-
- First, you need to select your build definition and look at the url: -
+const description = ` +[Azure Devops](https://dev.azure.com/) (formerly VSO, VSTS) is Microsoft Azure's CI/CD platform. + +To obtain your own badge, you need to get 3 pieces of information: +\`ORGANIZATION\`, \`PROJECT\`, \`DEFINITION_ID\`. + +First, you need to select your build definition and look at the url: +
-
- Your badge will then have the form:
- https://img.shields.io/azure-devops/tests/ORGANIZATION/PROJECT/DEFINITION_ID.svg.
-
- Optionally, you can specify a named branch:
- https://img.shields.io/azure-devops/tests/ORGANIZATION/PROJECT/DEFINITION_ID/NAMED_BRANCH.svg.
-
- You may change the "passed", "failed" and "skipped" text on this badge by supplying query parameters &passed_label=, &failed_label= and &skipped_label= respectively.
-
- There is also a &compact_message query parameter, which will default to displaying ✔, ✘ and ➟, separated by a horizontal bar |.
-
- For example, if you want to use a different terminology:
-
- /azure-devops/tests/ORGANIZATION/PROJECT/DEFINITION_ID.svg?passed_label=good&failed_label=bad&skipped_label=n%2Fa
-
- Or, use symbols:
-
- /azure-devops/tests/ORGANIZATION/PROJECT/DEFINITION_ID.svg?compact_message&passed_label=%F0%9F%8E%89&failed_label=%F0%9F%92%A2&skipped_label=%F0%9F%A4%B7
-
- If your Bugzilla badge errors, it might be because you are trying to load a private bug. -
+const description = ` +Use thebaseUrl query parameter to target different Bugzilla deployments.
+If your Bugzilla badge errors, it might be because you are trying to load a private bug.
`
export default class Bugzilla extends BaseJsonService {
static category = 'issue-tracking'
static route = { base: 'bugzilla', pattern: ':bugNumber', queryParamSchema }
- static examples = [
- {
- title: 'Bugzilla bug status (Mozilla)',
- namedParams: {
- bugNumber: '996038',
+ static openApi = {
+ '/bugzilla/{bugNumber}': {
+ get: {
+ summary: 'Bugzilla bug status',
+ description,
+ parameters: [
+ pathParam({
+ name: 'bugNumber',
+ example: '12345',
+ }),
+ queryParam({
+ name: 'baseUrl',
+ example: 'https://gcc.gnu.org/bugzilla',
+ description:
+ 'When not specified, this will default to `https://bugzilla.mozilla.org`.',
+ }),
+ ],
},
- staticPreview: this.render({
- bugNumber: 996038,
- status: 'FIXED',
- resolution: '',
- }),
- documentation,
},
- {
- title: 'Bugzilla bug status (non-Mozilla)',
- namedParams: {
- bugNumber: '545424',
- },
- queryParams: { baseUrl: 'https://bugs.eclipse.org/bugs' },
- staticPreview: this.render({
- bugNumber: 545424,
- status: 'RESOLVED',
- resolution: 'FIXED',
- }),
- documentation,
- },
- ]
+ }
static defaultBadgeData = { label: 'bugzilla' }
diff --git a/services/bugzilla/bugzilla.spec.js b/services/bugzilla/bugzilla.spec.js
index 114e29fa429e0..1baa9eb207cf8 100644
--- a/services/bugzilla/bugzilla.spec.js
+++ b/services/bugzilla/bugzilla.spec.js
@@ -5,7 +5,7 @@ describe('getDisplayStatus function', function () {
it('formats status correctly', async function () {
test(Bugzilla.getDisplayStatus, () => {
given({ status: 'RESOLVED', resolution: 'WORKSFORME' }).expect(
- 'works for me'
+ 'works for me',
)
given({ status: 'RESOLVED', resolution: 'WONTFIX' }).expect("won't fix")
given({ status: 'ASSIGNED', resolution: '' }).expect('assigned')
diff --git a/services/bugzilla/bugzilla.tester.js b/services/bugzilla/bugzilla.tester.js
index bcd46f9041bb2..da627f7c53cfa 100644
--- a/services/bugzilla/bugzilla.tester.js
+++ b/services/bugzilla/bugzilla.tester.js
@@ -11,7 +11,7 @@ const bzBugStatus = Joi.equal(
"won't fix",
'duplicate',
'works for me',
- 'incomplete'
+ 'incomplete',
)
t.create('Bugzilla valid bug status').get('/996038.json').expectBadge({
@@ -20,9 +20,9 @@ t.create('Bugzilla valid bug status').get('/996038.json').expectBadge({
})
t.create('Bugzilla valid bug status with custom baseUrl')
- .get('/545424.json?baseUrl=https://bugs.eclipse.org/bugs')
+ .get('/12345.json?baseUrl=https://gcc.gnu.org/bugzilla')
.expectBadge({
- label: 'bug 545424',
+ label: 'bug 12345',
message: bzBugStatus,
})
diff --git a/services/build-status.js b/services/build-status.js
index a658ff411e6ad..c48742536b65f 100644
--- a/services/build-status.js
+++ b/services/build-status.js
@@ -1,3 +1,9 @@
+/**
+ * Common functions and schemas for tasks related to build status.
+ *
+ * @module
+ */
+
import Joi from 'joi'
const greenStatuses = [
@@ -50,8 +56,23 @@ const allStatuses = greenStatuses
.concat(redStatuses)
.concat(otherStatuses)
+/**
+ * Joi schema for validating Build Status.
+ * Checks if the build status is present in the list of allowed build status.
+ *
+ * @type {Joi}
+ */
const isBuildStatus = Joi.equal(...allStatuses)
+/**
+ * Handles rendering concerns of badges that display build status.
+ * Determines the message and color of the badge according to the build status.
+ *
+ * @param {object} attrs Refer to individual attributes
+ * @param {string} [attrs.label] If provided then badge label is set to this value
+ * @param {string} attrs.status Build status
+ * @returns {object} Badge with label, message and color properties
+ */
function renderBuildStatusBadge({ label, status }) {
let message
let color
diff --git a/services/build-status.spec.js b/services/build-status.spec.js
index b135504bcbe78..9a06fd2730c5b 100644
--- a/services/build-status.spec.js
+++ b/services/build-status.spec.js
@@ -39,7 +39,7 @@ test(renderBuildStatusBadge, () => {
given({ status: 'success' }),
given({ status: 'successful' }),
]).assert('should be brightgreen', b =>
- expect(b).to.include({ color: 'brightgreen' })
+ expect(b).to.include({ color: 'brightgreen' }),
)
})
@@ -85,6 +85,6 @@ test(renderBuildStatusBadge, () => {
given({ status: 'testing' }),
given({ status: 'waiting' }),
]).assert('should have undefined color', b =>
- expect(b).to.include({ color: undefined })
+ expect(b).to.include({ color: undefined }),
)
})
diff --git a/services/buildkite/buildkite.service.js b/services/buildkite/buildkite.service.js
index c9cfb31648f7c..c9612b6e3c3cb 100644
--- a/services/buildkite/buildkite.service.js
+++ b/services/buildkite/buildkite.service.js
@@ -1,6 +1,6 @@
import Joi from 'joi'
import { isBuildStatus, renderBuildStatusBadge } from '../build-status.js'
-import { BaseJsonService } from '../index.js'
+import { BaseJsonService, pathParams } from '../index.js'
// unknown is a valid 'other' status for Buildkite
const schema = Joi.object({
@@ -11,31 +11,38 @@ export default class Buildkite extends BaseJsonService {
static category = 'build'
static route = { base: 'buildkite', pattern: ':identifier/:branch*' }
- static examples = [
- {
- title: 'Buildkite',
- pattern: ':identifier',
- namedParams: {
- identifier: '3826789cf8890b426057e6fe1c4e683bdf04fa24d498885489',
+ static openApi = {
+ '/buildkite/{identifier}': {
+ get: {
+ summary: 'Buildkite',
+ parameters: pathParams({
+ name: 'identifier',
+ example: '3826789cf8890b426057e6fe1c4e683bdf04fa24d498885489',
+ }),
},
- staticPreview: renderBuildStatusBadge({ status: 'passing' }),
},
- {
- title: 'Buildkite (branch)',
- pattern: ':identifier/:branch',
- namedParams: {
- identifier: '3826789cf8890b426057e6fe1c4e683bdf04fa24d498885489',
- branch: 'master',
+ '/buildkite/{identifier}/{branch}': {
+ get: {
+ summary: 'Buildkite (branch)',
+ parameters: pathParams(
+ {
+ name: 'identifier',
+ example: '3826789cf8890b426057e6fe1c4e683bdf04fa24d498885489',
+ },
+ {
+ name: 'branch',
+ example: 'master',
+ },
+ ),
},
- staticPreview: renderBuildStatusBadge({ status: 'passing' }),
},
- ]
+ }
static defaultBadgeData = { label: 'build' }
async fetch({ identifier, branch }) {
const url = `https://badge.buildkite.com/${identifier}.json`
- const options = { qs: { branch } }
+ const options = { searchParams: { branch } }
return this._requestJson({
schema,
url,
diff --git a/services/buildkite/buildkite.tester.js b/services/buildkite/buildkite.tester.js
index d2819659f97d0..fac5ea4751725 100644
--- a/services/buildkite/buildkite.tester.js
+++ b/services/buildkite/buildkite.tester.js
@@ -23,6 +23,6 @@ t.create('buildkite valid pipeline skipping branch')
t.create('buildkite unknown branch')
.get(
- '/3826789cf8890b426057e6fe1c4e683bdf04fa24d498885489/unknown-branch.json'
+ '/3826789cf8890b426057e6fe1c4e683bdf04fa24d498885489/unknown-branch.json',
)
.expectBadge({ label: 'build', message: 'unknown' })
diff --git a/services/bundlejs/bundlejs-package.service.js b/services/bundlejs/bundlejs-package.service.js
new file mode 100644
index 0000000000000..56170387b5507
--- /dev/null
+++ b/services/bundlejs/bundlejs-package.service.js
@@ -0,0 +1,158 @@
+import Joi from 'joi'
+import byteSize from 'byte-size'
+import { BaseJsonService, pathParam, queryParam } from '../index.js'
+import { renderSizeBadge } from '../size.js'
+import { nonNegativeInteger } from '../validators.js'
+
+const schema = Joi.object({
+ size: Joi.object({
+ rawCompressedSize: nonNegativeInteger,
+ rawUncompressedSize: nonNegativeInteger,
+ }).required(),
+}).required()
+
+const queryParamSchema = Joi.object({
+ exports: Joi.string(),
+ externals: Joi.string(),
+ format: Joi.string().valid('min', 'minzip', 'both'),
+}).required()
+
+const esbuild =
+ 'esbuild'
+const denoflate =
+ 'denoflate'
+const bundlejs =
+ 'bundlejs'
+
+const description = `
+View ${esbuild} minified and ${denoflate} gzipped size of a javascript package or selected exports, via ${bundlejs}.
+`
+
+export default class BundlejsPackage extends BaseJsonService {
+ static category = 'size'
+
+ static route = {
+ base: 'bundlejs/size',
+ pattern: ':scope(@[^/]+)?/:packageName+',
+ queryParamSchema,
+ }
+
+ static openApi = {
+ '/bundlejs/size/{packageName}': {
+ get: {
+ summary: 'npm package minimized gzipped size',
+ description,
+ parameters: [
+ pathParam({
+ name: 'packageName',
+ example: 'value-enhancer@3.1.2',
+ description:
+ 'This can either be a package name e.g: `value-enhancer`, or a package name and version e.g: `value-enhancer@3.1.2`',
+ }),
+ queryParam({
+ name: 'exports',
+ example: 'isVal,val',
+ }),
+ queryParam({
+ name: 'externals',
+ example: 'lodash,axios',
+ }),
+ queryParam({
+ name: 'format',
+ schema: { type: 'string', enum: ['min', 'minzip', 'both'] },
+ example: 'minzip',
+ }),
+ ],
+ },
+ },
+ '/bundlejs/size/{scope}/{packageName}': {
+ get: {
+ summary: 'npm package minimized gzipped size (scoped)',
+ description,
+ parameters: [
+ pathParam({
+ name: 'scope',
+ example: '@ngneat',
+ }),
+ pathParam({
+ name: 'packageName',
+ example: 'falso@6.4.0',
+ description:
+ 'This can either be a package name e.g: `falso`, or a package name and version e.g: `falso@6.4.0`',
+ }),
+ queryParam({
+ name: 'exports',
+ example: 'randEmail,randFullName',
+ }),
+ queryParam({
+ name: 'externals',
+ example: 'lodash,axios',
+ }),
+ queryParam({
+ name: 'format',
+ schema: { type: 'string', enum: ['min', 'minzip', 'both'] },
+ example: 'minzip',
+ }),
+ ],
+ },
+ },
+ }
+
+ static defaultBadgeData = { label: 'bundlejs', color: 'informational' }
+
+ async fetch({ scope, packageName, exports, externals }) {
+ const searchParams = {
+ q: `${scope ? `${scope}/` : ''}${packageName}`,
+ }
+ if (exports) {
+ searchParams.treeshake = `[{${exports}}]`
+ }
+ if (externals) {
+ searchParams.config = JSON.stringify({
+ esbuild: { external: externals.split(',') },
+ })
+ }
+ return this._requestJson({
+ schema,
+ url: 'https://deno.bundlejs.com',
+ options: {
+ searchParams,
+ timeout: {
+ request: 3500,
+ },
+ },
+ systemErrors: {
+ ETIMEDOUT: { prettyMessage: 'timeout', cacheSeconds: 10 },
+ },
+ httpErrors: {
+ 404: 'package or version not found',
+ },
+ })
+ }
+
+ async handle({ scope, packageName }, { exports, externals, format }) {
+ const json = await this.fetch({ scope, packageName, exports, externals })
+ switch (format) {
+ case 'min':
+ return renderSizeBadge(
+ json.size.rawUncompressedSize,
+ 'metric',
+ 'minified size',
+ )
+ case 'both':
+ return {
+ label: 'size',
+ message: `${byteSize(json.size.rawUncompressedSize, { units: 'metric' })} (gzip: ${byteSize(json.size.rawCompressedSize, { units: 'metric' })})`,
+ color: 'blue',
+ }
+ default:
+ // by default use format === 'minzip'
+ // because that's how it used to be before the format query param was added
+ return renderSizeBadge(
+ json.size.rawCompressedSize,
+ 'metric',
+ 'minified size (gzip)',
+ )
+ }
+ }
+}
diff --git a/services/bundlejs/bundlejs-package.tester.js b/services/bundlejs/bundlejs-package.tester.js
new file mode 100644
index 0000000000000..5dd247aa52efa
--- /dev/null
+++ b/services/bundlejs/bundlejs-package.tester.js
@@ -0,0 +1,62 @@
+import { isMetricFileSize } from '../test-validators.js'
+import { createServiceTester } from '../tester.js'
+export const t = await createServiceTester()
+
+t.create('bundlejs/package (packageName)')
+ .get('/jquery.json')
+ .expectBadge({ label: 'minified size (gzip)', message: isMetricFileSize })
+
+t.create('bundlejs/package (version)')
+ .get('/react@18.2.0.json')
+ .expectBadge({ label: 'minified size (gzip)', message: isMetricFileSize })
+
+t.create('bundlejs/package (scoped)')
+ .get('/@cycle/rx-run.json')
+ .expectBadge({ label: 'minified size (gzip)', message: isMetricFileSize })
+
+t.create('bundlejs/package (scoped with subpath export)')
+ .get('/@noble/hashes/sha3.js.json')
+ .expectBadge({ label: 'minified size (gzip)', message: isMetricFileSize })
+
+t.create('bundlejs/package (select exports)')
+ .get('/value-enhancer.json?exports=isVal,val')
+ .expectBadge({ label: 'minified size (gzip)', message: isMetricFileSize })
+
+t.create('bundlejs/package (scoped version select exports)')
+ .get('/@floating-ui/dom@1.6.0.json?exports=computePosition,autoUpdate')
+ .expectBadge({ label: 'minified size (gzip)', message: isMetricFileSize })
+
+t.create('bundlejs/package (externals)')
+ .get('/value-enhancer.json?externals=lodash,axios,jquery')
+ .expectBadge({ label: 'minified size (gzip)', message: isMetricFileSize })
+
+t.create('bundlejs/package (format min)')
+ .get('/value-enhancer.json?format=min')
+ .expectBadge({ label: 'minified size', message: isMetricFileSize })
+
+t.create('bundlejs/package (format both)')
+ .get('/value-enhancer.json?format=both')
+ .expectBadge({
+ label: 'size',
+ message: /^[\d.]+ [kMG]?B \(gzip: [\d.]+ [kMG]?B\)$/,
+ })
+
+t.create('bundlejs/package (not found)')
+ .get('/react@18.2.0.json')
+ .intercept(nock =>
+ nock('https://deno.bundlejs.com')
+ .get(/./)
+ .query({ q: 'react@18.2.0' })
+ .reply(404),
+ )
+ .expectBadge({ label: 'bundlejs', message: 'package or version not found' })
+
+t.create('bundlejs/package (timeout)')
+ .get('/react@18.2.0.json')
+ .intercept(nock =>
+ nock('https://deno.bundlejs.com')
+ .get(/./)
+ .query({ q: 'react@18.2.0' })
+ .replyWithError({ code: 'ETIMEDOUT' }),
+ )
+ .expectBadge({ label: 'bundlejs', message: 'timeout' })
diff --git a/services/bundlephobia/bundlephobia.service.js b/services/bundlephobia/bundlephobia.service.js
index 5609e0880ab72..437a37789f165 100644
--- a/services/bundlephobia/bundlephobia.service.js
+++ b/services/bundlephobia/bundlephobia.service.js
@@ -1,14 +1,15 @@
import Joi from 'joi'
-import prettyBytes from 'pretty-bytes'
+import { renderSizeBadge } from '../size.js'
import { nonNegativeInteger } from '../validators.js'
-import { BaseJsonService } from '../index.js'
+import { BaseJsonService, pathParams } from '../index.js'
const schema = Joi.object({
size: nonNegativeInteger,
gzip: nonNegativeInteger,
}).required()
-const keywords = ['node', 'bundlephobia']
+const description =
+ '[Bundlephobia](https://bundlephobia.com) lets you understand the size of a javascript package from NPM before it becomes a part of your bundle.'
export default class Bundlephobia extends BaseJsonService {
static category = 'size'
@@ -18,73 +19,112 @@ export default class Bundlephobia extends BaseJsonService {
pattern: ':format(min|minzip)/:scope(@[^/]+)?/:packageName/:version?',
}
- static examples = [
- {
- title: 'npm bundle size',
- pattern: ':format(min|minzip)/:packageName',
- namedParams: {
- format: 'min',
- packageName: 'react',
+ static openApi = {
+ '/bundlephobia/{format}/{packageName}': {
+ get: {
+ summary: 'npm bundle size',
+ description,
+ parameters: pathParams(
+ {
+ name: 'format',
+ schema: { type: 'string', enum: this.getEnum('format') },
+ example: 'min',
+ },
+ {
+ name: 'packageName',
+ example: 'react',
+ },
+ ),
},
- staticPreview: this.render({ format: 'min', size: 6652 }),
- keywords,
},
- {
- title: 'npm bundle size (scoped)',
- pattern: ':format(min|minzip)/:scope/:packageName',
- namedParams: {
- format: 'min',
- scope: '@cycle',
- packageName: 'core',
+ '/bundlephobia/{format}/{scope}/{packageName}': {
+ get: {
+ summary: 'npm bundle size (scoped)',
+ description,
+ parameters: pathParams(
+ {
+ name: 'format',
+ schema: { type: 'string', enum: this.getEnum('format') },
+ example: 'min',
+ },
+ {
+ name: 'scope',
+ example: '@cycle',
+ },
+ {
+ name: 'packageName',
+ example: 'core',
+ },
+ ),
},
- staticPreview: this.render({ format: 'min', size: 3562 }),
- keywords,
},
- {
- title: 'npm bundle size (version)',
- pattern: ':format(min|minzip)/:packageName/:version',
- namedParams: {
- format: 'min',
- packageName: 'react',
- version: '15.0.0',
+ '/bundlephobia/{format}/{packageName}/{version}': {
+ get: {
+ summary: 'npm bundle size (version)',
+ description,
+ parameters: pathParams(
+ {
+ name: 'format',
+ schema: { type: 'string', enum: this.getEnum('format') },
+ example: 'min',
+ },
+ {
+ name: 'packageName',
+ example: 'react',
+ },
+ {
+ name: 'version',
+ example: '15.0.0',
+ },
+ ),
},
- staticPreview: this.render({ format: 'min', size: 20535 }),
- keywords,
},
- {
- title: 'npm bundle size (scoped version)',
- pattern: ':format(min|minzip)/:scope/:packageName/:version',
- namedParams: {
- format: 'min',
- scope: '@cycle',
- packageName: 'core',
- version: '7.0.0',
+ '/bundlephobia/{format}/{scope}/{packageName}/{version}': {
+ get: {
+ summary: 'npm bundle size (scoped version)',
+ description,
+ parameters: pathParams(
+ {
+ name: 'format',
+ schema: { type: 'string', enum: this.getEnum('format') },
+ example: 'min',
+ },
+ {
+ name: 'scope',
+ example: '@cycle',
+ },
+ {
+ name: 'packageName',
+ example: 'core',
+ },
+ {
+ name: 'version',
+ example: '7.0.0',
+ },
+ ),
},
- staticPreview: this.render({ format: 'min', size: 3562 }),
- keywords,
},
- ]
+ }
+
+ static _cacheLength = 1800
static defaultBadgeData = { label: 'bundlephobia', color: 'informational' }
static render({ format, size }) {
const label = format === 'min' ? 'minified size' : 'minzipped size'
- return {
- label,
- message: prettyBytes(size),
- }
+ return renderSizeBadge(size, 'iec', label)
}
async fetch({ scope, packageName, version }) {
const packageQuery = `${scope ? `${scope}/` : ''}${packageName}${
version ? `@${version}` : ''
}`
- const options = { qs: { package: packageQuery } }
+ const options = { searchParams: { package: packageQuery } }
return this._requestJson({
schema,
url: 'https://bundlephobia.com/api/size',
options,
- errorMessages: {
+ httpErrors: {
404: 'package or version not found',
},
})
diff --git a/services/bundlephobia/bundlephobia.tester.js b/services/bundlephobia/bundlephobia.tester.js
index 525bff0e026b3..3bd229094b0be 100644
--- a/services/bundlephobia/bundlephobia.tester.js
+++ b/services/bundlephobia/bundlephobia.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()
@@ -13,42 +13,42 @@ const data = [
{
format: formats.A,
get: '/min/preact.json',
- expect: { label: 'minified size', message: isFileSize },
+ expect: { label: 'minified size', message: isIecFileSize },
},
{
format: formats.B,
get: '/min/preact/8.0.0.json',
- expect: { label: 'minified size', message: isFileSize },
+ expect: { label: 'minified size', message: isIecFileSize },
},
{
format: formats.C,
get: '/min/@cycle/core.json',
- expect: { label: 'minified size', message: isFileSize },
+ expect: { label: 'minified size', message: isIecFileSize },
},
{
format: formats.D,
get: '/min/@cycle/core/7.0.0.json',
- expect: { label: 'minified size', message: isFileSize },
+ expect: { label: 'minified size', message: isIecFileSize },
},
{
format: formats.A,
get: '/minzip/preact.json',
- expect: { label: 'minzipped size', message: isFileSize },
+ expect: { label: 'minzipped size', message: isIecFileSize },
},
{
format: formats.B,
get: '/minzip/preact/8.0.0.json',
- expect: { label: 'minzipped size', message: isFileSize },
+ expect: { label: 'minzipped size', message: isIecFileSize },
},
{
format: formats.C,
get: '/minzip/@cycle/core.json',
- expect: { label: 'minzipped size', message: isFileSize },
+ expect: { label: 'minzipped size', message: isIecFileSize },
},
{
format: formats.D,
get: '/minzip/@cycle/core/7.0.0.json',
- expect: { label: 'minzipped size', message: isFileSize },
+ expect: { label: 'minzipped size', message: isIecFileSize },
},
{
format: formats.A,
diff --git a/services/cangjie/cangjie-version.service.js b/services/cangjie/cangjie-version.service.js
new file mode 100644
index 0000000000000..1da5dde6cb348
--- /dev/null
+++ b/services/cangjie/cangjie-version.service.js
@@ -0,0 +1,104 @@
+import Joi from 'joi'
+import {
+ BaseJsonlService,
+ InvalidParameter,
+ NotFound,
+ pathParam,
+ queryParam,
+} from '../index.js'
+import { latest, renderVersionBadge } from '../version.js'
+
+const queryParamSchema = Joi.object({
+ organization: Joi.string(),
+}).required()
+
+const indexEntrySchema = Joi.object({
+ organization: Joi.string(),
+ name: Joi.string().required(),
+ version: Joi.string().required(),
+ yanked: Joi.boolean().required(),
+ 'index-version': Joi.string().required(),
+}).required()
+
+const description =
+ '[Cangjie Central Repository](https://pkg.cangjie-lang.cn/index) hosts packages for the Cangjie programming language.'
+
+export default class CangjieVersion extends BaseJsonlService {
+ static category = 'version'
+ static route = {
+ base: 'cangjie',
+ pattern: ':moduleName',
+ queryParamSchema,
+ }
+
+ static openApi = {
+ '/cangjie/{moduleName}': {
+ get: {
+ summary: 'Cangjie Version',
+ description,
+ parameters: [
+ pathParam({
+ name: 'moduleName',
+ example: 'stdx',
+ }),
+ queryParam({
+ name: 'organization',
+ example: 'fountain',
+ description: 'Optional organization name for organization modules.',
+ required: false,
+ }),
+ ],
+ },
+ },
+ }
+
+ static defaultBadgeData = { label: 'cangjie' }
+
+ static getIndexPath({ moduleName }) {
+ if (moduleName.length < 3) {
+ throw new InvalidParameter({ prettyMessage: 'invalid module name' })
+ }
+
+ if (moduleName.length === 3) {
+ return `${moduleName.slice(0, 2)}/${moduleName[2]}/${moduleName}`
+ }
+
+ return `${moduleName.slice(0, 2)}/${moduleName.slice(2, 4)}/${moduleName}`
+ }
+
+ static getLatestVersion(entries) {
+ const version = latest(
+ entries.filter(({ yanked }) => !yanked).map(({ version }) => version),
+ )
+
+ if (version === undefined) {
+ throw new NotFound({ prettyMessage: 'no releases' })
+ }
+
+ return version
+ }
+
+ static render({ version }) {
+ return renderVersionBadge({ version })
+ }
+
+ async fetch({ moduleName, organization }) {
+ const indexPath = this.constructor.getIndexPath({ moduleName })
+ const options = organization
+ ? { searchParams: { organization } }
+ : undefined
+
+ return this._requestJsonl({
+ schema: indexEntrySchema,
+ url: `https://pkg.cangjie-lang.cn/registry/index/${indexPath}`,
+ options,
+ prettyErrorMessage: 'invalid index entry',
+ })
+ }
+
+ async handle({ moduleName }, { organization }) {
+ const entries = await this.fetch({ moduleName, organization })
+ const version = this.constructor.getLatestVersion(entries)
+ return this.constructor.render({ version })
+ }
+}
diff --git a/services/cangjie/cangjie-version.spec.js b/services/cangjie/cangjie-version.spec.js
new file mode 100644
index 0000000000000..d26291eb7acf8
--- /dev/null
+++ b/services/cangjie/cangjie-version.spec.js
@@ -0,0 +1,109 @@
+import { expect } from 'chai'
+import { test, given } from 'sazerac'
+import sinon from 'sinon'
+import { InvalidResponse } from '../index.js'
+import CangjieVersion from './cangjie-version.service.js'
+
+describe('CangjieVersion', function () {
+ describe('getIndexPath', function () {
+ test(CangjieVersion.getIndexPath, () => {
+ given({ moduleName: 'dep' }).expect('de/p/dep')
+ given({ moduleName: 'aabbcc' }).expect('aa/bb/aabbcc')
+ given({ moduleName: 'f_store' }).expect('f_/st/f_store')
+ })
+
+ it('rejects undocumented short module names', function () {
+ expect(() => CangjieVersion.getIndexPath({ moduleName: 'ab' })).to.throw()
+ })
+ })
+
+ describe('fetch', function () {
+ it('parses line-delimited index entries', async function () {
+ const requestFetcher = sinon.stub().resolves({
+ buffer: `
+{"name":"demo","version":"1.0.0","yanked":false,"index-version":"1"}
+{"name":"demo","version":"1.1.0","yanked":true,"index-version":"1"}
+ `,
+ res: { statusCode: 200 },
+ })
+
+ const service = new CangjieVersion(
+ { requestFetcher },
+ { handleInternalErrors: false },
+ )
+
+ expect(await service.fetch({ moduleName: 'demo' })).to.deep.equal([
+ {
+ name: 'demo',
+ version: '1.0.0',
+ yanked: false,
+ 'index-version': '1',
+ },
+ {
+ name: 'demo',
+ version: '1.1.0',
+ yanked: true,
+ 'index-version': '1',
+ },
+ ])
+ })
+
+ it('throws unparseable jsonl response for malformed jsonl', async function () {
+ const requestFetcher = sinon.stub().resolves({
+ buffer: 'not json',
+ res: { statusCode: 200 },
+ })
+
+ const service = new CangjieVersion(
+ { requestFetcher },
+ { handleInternalErrors: false },
+ )
+
+ try {
+ await service.fetch({ moduleName: 'demo' })
+ expect.fail('expected fetch() to throw')
+ } catch (e) {
+ expect(e).to.be.instanceOf(InvalidResponse)
+ expect(e.prettyMessage).to.equal('unparseable jsonl response')
+ }
+ })
+
+ it('throws invalid index entry for schema-invalid lines', async function () {
+ const requestFetcher = sinon.stub().resolves({
+ buffer: '{"name":"demo","version":"1.0.0","index-version":"1"}',
+ res: { statusCode: 200 },
+ })
+
+ const service = new CangjieVersion(
+ { requestFetcher },
+ { handleInternalErrors: false },
+ )
+
+ try {
+ await service.fetch({ moduleName: 'demo' })
+ expect.fail('expected fetch() to throw')
+ } catch (e) {
+ expect(e).to.be.instanceOf(InvalidResponse)
+ expect(e.prettyMessage).to.equal('invalid index entry')
+ }
+ })
+ })
+
+ describe('getLatestVersion', function () {
+ it('selects the highest non-yanked version', function () {
+ const entries = [
+ { version: '1.0.0', yanked: false },
+ { version: '1.1.0', yanked: true },
+ { version: '1.0.5', yanked: false },
+ ]
+
+ expect(CangjieVersion.getLatestVersion(entries)).to.equal('1.0.5')
+ })
+
+ it('throws when all versions are yanked', function () {
+ expect(() =>
+ CangjieVersion.getLatestVersion([{ version: '1.0.0', yanked: true }]),
+ ).to.throw()
+ })
+ })
+})
diff --git a/services/cangjie/cangjie-version.tester.js b/services/cangjie/cangjie-version.tester.js
new file mode 100644
index 0000000000000..5e8d7801c53aa
--- /dev/null
+++ b/services/cangjie/cangjie-version.tester.js
@@ -0,0 +1,92 @@
+import { isVPlusDottedVersionNClausesWithOptionalSuffix as isVersion } from '../test-validators.js'
+import { createServiceTester } from '../tester.js'
+export const t = await createServiceTester()
+
+t.create('live cangjie version unicode_case')
+ .get('/unicode_case.json')
+ .expectBadge({
+ label: 'cangjie',
+ message: isVersion,
+ })
+
+t.create('live cangjie version organization')
+ .get('/f_store.json?organization=fountain')
+ .expectBadge({
+ label: 'cangjie',
+ message: isVersion,
+ })
+
+t.create('version')
+ .get('/stdx.json')
+ .intercept(nock =>
+ nock('https://pkg.cangjie-lang.cn')
+ .get('/registry/index/st/dx/stdx')
+ .reply(
+ 200,
+ [
+ '{"name":"stdx","version":"0.0.1","yanked":false,"index-version":"1"}',
+ '{"name":"stdx","version":"0.0.3","yanked":false,"index-version":"1"}',
+ '{"name":"stdx","version":"0.0.2","yanked":false,"index-version":"1"}',
+ ].join('\n'),
+ ),
+ )
+ .expectBadge({ label: 'cangjie', message: 'v0.0.3' })
+
+t.create('version (organization)')
+ .get('/f_store.json?organization=fountain')
+ .intercept(nock =>
+ nock('https://pkg.cangjie-lang.cn')
+ .get('/registry/index/f_/st/f_store')
+ .query({ organization: 'fountain' })
+ .reply(
+ 200,
+ '{"organization":"fountain","name":"f_store","version":"1.1.2","yanked":false,"index-version":"1"}',
+ ),
+ )
+ .expectBadge({ label: 'cangjie', message: 'v1.1.2' })
+
+t.create('version ignores yanked releases')
+ .get('/demo.json')
+ .intercept(nock =>
+ nock('https://pkg.cangjie-lang.cn')
+ .get('/registry/index/de/mo/demo')
+ .reply(
+ 200,
+ [
+ '{"name":"demo","version":"1.0.0","yanked":false,"index-version":"1"}',
+ '{"name":"demo","version":"1.1.0","yanked":true,"index-version":"1"}',
+ ].join('\n'),
+ ),
+ )
+ .expectBadge({ label: 'cangjie', message: 'v1.0.0' })
+
+t.create('version (invalid module name)')
+ .get('/ab.json')
+ .expectBadge({ label: 'cangjie', message: 'invalid module name' })
+
+t.create('version (unparseable jsonl response)')
+ .get('/stdx.json')
+ .intercept(nock =>
+ nock('https://pkg.cangjie-lang.cn')
+ .get('/registry/index/st/dx/stdx')
+ .reply(200, 'not json'),
+ )
+ .expectBadge({ label: 'cangjie', message: 'unparseable jsonl response' })
+
+t.create('version (invalid index entry)')
+ .get('/stdx.json')
+ .intercept(nock =>
+ nock('https://pkg.cangjie-lang.cn')
+ .get('/registry/index/st/dx/stdx')
+ .reply(200, '{"name":"stdx","version":"0.0.1","index-version":"1"}'),
+ )
+ .expectBadge({ label: 'cangjie', message: 'invalid index entry' })
+
+t.create('version (not found)')
+ .get('/not-a-real-package.json')
+ .intercept(nock =>
+ nock('https://pkg.cangjie-lang.cn')
+ .get('/registry/index/no/t-/not-a-real-package')
+ .reply(404),
+ )
+ .expectBadge({ label: 'cangjie', message: 'not found' })
diff --git a/services/categories.js b/services/categories.js
index a17077b32db23..72737b529d5d6 100644
--- a/services/categories.js
+++ b/services/categories.js
@@ -1,23 +1,23 @@
export default [
- { id: 'build', name: 'Build', keywords: ['build'] },
- { id: 'coverage', name: 'Code Coverage', keywords: ['coverage'] },
- { id: 'analysis', name: 'Analysis', keywords: ['analysis'] },
- { id: 'chat', name: 'Chat', keywords: ['chat'] },
- { id: 'dependencies', name: 'Dependencies', keywords: ['dependencies'] },
- { id: 'size', name: 'Size', keywords: ['size'] },
- { id: 'downloads', name: 'Downloads', keywords: ['downloads'] },
- { id: 'funding', name: 'Funding', keywords: ['funding'] },
- { id: 'issue-tracking', name: 'Issue Tracking', keywords: ['issue'] },
- { id: 'license', name: 'License', keywords: ['license'] },
- { id: 'rating', name: 'Rating', keywords: ['rating'] },
- { id: 'social', name: 'Social', keywords: ['social'] },
- { id: 'version', name: 'Version', keywords: ['version'] },
+ { id: 'build', name: 'Build' },
+ { id: 'coverage', name: 'Code Coverage' },
+ { id: 'test-results', name: 'Test Results' },
+ { id: 'analysis', name: 'Analysis' },
+ { id: 'chat', name: 'Chat' },
+ { id: 'dependencies', name: 'Dependencies' },
+ { id: 'size', name: 'Size' },
+ { id: 'downloads', name: 'Downloads' },
+ { id: 'funding', name: 'Funding' },
+ { id: 'issue-tracking', name: 'Issue Tracking' },
+ { id: 'license', name: 'License' },
+ { id: 'rating', name: 'Rating' },
+ { id: 'social', name: 'Social' },
+ { id: 'version', name: 'Version' },
{
id: 'platform-support',
name: 'Platform & Version Support',
- keywords: ['platform'],
},
- { id: 'monitoring', name: 'Monitoring', keywords: ['monitoring'] },
- { id: 'activity', name: 'Activity', keywords: ['activity'] },
- { id: 'other', name: 'Other', keywords: [] },
+ { id: 'monitoring', name: 'Monitoring' },
+ { id: 'activity', name: 'Activity' },
+ { id: 'other', name: 'Other' },
]
diff --git a/services/cdnjs/cdnjs.service.js b/services/cdnjs/cdnjs.service.js
index d1d9e967f59b2..fff7ec39fa5d8 100644
--- a/services/cdnjs/cdnjs.service.js
+++ b/services/cdnjs/cdnjs.service.js
@@ -1,6 +1,6 @@
import Joi from 'joi'
import { renderVersionBadge } from '../version.js'
-import { BaseJsonService, NotFound } from '../index.js'
+import { BaseJsonService, NotFound, pathParams } from '../index.js'
const cdnjsSchema = Joi.object({
// optional due to non-standard 'not found' condition
@@ -11,12 +11,17 @@ export default class Cdnjs extends BaseJsonService {
static category = 'version'
static route = { base: 'cdnjs/v', pattern: ':library' }
- static examples = [
- {
- namedParams: { library: 'jquery' },
- staticPreview: this.render({ version: '1.5.2' }),
+ static openApi = {
+ '/cdnjs/v/{library}': {
+ get: {
+ summary: 'Cdnjs',
+ parameters: pathParams({
+ name: 'library',
+ example: 'jquery',
+ }),
+ },
},
- ]
+ }
static defaultBadgeData = { label: 'cdnjs' }
diff --git a/services/check-services.spec.js b/services/check-services.spec.js
index 8ee0365156ac7..3f5e99c20a6bf 100644
--- a/services/check-services.spec.js
+++ b/services/check-services.spec.js
@@ -1,11 +1,14 @@
-import { checkNames, collectDefinitions } from '../core/base-service/loader.js'
+import {
+ loadServiceClasses,
+ collectDefinitions,
+} from '../core/base-service/loader.js'
// When these tests fail, they will throw AssertionErrors. Wrapping them in an
// `expect().not.to.throw()` makes the error output unreadable.
it('Services have unique names', async function () {
this.timeout(30000)
- await checkNames()
+ await loadServiceClasses()
})
it('Can collect the service definitions', async function () {
diff --git a/services/chocolatey/chocolatey.service.js b/services/chocolatey/chocolatey.service.js
index 584c6c9f14c50..16cb8daa457df 100644
--- a/services/chocolatey/chocolatey.service.js
+++ b/services/chocolatey/chocolatey.service.js
@@ -3,11 +3,7 @@ import { createServiceFamily } from '../nuget/nuget-v2-service-family.js'
export default createServiceFamily({
defaultLabel: 'chocolatey',
serviceBaseUrl: 'chocolatey',
- apiBaseUrl: 'https://www.chocolatey.org/api/v2',
- odataFormat: 'json',
+ apiBaseUrl: 'https://community.chocolatey.org/api/v2',
title: 'Chocolatey',
examplePackageName: 'git',
- exampleVersion: '2.19.2',
- examplePrereleaseVersion: '2.19.2',
- exampleDownloadCount: 2.2e6,
})
diff --git a/services/chocolatey/chocolatey.tester.js b/services/chocolatey/chocolatey.tester.js
index 6c2a3a18decd1..c2efd149c5de7 100644
--- a/services/chocolatey/chocolatey.tester.js
+++ b/services/chocolatey/chocolatey.tester.js
@@ -46,5 +46,8 @@ t.create('version (pre) (not found)')
.expectBadge({ label: 'chocolatey', message: 'not found' })
t.create('version (legacy redirect: vpre)')
- .get('/vpre/scriptcs.svg')
- .expectRedirect('/chocolatey/v/scriptcs.svg?include_prereleases')
+ .get('/vpre/scriptcs.json')
+ .expectBadge({
+ label: 'chocolatey',
+ message: 'https://github.com/badges/shields/pull/11583',
+ })
diff --git a/services/chrome-web-store/chrome-web-store-base.js b/services/chrome-web-store/chrome-web-store-base.js
index 77e917c2b71a4..ecc1eb71bc258 100644
--- a/services/chrome-web-store/chrome-web-store-base.js
+++ b/services/chrome-web-store/chrome-web-store-base.js
@@ -1,7 +1,13 @@
-import ChromeWebStore from 'webextension-store-meta/lib/chrome-web-store/index.js'
+import { ChromeWebStore } from 'webextension-store-meta/lib/chrome-web-store/index.js'
import checkErrorResponse from '../../core/base-service/check-error-response.js'
import { BaseService, Inaccessible } from '../index.js'
+const description = `
+Our servers make requests to the Chrome Web Store from the US East region. Therefore, the extension must not be regionally restricted by the publisher for that location.
+`
+
+export { description }
+
export default class BaseChromeWebStoreService extends BaseService {
async fetch({ storeId }) {
try {
diff --git a/services/chrome-web-store/chrome-web-store-last-updated.service.js b/services/chrome-web-store/chrome-web-store-last-updated.service.js
new file mode 100644
index 0000000000000..a2872ac4dc17e
--- /dev/null
+++ b/services/chrome-web-store/chrome-web-store-last-updated.service.js
@@ -0,0 +1,38 @@
+import { renderDateBadge } from '../date.js'
+import { NotFound, pathParams } from '../index.js'
+import BaseChromeWebStoreService, {
+ description,
+} from './chrome-web-store-base.js'
+
+export default class ChromeWebStoreLastUpdated extends BaseChromeWebStoreService {
+ static category = 'activity'
+ static route = { base: 'chrome-web-store/last-updated', pattern: ':storeId' }
+
+ static openApi = {
+ '/chrome-web-store/last-updated/{storeId}': {
+ get: {
+ summary: 'Chrome Web Store Last Updated',
+ description,
+ parameters: pathParams({
+ name: 'storeId',
+ example: 'nccfelhkfpbnefflolffkclhenplhiab',
+ }),
+ },
+ },
+ }
+
+ static defaultBadgeData = {
+ label: 'last updated',
+ }
+
+ async handle({ storeId }) {
+ const chromeWebStore = await this.fetch({ storeId })
+ const lastUpdated = chromeWebStore.lastUpdated()
+
+ if (lastUpdated == null) {
+ throw new NotFound({ prettyMessage: 'not found' })
+ }
+
+ return renderDateBadge(lastUpdated)
+ }
+}
diff --git a/services/chrome-web-store/chrome-web-store-last-updated.tester.js b/services/chrome-web-store/chrome-web-store-last-updated.tester.js
new file mode 100644
index 0000000000000..3459b3702c28f
--- /dev/null
+++ b/services/chrome-web-store/chrome-web-store-last-updated.tester.js
@@ -0,0 +1,18 @@
+import { isFormattedDate } from '../test-validators.js'
+import { createServiceTester } from '../tester.js'
+
+export const t = await createServiceTester()
+
+t.create('Last updated')
+ .get('/nccfelhkfpbnefflolffkclhenplhiab.json')
+ .expectBadge({
+ label: 'last updated',
+ message: isFormattedDate,
+ })
+
+t.create('Last updated (not found)')
+ .get('/invalid-name-of-addon.json')
+ .expectBadge({
+ label: 'last updated',
+ message: 'not found',
+ })
diff --git a/services/chrome-web-store/chrome-web-store-price.service.js b/services/chrome-web-store/chrome-web-store-price.service.js
deleted file mode 100644
index 99068389d04b6..0000000000000
--- a/services/chrome-web-store/chrome-web-store-price.service.js
+++ /dev/null
@@ -1,35 +0,0 @@
-import { currencyFromCode } from '../text-formatters.js'
-import { NotFound } from '../index.js'
-import BaseChromeWebStoreService from './chrome-web-store-base.js'
-
-export default class ChromeWebStorePrice extends BaseChromeWebStoreService {
- static category = 'funding'
- static route = { base: 'chrome-web-store/price', pattern: ':storeId' }
-
- static examples = [
- {
- title: 'Chrome Web Store',
- namedParams: { storeId: 'ogffaloegjglncjfehdfplabnoondfjo' },
- staticPreview: this.render({ priceCurrency: 'USD', price: 0 }),
- },
- ]
-
- static defaultBadgeData = { label: 'price' }
-
- static render({ priceCurrency, price }) {
- return {
- message: `${currencyFromCode(priceCurrency) + price}`,
- color: 'brightgreen',
- }
- }
-
- async handle({ storeId }) {
- const chromeWebStore = await this.fetch({ storeId })
- const priceCurrency = chromeWebStore.priceCurrency()
- const price = chromeWebStore.price()
- if (priceCurrency == null || price == null) {
- throw new NotFound({ prettyMessage: 'not found' })
- }
- return this.constructor.render({ priceCurrency, price })
- }
-}
diff --git a/services/chrome-web-store/chrome-web-store-price.tester.js b/services/chrome-web-store/chrome-web-store-price.tester.js
deleted file mode 100644
index 8c017faf5774c..0000000000000
--- a/services/chrome-web-store/chrome-web-store-price.tester.js
+++ /dev/null
@@ -1,20 +0,0 @@
-import Joi from 'joi'
-import { createServiceTester } from '../tester.js'
-export const t = await createServiceTester()
-
-t.create('Price')
- .get('/alhjnofcnnpeaphgeakdhkebafjcpeae.json')
- .expectBadge({
- label: 'price',
- message: Joi.string().regex(/^\$\d+(.\d{1,2})?$/),
- })
-
-t.create('Price (not found)')
- .get('/invalid-name-of-addon.json')
- .expectBadge({ label: 'price', message: 'not found' })
-
-// Keep this "inaccessible" test, since this service does not use BaseService#_request.
-t.create('Price (inaccessible)')
- .get('/alhjnofcnnpeaphgeakdhkebafjcpeae.json')
- .networkOff()
- .expectBadge({ label: 'price', message: 'inaccessible' })
diff --git a/services/chrome-web-store/chrome-web-store-rating.service.js b/services/chrome-web-store/chrome-web-store-rating.service.js
index 5c273b5357961..84fff759e0772 100644
--- a/services/chrome-web-store/chrome-web-store-rating.service.js
+++ b/services/chrome-web-store/chrome-web-store-rating.service.js
@@ -1,7 +1,9 @@
import { floorCount as floorCountColor } from '../color-formatters.js'
import { metric, starRating } from '../text-formatters.js'
-import { NotFound } from '../index.js'
-import BaseChromeWebStoreService from './chrome-web-store-base.js'
+import { NotFound, pathParams } from '../index.js'
+import BaseChromeWebStoreService, {
+ description,
+} from './chrome-web-store-base.js'
class BaseChromeWebStoreRating extends BaseChromeWebStoreService {
static category = 'rating'
@@ -15,13 +17,18 @@ class ChromeWebStoreRating extends BaseChromeWebStoreRating {
pattern: ':storeId',
}
- static examples = [
- {
- title: 'Chrome Web Store',
- namedParams: { storeId: 'ogffaloegjglncjfehdfplabnoondfjo' },
- staticPreview: this.render({ rating: '3.67' }),
+ static openApi = {
+ '/chrome-web-store/rating/{storeId}': {
+ get: {
+ summary: 'Chrome Web Store Rating',
+ description,
+ parameters: pathParams({
+ name: 'storeId',
+ example: 'ogffaloegjglncjfehdfplabnoondfjo',
+ }),
+ },
},
- ]
+ }
static render({ rating }) {
rating = Math.round(rating * 100) / 100
@@ -47,13 +54,18 @@ class ChromeWebStoreRatingCount extends BaseChromeWebStoreRating {
pattern: ':storeId',
}
- static examples = [
- {
- title: 'Chrome Web Store',
- namedParams: { storeId: 'ogffaloegjglncjfehdfplabnoondfjo' },
- staticPreview: this.render({ ratingCount: 12 }),
+ static openApi = {
+ '/chrome-web-store/rating-count/{storeId}': {
+ get: {
+ summary: 'Chrome Web Store Rating Count',
+ description,
+ parameters: pathParams({
+ name: 'storeId',
+ example: 'ogffaloegjglncjfehdfplabnoondfjo',
+ }),
+ },
},
- ]
+ }
static render({ ratingCount }) {
return {
@@ -81,13 +93,18 @@ class ChromeWebStoreRatingStars extends BaseChromeWebStoreRating {
pattern: ':storeId',
}
- static examples = [
- {
- title: 'Chrome Web Store',
- namedParams: { storeId: 'ogffaloegjglncjfehdfplabnoondfjo' },
- staticPreview: this.render({ rating: '3.75' }),
+ static openApi = {
+ '/chrome-web-store/stars/{storeId}': {
+ get: {
+ summary: 'Chrome Web Store Stars',
+ description,
+ parameters: pathParams({
+ name: 'storeId',
+ example: 'ogffaloegjglncjfehdfplabnoondfjo',
+ }),
+ },
},
- ]
+ }
static render({ rating }) {
return {
diff --git a/services/chrome-web-store/chrome-web-store-rating.tester.js b/services/chrome-web-store/chrome-web-store-rating.tester.js
index 3d4f20c1d5f32..27948c53d9c0a 100644
--- a/services/chrome-web-store/chrome-web-store-rating.tester.js
+++ b/services/chrome-web-store/chrome-web-store-rating.tester.js
@@ -1,4 +1,5 @@
import Joi from 'joi'
+import { Agent, MockAgent, setGlobalDispatcher } from 'undici'
import { isStarRating } from '../test-validators.js'
import { ServiceTester } from '../tester.js'
@@ -39,7 +40,16 @@ t.create('Stars (not found)')
.expectBadge({ label: 'rating', message: 'not found' })
// Keep this "inaccessible" test, since this service does not use BaseService#_request.
+const mockAgent = new MockAgent()
t.create('Rating (inaccessible)')
.get('/rating/alhjnofcnnpeaphgeakdhkebafjcpeae.json')
- .networkOff()
+ // webextension-store-meta uses undici internally, so we can't mock it with nock
+ .before(function () {
+ setGlobalDispatcher(mockAgent)
+ mockAgent.disableNetConnect()
+ })
+ .after(async function () {
+ await mockAgent.close()
+ setGlobalDispatcher(new Agent())
+ })
.expectBadge({ label: 'rating', message: 'inaccessible' })
diff --git a/services/chrome-web-store/chrome-web-store-size.service.js b/services/chrome-web-store/chrome-web-store-size.service.js
new file mode 100644
index 0000000000000..ee2425aadd0dd
--- /dev/null
+++ b/services/chrome-web-store/chrome-web-store-size.service.js
@@ -0,0 +1,49 @@
+import { InvalidResponse, NotFound, pathParams } from '../index.js'
+import BaseChromeWebStoreService, {
+ description,
+} from './chrome-web-store-base.js'
+
+export default class ChromeWebStoreSize extends BaseChromeWebStoreService {
+ static category = 'size'
+ static route = { base: 'chrome-web-store/size', pattern: ':storeId' }
+
+ static openApi = {
+ '/chrome-web-store/size/{storeId}': {
+ get: {
+ summary: 'Chrome Web Store Size',
+ description,
+ parameters: pathParams({
+ name: 'storeId',
+ example: 'nccfelhkfpbnefflolffkclhenplhiab',
+ }),
+ },
+ },
+ }
+
+ static defaultBadgeData = {
+ label: 'extension size',
+ color: 'blue',
+ }
+
+ static transform(sizeStr) {
+ const match = sizeStr.match(/^(\d+(?:\.\d+)?)([a-zA-Z]+)$/)
+ if (!match) {
+ throw new InvalidResponse({
+ prettyMessage: 'size does not match expected format',
+ })
+ }
+ const [, size, units] = match
+ return `${size} ${units}`
+ }
+
+ async handle({ storeId }) {
+ const chromeWebStore = await this.fetch({ storeId })
+ const size = chromeWebStore.size()
+
+ if (size == null) {
+ throw new NotFound({ prettyMessage: 'not found' })
+ }
+
+ return { message: this.constructor.transform(size) }
+ }
+}
diff --git a/services/chrome-web-store/chrome-web-store-size.spec.js b/services/chrome-web-store/chrome-web-store-size.spec.js
new file mode 100644
index 0000000000000..e7d940ca75732
--- /dev/null
+++ b/services/chrome-web-store/chrome-web-store-size.spec.js
@@ -0,0 +1,28 @@
+import { expect } from 'chai'
+import { test, given } from 'sazerac'
+import { InvalidResponse } from '../index.js'
+import ChromeWebStoreSize from './chrome-web-store-size.service.js'
+
+describe('transform function', function () {
+ it('formats size correctly', function () {
+ test(ChromeWebStoreSize.transform, () => {
+ given('0.55KiB').expect('0.55 KiB')
+ given('19.86KiB').expect('19.86 KiB')
+ given('432KiB').expect('432 KiB')
+ })
+ })
+
+ it('throws when the format is unexpected', function () {
+ expect(() => ChromeWebStoreSize.transform('432 KiB')).to.throw(
+ InvalidResponse,
+ )
+ expect(() => ChromeWebStoreSize.transform('432')).to.throw(InvalidResponse)
+ expect(() => ChromeWebStoreSize.transform('KiB')).to.throw(InvalidResponse)
+ expect(() => ChromeWebStoreSize.transform('foobar')).to.throw(
+ InvalidResponse,
+ )
+ expect(() => ChromeWebStoreSize.transform('4.4.4 KiB')).to.throw(
+ InvalidResponse,
+ )
+ })
+})
diff --git a/services/chrome-web-store/chrome-web-store-size.tester.js b/services/chrome-web-store/chrome-web-store-size.tester.js
new file mode 100644
index 0000000000000..40e8d62661903
--- /dev/null
+++ b/services/chrome-web-store/chrome-web-store-size.tester.js
@@ -0,0 +1,13 @@
+import { createServiceTester } from '../tester.js'
+import { isIecFileSize } from '../test-validators.js'
+
+export const t = await createServiceTester()
+
+t.create('Size').get('/nccfelhkfpbnefflolffkclhenplhiab.json').expectBadge({
+ label: 'extension size',
+ message: isIecFileSize,
+})
+
+t.create('Size (not found)')
+ .get('/invalid-name-of-addon.json')
+ .expectBadge({ label: 'extension size', message: 'not found' })
diff --git a/services/chrome-web-store/chrome-web-store-users.service.js b/services/chrome-web-store/chrome-web-store-users.service.js
index 7e8341c9694e2..da35e20ddc95d 100644
--- a/services/chrome-web-store/chrome-web-store-users.service.js
+++ b/services/chrome-web-store/chrome-web-store-users.service.js
@@ -1,36 +1,37 @@
-import { metric } from '../text-formatters.js'
-import { downloadCount } from '../color-formatters.js'
-import { redirector, NotFound } from '../index.js'
-import BaseChromeWebStoreService from './chrome-web-store-base.js'
+import { renderDownloadsBadge } from '../downloads.js'
+import { redirector, NotFound, pathParams } from '../index.js'
+import BaseChromeWebStoreService, {
+ description,
+} from './chrome-web-store-base.js'
class ChromeWebStoreUsers extends BaseChromeWebStoreService {
static category = 'downloads'
static route = { base: 'chrome-web-store/users', pattern: ':storeId' }
- static examples = [
- {
- title: 'Chrome Web Store',
- namedParams: { storeId: 'ogffaloegjglncjfehdfplabnoondfjo' },
- staticPreview: this.render({ downloads: 573 }),
+ static openApi = {
+ '/chrome-web-store/users/{storeId}': {
+ get: {
+ summary: 'Chrome Web Store Users',
+ description,
+ parameters: pathParams({
+ name: 'storeId',
+ example: 'ogffaloegjglncjfehdfplabnoondfjo',
+ }),
+ },
},
- ]
+ }
static defaultBadgeData = { label: 'users' }
- static render({ downloads }) {
- return {
- message: `${metric(downloads)}`,
- color: downloadCount(downloads),
- }
- }
-
async handle({ storeId }) {
const chromeWebStore = await this.fetch({ storeId })
const downloads = chromeWebStore.users()
if (downloads == null) {
throw new NotFound({ prettyMessage: 'not found' })
}
- return this.constructor.render({ downloads })
+ return renderDownloadsBadge({
+ downloads: String(downloads.replace(',', '')),
+ })
}
}
diff --git a/services/chrome-web-store/chrome-web-store-users.tester.js b/services/chrome-web-store/chrome-web-store-users.tester.js
index d00b6c3c13217..6b02b701d5468 100644
--- a/services/chrome-web-store/chrome-web-store-users.tester.js
+++ b/services/chrome-web-store/chrome-web-store-users.tester.js
@@ -1,3 +1,4 @@
+import { Agent, MockAgent, setGlobalDispatcher } from 'undici'
import { isMetric } from '../test-validators.js'
import { ServiceTester } from '../tester.js'
@@ -10,7 +11,7 @@ export const t = new ServiceTester({
t.create('Downloads (redirect)')
.get('/d/alhjnofcnnpeaphgeakdhkebafjcpeae.svg')
.expectRedirect(
- '/chrome-web-store/users/alhjnofcnnpeaphgeakdhkebafjcpeae.svg'
+ '/chrome-web-store/users/alhjnofcnnpeaphgeakdhkebafjcpeae.svg',
)
t.create('Users')
@@ -22,7 +23,16 @@ t.create('Users (not found)')
.expectBadge({ label: 'users', message: 'not found' })
// Keep this "inaccessible" test, since this service does not use BaseService#_request.
+const mockAgent = new MockAgent()
t.create('Users (inaccessible)')
.get('/users/alhjnofcnnpeaphgeakdhkebafjcpeae.json')
- .networkOff()
+ // webextension-store-meta uses undici internally, so we can't mock it with nock
+ .before(function () {
+ setGlobalDispatcher(mockAgent)
+ mockAgent.disableNetConnect()
+ })
+ .after(async function () {
+ await mockAgent.close()
+ setGlobalDispatcher(new Agent())
+ })
.expectBadge({ label: 'users', message: 'inaccessible' })
diff --git a/services/chrome-web-store/chrome-web-store-version.service.js b/services/chrome-web-store/chrome-web-store-version.service.js
index e9e42af9596a0..e20c487daf429 100644
--- a/services/chrome-web-store/chrome-web-store-version.service.js
+++ b/services/chrome-web-store/chrome-web-store-version.service.js
@@ -1,18 +1,25 @@
import { renderVersionBadge } from '../version.js'
-import { NotFound } from '../index.js'
-import BaseChromeWebStoreService from './chrome-web-store-base.js'
+import { NotFound, pathParams } from '../index.js'
+import BaseChromeWebStoreService, {
+ description,
+} from './chrome-web-store-base.js'
export default class ChromeWebStoreVersion extends BaseChromeWebStoreService {
static category = 'version'
static route = { base: 'chrome-web-store/v', pattern: ':storeId' }
- static examples = [
- {
- title: 'Chrome Web Store',
- namedParams: { storeId: 'ogffaloegjglncjfehdfplabnoondfjo' },
- staticPreview: renderVersionBadge({ version: 'v1.1.0' }),
+ static openApi = {
+ '/chrome-web-store/v/{storeId}': {
+ get: {
+ summary: 'Chrome Web Store Version',
+ description,
+ parameters: pathParams({
+ name: 'storeId',
+ example: 'ogffaloegjglncjfehdfplabnoondfjo',
+ }),
+ },
},
- ]
+ }
static defaultBadgeData = { label: 'chrome web store' }
diff --git a/services/chrome-web-store/chrome-web-store-version.tester.js b/services/chrome-web-store/chrome-web-store-version.tester.js
index cccf0b95e14be..f108c7e70827e 100644
--- a/services/chrome-web-store/chrome-web-store-version.tester.js
+++ b/services/chrome-web-store/chrome-web-store-version.tester.js
@@ -1,3 +1,4 @@
+import { Agent, MockAgent, setGlobalDispatcher } from 'undici'
import { isVPlusDottedVersionAtLeastOne } from '../test-validators.js'
import { createServiceTester } from '../tester.js'
export const t = await createServiceTester()
@@ -12,7 +13,16 @@ t.create('Version (not found)')
.expectBadge({ label: 'chrome web store', message: 'not found' })
// Keep this "inaccessible" test, since this service does not use BaseService#_request.
+const mockAgent = new MockAgent()
t.create('Version (inaccessible)')
.get('/alhjnofcnnpeaphgeakdhkebafjcpeae.json')
- .networkOff()
+ // webextension-store-meta uses undici internally, so we can't mock it with nock
+ .before(function () {
+ setGlobalDispatcher(mockAgent)
+ mockAgent.disableNetConnect()
+ })
+ .after(async function () {
+ await mockAgent.close()
+ setGlobalDispatcher(new Agent())
+ })
.expectBadge({ label: 'chrome web store', message: 'inaccessible' })
diff --git a/services/cii-best-practices/cii-best-practices.service.js b/services/cii-best-practices/cii-best-practices.service.js
index 4aefbf8af72a5..ae1263fc2bea7 100644
--- a/services/cii-best-practices/cii-best-practices.service.js
+++ b/services/cii-best-practices/cii-best-practices.service.js
@@ -1,14 +1,12 @@
import Joi from 'joi'
import { colorScale, coveragePercentage } from '../color-formatters.js'
-import { BaseJsonService } from '../index.js'
+import { BaseJsonService, pathParams } from '../index.js'
const schema = Joi.object({
badge_level: Joi.string().required(),
tiered_percentage: Joi.number().required(),
}).required()
-const keywords = ['core infrastructure initiative']
-
const summaryColorScale = colorScale(
[10, 20, 30, 40, 50, 60, 70, 80, 90, 100, 200, 300],
[
@@ -25,7 +23,7 @@ const summaryColorScale = colorScale(
'brightgreen',
'#BBBBBB',
'#E9C504',
- ]
+ ],
)
export default class CIIBestPracticesService extends BaseJsonService {
@@ -35,37 +33,26 @@ export default class CIIBestPracticesService extends BaseJsonService {
pattern: ':metric(level|percentage|summary)/:projectId',
}
- static exampless = [
- {
- title: 'CII Best Practices Level',
- pattern: 'level/:projectId',
- namedParams: {
- projectId: '1',
- },
- staticPreview: this.renderLevelBadge({ level: 'gold' }),
- keywords,
- },
- {
- title: 'CII Best Practices Tiered Percentage',
- pattern: 'percentage/:projectId',
- namedParams: {
- projectId: '29',
+ static openApi = {
+ '/cii/{metric}/{projectId}': {
+ get: {
+ summary: 'CII Best Practices',
+ description:
+ 'The Core Infrastructure Initiative (CII) Best Practices badge is a way for Open Source projects to show that they follow best practices',
+ parameters: pathParams(
+ {
+ name: 'metric',
+ example: 'level',
+ schema: { type: 'string', enum: this.getEnum('metric') },
+ },
+ {
+ name: 'projectId',
+ example: '1',
+ },
+ ),
},
- staticPreview: this.renderTieredPercentageBadge({ percentage: 107 }),
- keywords,
},
- {
- title: 'CII Best Practices Summary',
- pattern: 'summary/:projectId',
- namedParams: {
- projectId: '33',
- },
- staticPreview: this.renderSummaryBadge({ percentage: 94 }),
- keywords,
- documentation:
- 'This badge uses the same message and color scale as the native CII one, but with all the configuration and goodness that Shields provides!',
- },
- ]
+ }
static defaultBadgeData = { label: 'cii' }
@@ -115,7 +102,7 @@ export default class CIIBestPracticesService extends BaseJsonService {
await this._requestJson({
schema,
url: `https://bestpractices.coreinfrastructure.org/projects/${projectId}/badge.json`,
- errorMessages: {
+ httpErrors: {
404: 'project not found',
},
})
diff --git a/services/cii-best-practices/cii-best-practices.tester.js b/services/cii-best-practices/cii-best-practices.tester.js
index ba62ffaa11fa1..e75a527c7c8cf 100644
--- a/services/cii-best-practices/cii-best-practices.tester.js
+++ b/services/cii-best-practices/cii-best-practices.tester.js
@@ -3,39 +3,39 @@ import { createServiceTester } from '../tester.js'
export const t = await createServiceTester()
t.create('level known project')
- .get(`/level/1.json`)
+ .get('/level/1.json')
.expectBadge({
label: 'cii',
message: withRegex(/in progress|passing|silver|gold/),
})
t.create('percentage known project')
- .get(`/percentage/29.json`)
+ .get('/percentage/29.json')
.expectBadge({
label: 'cii',
message: withRegex(/([0-9]|[1-9][0-9]|1[0-9][0-9]|2[0-9][0-9]|300)%/),
})
t.create('summary known project')
- .get(`/summary/33.json`)
+ .get('/summary/33.json')
.expectBadge({
label: 'cii',
message: withRegex(/(in progress [0-9]|[1-9][0-9]%)|passing|silver|gold/),
})
t.create('unknown project')
- .get(`/level/abc.json`)
+ .get('/level/abc.json')
.expectBadge({ label: 'cii', message: 'project not found' })
t.create('level: gold project')
- .get(`/level/1.json`)
+ .get('/level/1.json')
.intercept(nock =>
nock('https://bestpractices.coreinfrastructure.org/projects')
.get('/1/badge.json')
.reply(200, {
badge_level: 'gold',
tiered_percentage: 300,
- })
+ }),
)
.expectBadge({
label: 'cii',
@@ -43,14 +43,14 @@ t.create('level: gold project')
})
t.create('level: silver project')
- .get(`/level/34.json`)
+ .get('/level/34.json')
.intercept(nock =>
nock('https://bestpractices.coreinfrastructure.org/projects')
.get('/34/badge.json')
.reply(200, {
badge_level: 'silver',
tiered_percentage: 297,
- })
+ }),
)
.expectBadge({
label: 'cii',
@@ -58,14 +58,14 @@ t.create('level: silver project')
})
t.create('level: passing project')
- .get(`/level/29.json`)
+ .get('/level/29.json')
.intercept(nock =>
nock('https://bestpractices.coreinfrastructure.org/projects')
.get('/29/badge.json')
.reply(200, {
badge_level: 'passing',
tiered_percentage: 107,
- })
+ }),
)
.expectBadge({
label: 'cii',
@@ -73,14 +73,14 @@ t.create('level: passing project')
})
t.create('level: in progress project')
- .get(`/level/33.json`)
+ .get('/level/33.json')
.intercept(nock =>
nock('https://bestpractices.coreinfrastructure.org/projects')
.get('/33/badge.json')
.reply(200, {
badge_level: 'in_progress',
tiered_percentage: 94,
- })
+ }),
)
.expectBadge({
label: 'cii',
@@ -88,14 +88,14 @@ t.create('level: in progress project')
})
t.create('percentage: gold project')
- .get(`/percentage/1.json`)
+ .get('/percentage/1.json')
.intercept(nock =>
nock('https://bestpractices.coreinfrastructure.org/projects')
.get('/1/badge.json')
.reply(200, {
badge_level: 'gold',
tiered_percentage: 300,
- })
+ }),
)
.expectBadge({
label: 'cii',
@@ -103,14 +103,14 @@ t.create('percentage: gold project')
})
t.create('percentage: silver project')
- .get(`/percentage/34.json`)
+ .get('/percentage/34.json')
.intercept(nock =>
nock('https://bestpractices.coreinfrastructure.org/projects')
.get('/34/badge.json')
.reply(200, {
badge_level: 'silver',
tiered_percentage: 297,
- })
+ }),
)
.expectBadge({
label: 'cii',
@@ -118,14 +118,14 @@ t.create('percentage: silver project')
})
t.create('percentage: passing project')
- .get(`/percentage/29.json`)
+ .get('/percentage/29.json')
.intercept(nock =>
nock('https://bestpractices.coreinfrastructure.org/projects')
.get('/29/badge.json')
.reply(200, {
badge_level: 'passing',
tiered_percentage: 107,
- })
+ }),
)
.expectBadge({
label: 'cii',
@@ -133,14 +133,14 @@ t.create('percentage: passing project')
})
t.create('percentage: in progress project')
- .get(`/percentage/33.json`)
+ .get('/percentage/33.json')
.intercept(nock =>
nock('https://bestpractices.coreinfrastructure.org/projects')
.get('/33/badge.json')
.reply(200, {
badge_level: 'in_progress',
tiered_percentage: 94,
- })
+ }),
)
.expectBadge({
label: 'cii',
@@ -148,14 +148,14 @@ t.create('percentage: in progress project')
})
t.create('summary: gold project')
- .get(`/summary/1.json`)
+ .get('/summary/1.json')
.intercept(nock =>
nock('https://bestpractices.coreinfrastructure.org/projects')
.get('/1/badge.json')
.reply(200, {
badge_level: 'gold',
tiered_percentage: 300,
- })
+ }),
)
.expectBadge({
label: 'cii',
@@ -163,14 +163,14 @@ t.create('summary: gold project')
})
t.create('summary: silver project')
- .get(`/summary/34.json`)
+ .get('/summary/34.json')
.intercept(nock =>
nock('https://bestpractices.coreinfrastructure.org/projects')
.get('/34/badge.json')
.reply(200, {
badge_level: 'silver',
tiered_percentage: 297,
- })
+ }),
)
.expectBadge({
label: 'cii',
@@ -178,14 +178,14 @@ t.create('summary: silver project')
})
t.create('summary: passing project')
- .get(`/summary/29.json`)
+ .get('/summary/29.json')
.intercept(nock =>
nock('https://bestpractices.coreinfrastructure.org/projects')
.get('/29/badge.json')
.reply(200, {
badge_level: 'passing',
tiered_percentage: 107,
- })
+ }),
)
.expectBadge({
label: 'cii',
@@ -193,14 +193,14 @@ t.create('summary: passing project')
})
t.create('summary: in progress project')
- .get(`/summary/33.json`)
+ .get('/summary/33.json')
.intercept(nock =>
nock('https://bestpractices.coreinfrastructure.org/projects')
.get('/33/badge.json')
.reply(200, {
badge_level: 'in_progress',
tiered_percentage: 94,
- })
+ }),
)
.expectBadge({
label: 'cii',
diff --git a/services/circleci/circleci.service.js b/services/circleci/circleci.service.js
index 2087af453136a..38d9f43f9c87e 100644
--- a/services/circleci/circleci.service.js
+++ b/services/circleci/circleci.service.js
@@ -1,19 +1,23 @@
import Joi from 'joi'
import { isBuildStatus, renderBuildStatusBadge } from '../build-status.js'
-import { BaseSvgScrapingService, redirector } from '../index.js'
+import {
+ BaseSvgScrapingService,
+ retiredService,
+ redirector,
+ pathParam,
+ queryParam,
+} from '../index.js'
const circleSchema = Joi.object({ message: isBuildStatus }).required()
const queryParamSchema = Joi.object({ token: Joi.string() }).required()
-const documentation = `
-
- You may specify an optional token to get the status for a private repository.
-
- If you need to use a token, please use a Project Token and only assign your token the 'Status' permission. Never use a Personal Token as they grant full read write permissions to your projects.
-
- For more information about managing Circle CI tokens, please read this article.
-
- You may specify a Codecov badge token to get coverage for a private repository. -
-
- You can find the token under the badge section of your project settings page, in this url: https://codecov.io/{vcsName}/{user}/{repo}/settings/badge.
-
https://codecov.io/[vcsName]/[user]/[repo]/config/badge.
+`
+
+const tokenDescription = `Required only for private repositories.`
+
+const flagDescription = `
+Display coverage for a specific subset of your project.
+See [Codecov's documentation](https://docs.codecov.io/docs/flags) for details.
+`
+
+const componentDescription = `
+Display coverage for a specific component of your project.
+See [Codecov's documentation](https://docs.codecov.com/docs/components) for details.
`
export default class Codecov extends BaseSvgScrapingService {
@@ -54,39 +67,69 @@ export default class Codecov extends BaseSvgScrapingService {
queryParamSchema,
}
- static examples = [
- {
- title: 'Codecov',
- pattern: ':vcsName(github|gh|bitbucket|bb|gl|gitlab)/:user/:repo',
- namedParams: {
- vcsName: 'github',
- user: 'codecov',
- repo: 'example-node',
- },
- queryParams: {
- token: 'a1b2c3d4e5',
- flag: 'flag_name',
+ static openApi = {
+ '/codecov/c/{vcsName}/{user}/{repo}': {
+ get: {
+ summary: 'Codecov',
+ description,
+ parameters: [
+ pathParam({
+ name: 'vcsName',
+ example: 'github',
+ schema: { type: 'string', enum: this.getEnum('vcsName') },
+ }),
+ pathParam({ name: 'user', example: 'codecov' }),
+ pathParam({ name: 'repo', example: 'example-node' }),
+ queryParam({
+ name: 'token',
+ description: tokenDescription,
+ example: 'a1b2c3d4e5',
+ }),
+ queryParam({
+ name: 'flag',
+ description: flagDescription,
+ example: 'flag_name',
+ }),
+ queryParam({
+ name: 'component',
+ description: componentDescription,
+ example: 'component_id_or_name',
+ }),
+ ],
},
- staticPreview: this.render({ coverage: 90 }),
- documentation,
},
- {
- title: 'Codecov branch',
- pattern: ':vcsName(github|gh|bitbucket|bb|gl|gitlab)/:user/:repo/:branch',
- namedParams: {
- vcsName: 'github',
- user: 'codecov',
- repo: 'example-node',
- branch: 'master',
- },
- queryParams: {
- token: 'a1b2c3d4e5',
- flag: 'flag_name',
+ '/codecov/c/{vcsName}/{user}/{repo}/{branch}': {
+ get: {
+ summary: 'Codecov (with branch)',
+ description,
+ parameters: [
+ pathParam({
+ name: 'vcsName',
+ example: 'github',
+ schema: { type: 'string', enum: this.getEnum('vcsName') },
+ }),
+ pathParam({ name: 'user', example: 'codecov' }),
+ pathParam({ name: 'repo', example: 'example-node' }),
+ pathParam({ name: 'branch', example: 'master' }),
+ queryParam({
+ name: 'token',
+ description: tokenDescription,
+ example: 'a1b2c3d4e5',
+ }),
+ queryParam({
+ name: 'flag',
+ description: flagDescription,
+ example: 'flag_name',
+ }),
+ queryParam({
+ name: 'component',
+ description: componentDescription,
+ example: 'component_id_or_name',
+ }),
+ ],
},
- staticPreview: this.render({ coverage: 90 }),
- documentation,
},
- ]
+ }
static defaultBadgeData = { label: 'coverage' }
@@ -107,7 +150,7 @@ export default class Codecov extends BaseSvgScrapingService {
async legacyFetch({ vcsName, user, repo, branch, token }) {
// Codecov Docs: https://docs.codecov.io/reference#section-get-a-single-repository
const url = `https://codecov.io/api/${vcsName}/${user}/${repo}${
- branch ? `/branches/${branch}` : ''
+ branch ? `/branch/${branch}` : ''
}`
const { buffer } = await this._request({
url,
@@ -117,7 +160,7 @@ export default class Codecov extends BaseSvgScrapingService {
Authorization: `token ${token}`,
},
},
- errorMessages: {
+ httpErrors: {
401: 'not authorized to access repository',
404: 'repository not found',
},
@@ -135,25 +178,25 @@ export default class Codecov extends BaseSvgScrapingService {
return { coverage: +json.commit.totals.c }
}
- // Doesn't support `flag` feature. Here for backward-compatibility purpose.
+ // Doesn't support `flag` or `component` features. Here for backward-compatibility purpose.
async legacyHandle({ vcsName, user, repo, branch }, { token }) {
const json = await this.legacyFetch({ vcsName, user, repo, branch, token })
const { coverage } = this.legacyTransform({ json })
return this.constructor.render({ coverage })
}
- async fetch({ vcsName, user, repo, branch, token, flag }) {
+ async fetch({ vcsName, user, repo, branch, token, flag, component }) {
const url = `https://codecov.io/${vcsName}/${user}/${repo}${
- branch ? `/branches/${branch}` : ''
+ branch ? `/branch/${branch}` : ''
}/graph/badge.svg`
return this._requestSvg({
schema,
valueMatcher: svgValueMatcher,
url,
options: {
- qs: { token, flag },
+ searchParams: { token, flag, component },
},
- errorMessages: token ? { 400: 'invalid token pattern' } : {},
+ httpErrors: token ? { 400: 'invalid token pattern' } : {},
})
}
@@ -167,12 +210,20 @@ export default class Codecov extends BaseSvgScrapingService {
return { coverage }
}
- async handle({ vcsName, user, repo, branch }, { token, flag }) {
- if (!flag && token && !badgeTokenPattern.test(token)) {
+ async handle({ vcsName, user, repo, branch }, { token, flag, component }) {
+ if (!flag && !component && token && !badgeTokenPattern.test(token)) {
return this.legacyHandle({ vcsName, user, repo, branch }, { token })
}
- const data = await this.fetch({ vcsName, user, repo, branch, token, flag })
+ const data = await this.fetch({
+ vcsName,
+ user,
+ repo,
+ branch,
+ token,
+ flag,
+ component,
+ })
const { coverage } = this.transform({ data })
return this.constructor.render({ coverage })
}
diff --git a/services/codecov/codecov.tester.js b/services/codecov/codecov.tester.js
index 4eeea1c609c43..19b1394a3e280 100644
--- a/services/codecov/codecov.tester.js
+++ b/services/codecov/codecov.tester.js
@@ -16,6 +16,13 @@ t.create('gets coverage status with flag')
message: isIntegerPercentage,
})
+t.create('gets coverage status with component')
+ .get('/github/codecov/gazebo.json?component=dir_shared')
+ .expectBadge({
+ label: 'coverage',
+ message: isIntegerPercentage,
+ })
+
t.create('gets coverage status for branch')
.get('/github/codecov/example-python/master.json')
.expectBadge({
@@ -30,6 +37,13 @@ t.create('gets coverage status for branch with flag')
message: isIntegerPercentage,
})
+t.create('gets coverage status for branch with component')
+ .get('/github/codecov/gazebo/main.json?component=dir_shared')
+ .expectBadge({
+ label: 'coverage',
+ message: isIntegerPercentage,
+ })
+
t.create('handles unknown repository')
.get('/github/codecov2/fake-not-even-a-little-bit-real-python.json')
.expectBadge({
@@ -39,7 +53,16 @@ t.create('handles unknown repository')
t.create('handles unknown repository with flag')
.get(
- '/github/codecov2/fake-not-even-a-little-bit-real-node.json?flag=istanbul_mocha'
+ '/github/codecov2/fake-not-even-a-little-bit-real-node.json?flag=istanbul_mocha',
+ )
+ .expectBadge({
+ label: 'coverage',
+ message: 'unknown',
+ })
+
+t.create('handles unknown repository with component')
+ .get(
+ '/github/codecov2/fake-not-even-a-little-bit-real-node.json?component=dir_shared',
)
.expectBadge({
label: 'coverage',
@@ -53,6 +76,13 @@ t.create('gets coverage status for unknown flag')
message: 'unknown',
})
+t.create('gets coverage status for unknown component')
+ .get('/github/codecov/example-node.json?component=unknown_component')
+ .expectBadge({
+ label: 'coverage',
+ message: 'unknown',
+ })
+
// Using a mocked response here because we did not have a known
// private repository hooked up with Codecov that we could use.
t.create('handles unauthorized private repository')
@@ -60,9 +90,9 @@ t.create('handles unauthorized private repository')
.intercept(nock =>
nock('https://codecov.io')
.get('/github/codecov/private-example-python/graph/badge.svg')
- .reply(200, `Project ID in order access the
+CurseForge API.
+
+The Project ID is different from the URL slug and can be found in the 'About Project' section of your
+CurseForge mod page.
+
+- Supply a unix timestamp in seconds to display the relative time from/to now -
+const description = ` +Supply a unix timestamp in seconds to display the relative time from/to now ` export default class Date extends BaseService { static category = 'other' - static route = { base: 'date', pattern: ':timestamp([0-9]+)' } + static route = { base: 'date', pattern: ':timestamp(-?[0-9]+)' } - static examples = [ - { - title: 'Relative date', - pattern: ':timestamp', - namedParams: { timestamp: '1540814400' }, - staticPreview: this.render({ relativeDateString: '2 days ago' }), - keywords: ['time', 'countdown', 'countup', 'moment'], - documentation, + static openApi = { + '/date/{timestamp}': { + get: { + summary: 'Relative date', + description, + parameters: pathParams({ + name: 'timestamp', + example: '1540814400', + }), + }, }, - ] + } static defaultBadgeData = { label: 'date' } diff --git a/services/date/date.tester.js b/services/date/date.tester.js index ec5000da23e66..423cdc601394f 100644 --- a/services/date/date.tester.js +++ b/services/date/date.tester.js @@ -10,6 +10,10 @@ t.create('Relative date') .get('/1540814400.json') .expectBadge({ label: 'date', message: isRelativeFormattedDate }) +t.create('Relative date (negative)') + .get('/-1.json') + .expectBadge({ label: 'date', message: isRelativeFormattedDate }) + t.create('Relative date - Invalid') .get('/9999999999999.json') .expectBadge({ label: 'date', message: 'invalid date' }) diff --git a/services/david/david.service.js b/services/david/david.service.js deleted file mode 100644 index 67e171d93b08e..0000000000000 --- a/services/david/david.service.js +++ /dev/null @@ -1,98 +0,0 @@ -import Joi from 'joi' -import { BaseJsonService } from '../index.js' - -const schema = Joi.object({ - status: Joi.allow( - 'insecure', - 'outofdate', - 'notsouptodate', - 'uptodate', - 'none' - ).required(), -}).required() - -const queryParamSchema = Joi.object({ - path: Joi.string(), -}).required() - -const statusMap = { - insecure: { - color: 'red', - message: 'insecure', - }, - outofdate: { - color: 'red', - message: 'out of date', - }, - notsouptodate: { - color: 'yellow', - message: 'up to date', - }, - uptodate: { - color: 'brightgreen', - message: 'up to date', - }, - none: { - color: 'brightgreen', - message: 'none', - }, -} - -export default class David extends BaseJsonService { - static category = 'dependencies' - static route = { - base: 'david', - pattern: ':kind(dev|optional|peer)?/:user/:repo', - queryParamSchema, - } - - static examples = [ - { - title: 'David', - namedParams: { user: 'expressjs', repo: 'express' }, - staticPreview: this.render({ status: 'uptodate' }), - }, - { - title: 'David (path)', - namedParams: { user: 'babel', repo: 'babel' }, - queryParams: { path: 'packages/babel-core' }, - staticPreview: this.render({ status: 'uptodate' }), - }, - ] - - static defaultBadgeData = { label: 'dependencies' } - - static render({ status, kind }) { - return { - message: statusMap[status].message, - color: statusMap[status].color, - label: `${kind ? `${kind} ` : ''}dependencies`, - } - } - - async fetch({ kind, user, repo, path }) { - const url = `https://david-dm.org/${user}/${repo}/${ - kind ? `${kind}-` : '' - }info.json` - - return this._requestJson({ - schema, - url, - options: { qs: { path } }, - errorMessages: { - /* note: - david returns a 504 response for 'not found' - e.g: https://david-dm.org/foo/barbaz/info.json - not a 404 so we can't handle 'not found' cleanly - because this might also be some other error. - */ - 504: 'repo or path not found or david internal error', - }, - }) - } - - async handle({ kind, user, repo }, { path }) { - const json = await this.fetch({ kind, user, repo, path }) - return this.constructor.render({ status: json.status, kind }) - } -} diff --git a/services/david/david.tester.js b/services/david/david.tester.js deleted file mode 100644 index b7e4ac0761c24..0000000000000 --- a/services/david/david.tester.js +++ /dev/null @@ -1,70 +0,0 @@ -import Joi from 'joi' -import { createServiceTester } from '../tester.js' -export const t = await createServiceTester() - -const isDependencyStatus = Joi.string().valid( - 'insecure', - 'up to date', - 'out of date' -) - -t.create('david dependencies (valid)') - .get('/expressjs/express.json') - .timeout(15000) - .expectBadge({ - label: 'dependencies', - message: isDependencyStatus, - }) - -t.create('david dev dependencies (valid)') - .get('/dev/expressjs/express.json') - .timeout(15000) - .expectBadge({ - label: 'dev dependencies', - message: isDependencyStatus, - }) - -t.create('david optional dependencies (valid)') - .get('/optional/elnounch/byebye.json') - .timeout(15000) - .expectBadge({ - label: 'optional dependencies', - message: isDependencyStatus, - }) - -t.create('david peer dependencies (valid)') - .get('/peer/webcomponents/generator-element.json') - .timeout(15000) - .expectBadge({ - label: 'peer dependencies', - message: isDependencyStatus, - }) - -t.create('david dependencies with path (valid)') - .get('/babel/babel.json?path=packages/babel-core') - .timeout(15000) - .expectBadge({ - label: 'dependencies', - message: isDependencyStatus, - }) - -t.create('david dependencies (none)') - .get('/peer/expressjs/express.json') // express does not specify peer dependencies - .timeout(15000) - .expectBadge({ label: 'peer dependencies', message: 'none' }) - -t.create('david dependencies (repo not found)') - .get('/pyvesb/emptyrepo.json') - .timeout(15000) - .expectBadge({ - label: 'dependencies', - message: 'repo or path not found or david internal error', - }) - -t.create('david dependencies (path not found') - .get('/babel/babel.json?path=invalid/path') - .timeout(15000) - .expectBadge({ - label: 'dependencies', - message: 'repo or path not found or david internal error', - }) diff --git a/services/debian/debian.service.js b/services/debian/debian.service.js index 404cc75b3d469..0accce7820ce2 100644 --- a/services/debian/debian.service.js +++ b/services/debian/debian.service.js @@ -1,6 +1,11 @@ import Joi from 'joi' import { latest, renderVersionBadge } from '../version.js' -import { BaseJsonService, NotFound, InvalidResponse } from '../index.js' +import { + BaseJsonService, + NotFound, + InvalidResponse, + pathParams, +} from '../index.js' const schema = Joi.array() .items( @@ -8,8 +13,8 @@ const schema = Joi.array() /./, Joi.object() .pattern(/./, Joi.object().pattern(/./, Joi.object())) - .required() - ) // Optional, missing means not found + .required(), + ), // Optional, missing means not found ) .max(1) .required() @@ -23,13 +28,32 @@ export default class Debian extends BaseJsonService { pattern: ':packageName/:distribution?', } - static examples = [ - { - title: 'Debian package', - namedParams: { packageName: 'apt', distribution: 'unstable' }, - staticPreview: renderVersionBadge({ version: '1.8.0' }), + static openApi = { + '/debian/v/{packageName}/{distribution}': { + get: { + summary: 'Debian package (for distribution)', + parameters: pathParams( + { + name: 'packageName', + example: 'apt', + }, + { + name: 'distribution', + example: 'unstable', + }, + ), + }, + }, + '/debian/v/{packageName}': { + get: { + summary: 'Debian package', + parameters: pathParams({ + name: 'packageName', + example: 'apt', + }), + }, }, - ] + } static defaultBadgeData = { label: 'debian' } @@ -38,7 +62,7 @@ export default class Debian extends BaseJsonService { schema, url: 'https://api.ftp-master.debian.org/madison', options: { - qs: { + searchParams: { f: 'json', s: distribution, package: packageName, diff --git a/services/debian/debian.tester.js b/services/debian/debian.tester.js index d56026c476516..d379c00ff06db 100644 --- a/services/debian/debian.tester.js +++ b/services/debian/debian.tester.js @@ -25,7 +25,7 @@ t.create('Debian package (valid)') { apt: { unstable: { '1.8.0': { source: 'apt', component: 'main' } } }, }, - ]) + ]), ) .expectBadge({ label: 'debian', message: 'v1.8.0' }) @@ -41,7 +41,7 @@ t.create('Debian package (invalid, more than one result)') { apt: { unstable: { '1.8.1': { source: 'apt', component: 'main' } } }, }, - ]) + ]), ) .expectBadge({ label: 'debian', message: 'invalid response data' }) @@ -56,7 +56,7 @@ t.create('Debian package (invalid, requested package missing from response)') unstable: { '1.8.0': { source: 'apt', component: 'main' } }, }, }, - ]) + ]), ) .expectBadge({ label: 'debian', message: 'invalid response data' }) diff --git a/services/dependabot/dependabot.service.js b/services/dependabot/dependabot.service.js deleted file mode 100644 index 57d2cf3a31106..0000000000000 --- a/services/dependabot/dependabot.service.js +++ /dev/null @@ -1,53 +0,0 @@ -import Joi from 'joi' -import { BaseJsonService } from '../index.js' - -const schema = Joi.object({ - status: Joi.string().required(), - colour: Joi.string().required(), -}) - -export default class DependabotSemverCompatibility extends BaseJsonService { - static category = 'analysis' - static route = { - base: 'dependabot/semver', - pattern: ':packageManager/:dependencyName', - } - - static examples = [ - { - title: 'Dependabot SemVer Compatibility', - namedParams: { packageManager: 'bundler', dependencyName: 'puma' }, - staticPreview: { - color: 'green', - message: '98%', - }, - }, - ] - - static defaultBadgeData = { label: 'semver stability' } - - _getQuery({ packageManager, dependencyName }) { - return { - 'package-manager': packageManager, - 'dependency-name': dependencyName, - 'version-scheme': 'semver', - } - } - - async fetch({ packageManager, dependencyName }) { - const url = `https://api.dependabot.com/badges/compatibility_score` - return this._requestJson({ - schema, - url, - options: { qs: this._getQuery({ packageManager, dependencyName }) }, - }) - } - - async handle({ packageManager, dependencyName }) { - const json = await this.fetch({ packageManager, dependencyName }) - return { - color: json.colour, - message: json.status, - } - } -} diff --git a/services/dependabot/dependabot.tester.js b/services/dependabot/dependabot.tester.js deleted file mode 100644 index 9ac85a9e1ee0b..0000000000000 --- a/services/dependabot/dependabot.tester.js +++ /dev/null @@ -1,23 +0,0 @@ -import { isIntegerPercentage } from '../test-validators.js' -import { createServiceTester } from '../tester.js' -export const t = await createServiceTester() - -t.create('semver stability (valid)').get('/bundler/puma.json').expectBadge({ - label: 'semver stability', - message: isIntegerPercentage, -}) - -t.create('semver stability (invalid error)') - .get('/invalid-manager/puma.json') - .expectBadge({ - label: 'semver stability', - message: 'invalid', - color: 'lightgrey', - }) - -t.create('semver stability (missing dependency)') - .get('/bundler/some-random-missing-dependency.json') - .expectBadge({ - label: 'semver stability', - message: 'unknown', - }) diff --git a/services/depfu/depfu.service.js b/services/depfu/depfu.service.js index b35daf9f904f3..a1c0dbc2eec93 100644 --- a/services/depfu/depfu.service.js +++ b/services/depfu/depfu.service.js @@ -1,24 +1,41 @@ import Joi from 'joi' -import { BaseJsonService } from '../index.js' +import { + BaseJsonService, + InvalidParameter, + redirector, + pathParams, +} from '../index.js' const depfuSchema = Joi.object({ text: Joi.string().required(), colorscheme: Joi.string().required(), }).required() -export default class Depfu extends BaseJsonService { +class Depfu extends BaseJsonService { static category = 'dependencies' - static route = { base: 'depfu', pattern: ':user/:repo' } - static examples = [ - { - title: 'Depfu', - namedParams: { user: 'depfu', repo: 'example-ruby' }, - staticPreview: this.render({ - text: 'recent', - colorscheme: 'brightgreen', - }), + static route = { + base: 'depfu/dependencies', + pattern: ':vcsType(github|gitlab)/:project+', + } + + static openApi = { + '/depfu/dependencies/{vcsType}/{project}': { + get: { + summary: 'Depfu', + parameters: pathParams( + { + name: 'vcsType', + example: 'github', + schema: { type: 'string', enum: this.getEnum('vcsType') }, + }, + { + name: 'project', + example: 'depfu/example-ruby', + }, + ), + }, }, - ] + } static defaultBadgeData = { label: 'dependencies' } @@ -29,13 +46,31 @@ export default class Depfu extends BaseJsonService { } } - async fetch({ user, repo }) { - const url = `https://depfu.com/github/shields/${user}/${repo}` + async fetch({ vcsType, project }) { + const separatorPosition = project.lastIndexOf('/') + if (separatorPosition < 0) { + throw new InvalidParameter() + } + const user = encodeURIComponent(project.substr(0, separatorPosition)) + const repo = project.substr(separatorPosition) + const url = `https://depfu.com/${vcsType}/shields/${user}/${repo}` return this._requestJson({ url, schema: depfuSchema }) } - async handle({ user, repo }) { - const { text, colorscheme } = await this.fetch({ user, repo }) + async handle({ vcsType, project }) { + const { text, colorscheme } = await this.fetch({ vcsType, project }) return this.constructor.render({ text, colorscheme }) } } + +const legacyRoutes = [ + redirector({ + category: 'dependencies', + route: { base: 'depfu', pattern: ':user/:repo' }, + transformPath: ({ user, repo }) => + `/depfu/dependencies/github/${user}/${repo}`, + dateAdded: new Date('2022-01-11'), + }), +] + +export default { ...legacyRoutes, Depfu } diff --git a/services/depfu/depfu.tester.js b/services/depfu/depfu.tester.js index 04aeaf2b02cf9..17e6c829cf477 100644 --- a/services/depfu/depfu.tester.js +++ b/services/depfu/depfu.tester.js @@ -5,18 +5,56 @@ const isDependencyStatus = Joi.string().valid( 'insecure', 'latest', 'recent', - 'stale' + 'stale', ) export const t = new ServiceTester({ id: 'depfu', title: 'Depfu' }) -t.create('depfu dependencies (valid)') - .get('/depfu/example-ruby.json') +t.create('depfu Github dependencies (valid)') + .get('/dependencies/github/depfu/example-ruby.json') .expectBadge({ label: 'dependencies', message: isDependencyStatus, }) -t.create('depfu dependencies (repo not found)') - .get('/pyvesb/emptyrepo.json') +t.create('depfu Github dependencies (repo not found)') + .get('/dependencies/github/pyvesb/emptyrepo.json') .expectBadge({ label: 'dependencies', message: 'not found' }) + +t.create('depfu Gitlab dependencies (valid)') + .get('/dependencies/gitlab/depfu/example-ruby.json') + .expectBadge({ + label: 'dependencies', + message: isDependencyStatus, + }) + +t.create('depfu Github dependencies (no separator)') + .get('/dependencies/github/example-ruby.json') + .expectBadge({ + label: 'dependencies', + message: 'invalid parameter', + }) + +t.create('depfu Gitlab dependencies (valid with subgroup)') + .get( + '/dependencies/gitlab/shields-example-group/subgroup/example-nodejs.json', + ) + .expectBadge({ + label: 'dependencies', + message: isDependencyStatus, + }) + +t.create('depfu Gitlab dependencies (repo not found)') + .get('/dependencies/gitlab/fdroid/nonexistant.json') + .expectBadge({ label: 'dependencies', message: 'not found' }) + +t.create('depfu Gitlab dependencies (no separator)') + .get('/dependencies/gitlab/example-ruby.json') + .expectBadge({ + label: 'dependencies', + message: 'invalid parameter', + }) + +t.create('legacy route (assume "github" as a default VCS)') + .get('/depfu/example-ruby.svg') + .expectRedirect('/depfu/dependencies/github/depfu/example-ruby.svg') diff --git a/services/deprecation-helpers.js b/services/deprecation-helpers.js deleted file mode 100644 index fb6ecc6b7968b..0000000000000 --- a/services/deprecation-helpers.js +++ /dev/null @@ -1,9 +0,0 @@ -import { Deprecated } from './index.js' - -function enforceDeprecation(effectiveDate) { - if (Date.now() >= effectiveDate.getTime()) { - throw new Deprecated() - } -} - -export { enforceDeprecation } diff --git a/services/deprecation-helpers.spec.js b/services/deprecation-helpers.spec.js deleted file mode 100644 index 34779dad7f9a3..0000000000000 --- a/services/deprecation-helpers.spec.js +++ /dev/null @@ -1,15 +0,0 @@ -import { expect } from 'chai' -import { Deprecated } from '../core/base-service/errors.js' -import { enforceDeprecation } from './deprecation-helpers.js' - -describe('enforceDeprecation', function () { - it('throws Deprecated for a date in the past', function () { - expect(() => enforceDeprecation(new Date())).to.throw(Deprecated) - }) - - it('does not throw for a date in the future', function () { - expect(() => - enforceDeprecation(new Date(Date.now() + 10000)) - ).not.to.throw() - }) -}) diff --git a/services/deps-rs/deps-rs-base.js b/services/deps-rs/deps-rs-base.js new file mode 100644 index 0000000000000..2e8acd8c9574c --- /dev/null +++ b/services/deps-rs/deps-rs-base.js @@ -0,0 +1,85 @@ +import Joi from 'joi' +import { BaseJsonService } from '../index.js' + +const depsResponseSchema = Joi.object({ + message: Joi.string().required(), +}).required() + +class BaseDepsRsService extends BaseJsonService { + static defaultBadgeData = { label: 'dependencies' } + + /** + * Maps message values to shields color scheme + * + * @param {string} message - The message to help determine appropriate color + * @returns {string} The shields color + */ + static mapColor(message) { + const normalizedMessage = message.toLowerCase() + + const colorMap = { + 'up to date': 'brightgreen', + none: 'brightgreen', + 'maybe insecure': 'yellowgreen', + insecure: 'red', + unknown: 'lightgrey', + 'not found': 'lightgrey', + invalid: 'lightgrey', + } + + if (colorMap[normalizedMessage]) { + return colorMap[normalizedMessage] + } + + // e.g. "1 of 2 outdated" + if (normalizedMessage.endsWith('outdated')) { + return 'yellow' + } + + return 'lightgrey' + } + + /** + * Fetches data from the deps.rs API. + * + * @param {object} options - The options for the request + * @param {string} options.crate - The crate name. + * @param {string} options.version - The crate version number or 'latest'. + * @returns {Promise
- The Discord badge requires the SERVER ID in order access the Discord JSON API.
-
- The SERVER ID can be located in the url of the channel that the badge is accessing.
-
SERVER ID in order access the Discord JSON API.
+
+The SERVER ID can be located in the url of the channel that the badge is accessing.
+
-- To use the Discord badge a Discord server admin must enable the widget setting on the server. -
- + +To use the Discord badge a Discord server admin must enable the widget setting on the server. + + ` export default class Discord extends BaseJsonService { @@ -36,22 +35,26 @@ export default class Discord extends BaseJsonService { isRequired: false, } - static examples = [ - { - title: 'Discord', - namedParams: { serverId: '102860784329052160' }, - staticPreview: this.render({ members: 23 }), - documentation, + static openApi = { + '/discord/{serverId}': { + get: { + summary: 'Discord', + description, + parameters: pathParams({ + name: 'serverId', + example: '308323056592486420', + }), + }, }, - ] + } - static _cacheLength = 30 + static _cacheLength = 300 static defaultBadgeData = { label: 'chat' } static render({ members }) { return { - message: `${members} online`, + message: `${metric(members)} online`, color: 'brightgreen', } } @@ -63,13 +66,13 @@ export default class Discord extends BaseJsonService { { url, schema, - errorMessages: { + httpErrors: { 404: 'invalid server', 403: 'widget disabled', }, }, - 'Bot' - ) + 'Bot', + ), ) } diff --git a/services/discord/discord.spec.js b/services/discord/discord.spec.js index 4dbbeae36b858..4da1d14de5720 100644 --- a/services/discord/discord.spec.js +++ b/services/discord/discord.spec.js @@ -1,38 +1,15 @@ -import { expect } from 'chai' -import nock from 'nock' -import { cleanUpNockAfterEach, defaultContext } from '../test-helpers.js' +import { testAuth } from '../test-helpers.js' import Discord from './discord.service.js' describe('Discord', function () { - cleanUpNockAfterEach() - - it('sends the auth information as configured', async function () { - const pass = 'password' - const config = { - private: { - discord_bot_token: pass, - }, - } - - const scope = nock(`https://discord.com`, { - // This ensures that the expected credential is actually being sent with the HTTP request. - // Without this the request wouldn't match and the test would fail. - reqheaders: { Authorization: `Bot password` }, + describe('auth', function () { + it('sends the auth information as configured', async function () { + return testAuth( + Discord, + 'BearerAuthHeader', + { presence_count: 125 }, + { bearerHeaderKey: 'Bot' }, + ) }) - .get(`/api/v6/guilds/12345/widget.json`) - .reply(200, { - presence_count: 125, - }) - - expect( - await Discord.invoke(defaultContext, config, { - serverId: '12345', - }) - ).to.deep.equal({ - message: '125 online', - color: 'brightgreen', - }) - - scope.done() }) }) diff --git a/services/discord/discord.tester.js b/services/discord/discord.tester.js index 4e9ca9d6d627a..68bd780f2c7ee 100644 --- a/services/discord/discord.tester.js +++ b/services/discord/discord.tester.js @@ -1,12 +1,13 @@ -import Joi from 'joi' import { createServiceTester } from '../tester.js' +import { isMetricWithPattern } from '../test-validators.js' + export const t = await createServiceTester() -t.create('gets status for Reactiflux') - .get('/102860784329052160.json') +t.create('gets status for shields') + .get('/308323056592486420.json') .expectBadge({ label: 'chat', - message: Joi.string().regex(/^[0-9]+ online$/), + message: isMetricWithPattern(/ online/), color: 'brightgreen', }) @@ -22,7 +23,7 @@ t.create('widget disabled') .reply(403, { code: 50004, message: 'Widget Disabled', - }) + }), ) .expectBadge({ label: 'chat', message: 'widget disabled' }) @@ -31,6 +32,6 @@ t.create('server error') .intercept(nock => nock('https://discord.com/') .get('/api/v6/guilds/12345/widget.json') - .reply(500, 'Something broke') + .reply(500, 'Something broke'), ) .expectBadge({ label: 'chat', message: 'inaccessible' }) diff --git a/services/discourse/discourse-redirect.tester.js b/services/discourse/discourse-redirect.tester.js index 6dce032f5b321..84e15b0565b2d 100644 --- a/services/discourse/discourse-redirect.tester.js +++ b/services/discourse/discourse-redirect.tester.js @@ -10,38 +10,38 @@ t.create('discourse status') .get('/https/meta.discourse.org/status.svg') .expectRedirect( `/discourse/status.svg?server=${encodeURIComponent( - 'https://meta.discourse.org' - )}` + 'https://meta.discourse.org', + )}`, ) t.create('discourse topics') .get('/https/meta.discourse.org/topics.svg') .expectRedirect( `/discourse/topics.svg?server=${encodeURIComponent( - 'https://meta.discourse.org' - )}` + 'https://meta.discourse.org', + )}`, ) t.create('discourse users') .get('/https/meta.discourse.org/users.svg') .expectRedirect( `/discourse/users.svg?server=${encodeURIComponent( - 'https://meta.discourse.org' - )}` + 'https://meta.discourse.org', + )}`, ) t.create('discourse likes') .get('/https/meta.discourse.org/likes.svg') .expectRedirect( `/discourse/likes.svg?server=${encodeURIComponent( - 'https://meta.discourse.org' - )}` + 'https://meta.discourse.org', + )}`, ) t.create('discourse posts') .get('/https/meta.discourse.org/posts.svg') .expectRedirect( `/discourse/posts.svg?server=${encodeURIComponent( - 'https://meta.discourse.org' - )}` + 'https://meta.discourse.org', + )}`, ) diff --git a/services/discourse/discourse.service.js b/services/discourse/discourse.service.js index e6c1322c38300..7d18db4f0dcd8 100644 --- a/services/discourse/discourse.service.js +++ b/services/discourse/discourse.service.js @@ -1,31 +1,41 @@ -import camelcase from 'camelcase' import Joi from 'joi' import { metric } from '../text-formatters.js' -import { nonNegativeInteger, optionalUrl } from '../validators.js' -import { BaseJsonService } from '../index.js' +import { nonNegativeInteger, url } from '../validators.js' +import { BaseJsonService, queryParams } from '../index.js' -const schema = Joi.object({ +const schemaSingular = Joi.object({ topic_count: nonNegativeInteger, user_count: nonNegativeInteger, post_count: nonNegativeInteger, like_count: nonNegativeInteger, }).required() +const schemaPlural = Joi.object({ + topics_count: nonNegativeInteger, + users_count: nonNegativeInteger, + posts_count: nonNegativeInteger, + likes_count: nonNegativeInteger, +}) + +const schema = Joi.alternatives(schemaSingular, schemaPlural) + const queryParamSchema = Joi.object({ - server: optionalUrl.required(), + server: url, }).required() +function singular(variant) { + return variant.slice(0, -1) +} + +const params = queryParams({ + name: 'server', + example: 'https://meta.discourse.org', + required: true, +}) + class DiscourseBase extends BaseJsonService { static category = 'chat' - static buildRoute(metric) { - return { - base: 'discourse', - pattern: metric, - queryParamSchema, - } - } - static defaultBadgeData = { label: 'discourse' } async fetch({ server }) { @@ -36,50 +46,61 @@ class DiscourseBase extends BaseJsonService { } } -function DiscourseMetricIntegrationFactory({ metricName, property }) { - return class DiscourseMetric extends DiscourseBase { - // The space is needed so we get 'DiscourseTopics' rather than - // 'Discoursetopics'. `camelcase()` removes it. - static name = camelcase(`Discourse ${metricName}`, { pascalCase: true }) - static route = this.buildRoute(metricName) - - static examples = [ - { - title: `Discourse ${metricName}`, - namedParams: {}, - queryParams: { - server: 'https://meta.discourse.org', - }, - staticPreview: this.render({ stat: 100 }), - }, - ] +class DiscourseMetric extends DiscourseBase { + static route = { + base: 'discourse', + pattern: ':variant(topics|users|posts|likes)', + queryParamSchema, + } + + static openApi = { + '/discourse/topics': { + get: { summary: 'Discourse Topics', parameters: params }, + }, + '/discourse/users': { + get: { summary: 'Discourse Users', parameters: params }, + }, + '/discourse/posts': { + get: { summary: 'Discourse Posts', parameters: params }, + }, + '/discourse/likes': { + get: { summary: 'Discourse Likes', parameters: params }, + }, + } - static render({ stat }) { - return { - message: `${metric(stat)} ${metricName}`, - color: 'brightgreen', - } + static render({ variant, stat }) { + return { + message: `${metric(stat)} ${variant}`, + color: 'brightgreen', } + } - async handle(_routeParams, { server }) { - const data = await this.fetch({ server }) - return this.constructor.render({ stat: data[property] }) + async handle({ variant }, { server }) { + const data = await this.fetch({ server }) + // e.g. variant == 'topics' --> try 'topic_count' then 'topics_count' + let stat = data[`${singular(variant)}_count`] + if (stat === undefined) { + stat = data[`${variant}_count`] } + return this.constructor.render({ variant, stat }) } } class DiscourseStatus extends DiscourseBase { - static route = this.buildRoute('status') - static examples = [ - { - title: `Discourse status`, - namedParams: {}, - queryParams: { - server: 'https://meta.discourse.org', + static route = { + base: 'discourse', + pattern: 'status', + queryParamSchema, + } + + static openApi = { + '/discourse/status': { + get: { + summary: 'Discourse Status', + parameters: params, }, - staticPreview: this.render(), }, - ] + } static render() { return { @@ -96,11 +117,4 @@ class DiscourseStatus extends DiscourseBase { } } -const metricIntegrations = [ - { metricName: 'topics', property: 'topic_count' }, - { metricName: 'users', property: 'user_count' }, - { metricName: 'posts', property: 'post_count' }, - { metricName: 'likes', property: 'like_count' }, -].map(DiscourseMetricIntegrationFactory) - -export default [...metricIntegrations, DiscourseStatus] +export default [DiscourseMetric, DiscourseStatus] diff --git a/services/discourse/discourse.tester.js b/services/discourse/discourse.tester.js index dc779eb291a45..763264a31255a 100644 --- a/services/discourse/discourse.tester.js +++ b/services/discourse/discourse.tester.js @@ -6,83 +6,105 @@ export const t = new ServiceTester({ title: 'Discourse', }) -const data = { - topic_count: 22513, - post_count: 337719, - user_count: 31220, - topics_7_days: 143, - topics_30_days: 551, - posts_7_days: 2679, - posts_30_days: 10445, - users_7_days: 204, - users_30_days: 803, - active_users_7_days: 762, - active_users_30_days: 1495, - like_count: 308833, - likes_7_days: 3633, - likes_30_days: 13397, -} +const dataCases = [ + { + // Singular form + topic_count: 22513, + post_count: 337719, + user_count: 31220, + topics_7_days: 143, + topics_30_days: 551, + posts_7_days: 2679, + posts_30_days: 10445, + users_7_days: 204, + users_30_days: 803, + active_users_7_days: 762, + active_users_30_days: 1495, + like_count: 308833, + likes_7_days: 3633, + likes_30_days: 13397, + }, + { + // Plural form + topics_count: 22513, + posts_count: 337719, + users_count: 31220, + topics_7_days: 143, + topics_30_days: 551, + posts_7_days: 2679, + posts_30_days: 10445, + users_7_days: 204, + users_30_days: 803, + active_users_7_days: 762, + active_users_30_days: 1495, + likes_count: 308833, + likes_7_days: 3633, + likes_30_days: 13397, + }, +] -t.create('Topics') - .get('/topics.json?server=https://meta.discourse.org') - .intercept(nock => - nock('https://meta.discourse.org') - .get('/site/statistics.json') - .reply(200, data) - ) - .expectBadge({ label: 'discourse', message: '23k topics' }) +dataCases.forEach(data => { + t.create('Topics') + .get('/topics.json?server=https://meta.discourse.org') + .intercept(nock => + nock('https://meta.discourse.org') + .get('/site/statistics.json') + .reply(200, data), + ) + .expectBadge({ label: 'discourse', message: '23k topics' }) -t.create('Posts') - .get('/posts.json?server=https://meta.discourse.org') - .intercept(nock => - nock('https://meta.discourse.org') - .get('/site/statistics.json') - .reply(200, data) - ) - .expectBadge({ label: 'discourse', message: '338k posts' }) + t.create('Posts') + .get('/posts.json?server=https://meta.discourse.org') + .intercept(nock => + nock('https://meta.discourse.org') + .get('/site/statistics.json') + .reply(200, data), + ) + .expectBadge({ label: 'discourse', message: '338k posts' }) -t.create('Users') - .get('/users.json?server=https://meta.discourse.org') - .intercept(nock => - nock('https://meta.discourse.org') - .get('/site/statistics.json') - .reply(200, data) - ) - .expectBadge({ label: 'discourse', message: '31k users' }) + t.create('Users') + .get('/users.json?server=https://meta.discourse.org') + .intercept(nock => + nock('https://meta.discourse.org') + .get('/site/statistics.json') + .reply(200, data), + ) + .expectBadge({ label: 'discourse', message: '31k users' }) -t.create('Likes') - .get('/likes.json?server=https://meta.discourse.org') - .intercept(nock => - nock('https://meta.discourse.org') - .get('/site/statistics.json') - .reply(200, data) - ) - .expectBadge({ label: 'discourse', message: '309k likes' }) + t.create('Likes') + .get('/likes.json?server=https://meta.discourse.org') + .intercept(nock => + nock('https://meta.discourse.org') + .get('/site/statistics.json') + .reply(200, data), + ) + .expectBadge({ label: 'discourse', message: '309k likes' }) -t.create('Status') - .get('/status.json?server=https://meta.discourse.org') - .intercept(nock => - nock('https://meta.discourse.org') - .get('/site/statistics.json') - .reply(200, data) - ) - .expectBadge({ label: 'discourse', message: 'online' }) + t.create('Status') + .get('/status.json?server=https://meta.discourse.org') + .intercept(nock => + nock('https://meta.discourse.org') + .get('/site/statistics.json') + .reply(200, data), + ) + .expectBadge({ label: 'discourse', message: 'online' }) -t.create('Status with http (not https)') - .get('/status.json?server=http://meta.discourse.org') - .intercept(nock => - nock('http://meta.discourse.org') - .get('/site/statistics.json') - .reply(200, data) - ) - .expectBadge({ label: 'discourse', message: 'online' }) + t.create('Status with http (not https)') + .get('/status.json?server=http://meta.discourse.org') + .intercept(nock => + nock('http://meta.discourse.org') + .get('/site/statistics.json') + .reply(200, data), + ) + .expectBadge({ label: 'discourse', message: 'online' }) +}) t.create('Invalid Host') .get('/status.json?server=https://some.host') .intercept(nock => nock('https://some.host') .get('/site/statistics.json') - .reply(404, 'For the new Docker Hub (https://cloud.docker.com)
', - namedParams: { - user: 'jrottenberg', - repo: 'ffmpeg', - }, - staticPreview: this.render({ buildSettings: ['test'] }), - }, - ] - - static defaultBadgeData = { label: 'docker build' } - - static render({ buildSettings }) { - if (buildSettings.length >= 1) { - return { message: 'automated', color: dockerBlue } - } - return { message: 'manual', color: 'yellow' } - } - - async handle({ user, repo }) { - const data = await fetchBuild(this, { user, repo }) - return this.constructor.render({ - buildSettings: data.objects[0].build_settings, - }) - } -} +import { retiredService } from '../index.js' + +export default retiredService({ + category: 'build', + route: { + base: 'docker/cloud/automated', + pattern: ':user/:repo', + }, + label: 'dockercloud', + dateAdded: new Date('2025-11-15'), +}) diff --git a/services/docker/docker-cloud-automated.tester.js b/services/docker/docker-cloud-automated.tester.js index 936451900d448..d616c93d6be85 100644 --- a/services/docker/docker-cloud-automated.tester.js +++ b/services/docker/docker-cloud-automated.tester.js @@ -1,48 +1,14 @@ -import Joi from 'joi' -import { createServiceTester } from '../tester.js' -import { dockerBlue } from './docker-helpers.js' -export const t = await createServiceTester() +import { ServiceTester } from '../tester.js' -const isAutomatedBuildStatus = Joi.string().valid('automated', 'manual') +export const t = new ServiceTester({ + id: 'dockercloudautomated', + title: 'DockerCloudAutomated', + pathPrefix: '/docker/cloud/automated', +}) -t.create('docker cloud automated build (valid, user)') - .get('/jrottenberg/ffmpeg.json') +t.create('docker cloud automated build') + .get('/pavics/magpie.json') .expectBadge({ - label: 'docker build', - message: isAutomatedBuildStatus, + label: 'dockercloud', + message: 'retired badge', }) - -t.create('docker cloud automated build (not found)') - .get('/badges/not-a-real-repo.json') - .intercept(nock => - nock('https://cloud.docker.com/') - .get( - `/api/build/v1/source?image=${encodeURIComponent( - 'badges/not-a-real-repo' - )}` - ) - .reply(404, { detail: 'Object not found' }) - ) - .expectBadge({ label: 'docker build', message: 'repo not found' }) - -t.create('docker cloud automated build - automated') - .get('/xenolf/lego.json') - .intercept(nock => - nock('https://cloud.docker.com/') - .get(`/api/build/v1/source?image=${encodeURIComponent('xenolf/lego')}`) - .reply(200, { objects: [{ build_settings: ['test1'] }] }) - ) - .expectBadge({ - label: 'docker build', - message: 'automated', - color: `#${dockerBlue}`, - }) - -t.create('docker cloud automated build - manual') - .get('/xenolf/lego.json') - .intercept(nock => - nock('https://cloud.docker.com/') - .get(`/api/build/v1/source?image=${encodeURIComponent('xenolf/lego')}`) - .reply(200, { objects: [{ build_settings: [] }] }) - ) - .expectBadge({ label: 'docker build', message: 'manual', color: 'yellow' }) diff --git a/services/docker/docker-cloud-build.service.js b/services/docker/docker-cloud-build.service.js index 8fd3aa8b45f3e..74aa04152f117 100644 --- a/services/docker/docker-cloud-build.service.js +++ b/services/docker/docker-cloud-build.service.js @@ -1,36 +1,11 @@ -import { BaseJsonService } from '../index.js' -import { dockerBlue, buildDockerUrl } from './docker-helpers.js' -import { fetchBuild } from './docker-cloud-common-fetch.js' - -export default class DockerCloudBuild extends BaseJsonService { - static category = 'build' - static route = buildDockerUrl('cloud/build') - static examples = [ - { - title: 'Docker Cloud Build Status', - documentation: 'For the new Docker Hub (https://cloud.docker.com)
', - namedParams: { - user: 'jrottenberg', - repo: 'ffmpeg', - }, - staticPreview: this.render({ state: 'Success' }), - }, - ] - - static defaultBadgeData = { label: 'docker build' } - - static render({ state }) { - if (state === 'Success') { - return { message: 'passing', color: 'brightgreen' } - } - if (state === 'Failed') { - return { message: 'failing', color: 'red' } - } - return { message: 'building', color: dockerBlue } - } - - async handle({ user, repo }) { - const data = await fetchBuild(this, { user, repo }) - return this.constructor.render({ state: data.objects[0].state }) - } -} +import { retiredService } from '../index.js' + +export default retiredService({ + category: 'build', + route: { + base: 'docker/cloud/build', + pattern: ':user/:repo', + }, + label: 'dockercloud', + dateAdded: new Date('2025-11-15'), +}) diff --git a/services/docker/docker-cloud-build.tester.js b/services/docker/docker-cloud-build.tester.js index 9335af250124d..b4be55e6e9658 100644 --- a/services/docker/docker-cloud-build.tester.js +++ b/services/docker/docker-cloud-build.tester.js @@ -1,59 +1,12 @@ -import { isBuildStatus } from '../build-status.js' -import { createServiceTester } from '../tester.js' -import { dockerBlue } from './docker-helpers.js' -export const t = await createServiceTester() - -t.create('docker cloud build status (valid, user)') - .get('/jrottenberg/ffmpeg.json') - .expectBadge({ - label: 'docker build', - message: isBuildStatus, - }) - -t.create('docker cloud build status (not found)') - .get('/badges/not-a-real-repo.json') - .intercept(nock => - nock('https://cloud.docker.com/') - .get( - `/api/build/v1/source?image=${encodeURIComponent( - 'badges/not-a-real-repo' - )}` - ) - .reply(404, { detail: 'Object not found' }) - ) - .expectBadge({ label: 'docker build', message: 'repo not found' }) - -t.create('docker cloud build status (passing)') - .get('/xenolf/lego.json') - .intercept(nock => - nock('https://cloud.docker.com/') - .get(`/api/build/v1/source?image=${encodeURIComponent('xenolf/lego')}`) - .reply(200, { objects: [{ state: 'Success' }] }) - ) - .expectBadge({ - label: 'docker build', - message: 'passing', - color: 'brightgreen', - }) - -t.create('docker cloud build status (failing)') - .get('/xenolf/lego.json') - .intercept(nock => - nock('https://cloud.docker.com/') - .get(`/api/build/v1/source?image=${encodeURIComponent('xenolf/lego')}`) - .reply(200, { objects: [{ state: 'Failed' }] }) - ) - .expectBadge({ label: 'docker build', message: 'failing', color: 'red' }) - -t.create('docker cloud build status (building)') - .get('/xenolf/lego.json') - .intercept(nock => - nock('https://cloud.docker.com/') - .get(`/api/build/v1/source?image=${encodeURIComponent('xenolf/lego')}`) - .reply(200, { objects: [{ state: 'Empty' }] }) - ) - .expectBadge({ - label: 'docker build', - message: 'building', - color: `#${dockerBlue}`, - }) +import { ServiceTester } from '../tester.js' + +export const t = new ServiceTester({ + id: 'dockercloudbuild', + title: 'DockerCloudBuild', + pathPrefix: '/docker/cloud/build', +}) + +t.create('docker cloud build status').get('/pavics/magpie.json').expectBadge({ + label: 'dockercloud', + message: 'retired badge', +}) diff --git a/services/docker/docker-cloud-common-fetch.js b/services/docker/docker-cloud-common-fetch.js deleted file mode 100644 index 51a5317348a6a..0000000000000 --- a/services/docker/docker-cloud-common-fetch.js +++ /dev/null @@ -1,23 +0,0 @@ -import Joi from 'joi' - -const cloudBuildSchema = Joi.object({ - objects: Joi.array() - .items( - Joi.object({ - state: Joi.string(), - build_settings: Joi.array(), - }).required() - ) - .required(), -}).required() - -async function fetchBuild(serviceInstance, { user, repo }) { - return serviceInstance._requestJson({ - schema: cloudBuildSchema, - url: `https://cloud.docker.com/api/build/v1/source`, - options: { qs: { image: `${user}/${repo}` } }, - errorMessages: { 404: 'repo not found' }, - }) -} - -export { fetchBuild } diff --git a/services/docker/docker-fixtures.js b/services/docker/docker-fixtures.js index 26218ca350c12..5f0001af3f5d6 100644 --- a/services/docker/docker-fixtures.js +++ b/services/docker/docker-fixtures.js @@ -1,71 +1,36 @@ const sizeDataNoTagSemVerSort = [ - { name: 'master', full_size: 13449470 }, - { name: 'feature-smtps-support', full_size: 13449638 }, - { name: 'latest', full_size: 13448411 }, - { name: '4', full_size: 13448411 }, - { name: '4.3', full_size: 13448411 }, - { name: '4.3.0', full_size: 13448411 }, - { name: '4.2', full_size: 13443674 }, - { name: '4.2.0', full_size: 13443674 }, - { name: '4.1', full_size: 19244435 }, - { name: '4.1.0', full_size: 19244435 }, - { name: 'v4.0.0-alpha2', full_size: 10933605 }, - { name: 'v4.0.0-alpha1', full_size: 10933644 }, - { name: '4.0.0', full_size: 11512227 }, - { name: '4.0', full_size: 11512227 }, - { name: 'v2.1.9', full_size: 29739490 }, - { name: 'v2.1.10', full_size: 29739842 }, - { name: 'v3.0.0', full_size: 32882980 }, - { name: 'v3.0.1', full_size: 32880923 }, - { name: 'v3.1.0', full_size: 32441549 }, - { name: 'v3.1.1', full_size: 32441767 }, - { name: 'v3.1.2', full_size: 32442741 }, - { name: 'v3.1.3', full_size: 32442629 }, - { name: 'v3.1.4', full_size: 32478607 }, - { name: 'v3.2.0', full_size: 33489914 }, - { name: 'v3.3.0', full_size: 33628545 }, - { name: 'v3.3.1', full_size: 33629018 }, - { name: 'v3.3.3', full_size: 33628988 }, - { name: 'v3.3.4', full_size: 33629019 }, - { name: 'v3.3.6', full_size: 33628753 }, - { name: 'v3.3.7', full_size: 33629556 }, - { name: 'v3.3.8', full_size: 33644261 }, - { name: 'v3.3.9', full_size: 33644175 }, - { name: 'v3.3.10', full_size: 33644406 }, - { name: 'v3.3.11', full_size: 33644430 }, - { name: 'v3.3.12', full_size: 33644703 }, - { name: 'v3.3.13', full_size: 33644377 }, - { name: 'v3.3.15', full_size: 33644581 }, - { name: 'v3.3.16', full_size: 33644663 }, - { name: 'v3.3.17', full_size: 33644228 }, - { name: 'v3.3.18', full_size: 33644466 }, - { name: 'v3.3.19', full_size: 33644724 }, - { name: 'v3.4.0', full_size: 34918552 }, - { name: 'v3.4.2', full_size: 33605129 }, - { name: 'v3.5.0', full_size: 33582915 }, - { name: 'v3.6.0', full_size: 34789944 }, - { name: 'develop', full_size: 38129308 }, - { name: 'v3.7.0', full_size: 38179583 }, - { name: 'v3.7.1', full_size: 38614944 }, - { name: 'v3.8.0', full_size: 42962384 }, - { name: 'v3.8.1', full_size: 40000713 }, - { name: 'v3.8.2', full_size: 40000567 }, - { name: 'v3.8.3', full_size: 40040963 }, - { name: 'v3.9.0', full_size: 40044357 }, - { name: 'v3.9.1', full_size: 40048123 }, - { name: 'v3.9.2', full_size: 40047663 }, - { name: 'v3.9.3', full_size: 40048204 }, - { name: 'v3.9.4', full_size: 40049571 }, - { name: 'v3.9.5', full_size: 40049695 }, - { name: 'v3.10.0', full_size: 39940736 }, - { name: 'v3.11.0', full_size: 39928170 }, - { name: 'v3.12.0', full_size: 39966770 }, - { name: 'v3.13.0', full_size: 38556045 }, - { name: 'v3.14.0', full_size: 38574008 }, - { name: 'v3.15.0', full_size: 38578507 }, - { name: 'v3.16.0', full_size: 38852598 }, - { name: 'v3.16.1', full_size: 38851702 }, - { name: 'v3.16.2', full_size: 38969822 }, + { + full_size: 300000000, + name: 'v4.0.0-alpha2', + images: [ + { architecture: 'amd64', size: 219939484 }, + { architecture: 'arm64', size: 200000000 }, + ], + }, + { + full_size: 400000000, + name: 'v4.2.4', + images: [ + { architecture: 'amd64', size: 220000000 }, + { architecture: 'arm64', size: 210000000 }, + ], + }, + { + full_size: 100000000, + name: 'v3.9.7', + images: [ + { architecture: 'amd64', size: 120000000 }, + { architecture: 'arm64', size: 110000000 }, + ], + }, + { + full_size: 500000000, + name: 'latest', + images: [ + { architecture: 'amd64', size: 560000000 }, + { architecture: 'arm64', size: 460000000 }, + ], + }, ] const versionDataNoTagDateSort = { count: 4, @@ -3033,6 +2998,49 @@ const versionDataWithVaryingArchitectures = [ { name: '2.6', images: [] }, ] +const versionDataWithArchSpecificVersions = [ + { + name: '3.8-arm64', + images: [ + { + digest: + 'sha256:3d62c3ceeba113f72efaaf3a35d83081963fa2cd29be76ed1aaa87b59f6f8eae', + architecture: 'arm64', + }, + ], + }, + { + name: '3.8-amd64', + images: [ + { + digest: + 'sha256:661bdba24d94ee8f1e4002eb3a87d7062244dd2583fb294471e9e00a7bfba45d', + architecture: 'amd64', + }, + ], + }, + { + name: '3.9-arm64', + images: [ + { + digest: + 'sha256:1c1d100e50a7684afcbdc82929de1bc79bee8adcaafd367c38e5aa7eed4d6e19', + architecture: 'arm64', + }, + ], + }, + { + name: '3.9-amd64', + images: [ + { + digest: + 'sha256:faf4f034c82d40b66a7285a5e9d305f543a2726961cc6da11206770ef013244e', + architecture: 'amd64', + }, + ], + }, +] + export { sizeDataNoTagSemVerSort, versionDataNoTagDateSort, @@ -3040,4 +3048,5 @@ export { versionDataNoTagSemVerSort, versionDataWithTag, versionDataWithVaryingArchitectures, + versionDataWithArchSpecificVersions, } diff --git a/services/docker/docker-helpers.js b/services/docker/docker-helpers.js index 0b9b1d6879494..b5816810c8f49 100644 --- a/services/docker/docker-helpers.js +++ b/services/docker/docker-helpers.js @@ -1,7 +1,31 @@ +import Joi from 'joi' // see https://github.com/badges/shields/pull/1690 import { NotFound } from '../index.js' const dockerBlue = '066da5' +const archEnum = [ + 'amd64', + 'arm', + 'arm64', + 's390x', + '386', + 'ppc64', + 'ppc64le', + 'wasm', + 'mips', + 'mipsle', + 'mips64', + 'mips64le', + 'riscv64', + 'loong64', +] + +// Valid architecture values: https://golang.org/doc/install/source#environment (GOARCH) +const archSchema = Joi.alternatives( + Joi.string().valid(...archEnum), + Joi.number().valid(386).cast('string'), +) + function buildDockerUrl(badgeName, includeTagRoute) { if (includeTagRoute) { return { @@ -35,8 +59,8 @@ async function getMultiPageData({ user, repo, fetch }) { const pageData = await Promise.all( [...Array(numberOfPages - 1).keys()].map((_, i) => - fetch({ user, repo, page: ++i + 1 }) - ) + fetch({ user, repo, page: ++i + 1 }), + ), ) return [...data.results].concat(...pageData.map(p => p.results)) } @@ -55,6 +79,8 @@ function getDigestSemVerMatches({ data, digest }) { } export { + archEnum, + archSchema, dockerBlue, buildDockerUrl, getDockerHubUser, diff --git a/services/docker/docker-hub-common-fetch.js b/services/docker/docker-hub-common-fetch.js new file mode 100644 index 0000000000000..c5ef9c39e59e7 --- /dev/null +++ b/services/docker/docker-hub-common-fetch.js @@ -0,0 +1,10 @@ +async function fetch(serviceInstance, params) { + return serviceInstance._requestJson( + await serviceInstance.authHelper.withJwtAuth( + params, + 'https://hub.docker.com/v2/users/login/', + ), + ) +} + +export { fetch } diff --git a/services/docker/docker-hub-common-fetch.spec.js b/services/docker/docker-hub-common-fetch.spec.js new file mode 100644 index 0000000000000..0aac7416ca0ec --- /dev/null +++ b/services/docker/docker-hub-common-fetch.spec.js @@ -0,0 +1,20 @@ +import sinon from 'sinon' +import { expect } from 'chai' +import { fetch } from './docker-hub-common-fetch.js' + +describe('fetch', function () { + it('invokes withJwtAuth', async function () { + const serviceInstance = { + _requestJson: sinon.stub().resolves('fake-response'), + authHelper: { + withJwtAuth: sinon.stub(), + }, + } + + const resp = await fetch(serviceInstance, {}) + + expect(serviceInstance.authHelper.withJwtAuth.calledOnce).to.be.true + expect(serviceInstance._requestJson.calledOnce).to.be.true + expect(resp).to.equal('fake-response') + }) +}) diff --git a/services/docker/docker-pulls.service.js b/services/docker/docker-pulls.service.js index 9d741f8f96158..eb1e27dc91ddb 100644 --- a/services/docker/docker-pulls.service.js +++ b/services/docker/docker-pulls.service.js @@ -1,12 +1,13 @@ 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 { dockerBlue, buildDockerUrl, getDockerHubUser, } from './docker-helpers.js' +import { fetch } from './docker-hub-common-fetch.js' const pullsSchema = Joi.object({ pull_count: nonNegativeInteger, @@ -15,33 +16,47 @@ const pullsSchema = Joi.object({ export default class DockerPulls extends BaseJsonService { static category = 'downloads' static route = buildDockerUrl('pulls') - static examples = [ - { - title: 'Docker Pulls', - namedParams: { - user: '_', - repo: 'ubuntu', + + static auth = { + userKey: 'dockerhub_username', + passKey: 'dockerhub_pat', + authorizedOrigins: ['https://hub.docker.com'], + isRequired: false, + } + + static openApi = { + '/docker/pulls/{user}/{repo}': { + get: { + summary: 'Docker Pulls', + parameters: pathParams( + { + name: 'user', + example: '_', + }, + { + name: 'repo', + example: 'ubuntu', + }, + ), }, - staticPreview: this.render({ count: 765400000 }), }, - ] + } + + static _cacheLength = 21600 static defaultBadgeData = { label: 'docker pulls' } - static render({ count }) { - return { - message: metric(count), - color: dockerBlue, - } + static render({ count: downloads }) { + return renderDownloadsBadge({ downloads, colorOverride: dockerBlue }) } async fetch({ user, repo }) { - return this._requestJson({ + return await fetch(this, { schema: pullsSchema, url: `https://hub.docker.com/v2/repositories/${getDockerHubUser( - user + user, )}/${repo}`, - errorMessages: { 404: 'repo not found' }, + httpErrors: { 404: 'repo not found' }, }) } diff --git a/services/docker/docker-size.service.js b/services/docker/docker-size.service.js index 16c7ea49467ce..24eeaa35e86a9 100644 --- a/services/docker/docker-size.service.js +++ b/services/docker/docker-size.service.js @@ -1,17 +1,26 @@ import Joi from 'joi' -import prettyBytes from 'pretty-bytes' +import { renderSizeBadge } from '../size.js' import { nonNegativeInteger } from '../validators.js' import { latest } from '../version.js' -import { BaseJsonService, NotFound } from '../index.js' +import { BaseJsonService, NotFound, pathParams, queryParams } from '../index.js' import { + archEnum, + archSchema, buildDockerUrl, getDockerHubUser, getMultiPageData, } from './docker-helpers.js' +import { fetch } from './docker-hub-common-fetch.js' const buildSchema = Joi.object({ name: Joi.string().required(), full_size: nonNegativeInteger.required(), + images: Joi.array().items( + Joi.object({ + size: nonNegativeInteger.required(), + architecture: Joi.string().required(), + }), + ), }).required() const pagedSchema = Joi.object({ @@ -20,83 +29,191 @@ const pagedSchema = Joi.object({ Joi.object({ name: Joi.string().required(), full_size: nonNegativeInteger.required(), - }) + images: Joi.array().items( + Joi.object({ + size: nonNegativeInteger.required(), + architecture: Joi.string().required(), + }), + ), + }), ), }).required() +const sortEnum = ['date', 'semver'] + const queryParamSchema = Joi.object({ - sort: Joi.string().valid('date', 'semver').default('date'), + sort: Joi.string() + .valid(...sortEnum) + .default('date'), + arch: archSchema, }).required() +const openApiQueryParams = queryParams( + { + name: 'sort', + example: 'semver', + schema: { type: 'string', enum: sortEnum }, + description: 'If not specified, the default is `date`', + }, + { + name: 'arch', + example: 'amd64', + schema: { type: 'string', enum: archEnum }, + }, +) + +// If user provided the arch parameter, +// check if any of the returned images has an architecture matching the arch parameter provided. +// If yes, return the size of the image with this arch. +// If not, throw the `NotFound` error. +// For details see: https://github.com/badges/shields/issues/8238 +function getImageSizeForArch(images, arch) { + const imgWithArch = Object.values(images).find( + img => img.architecture === arch, + ) + + if (!imgWithArch) { + throw new NotFound({ prettyMessage: 'architecture not found' }) + } + return imgWithArch.size +} + export default class DockerSize extends BaseJsonService { static category = 'size' static route = { ...buildDockerUrl('image-size', true), queryParamSchema } - static examples = [ - { - title: 'Docker Image Size (latest by date)', - pattern: ':user/:repo', - namedParams: { user: 'fedora', repo: 'apache' }, - queryParams: { sort: 'date' }, - staticPreview: this.render({ size: 126000000 }), - }, - { - title: 'Docker Image Size (latest semver)', - pattern: ':user/:repo', - namedParams: { user: 'fedora', repo: 'apache' }, - queryParams: { sort: 'semver' }, - staticPreview: this.render({ size: 136000000 }), + + static auth = { + userKey: 'dockerhub_username', + passKey: 'dockerhub_pat', + authorizedOrigins: [ + 'https://hub.docker.com', + 'https://registry.hub.docker.com', + ], + isRequired: false, + } + + static openApi = { + '/docker/image-size/{user}/{repo}': { + get: { + summary: 'Docker Image Size', + parameters: [ + ...pathParams( + { name: 'user', example: 'fedora' }, + { name: 'repo', example: 'apache' }, + ), + ...openApiQueryParams, + ], + }, }, - { - title: 'Docker Image Size (tag)', - pattern: ':user/:repo/:tag', - namedParams: { user: 'fedora', repo: 'apache', tag: 'latest' }, - staticPreview: this.render({ size: 103000000 }), + '/docker/image-size/{user}/{repo}/{tag}': { + get: { + summary: 'Docker Image Size (tag)', + parameters: [ + ...pathParams( + { name: 'user', example: 'fedora' }, + { name: 'repo', example: 'apache' }, + { name: 'tag', example: 'latest' }, + ), + ...openApiQueryParams, + ], + }, }, - ] + } - static defaultBadgeData = { label: 'image size', color: 'blue' } + static _cacheLength = 900 - static render({ size }) { - return { message: prettyBytes(size) } - } + static defaultBadgeData = { label: 'image size', color: 'blue' } async fetch({ user, repo, tag, page }) { page = page ? `&page=${page}` : '' - return this._requestJson({ + return await fetch(this, { schema: tag ? buildSchema : pagedSchema, url: `https://registry.hub.docker.com/v2/repositories/${getDockerHubUser( - user + user, )}/${repo}/tags${ tag ? `/${tag}` : '?page_size=100&ordering=last_updated' }${page}`, - errorMessages: { 404: 'repository or tag not found' }, + httpErrors: { 404: 'repository or tag not found' }, }) } - transform({ tag, sort, data }) { - if (!tag && sort === 'date') { - if (data.count === 0) { - throw new NotFound({ prettyMessage: 'repository not found' }) + getSizeFromImageByLatestDate(data, arch) { + if (data.count === 0) { + throw new NotFound({ prettyMessage: 'repository not found' }) + } else { + const latestEntry = data.results[0] + + if (arch) { + return { size: getImageSizeForArch(latestEntry.images, arch) } } else { - return { size: data.results[0].full_size } + return { size: latestEntry.full_size } } - } else if (!tag && sort === 'semver') { - const [matches, versions] = data.reduce( - ([m, v], d) => { - m[d.name] = d.full_size - v.push(d.name) - return [m, v] - }, - [{}, []] - ) - const version = latest(versions) + } + } + + getSizeFromImageByLatestSemver(data, arch) { + // If no tag is specified, and sorting is by semver, first filter out the entry containing the latest semver from the response with Docker images. + // If no architecture is supplied by the user, return `full_size` from this entry. + // If the architecture is supplied by the user, check if any of the returned images for this entry has an architecture matching the arch parameter supplied by the user. + // If yes, return the size of the image with this arch. + // If not, throw the `NotFound` error. + + const [matches, versions, images] = data.reduce( + ([m, v, i], d) => { + m[d.name] = d.full_size + v.push(d.name) + i[d.name] = d.images + return [m, v, i] + }, + [{}, [], {}], + ) + + const version = latest(versions) + + let sizeOfImgWithArch + + if (arch) { + Object.keys(images).forEach(ver => { + if (ver === version) { + sizeOfImgWithArch = getImageSizeForArch(images[ver], arch) + return { size: sizeOfImgWithArch } + } + }) + + if (sizeOfImgWithArch) { + return { size: sizeOfImgWithArch } + } else { + throw new NotFound({ prettyMessage: 'architecture not found' }) + } + } else { return { size: matches[version] } + } + } + + getSizeFromTag(data, arch) { + // If the tag is specified, and the architecture is supplied by the user, + // check if any of the returned images has an architecture matching the arch parameter supplied by the user. + // If yes, return the size of the image with this arch. + // If no, throw the `NotFound` error. + // If no architecture is supplied by the user, return the value of the `full_size` from the response (the image with the `latest` tag). + if (arch) { + return { size: getImageSizeForArch(data.images, arch) } } else { return { size: data.full_size } } } - async handle({ user, repo, tag }, { sort }) { + transform({ tag, sort, data, arch }) { + if (!tag && sort === 'date') { + return this.getSizeFromImageByLatestDate(data, arch) + } else if (!tag && sort === 'semver') { + return this.getSizeFromImageByLatestSemver(data, arch) + } else { + return this.getSizeFromTag(data, arch) + } + } + + async handle({ user, repo, tag }, { sort, arch }) { let data if (!tag && sort === 'date') { @@ -111,7 +228,7 @@ export default class DockerSize extends BaseJsonService { data = await this.fetch({ user, repo, tag }) } - const { size } = await this.transform({ tag, sort, data }) - return this.constructor.render({ size }) + const { size } = await this.transform({ tag, sort, data, arch }) + return renderSizeBadge(size, 'iec', 'image size') } } diff --git a/services/docker/docker-size.spec.js b/services/docker/docker-size.spec.js index 946e0d161b668..b53cd4d67ab65 100644 --- a/services/docker/docker-size.spec.js +++ b/services/docker/docker-size.spec.js @@ -3,40 +3,99 @@ import DockerSize from './docker-size.service.js' import { sizeDataNoTagSemVerSort } from './docker-fixtures.js' describe('DockerSize', function () { - test(DockerSize.prototype.transform, () => { - given({ - tag: '', - sort: 'date', - data: { results: [{ name: 'next', full_size: 219939484 }] }, - }).expect({ + test(DockerSize.prototype.getSizeFromImageByLatestDate, () => { + given( + { + count: 0, + results: [], + }, + 'amd64', + ).expectError('Not Found: repository not found') + given( + { + count: 1, + results: [ + { + full_size: 300000000, + name: 'next', + images: [{ architecture: 'amd64', size: 219939484 }], + }, + ], + }, + 'amd64', + ).expect({ size: 219939484, }) given({ - tag: '', - sort: 'date', - data: { + count: 1, + results: [ + { + full_size: 300000000, + name: 'next', + images: [ + { architecture: 'amd64', size: 219939484 }, + { architecture: 'arm64', size: 200000000 }, + ], + }, + ], + }).expect({ + size: 300000000, + }) + given( + { + count: 1, results: [ - { name: 'latest', full_size: 74661264 }, - { name: 'arm64v8-latest', full_size: 76310416 }, - { name: 'arm32v7-latest', full_size: 68001970 }, - { name: 'amd64-latest', full_size: 74661264 }, + { + full_size: 300000000, + name: 'next', + images: [ + { architecture: 'amd64', size: 219939484 }, + { architecture: 'arm64', size: 200000000 }, + ], + }, ], }, - }).expect({ - size: 74661264, + 'arm64777', + ).expectError('Not Found: architecture not found') + }) + + test(DockerSize.prototype.getSizeFromTag, () => { + given( + { + full_size: 300000000, + name: 'next', + images: [{ architecture: 'amd64', size: 219939484 }], + }, + 'amd64', + ).expect({ + size: 219939484, }) given({ - tag: '', - sort: 'semver', - data: sizeDataNoTagSemVerSort, + full_size: 300000000, + name: 'next', + images: [{ architecture: 'amd64', size: 219939484 }], }).expect({ - size: 13448411, + size: 300000000, }) - given({ - tag: 'latest', - data: { name: 'latest', full_size: 13448411 }, - }).expect({ - size: 13448411, + given( + { + full_size: 300000000, + name: 'next', + images: [{ architecture: 'amd64', size: 219939484 }], + }, + 'arm64777', + ).expectError('Not Found: architecture not found') + }) + + test(DockerSize.prototype.getSizeFromImageByLatestSemver, () => { + given(sizeDataNoTagSemVerSort, 'amd64').expect({ + size: 220000000, + }) + given(sizeDataNoTagSemVerSort).expect({ + size: 400000000, }) + given(sizeDataNoTagSemVerSort, 'nonexistentArch').expectError( + 'Not Found: architecture not found', + ) }) }) diff --git a/services/docker/docker-size.tester.js b/services/docker/docker-size.tester.js index c476a773f32c8..a5f078946e13c 100644 --- a/services/docker/docker-size.tester.js +++ b/services/docker/docker-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,28 +6,35 @@ t.create('docker image size (valid, library)') .get('/_/alpine.json') .expectBadge({ label: 'image size', - message: isFileSize, + message: isIecFileSize, + }) + +t.create('docker image size (valid, library, arch parameter )') + .get('/_/mysql.json?arch=amd64') + .expectBadge({ + label: 'image size', + message: isIecFileSize, }) t.create('docker image size (valid, library with tag)') .get('/_/alpine/latest.json') .expectBadge({ label: 'image size', - message: isFileSize, + message: isIecFileSize, }) t.create('docker image size (valid, user)') .get('/jrottenberg/ffmpeg.json') .expectBadge({ label: 'image size', - message: isFileSize, + message: isIecFileSize, }) t.create('docker image size (valid, user with tag)') .get('/jrottenberg/ffmpeg/3.2-alpine.json') .expectBadge({ label: 'image size', - message: isFileSize, + message: isIecFileSize, }) t.create('docker image size (invalid, incorrect tag)') @@ -41,5 +48,19 @@ t.create('docker image size (invalid, unknown repository)') .get('/_/not-a-real-repo.json') .expectBadge({ label: 'image size', - message: 'repository not found', + message: 'repository or tag not found', + }) + +t.create('docker image size (invalid, wrong sorting method)') + .get('/jrottenberg/ffmpeg/3.2-alpine.json?sort=daterrr') + .expectBadge({ + label: 'image size', + message: 'invalid query parameter: sort', + }) + +t.create('docker image size (invalid, nonexisting arch)') + .get('/jrottenberg/ffmpeg/3.2-alpine.json?arch=nonexistingArch') + .expectBadge({ + label: 'image size', + message: 'invalid query parameter: arch', }) diff --git a/services/docker/docker-stars.service.js b/services/docker/docker-stars.service.js index 92cc8eb81e85b..588d9849bfb0a 100644 --- a/services/docker/docker-stars.service.js +++ b/services/docker/docker-stars.service.js @@ -1,25 +1,48 @@ +import Joi from 'joi' import { metric } from '../text-formatters.js' import { nonNegativeInteger } from '../validators.js' -import { BaseService } from '../index.js' +import { BaseJsonService, pathParams } from '../index.js' import { dockerBlue, buildDockerUrl, getDockerHubUser, } from './docker-helpers.js' +import { fetch } from './docker-hub-common-fetch.js' -export default class DockerStars extends BaseService { +const schema = Joi.object({ + star_count: nonNegativeInteger.required(), +}).required() + +export default class DockerStars extends BaseJsonService { static category = 'rating' static route = buildDockerUrl('stars') - static examples = [ - { - title: 'Docker Stars', - namedParams: { - user: '_', - repo: 'ubuntu', + + static auth = { + userKey: 'dockerhub_username', + passKey: 'dockerhub_pat', + authorizedOrigins: ['https://hub.docker.com'], + isRequired: false, + } + + static openApi = { + '/docker/stars/{user}/{repo}': { + get: { + summary: 'Docker Stars', + parameters: pathParams( + { + name: 'user', + example: '_', + }, + { + name: 'repo', + example: 'ubuntu', + }, + ), }, - staticPreview: this.render({ stars: 9000 }), }, - ] + } + + static _cacheLength = 21600 static defaultBadgeData = { label: 'docker stars' } @@ -31,18 +54,17 @@ export default class DockerStars extends BaseService { } async fetch({ user, repo }) { - const url = `https://hub.docker.com/v2/repositories/${getDockerHubUser( - user - )}/${repo}/stars/count/` - const { buffer } = await this._request({ - url, - errorMessages: { 404: 'repo not found' }, + return await fetch(this, { + schema, + url: `https://hub.docker.com/v2/repositories/${getDockerHubUser( + user, + )}/${repo}/`, + httpErrors: { 404: 'repo not found' }, }) - return this.constructor._validate(buffer, nonNegativeInteger) } async handle({ user, repo }) { - const stars = await this.fetch({ user, repo }) - return this.constructor.render({ stars }) + const resp = await this.fetch({ user, repo }) + return this.constructor.render({ stars: resp.star_count }) } } diff --git a/services/docker/docker-version.service.js b/services/docker/docker-version.service.js index 00bb00e985698..b211fbfec609b 100644 --- a/services/docker/docker-version.service.js +++ b/services/docker/docker-version.service.js @@ -1,13 +1,22 @@ import Joi from 'joi' import { nonNegativeInteger } from '../validators.js' import { latest, renderVersionBadge } from '../version.js' -import { BaseJsonService, NotFound, InvalidResponse } from '../index.js' import { + BaseJsonService, + NotFound, + InvalidResponse, + pathParams, + queryParams, +} from '../index.js' +import { + archEnum, + archSchema, buildDockerUrl, getDockerHubUser, getMultiPageData, getDigestSemVerMatches, } from './docker-helpers.js' +import { fetch } from './docker-hub-common-fetch.js' const buildSchema = Joi.object({ count: nonNegativeInteger.required(), @@ -18,58 +27,77 @@ const buildSchema = Joi.object({ Joi.object({ digest: Joi.string(), architecture: Joi.string().required(), - }) + }), ), - }) + }), ), }).required() +const sortEnum = ['date', 'semver'] + const queryParamSchema = Joi.object({ sort: Joi.string().valid('date', 'semver').default('date'), - arch: Joi.string() - // Valid architecture values: https://golang.org/doc/install/source#environment (GOARCH) - .valid( - 'amd64', - 'arm', - 'arm64', - 's390x', - '386', - 'ppc64', - 'ppc64le', - 'wasm', - 'mips', - 'mipsle', - 'mips64', - 'mips64le' - ) - .default('amd64'), + arch: archSchema.default('amd64'), }).required() +const openApiQueryParams = queryParams( + { + name: 'sort', + example: 'semver', + schema: { type: 'string', enum: sortEnum }, + description: 'If not specified, the default is `date`', + }, + { + name: 'arch', + example: 'amd64', + schema: { type: 'string', enum: archEnum }, + description: 'If not specified, the default is `amd64`', + }, +) + export default class DockerVersion extends BaseJsonService { static category = 'version' static route = { ...buildDockerUrl('v', true), queryParamSchema } - static examples = [ - { - title: 'Docker Image Version (latest by date)', - pattern: ':user/:repo', - namedParams: { user: '_', repo: 'alpine' }, - queryParams: { sort: 'date', arch: 'amd64' }, - staticPreview: this.render({ version: '3.9.5' }), - }, - { - title: 'Docker Image Version (latest semver)', - pattern: ':user/:repo', - namedParams: { user: '_', repo: 'alpine' }, - queryParams: { sort: 'semver' }, - staticPreview: this.render({ version: '3.11.3' }), + + static auth = { + userKey: 'dockerhub_username', + passKey: 'dockerhub_pat', + authorizedOrigins: [ + 'https://hub.docker.com', + 'https://registry.hub.docker.com', + ], + isRequired: false, + } + + static openApi = { + '/docker/v/{user}/{repo}': { + get: { + summary: 'Docker Image Version', + parameters: [ + ...pathParams( + { name: 'user', example: '_' }, + { name: 'repo', example: 'alpine' }, + ), + ...openApiQueryParams, + ], + }, }, - { - title: 'Docker Image Version (tag latest semver)', - pattern: ':user/:repo/:tag', - namedParams: { user: '_', repo: 'alpine', tag: '3.6' }, - staticPreview: this.render({ version: '3.6.5' }), + '/docker/v/{user}/{repo}/{tag}': { + get: { + summary: 'Docker Image Version (tag)', + parameters: [ + ...pathParams( + { name: 'user', example: '_' }, + { name: 'repo', example: 'alpine' }, + { name: 'tag', example: '3.6' }, + ), + ...openApiQueryParams, + ], + }, }, - ] + } + + static _cacheLength = 900 static defaultBadgeData = { label: 'version', color: 'blue' } @@ -79,12 +107,12 @@ export default class DockerVersion extends BaseJsonService { async fetch({ user, repo, page }) { page = page ? `&page=${page}` : '' - return this._requestJson({ + return await fetch(this, { schema: buildSchema, url: `https://registry.hub.docker.com/v2/repositories/${getDockerHubUser( - user + user, )}/${repo}/tags?page_size=100&ordering=last_updated${page}`, - errorMessages: { 404: 'repository or tag not found' }, + httpErrors: { 404: 'repository or tag not found' }, }) } @@ -105,7 +133,14 @@ export default class DockerVersion extends BaseJsonService { const { digest } = imageTag return { version: getDigestSemVerMatches({ data: pagedData, digest }) } } else if (!tag && sort === 'semver') { - const matches = data.map(d => d.name) + const matches = data + .filter(d => d.images.some(image => image.architecture === arch)) + .map(d => d.name) + if (matches.length === 0) { + throw new InvalidResponse({ + prettyMessage: `no images found for arch ${arch}`, + }) + } return { version: latest(matches) } } else { version = data.find(d => d.name === tag) @@ -149,7 +184,7 @@ export default class DockerVersion extends BaseJsonService { }) } - const { version } = await this.transform({ + const { version } = this.transform({ tag, sort, data, diff --git a/services/docker/docker-version.spec.js b/services/docker/docker-version.spec.js index bd1681c27b0da..73c61e4eab85f 100644 --- a/services/docker/docker-version.spec.js +++ b/services/docker/docker-version.spec.js @@ -8,6 +8,7 @@ import { versionDataNoTagSemVerSort, versionDataWithTag, versionDataWithVaryingArchitectures, + versionDataWithArchSpecificVersions, } from './docker-fixtures.js' describe('DockerVersion', function () { @@ -65,6 +66,13 @@ describe('DockerVersion', function () { }).expect({ version: '3.10.4', }) + given({ + data: versionDataWithArchSpecificVersions, + sort: 'semver', + arch: 'arm64', + }).expect({ + version: '3.9-arm64', + }) }) it('throws InvalidResponse error with latest tag and no amd64 architecture digests', function () { diff --git a/services/docker/docker-version.tester.js b/services/docker/docker-version.tester.js index fe13f285943b8..8410a69ffb060 100644 --- a/services/docker/docker-version.tester.js +++ b/services/docker/docker-version.tester.js @@ -2,10 +2,12 @@ import { isSemver } from '../test-validators.js' import { createServiceTester } from '../tester.js' export const t = await createServiceTester() -t.create('docker version (valid, library)').get('/_/alpine.json').expectBadge({ - label: 'version', - message: isSemver, -}) +t.create('docker version (valid, library)') + .get('/docker/example-voting-app-vote.json') + .expectBadge({ + label: 'version', + message: 'latest', + }) t.create('docker version (valid, library with tag)') .get('/_/alpine/latest.json') @@ -39,5 +41,5 @@ t.create('docker version (invalid, unknown repository)') .get('/_/not-a-real-repo.json') .expectBadge({ label: 'version', - message: 'repository not found', + message: 'repository or tag not found', }) diff --git a/services/docsrs/docsrs.service.js b/services/docsrs/docsrs.service.js index ca88f67e49259..f01b32906a72d 100644 --- a/services/docsrs/docsrs.service.js +++ b/services/docsrs/docsrs.service.js @@ -1,35 +1,48 @@ import Joi from 'joi' -import { BaseJsonService } from '../index.js' +import { BaseJsonService, pathParams } from '../index.js' -const schema = Joi.array() - .items( - Joi.object({ - build_status: Joi.boolean().required(), - }) - ) - .min(1) - .required() +const schema = Joi.object({ + doc_status: Joi.boolean().required(), +}).required() export default class DocsRs extends BaseJsonService { static category = 'build' static route = { base: 'docsrs', pattern: ':crate/:version?' } - static examples = [ - { - title: 'docs.rs', - namedParams: { crate: 'regex', version: 'latest' }, - staticPreview: this.render({ version: 'latest', buildStatus: true }), - keywords: ['rust'], + static openApi = { + '/docsrs/{crate}/{version}': { + get: { + summary: 'docs.rs (with version)', + parameters: pathParams( + { + name: 'crate', + example: 'regex', + }, + { + name: 'version', + example: 'latest', + }, + ), + }, + }, + '/docsrs/{crate}': { + get: { + summary: 'docs.rs', + parameters: pathParams({ + name: 'crate', + example: 'regex', + }), + }, }, - ] + } static defaultBadgeData = { label: 'docs' } - static render({ buildStatus, version }) { + static render({ docStatus, version }) { let label = `docs@${version}` if (version === 'latest') { label = 'docs' } - if (buildStatus) { + if (docStatus) { return { label, message: 'passing', @@ -47,14 +60,13 @@ export default class DocsRs extends BaseJsonService { async fetch({ crate, version }) { return await this._requestJson({ schema, - url: `https://docs.rs/crate/${crate}/${version}/builds.json`, + url: `https://docs.rs/crate/${crate}/${version}/status.json`, + httpErrors: version ? { 400: 'malformed version' } : {}, }) } async handle({ crate, version = 'latest' }) { - const { build_status: buildStatus } = ( - await this.fetch({ crate, version }) - ).pop() - return this.constructor.render({ version, buildStatus }) + const { doc_status: docStatus } = await this.fetch({ crate, version }) + return this.constructor.render({ version, docStatus }) } } diff --git a/services/docsrs/docsrs.tester.js b/services/docsrs/docsrs.tester.js index 0bd6157bdc6ff..715ec413b9439 100644 --- a/services/docsrs/docsrs.tester.js +++ b/services/docsrs/docsrs.tester.js @@ -2,17 +2,48 @@ import Joi from 'joi' import { createServiceTester } from '../tester.js' export const t = await createServiceTester() -t.create('Passing docs') - .get('/tokio/0.3.0.json') - .expectBadge({ label: 'docs@0.3.0', message: 'passing' }) +t.create('Docs with no version specified') + .get('/tokio.json') + .expectBadge({ + label: 'docs', + message: Joi.equal('passing', 'failing'), + }) + +t.create('Passing docs for version').get('/tokio/1.37.0.json').expectBadge({ + label: 'docs@1.37.0', + message: 'passing', + color: 'brightgreen', +}) + +t.create('Failing docs for version').get('/tokio/1.32.1.json').expectBadge({ + label: 'docs@1.32.1', + message: 'failing', + color: 'red', +}) -t.create('Failing docs') - .get('/tensorflow/0.16.1.json') - .expectBadge({ label: 'docs@0.16.1', message: 'failing' }) +t.create('Multiple builds, latest passing') + .get('/bevy_tweening/0.3.1.json') + .expectBadge({ + label: 'docs@0.3.1', + message: 'passing', + color: 'brightgreen', + }) t.create('Getting latest version works') .get('/rand/latest.json') .expectBadge({ label: 'docs', - messsage: Joi.allow('passing', 'failing'), + message: Joi.equal('passing', 'failing'), }) + +t.create('Crate not found') + .get('/not-a-crate/latest.json') + .expectBadge({ label: 'docs', message: 'not found' }) + +t.create('Version not found') + .get('/tokio/0.8.json') + .expectBadge({ label: 'docs', message: 'not found' }) + +t.create('Malformed version') + .get('/tokio/not-a-version.json') + .expectBadge({ label: 'docs', message: 'malformed version' }) diff --git a/services/downloads.js b/services/downloads.js new file mode 100644 index 0000000000000..f2fb0690726ee --- /dev/null +++ b/services/downloads.js @@ -0,0 +1,56 @@ +/** + * @module + */ + +import { downloadCount } from './color-formatters.js' +import { metric } from './text-formatters.js' + +/** + * Handles rendering concerns of badges that display + * download counts, with override/customization support + * + * @param {object} attrs Refer to individual attrs + * @param {number} attrs.downloads Number of downloads + * @param {string} [attrs.interval] Period or interval the downloads occurred + * (e.g. day, week, month). If provided then this Will be reflected + * in the badge message unless overridden by other message-related parameters + * @param {string} [attrs.version] Version or tag that was downloaded + * which will be reflected in the badge label (unless the label is overridden) + * @param {string} [attrs.labelOverride] If provided then the badge label is set to this + * value overriding any other label-related parameters + * @param {string} [attrs.colorOverride] If provided then the badge color is set to this + * value instead of the color being based on the count of downloads + * @param {string} [attrs.messageSuffixOverride] If provided then the badge message will + * will have this value added to the download count, separated with a space + * @returns {object} Badge + */ +function renderDownloadsBadge({ + downloads, + interval, + version, + labelOverride, + colorOverride, + messageSuffixOverride, +}) { + let messageSuffix = '' + if (messageSuffixOverride) { + messageSuffix = ` ${messageSuffixOverride}` + } else if (interval) { + messageSuffix = `/${interval}` + } + + let label + if (labelOverride) { + label = labelOverride + } else if (version) { + label = `downloads@${version}` + } + + return { + label, + color: colorOverride || downloadCount(downloads), + message: `${metric(downloads)}${messageSuffix}`, + } +} + +export { renderDownloadsBadge } diff --git a/services/downloads.spec.js b/services/downloads.spec.js new file mode 100644 index 0000000000000..bf29274f82964 --- /dev/null +++ b/services/downloads.spec.js @@ -0,0 +1,43 @@ +import { test, given } from 'sazerac' +import { renderDownloadsBadge } from './downloads.js' +import { downloadCount } from './color-formatters.js' +import { metric } from './text-formatters.js' + +const downloads = 2345 +const message = metric(downloads) +const color = downloadCount(downloads) + +describe('downloads', function () { + test(renderDownloadsBadge, () => { + given({ downloads }).expect({ label: undefined, color, message }) + given({ downloads, labelOverride: 'recent downloads' }).expect({ + label: 'recent downloads', + color, + message, + }) + given({ downloads, version: 'v1.0.0' }).expect({ + label: 'downloads@v1.0.0', + color, + message, + }) + given({ + downloads, + messageSuffixOverride: '[foo.tar.gz]', + interval: 'week', + }).expect({ + label: undefined, + color, + message: `${message} [foo.tar.gz]`, + }) + given({ downloads, interval: 'year' }).expect({ + label: undefined, + color, + message: `${message}/year`, + }) + given({ downloads, colorOverride: 'pink' }).expect({ + label: undefined, + color: 'pink', + message, + }) + }) +}) diff --git a/services/drone/drone-build.service.js b/services/drone/drone-build.service.js index 5b5986efb6dc3..cda006a1b3018 100644 --- a/services/drone/drone-build.service.js +++ b/services/drone/drone-build.service.js @@ -1,7 +1,7 @@ import Joi from 'joi' import { isBuildStatus, renderBuildStatusBadge } from '../build-status.js' import { optionalUrl } from '../validators.js' -import { BaseJsonService } from '../index.js' +import { BaseJsonService, queryParam, pathParam } from '../index.js' const schema = Joi.object({ status: Joi.alternatives() @@ -22,48 +22,51 @@ export default class DroneBuild extends BaseJsonService { } static auth = { passKey: 'drone_token', serviceKey: 'drone' } - static examples = [ - { - title: 'Drone (cloud)', - pattern: ':user/:repo', - namedParams: { - user: 'drone', - repo: 'drone', - }, - staticPreview: renderBuildStatusBadge({ status: 'success' }), - }, - { - title: 'Drone (cloud) with branch', - pattern: ':user/:repo/:branch', - namedParams: { - user: 'drone', - repo: 'drone', - branch: 'master', - }, - staticPreview: renderBuildStatusBadge({ status: 'success' }), - }, - { - title: 'Drone (self-hosted)', - pattern: ':user/:repo', - queryParams: { server: 'https://drone.shields.io' }, - namedParams: { - user: 'badges', - repo: 'shields', + + static openApi = { + '/drone/build/{user}/{repo}': { + get: { + summary: 'Drone', + parameters: [ + pathParam({ + name: 'user', + example: 'drone', + }), + pathParam({ + name: 'repo', + example: 'autoscaler', + }), + queryParam({ + name: 'server', + example: 'https://drone.shields.io', + }), + ], }, - staticPreview: renderBuildStatusBadge({ status: 'success' }), }, - { - title: 'Drone (self-hosted) with branch', - pattern: ':user/:repo/:branch', - queryParams: { server: 'https://drone.shields.io' }, - namedParams: { - user: 'badges', - repo: 'shields', - branch: 'feat/awesome-thing', + '/drone/build/{user}/{repo}/{branch}': { + get: { + summary: 'Drone (branch)', + parameters: [ + pathParam({ + name: 'user', + example: 'drone', + }), + pathParam({ + name: 'repo', + example: 'autoscaler', + }), + pathParam({ + name: 'branch', + example: 'master', + }), + queryParam({ + name: 'server', + example: 'https://drone.shields.io', + }), + ], }, - staticPreview: renderBuildStatusBadge({ status: 'success' }), }, - ] + } static defaultBadgeData = { label: 'build' } @@ -73,12 +76,12 @@ export default class DroneBuild extends BaseJsonService { schema, url: `${server}/api/repos/${user}/${repo}/builds/latest`, options: { - qs: { ref: branch ? `refs/heads/${branch}` : undefined }, + searchParams: { ref: branch ? `refs/heads/${branch}` : undefined }, }, - errorMessages: { + httpErrors: { 401: 'repo not found or not authorized', }, - }) + }), ) return renderBuildStatusBadge({ status: json.status }) } diff --git a/services/drone/drone-build.spec.js b/services/drone/drone-build.spec.js index 0cb15273618a2..d14812cc86d81 100644 --- a/services/drone/drone-build.spec.js +++ b/services/drone/drone-build.spec.js @@ -10,7 +10,7 @@ describe('DroneBuild', function () { const token = 'abc123' const scope = nock('https://cloud.drone.io', { - reqheaders: { Authorization: `Bearer abc123` }, + reqheaders: { Authorization: 'Bearer abc123' }, }) .get(/.*/) .reply(200, { status: 'passing' }) @@ -30,8 +30,8 @@ describe('DroneBuild', function () { drone_token: token, }, }, - { user: 'atlassian', repo: 'python-bitbucket' } - ) + { user: 'atlassian', repo: 'python-bitbucket' }, + ), ).to.deep.equal({ label: undefined, message: 'passing', diff --git a/services/drone/drone-build.tester.js b/services/drone/drone-build.tester.js index abd50e8feeb97..125d656d6dfa7 100644 --- a/services/drone/drone-build.tester.js +++ b/services/drone/drone-build.tester.js @@ -6,18 +6,18 @@ export const t = await createServiceTester() const isDroneBuildStatus = Joi.alternatives().try( isBuildStatus, Joi.equal('none'), - Joi.equal('killed') + Joi.equal('killed'), ) t.create('cloud-hosted build status on default branch') - .get('/drone/drone.json') + .get('/drone/autoscaler.json') .expectBadge({ label: 'build', message: isDroneBuildStatus, }) t.create('cloud-hosted build status on named branch') - .get('/drone/drone/master.json') + .get('/drone/autoscaler/master.json') .expectBadge({ label: 'build', message: isDroneBuildStatus, @@ -35,7 +35,7 @@ t.create('self-hosted build status on default branch') .intercept(nock => nock('https://drone.shields.io/api/repos') .get('/badges/shields/builds/latest') - .reply(200, { status: 'success' }) + .reply(200, { status: 'success' }), ) .expectBadge({ label: 'build', @@ -44,13 +44,13 @@ t.create('self-hosted build status on default branch') t.create('self-hosted build status on named branch') .get( - '/badges/shields/feat/awesome-thing.json?server=https://drone.shields.io' + '/badges/shields/feat/awesome-thing.json?server=https://drone.shields.io', ) .intercept(nock => nock('https://drone.shields.io/api/repos') .get('/badges/shields/builds/latest') .query({ ref: 'refs/heads/feat/awesome-thing' }) - .reply(200, { status: 'success' }) + .reply(200, { status: 'success' }), ) .expectBadge({ label: 'build', diff --git a/services/dub/dub-download.service.js b/services/dub/dub-download.service.js index e82bf59d6eae6..1afe83180b010 100644 --- a/services/dub/dub-download.service.js +++ b/services/dub/dub-download.service.js @@ -1,8 +1,7 @@ import Joi from 'joi' -import { metric } from '../text-formatters.js' -import { downloadCount as downloadCountColor } from '../color-formatters.js' +import { renderDownloadsBadge } from '../downloads.js' import { nonNegativeInteger } from '../validators.js' -import { BaseJsonService } from '../index.js' +import { BaseJsonService, pathParams } from '../index.js' const schema = Joi.object({ downloads: Joi.object({ @@ -16,19 +15,19 @@ const schema = Joi.object({ const intervalMap = { dd: { transform: json => json.downloads.daily, - messageSuffix: '/day', + interval: 'day', }, dw: { transform: json => json.downloads.weekly, - messageSuffix: '/week', + interval: 'week', }, dm: { transform: json => json.downloads.monthly, - messageSuffix: '/month', + interval: 'month', }, dt: { transform: json => json.downloads.total, - messageSuffix: '', + interval: '', }, } @@ -39,50 +38,57 @@ export default class DubDownloads extends BaseJsonService { pattern: ':interval(dd|dw|dm|dt)/:packageName/:version*', } - static examples = [ - { - title: 'DUB', - namedParams: { interval: 'dm', packageName: 'vibe-d' }, - staticPreview: this.render({ interval: 'dm', downloadCount: 5000 }), - }, - { - title: 'DUB (version)', - namedParams: { - interval: 'dm', - packageName: 'vibe-d', - version: '0.8.4', + static openApi = { + '/dub/{interval}/{packageName}': { + get: { + summary: 'DUB Downloads', + parameters: pathParams( + { + name: 'interval', + example: 'dm', + schema: { type: 'string', enum: this.getEnum('interval') }, + description: 'Daily, Weekly, Monthly, or Total downloads', + }, + { + name: 'packageName', + example: 'vibe-d', + }, + ), }, - staticPreview: this.render({ - interval: 'dm', - version: '0.8.4', - downloadCount: 100, - }), }, - { - title: 'DUB (latest)', - namedParams: { - interval: 'dm', - packageName: 'vibe-d', - version: 'latest', + '/dub/{interval}/{packageName}/{version}': { + get: { + summary: 'DUB Downloads (specific version)', + parameters: pathParams( + { + name: 'interval', + example: 'dm', + schema: { type: 'string', enum: this.getEnum('interval') }, + description: 'Daily, Weekly, Monthly, or Total downloads', + }, + { + name: 'packageName', + example: 'vibe-d', + }, + { + name: 'version', + description: + 'This can either be a numeric version like `0.8.4` or the string `latest`', + example: '0.8.4', + }, + ), }, - staticPreview: this.render({ - interval: 'dm', - version: 'latest', - downloadCount: 100, - }), }, - ] + } static defaultBadgeData = { label: 'downloads' } - static render({ interval, version, downloadCount }) { - const { messageSuffix } = intervalMap[interval] - - return { - label: version ? `downloads@${version}` : 'downloads', - message: `${metric(downloadCount)}${messageSuffix}`, - color: downloadCountColor(downloadCount), - } + static render({ interval, version, downloads }) { + return renderDownloadsBadge({ + downloads, + version, + interval: intervalMap[interval].interval, + }) } async fetch({ packageName, version }) { @@ -98,7 +104,7 @@ export default class DubDownloads extends BaseJsonService { const { transform } = intervalMap[interval] const json = await this.fetch({ packageName, version }) - const downloadCount = transform(json) - return this.constructor.render({ interval, downloadCount, version }) + const downloads = transform(json) + return this.constructor.render({ interval, downloads, version }) } } diff --git a/services/dub/dub-download.tester.js b/services/dub/dub-download.tester.js index 4733e5343a2b9..aa16978372403 100644 --- a/services/dub/dub-download.tester.js +++ b/services/dub/dub-download.tester.js @@ -8,7 +8,7 @@ const isDownloadsColor = Joi.equal( 'yellow', 'yellowgreen', 'green', - 'brightgreen' + 'brightgreen', ) t.create('total downloads (valid)').get('/dt/vibe-d.json').expectBadge({ diff --git a/services/dub/dub-license.service.js b/services/dub/dub-license.service.js index 52608f43d3e26..f474b33c4bc1f 100644 --- a/services/dub/dub-license.service.js +++ b/services/dub/dub-license.service.js @@ -1,6 +1,6 @@ import Joi from 'joi' import { renderLicenseBadge } from '../licenses.js' -import { BaseJsonService } from '../index.js' +import { BaseJsonService, pathParams } from '../index.js' const schema = Joi.object({ info: Joi.object({ license: Joi.string().required() }).required(), @@ -9,13 +9,17 @@ const schema = Joi.object({ export default class DubLicense extends BaseJsonService { static category = 'license' static route = { base: 'dub/l', pattern: ':packageName' } - static examples = [ - { - title: 'DUB', - namedParams: { packageName: 'vibe-d' }, - staticPreview: renderLicenseBadge({ licenses: ['MIT'] }), + static openApi = { + '/dub/l/{packageName}': { + get: { + summary: 'DUB License', + parameters: pathParams({ + name: 'packageName', + example: 'vibe-d', + }), + }, }, - ] + } static defaultBadgeData = { label: 'license' } diff --git a/services/dub/dub-score.service.js b/services/dub/dub-score.service.js new file mode 100644 index 0000000000000..5422b35192fd7 --- /dev/null +++ b/services/dub/dub-score.service.js @@ -0,0 +1,46 @@ +import Joi from 'joi' +import { BaseJsonService, pathParams } from '../index.js' +import { colorScale } from '../color-formatters.js' + +const schema = Joi.object({ + score: Joi.number().required(), +}) + +export default class DubScore extends BaseJsonService { + static category = 'rating' + + static route = { base: 'dub/score', pattern: ':packageName' } + + static openApi = { + '/dub/score/{packageName}': { + get: { + summary: 'DUB Score', + parameters: pathParams({ + name: 'packageName', + example: 'vibe-d', + }), + }, + }, + } + + static defaultBadgeData = { label: 'score' } + + static render({ score }) { + return { + label: 'score', + color: colorScale([1, 2, 3, 4, 5])(score), + message: score, + } + } + + async fetch({ packageName }) { + const url = `https://code.dlang.org/api/packages/${packageName}/stats` + return this._requestJson({ schema, url }) + } + + async handle({ packageName }) { + let { score } = await this.fetch({ packageName }) + score = score.toFixed(1) + return this.constructor.render({ score }) + } +} diff --git a/services/dub/dub-score.tester.js b/services/dub/dub-score.tester.js new file mode 100644 index 0000000000000..29d09a706914a --- /dev/null +++ b/services/dub/dub-score.tester.js @@ -0,0 +1,25 @@ +import Joi from 'joi' +import { createServiceTester } from '../tester.js' + +export const t = await createServiceTester() + +const isScoreColor = Joi.equal( + 'red', + 'orange', + 'yellow', + 'yellowgreen', + 'green', + 'brightgreen', +) + +t.create('version (valid)') + .get('/vibe-d.json') + .expectBadge({ + label: 'score', + message: Joi.number().min(0).max(5), + color: isScoreColor, + }) + +t.create('version (not found)') + .get('/not-a-package.json') + .expectBadge({ label: 'score', message: 'not found' }) diff --git a/services/dub/dub-version.service.js b/services/dub/dub-version.service.js index 19c2c1626f0dc..2b1cd1eb4d859 100644 --- a/services/dub/dub-version.service.js +++ b/services/dub/dub-version.service.js @@ -1,19 +1,23 @@ import Joi from 'joi' import { renderVersionBadge } from '../version.js' -import { BaseJsonService } from '../index.js' +import { BaseJsonService, pathParams } from '../index.js' const schema = Joi.string().required() export default class DubVersion extends BaseJsonService { static category = 'version' static route = { base: 'dub/v', pattern: ':packageName' } - static examples = [ - { - title: 'DUB', - namedParams: { packageName: 'vibe-d' }, - staticPreview: renderVersionBadge({ version: 'v0.8.4' }), + static openApi = { + '/dub/v/{packageName}': { + get: { + summary: 'DUB Version', + parameters: pathParams({ + name: 'packageName', + example: 'vibe-d', + }), + }, }, - ] + } static defaultBadgeData = { label: 'dub' } diff --git a/services/dynamic-common.js b/services/dynamic-common.js index 7cacafa411233..c1be2faf2bb5a 100644 --- a/services/dynamic-common.js +++ b/services/dynamic-common.js @@ -1,22 +1,54 @@ +/** + * Common functions and utilities for tasks related to dynamic badges. + * + * @module + */ + import Joi from 'joi' import toArray from '../core/base-service/to-array.js' import validate from '../core/base-service/validate.js' import { InvalidResponse } from './index.js' -const errorMessages = { +/** + * Map of error codes and their corresponding error messages. + * + * @type {object} + */ +const httpErrors = { 404: 'resource not found', } +/** + * Joi schema for validating individual value. + * Checks if the individual value is of type string or number. + * + * @type {Joi} + */ const individualValueSchema = Joi.alternatives() .try(Joi.string(), Joi.number()) .required() +/** + * Joi schema for validating compound value. + * Checks if the compound value is of type individualValueSchema, array of individualValueSchema or empty array. + * + * @type {Joi} + */ const compoundValueSchema = Joi.alternatives().try( individualValueSchema, Joi.array().items(individualValueSchema).required(), - Joi.array().length(0) + Joi.array().length(0), ) +/** + * Look up the value in the data object by key and validate the value against compoundValueSchema. + * + * @param {object} attrs Refer to individual attributes + * @param {object} attrs.data Object containing the data for validation + * @param {string} attrs.key Key to retrieve the data from object for validation + * @throws {InvalidResponse|Error} Error if Joi validation fails due to invalid or no schema + * @returns {object} Value if Joi validation is success + */ function transformAndValidate({ data, key }) { return validate( { @@ -26,10 +58,24 @@ function transformAndValidate({ data, key }) { traceSuccessMessage: 'Key value after validation', }, data[key], - compoundValueSchema + compoundValueSchema, ) } +/** + * Handles rendering concerns of dynamic badges. + * Determines the label of the badge according to the tag and defaultLabel. + * Determines the message of the badge according to the prefix, suffix and value. + * Sets the color of the badge to blue. + * + * @param {object} attrs Refer to individual attributes + * @param {string} attrs.defaultLabel default badge label + * @param {string} [attrs.tag] If provided then this value will be appended to the badge label, e.g. `foobar@v1.23` + * @param {any} attrs.value Value or array of value to be used for the badge message + * @param {string} [attrs.prefix] If provided then the badge message will use this value as a prefix + * @param {string} [attrs.suffix] If provided then the badge message will use this value as a suffix + * @returns {object} Badge with label, message and color properties + */ function renderDynamicBadge({ defaultLabel, tag, @@ -47,7 +93,7 @@ function renderDynamicBadge({ } export { - errorMessages, + httpErrors, individualValueSchema, transformAndValidate, renderDynamicBadge, diff --git a/services/dynamic/dynamic-helpers.js b/services/dynamic/dynamic-helpers.js index cb3b6c6ebe3d8..269d681128803 100644 --- a/services/dynamic/dynamic-helpers.js +++ b/services/dynamic/dynamic-helpers.js @@ -1,8 +1,8 @@ import Joi from 'joi' -import { optionalUrl } from '../validators.js' +import { url } from '../validators.js' const queryParamSchema = Joi.object({ - url: optionalUrl.required(), + url, query: Joi.string().required(), prefix: Joi.alternatives().try(Joi.string(), Joi.number()), suffix: Joi.alternatives().try(Joi.string(), Joi.number()), diff --git a/services/dynamic/dynamic-json.service.js b/services/dynamic/dynamic-json.service.js index 5e01de94d1cde..45eeaeed528e2 100644 --- a/services/dynamic/dynamic-json.service.js +++ b/services/dynamic/dynamic-json.service.js @@ -1,17 +1,58 @@ import { MetricNames } from '../../core/base-service/metric-helper.js' -import { BaseJsonService } from '../index.js' +import { BaseJsonService, queryParams } from '../index.js' import { createRoute } from './dynamic-helpers.js' import jsonPath from './json-path.js' +const description = ` +The Dynamic JSON Badge allows you to extract an arbitrary value from any +JSON Document using a JSONPath selector and show it on a badge. +` + export default class DynamicJson extends jsonPath(BaseJsonService) { static enabledMetrics = [MetricNames.SERVICE_RESPONSE_SIZE] static route = createRoute('json') + static openApi = { + '/badge/dynamic/json': { + get: { + summary: 'Dynamic JSON Badge', + description, + parameters: queryParams( + { + name: 'url', + description: 'The URL to a JSON document', + required: true, + example: + 'https://github.com/badges/shields/raw/master/package.json', + }, + { + name: 'query', + description: + 'A JSONPath expression that will be used to query the document', + required: true, + example: '$.name', + }, + { + 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._requestJson({ schema, url, - errorMessages, + httpErrors, + logErrors: [], + options: { timeout: { request: 3500 } }, }) } } diff --git a/services/dynamic/dynamic-json.tester.js b/services/dynamic/dynamic-json.tester.js index 7d34bdc1db3d5..a033f907a6d8b 100644 --- a/services/dynamic/dynamic-json.tester.js +++ b/services/dynamic/dynamic-json.tester.js @@ -13,7 +13,7 @@ t.create('No URL specified') t.create('No query specified') .get( - '.json?url=https://github.com/badges/shields/raw/master/package.json&label=Package Name' + '.json?url=https://github.com/badges/shields/raw/master/package.json&label=Package Name', ) .expectBadge({ label: 'Package Name', @@ -23,17 +23,17 @@ t.create('No query specified') t.create('Malformed url') .get( - '.json?url=https://github.com/badges/shields/raw/master/%0package.json&query=$.name&label=Package Name' + '.json?url=https://github.com/badges/shields/raw/master/%0package.json&query=$.name&label=Package Name', ) .expectBadge({ label: 'Package Name', - message: 'inaccessible', + message: 'invalid', color: 'lightgrey', }) t.create('JSON from url') .get( - '.json?url=https://github.com/badges/shields/raw/master/package.json&query=$.name' + '.json?url=https://github.com/badges/shields/raw/master/package.json&query=$.name', ) .expectBadge({ label: 'custom badge', @@ -43,7 +43,7 @@ t.create('JSON from url') t.create('support uri query parameter') .get( - '.json?uri=https://github.com/badges/shields/raw/master/package.json&query=$.name' + '.json?uri=https://github.com/badges/shields/raw/master/package.json&query=$.name', ) .expectBadge({ label: 'custom badge', @@ -53,13 +53,13 @@ t.create('support uri query parameter') t.create('multiple results') .get( - '.json?url=https://github.com/badges/shields/raw/master/package.json&query=$..keywords[0:2:1]' + '.json?url=https://github.com/badges/shields/raw/master/package.json&query=$..keywords[0:2:1]', ) .expectBadge({ label: 'custom badge', message: 'GitHub, badge' }) t.create('caching with new query params') .get( - '.json?url=https://github.com/badges/shields/raw/master/package.json&query=$.version' + '.json?url=https://github.com/badges/shields/raw/master/package.json&query=$.version', ) .expectBadge({ label: 'custom badge', @@ -68,7 +68,7 @@ t.create('caching with new query params') t.create('prefix & suffix & label') .get( - '.json?url=https://github.com/badges/shields/raw/master/package.json&query=$.version&prefix=v&suffix= dev&label=Shields' + '.json?url=https://github.com/badges/shields/raw/master/package.json&query=$.version&prefix=v&suffix= dev&label=Shields', ) .expectBadge({ label: 'Shields', @@ -77,7 +77,7 @@ t.create('prefix & suffix & label') t.create("key doesn't exist") .get( - '.json?url=https://github.com/badges/shields/raw/master/package.json&query=$.does_not_exist' + '.json?url=https://github.com/badges/shields/raw/master/package.json&query=$.does_not_exist', ) .expectBadge({ label: 'custom badge', @@ -87,7 +87,7 @@ t.create("key doesn't exist") t.create('invalid url') .get( - '.json?url=https://github.com/badges/shields/raw/master/notafile.json&query=$.version' + '.json?url=https://github.com/badges/shields/raw/master/notafile.json&query=$.version', ) .expectBadge({ label: 'custom badge', @@ -97,7 +97,7 @@ t.create('invalid url') t.create('user color overrides default') .get( - '.json?url=https://github.com/badges/shields/raw/master/package.json&query=$.name&color=10ADED' + '.json?url=https://github.com/badges/shields/raw/master/package.json&query=$.name&color=10ADED', ) .expectBadge({ label: 'custom badge', @@ -107,7 +107,7 @@ t.create('user color overrides default') t.create('error color overrides default') .get( - '.json?url=https://github.com/badges/shields/raw/master/notafile.json&query=$.version' + '.json?url=https://github.com/badges/shields/raw/master/notafile.json&query=$.version', ) .expectBadge({ label: 'custom badge', @@ -132,7 +132,7 @@ t.create('request should set Accept header') .reply(200, function (uri, requestBody) { headers = this.req.headers return '{"name":"test"}' - }) + }), ) .expectBadge({ label: 'custom badge', message: 'test' }) .after(() => { @@ -141,43 +141,72 @@ t.create('request should set Accept header') t.create('query with lexical error') .get( - '.json?url=https://github.com/badges/shields/raw/master/package.json&query=$[?' + '.json?url=https://github.com/badges/shields/raw/master/package.json&query=$[?', ) .expectBadge({ label: 'custom badge', - message: 'unparseable jsonpath query', - color: 'red', + message: 'no result', + color: 'lightgrey', }) t.create('query with parse error') .get( - '.json?url=https://github.com/badges/shields/raw/master/package.json&query=$.foo,' + '.json?url=https://github.com/badges/shields/raw/master/package.json&query=$.foo,', ) .expectBadge({ label: 'custom badge', - message: 'unparseable jsonpath query', - color: 'red', + message: 'no result', + color: 'lightgrey', }) // Example from https://stackoverflow.com/q/11670384/893113 -const badQuery = +const invalidTokenQuery = "$[?(en|**|(@.object.property.one=='other') && (@.object.property.two=='something(abc/def)'))]" t.create('query with invalid token') .get( `.json?url=https://github.com/badges/shields/raw/master/package.json&query=${encodeURIComponent( - badQuery - )}` + invalidTokenQuery, + )}`, ) .expectBadge({ label: 'custom badge', - message: 'unparseable jsonpath query', + message: 'query not supported', + color: 'red', + }) + +const invalidEscapequery = "$.definitions.common.properties.['@id'].format" +t.create('query with invalid escape') + .get( + `.json?url=https://raw.githubusercontent.com/json-ld/json-ld.org/refs/heads/main/schemas/jsonld-schema.json&query=${encodeURIComponent( + invalidEscapequery, + )}`, + ) + .expectBadge({ + label: 'custom badge', + message: 'query not supported', + color: 'red', + }) + +/* +Based on https://github.com/JSONPath-Plus/JSONPath/blob/v9.0.0/test/test.errors.js#L53-L68 +This functionality is disabled for security reasons. +*/ +t.create('query with eval filtering expression') + .get( + `.json?url=https://github.com/badges/shields/raw/master/package.json&query=${encodeURIComponent( + '$..keywords[(@.length-1)]', + )}`, + ) + .expectBadge({ + label: 'custom badge', + message: 'query not supported', color: 'red', }) t.create('JSON contains an array') .get('.json?url=https://example.test/json&query=$[0]') .intercept(nock => - nock('https://example.test').get('/json').reply(200, '["foo"]') + nock('https://example.test').get('/json').reply(200, '["foo"]'), ) .expectBadge({ label: 'custom badge', @@ -185,12 +214,11 @@ t.create('JSON contains an array') }) t.create('JSON contains a string') - .get('.json?url=https://example.test/json&query=$.foo,') + .get('.json?url=https://example.test/json&query=$') .intercept(nock => - nock('https://example.test').get('/json').reply(200, '"foo"') + nock('https://example.test').get('/json').reply(200, '"foo"'), ) .expectBadge({ label: 'custom badge', - message: 'resource must contain an object or array', - color: 'lightgrey', + message: 'foo', }) diff --git a/services/dynamic/dynamic-regex.service.js b/services/dynamic/dynamic-regex.service.js new file mode 100644 index 0000000000000..39ae3328174a7 --- /dev/null +++ b/services/dynamic/dynamic-regex.service.js @@ -0,0 +1,116 @@ +import Joi from 'joi' +import RE2 from 're2' +import { + BaseService, + InvalidParameter, + InvalidResponse, + queryParams, +} from '../index.js' +import { url } from '../validators.js' +import { httpErrors, renderDynamicBadge } from '../dynamic-common.js' + +const VALID_FLAGS = 'ims' +// Note: both the 'U' flag and the '-' anti-flag don't work in this particular re2 implementation +// Also, there are other flags that are supported, but they modify the javascript search and elements returned, which are not configurable by the user +// For now we only allow the documented & supported ones + +export default class DynamicRegex extends BaseService { + static category = 'dynamic' + static route = { + base: `badge/dynamic/regex`, + pattern: '', + queryParamSchema: Joi.object({ + url, + search: Joi.string().required(), + replace: Joi.string().optional(), + flags: Joi.string().optional(), + }), + } + static openApi = { + '/badge/dynamic/regex': { + get: { + summary: 'Dynamic Regex Badge', + description: + '⚠️ Experimental: This badge is considered experimental and may change or be removed at any time.\n\nThis badge will extract text from a file using re2 (a subset of regex: https://github.com/google/re2).\nThe main use-case is to extract values from unstructured plain-text files.\nFor example: if a file contains a line like `version - 2.4` you can extract the value `2.4` by using a search regex of `version - (.*)` and `$1` as replacement.\n\nFull Syntax documentation here: https://github.com/google/re2/wiki/Syntax', + parameters: queryParams( + { + name: 'url', + description: + 'The URL to a file to search. The full raw content will be used as the search string.', + required: true, + example: + 'https://raw.githubusercontent.com/badges/shields/refs/heads/master/README.md', + }, + { + name: 'search', + description: + 'A re2 expression that will be used to extract data from the document. Only the first matched text will be returned.', + required: true, + example: 'Every (.\\*?) it serves (?+ 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. +
+url, which is the URL to your JSON endpoint.
+
+A collection of APIs compatible with Shields.io's endpoint badge is available here.
+
+{ "schemaVersion": 1, "label": "hello", "message": "sweet world", "color": "orange" }
+ | 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.
+ |
+
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..25638174e24a5 100644
--- a/services/feedz/feedz.service.js
+++ b/services/feedz/feedz.service.js
@@ -1,26 +1,10 @@
import Joi from 'joi'
-import { BaseJsonService, NotFound } from '../index.js'
-import {
- renderVersionBadge,
- searchServiceUrl,
- stripBuildMetadata,
- selectVersion,
-} from '../nuget/nuget-helpers.js'
+import { BaseJsonService, NotFound, pathParams } from '../index.js'
+import { stripBuildMetadata, selectVersion } from '../nuget/nuget-helpers.js'
+import { renderVersionBadge } from '../version.js'
-const schema = Joi.object({
- items: Joi.array()
- .items(
- Joi.object({
- items: Joi.array().items(
- Joi.object({
- catalogEntry: Joi.object({
- version: Joi.string().required(),
- }).required(),
- })
- ),
- }).required()
- )
- .default([]),
+const packagesSchema = Joi.object({
+ versions: Joi.array().items(Joi.string()).required(),
}).required()
class FeedzVersionService extends BaseJsonService {
@@ -28,77 +12,71 @@ class FeedzVersionService extends BaseJsonService {
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)
+ packagesUrl({ organization, repository, packageName }) {
+ return `https://f.feedz.io/${organization}/${repository}/nuget/v3/packages/${packageName}/index.json`
}
- apiUrl({ organization, repository }) {
- return `https://f.feedz.io/${organization}/${repository}/nuget`
+ transform({ versions, includePrereleases }) {
+ if (versions.length === 0) {
+ throw new NotFound({ prettyMessage: 'package not found' })
+ }
+ // Strip build metadata and select the appropriate version
+ const cleanedVersions = versions.map(v => stripBuildMetadata(v))
+ return selectVersion(cleanedVersions, includePrereleases)
}
- async fetch({ baseUrl, packageName }) {
- const registrationsBaseUrl = await searchServiceUrl(
- baseUrl,
- 'RegistrationsBaseUrl'
- )
- return await this._requestJson({
- schema,
- url: `${registrationsBaseUrl}${packageName}/index.json`,
- errorMessages: {
+ async handle({ variant, organization, repository, packageName }) {
+ const includePrereleases = variant === 'vpre'
+ const url = this.packagesUrl({ organization, repository, packageName })
+ const json = await this._requestJson({
+ schema: packagesSchema,
+ url,
+ httpErrors: {
404: 'repository or package not found',
},
})
- }
-
- transform({ json, includePrereleases }) {
- const versions = json.items.flatMap(tl =>
- tl.items.map(i => stripBuildMetadata(i.catalogEntry.version))
- )
- if (versions.length >= 1) {
- return selectVersion(versions, includePrereleases)
- } else {
- throw new NotFound({ prettyMessage: 'package not found' })
- }
- }
-
- async handle({ which, organization, repository, packageName }) {
- const includePrereleases = which === '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 version = this.transform({
+ versions: json.versions,
+ 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..28e722b0e8c83 100644
--- a/services/feedz/feedz.service.spec.js
+++ b/services/feedz/feedz.service.spec.js
@@ -1,96 +1,66 @@
import { test, given } from 'sazerac'
import { FeedzVersionService } from './feedz.service.js'
-function json(versions) {
- return {
- items: versions.map(topLevel => ({
- items: topLevel.map(v => ({
- catalogEntry: {
- version: v,
- },
- })),
- })),
- }
-}
-
-function noItemsJson() {
- return {
- items: [],
- }
-}
-
describe('Feedz service', function () {
- test(FeedzVersionService.prototype.apiUrl, () => {
- given({ organization: 'shieldstests', repository: 'public' }).expect(
- 'https://f.feedz.io/shieldstests/public/nuget'
+ test(FeedzVersionService.prototype.packagesUrl, () => {
+ given({
+ organization: 'shieldstests',
+ repository: 'public',
+ packageName: 'Shields.TestPackage',
+ }).expect(
+ 'https://f.feedz.io/shieldstests/public/nuget/v3/packages/Shields.TestPackage/index.json',
)
})
test(FeedzVersionService.prototype.transform, () => {
- given({ json: json([['1.0.0']]), includePrereleases: false }).expect(
- '1.0.0'
- )
given({
- json: json([['1.0.0', '1.0.1']]),
+ versions: ['1.0.0'],
+ includePrereleases: false,
+ }).expect('1.0.0')
+ given({
+ versions: ['1.0.0', '1.0.1'],
includePrereleases: false,
}).expect('1.0.1')
given({
- json: json([['1.0.0', '1.0.1-beta1']]),
+ versions: ['1.0.0', '1.0.1-beta1'],
includePrereleases: false,
}).expect('1.0.0')
given({
- json: json([['1.0.0', '1.0.1-beta1']]),
+ versions: ['1.0.0', '1.0.1-beta1'],
includePrereleases: true,
}).expect('1.0.1-beta1')
given({
- json: json([['1.0.0'], ['1.0.1']]),
+ versions: ['1.0.0', '1.0.1'],
includePrereleases: false,
}).expect('1.0.1')
- given({ json: json([['1.0.1'], []]), includePrereleases: false }).expect(
- '1.0.1'
- )
- given({ json: json([[], ['1.0.1']]), includePrereleases: false }).expect(
- '1.0.1'
- )
given({
- json: json([['1.0.0'], ['1.0.1-beta1']]),
+ versions: ['1.0.1'],
+ includePrereleases: false,
+ }).expect('1.0.1')
+ given({
+ versions: ['1.0.0', '1.0.1-beta1'],
includePrereleases: false,
}).expect('1.0.0')
given({
- json: json([['1.0.0'], ['1.0.1-beta1']]),
+ versions: ['1.0.0', '1.0.1-beta1'],
includePrereleases: true,
}).expect('1.0.1-beta1')
given({
- json: json([['1.0.0+1', '1.0.1-beta1+1']]),
+ versions: ['1.0.0+1', '1.0.1-beta1+1'],
includePrereleases: false,
}).expect('1.0.0')
given({
- json: json([['1.0.0+1', '1.0.1-beta1+1']]),
+ versions: ['1.0.0+1', '1.0.1-beta1+1'],
includePrereleases: true,
}).expect('1.0.1-beta1')
- given({ json: json([]), includePrereleases: false }).expectError(
- 'Not Found: package not found'
- )
- given({ json: json([[]]), includePrereleases: false }).expectError(
- 'Not Found: package not found'
- )
- given({ json: json([[], []]), includePrereleases: false }).expectError(
- 'Not Found: package not found'
- )
- given({ json: json([]), includePrereleases: true }).expectError(
- 'Not Found: package not found'
- )
- given({ json: json([[]]), includePrereleases: true }).expectError(
- 'Not Found: package not found'
- )
- given({ json: noItemsJson(), includePrereleases: false }).expectError(
- 'Not Found: package not found'
+ given({ versions: [], includePrereleases: false }).expectError(
+ 'Not Found: package not found',
)
- given({ json: noItemsJson(), includePrereleases: true }).expectError(
- 'Not Found: package not found'
+ given({ versions: [], includePrereleases: true }).expectError(
+ '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!
' + - '' - ) + 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!
' + + '', + ) + + onTokenAccepted(token) }) } diff --git a/services/github/auth/acceptor.spec.js b/services/github/auth/acceptor.spec.js index 2d6cab6e24d83..c8357c93767ad 100644 --- a/services/github/auth/acceptor.spec.js +++ b/services/github/auth/acceptor.spec.js @@ -1,19 +1,19 @@ import { expect } from 'chai' import Camp from '@shields_io/camp' -import FormData from 'form-data' 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 +49,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 +62,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 +78,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 +110,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..8d2b4b8c1317f
--- /dev/null
+++ b/services/github/gist/github-gist-stars-redirect.service.js
@@ -0,0 +1,9 @@
+import { retiredService } from '../../index.js'
+
+export default retiredService({
+ 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>
- Note:
- 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:
- 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..5e800a81b6a61 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,31 @@ 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.apiProvider.metricInstance = metricInstance
+
this.scheduleDebugLogging()
+ this.scheduleMetricsCollection()
if (!this.persistence) {
return
@@ -74,9 +97,6 @@ class GithubConstellation {
this.apiProvider.addToken(tokenString)
})
- const { shieldsSecret, apiProvider } = this
- setAdminRoutes({ shieldsSecret }, { apiProvider, server })
-
if (this.oauthHelper.isConfigured) {
setAcceptorRoutes({
server,
@@ -118,6 +138,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}
-
- 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.
-
-
+- 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.
- ?suggestion_label=good%20first%20issue).
-
- 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', + 'not planned': '666c76', + duplicate: '666c76', + }[s] } -function errorMessagesFor(notFoundMessage = 'repo not found') { +function httpErrorsFor(notFoundMessage = 'repo not found') { return { 404: notFoundMessage, 422: notFoundMessage, @@ -33,8 +35,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..4e56b30620119 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 { retiredService } from '../index.js' export default [ - redirector({ + retiredService({ 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..8289fa056fdbc 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,29 @@ const commonSchemaFields = { const stateMap = { schema: Joi.object({ ...commonSchemaFields, - state: Joi.string().allow('open', 'closed').required(), + state: Joi.equal('open', 'closed').required(), + state_reason: Joi.string().allow(null), // only for issues 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 + const stateWithReason = + json.state_reason === 'not_planned' || json.state_reason === 'duplicate' + ? json.state_reason.replace('_', ' ') + : json.state + + return { + state: stateWithReason, + 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 +94,7 @@ const labelMap = { Joi.object({ name: Joi.string().required(), color: Joi.string().required(), - }) + }), ) .required(), }).required(), @@ -133,10 +141,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 +178,7 @@ const propertyMap = { comments: commentsMap, age: ageUpdateMap, 'last-update': ageUpdateMap, + milestone: milestoneMap, } export default class GithubIssueDetail extends GithubAuthV3Service { @@ -155,37 +186,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 +235,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..f1cf1e0b8f737 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,27 @@ describe('GithubIssueDetail', function () { }).expect({ label: 'issue 15', message: 'closed', - color: stateColor('closed'), + color: issueStateColor('closed'), + }) + given({ + property: 'state', + value: { state: 'not planned' }, + number: '93', + isPR: false, + }).expect({ + label: 'issue 93', + message: 'not planned', + color: issueStateColor('not planned'), + }) + given({ + property: 'state', + value: { state: 'duplicate' }, + number: '95', + isPR: false, + }).expect({ + label: 'issue 95', + message: 'duplicate', + color: issueStateColor('duplicate'), }) given({ property: 'title', @@ -90,6 +110,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 +126,47 @@ 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', state_reason: 'not_planned' }, + }).expect({ + value: { state: 'not planned', merged: false }, + isPR: false, + }) + given({ + property: 'state', + json: { state: 'closed', state_reason: 'duplicate' }, + }).expect({ + value: { state: 'duplicate', merged: false }, + isPR: false, + }) + given({ + property: 'state', + json: { state: 'closed', state_reason: 'other_reason' }, + }).expect({ + 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 +244,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 +267,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..c13d0ec289470 100644 --- a/services/github/github-issue-detail.tester.js +++ b/services/github/github-issue-detail.tester.js @@ -7,7 +7,7 @@ t.create('github issue state') .get('/issues/detail/state/badges/shields/979.json') .expectBadge({ label: 'issue 979', - message: Joi.equal('open', 'closed'), + message: 'closed', }) t.create('github issue state (repo not found)') @@ -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..f5236b29a0e7d 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' } @@ -242,7 +289,11 @@ class DynamicGithubPackageJson extends ConditionalGithubAuthV3Service { branch, filename: 'package.json', }) - const value = transformAndValidate({ data, key }) + let value = transformAndValidate({ data, key }) + // Strip build metadata suffix from packageManager field (e.g. yarn@3.2.3+sha224.abc -> yarn@3.2.3) + if (key === 'packageManager' && typeof value === 'string') { + value = value.replace(/\+.*$/, '') + } return this.constructor.render({ key, value, branch }) } } diff --git a/services/github/github-package-json.tester.js b/services/github/github-package-json.tester.js index ec60dfe3e9806..4a3c90bd8103a 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, }) @@ -101,3 +112,17 @@ t.create('Unknown dependency') label: 'dependency', message: 'dev dependency not found', }) + +t.create('Package manager (strips build metadata suffix)') + .get('/packageManager/nodejs/corepack.json') + .expectBadge({ + label: 'packageManager', + message: Joi.string().regex(/^(npm|yarn|pnpm|bun)@[\d.]+$/), + }) + +t.create('Package manager (repo not found)') + .get('/packageManager/badges/helmets.json') + .expectBadge({ + label: 'package.json', + message: 'repo not found, branch not found, or package.json missing', + }) 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.
-
${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.
-
${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..41004e1c67529 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 { retiredService } 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 retiredService({
+ 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>
- 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..f5373bc0cd805 --- /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(), + }), +}).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..4a5c4e8e5bc74
--- /dev/null
+++ b/services/gitlab/gitlab-issues.tester.js
@@ -0,0 +1,148 @@
+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')
+ .timeout(10000)
+ .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..870004cd706d4
--- /dev/null
+++ b/services/gitlab/gitlab-last-commit.tester.js
@@ -0,0 +1,68 @@
+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)')
+ .timeout(10000)
+ .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..9513d6ae2bccd
--- /dev/null
+++ b/services/gitlab/gitlab-license-redirect.service.js
@@ -0,0 +1,13 @@
+import { retiredService } from '../index.js'
+
+// https://github.com/badges/shields/issues/8138
+export default retiredService({
+ 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:
+
+
+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: +
-- 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/gnome-extensions/gnome-extensions-version.service.js b/services/gnome-extensions/gnome-extensions-version.service.js new file mode 100644 index 0000000000000..14aa28416cd0a --- /dev/null +++ b/services/gnome-extensions/gnome-extensions-version.service.js @@ -0,0 +1,68 @@ +import Joi from 'joi' +import { renderVersionBadge } from '../version.js' +import { BaseJsonService, NotFound, pathParams } from '../index.js' + +const versionSchema = Joi.object({ + results: Joi.array() + .items( + Joi.object({ + version: Joi.number().required(), + version_name: Joi.string().allow(null).required(), + status: Joi.number().required(), + }).unknown(true), + ) + .required(), +}).unknown(true) + +const ACTIVE_STATUS = 3 + +export default class GnomeExtensionsVersion extends BaseJsonService { + static category = 'version' + + static route = { + base: 'gnome-extensions/v', + pattern: ':extensionId', + } + + static openApi = { + '/gnome-extensions/v/{extensionId}': { + get: { + summary: 'Gnome Extensions Version', + parameters: pathParams({ + name: 'extensionId', + description: 'Id of the Gnome Extension', + example: 'just-perfection-desktop@just-perfection', + }), + }, + }, + } + + static defaultBadgeData = { label: 'version' } + + static render({ version }) { + return renderVersionBadge({ version }) + } + + async fetch({ extensionId }) { + return await this._requestJson({ + schema: versionSchema, + url: `https://extensions.gnome.org/api/v1/extensions/${extensionId}/versions/?page_size=100`, + httpErrors: { + 404: 'extension not found', + }, + }) + } + + async handle({ extensionId }) { + const { results } = await this.fetch({ extensionId }) + const activeVersions = results.filter(r => r.status === ACTIVE_STATUS) + if (activeVersions.length === 0) { + throw new NotFound({ prettyMessage: 'no active version found' }) + } + const latest = activeVersions.reduce((a, b) => + a.version > b.version ? a : b, + ) + const version = latest.version_name ?? String(latest.version) + return this.constructor.render({ version }) + } +} diff --git a/services/gnome-extensions/gnome-extensions-version.tester.js b/services/gnome-extensions/gnome-extensions-version.tester.js new file mode 100644 index 0000000000000..6a29f76695375 --- /dev/null +++ b/services/gnome-extensions/gnome-extensions-version.tester.js @@ -0,0 +1,26 @@ +import { createServiceTester } from '../tester.js' +import { isVPlusDottedVersionAtLeastOne } from '../test-validators.js' + +export const t = await createServiceTester() + +t.create('Version') + .get('/just-perfection-desktop@just-perfection.json') + .expectBadge({ label: 'version', message: isVPlusDottedVersionAtLeastOne }) + +t.create('Version (not found)').get('/non-existent.json').expectBadge({ + label: 'version', + message: 'no active version found', +}) + +t.create('No active versions') + .get('/no-active.json') + .intercept(nock => + nock('https://extensions.gnome.org') + .get('/api/v1/extensions/no-active/versions/') + .query({ page_size: '100' }) + .reply(200, { results: [] }), + ) + .expectBadge({ + label: 'version', + message: 'no active version 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..02b51161317da 100644 --- a/services/gradle-plugin-portal/gradle-plugin-portal.service.js +++ b/services/gradle-plugin-portal/gradle-plugin-portal.service.js @@ -1,32 +1,25 @@ -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', - isDeprecated: false, + isRetired: false, route: { 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 deleted file mode 100644 index f3cbd1deb996b..0000000000000 --- a/services/hackage/hackage-deps.service.js +++ /dev/null @@ -1,44 +0,0 @@ -import { BaseService } from '../index.js' - -export default class HackageDeps extends BaseService { - static category = 'dependencies' - - static 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 }) - } -} diff --git a/services/hackage/hackage-deps.tester.js b/services/hackage/hackage-deps.tester.js deleted file mode 100644 index 6133b776e6099..0000000000000 --- a/services/hackage/hackage-deps.tester.js +++ /dev/null @@ -1,14 +0,0 @@ -import Joi from 'joi' -import { createServiceTester } from '../tester.js' -export const t = await createServiceTester() - -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' }) 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..6c9a9939e585b 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 { retiredService } 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({ + retiredService({ route: { base: 'jenkins-ci/s', pattern: ':protocol(http|https)/:host/:job+', }, - dateAdded: new Date('2019-04-20'), ...commonProps, }), - redirector({ + retiredService({ route: { base: 'jenkins/s', pattern: ':protocol(http|https)/:host/:job+', }, - dateAdded: new Date('2019-04-20'), ...commonProps, }), - redirector({ + retiredService({ 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..111b3f6f1710d 100644 --- a/services/jenkins/jenkins-build.tester.js +++ b/services/jenkins/jenkins-build.tester.js @@ -1,25 +1,15 @@ -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' - )}` - ) - .expectBadge({ label: 'build', message: isJenkinsBuildStatus }) + .get('/build.json?jobUrl=https://ci.eclipse.org/jgit/view/all/job/jgit') + .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..62ffb3dede022 100644 --- a/services/jenkins/jenkins-coverage-redirector.service.js +++ b/services/jenkins/jenkins-coverage-redirector.service.js @@ -1,33 +1,42 @@ -import { redirector } from '../index.js' -import { buildRedirectUrl } from './jenkins-common.js' +import { redirector, retiredService } 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({ + retiredService({ 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({ + retiredService({ 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, }), + retiredService({ + route: { + base: 'jenkins/coverage/api', + pattern: '', + }, + ...commonProps, + }), + redirector({ + category: 'coverage', + route: { + base: 'jenkins/coverage', + pattern: ':format(jacoco|cobertura|apiv1|apiv4)', + }, + transformPath: () => '/jenkins/coverage', + dateAdded: new Date('2026-05-17'), + }), ] diff --git a/services/jenkins/jenkins-coverage-redirector.tester.js b/services/jenkins/jenkins-coverage-redirector.tester.js index d6475017c59e9..43c2af07f12fc 100644 --- a/services/jenkins/jenkins-coverage-redirector.tester.js +++ b/services/jenkins/jenkins-coverage-redirector.tester.js @@ -8,48 +8,84 @@ 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' + '/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.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.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.json', + ) + .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', + }) + +t.create('jacoco format redirects to unique route') + .get( + `/coverage/jacoco.svg?jobUrl=https://jenkins.mm12.xyz/jenkins/job/nmfu/job/master`, ) .expectRedirect( - `/jenkins/coverage/jacoco.svg?jobUrl=${encodeURIComponent( - 'https://wso2.org/jenkins/view/All Builds/job/sonar/job/sonar-carbon-dashboards' - )}` + `/jenkins/coverage.svg?jobUrl=https://jenkins.mm12.xyz/jenkins/job/nmfu/job/master`, ) -t.create('old Cobertura prefix + job url in path') - .get('/c/https/jenkins.sqlalchemy.org/job/alembic_coverage.svg') +t.create('cobertura format redirects to unique route') + .get( + `/coverage/cobertura.svg?jobUrl=https://jenkins.mm12.xyz/jenkins/job/nmfu/job/master`, + ) .expectRedirect( - `/jenkins/coverage/cobertura.svg?jobUrl=${encodeURIComponent( - 'https://jenkins.sqlalchemy.org/job/alembic_coverage' - )}` + `/jenkins/coverage.svg?jobUrl=https://jenkins.mm12.xyz/jenkins/job/nmfu/job/master`, ) -t.create('new Cobertura prefix + job url in path') +t.create('apiv1 format redirects to unique route') .get( - '/coverage/cobertura/https/jenkins.sqlalchemy.org/job/alembic_coverage.svg' + `/coverage/apiv1.svg?jobUrl=https://jenkins.mm12.xyz/jenkins/job/nmfu/job/master`, ) .expectRedirect( - `/jenkins/coverage/cobertura.svg?jobUrl=${encodeURIComponent( - 'https://jenkins.sqlalchemy.org/job/alembic_coverage' - )}` + `/jenkins/coverage.svg?jobUrl=https://jenkins.mm12.xyz/jenkins/job/nmfu/job/master`, ) -t.create('api prefix + job url in path') +t.create('apiv4 format redirects to unique route') .get( - '/coverage/api/https/jenkins.library.illinois.edu/job/OpenSourceProjects/job/Speedwagon/job/master.svg' + `/coverage/apiv4.svg?jobUrl=https://jenkins.mm12.xyz/jenkins/job/nmfu/job/master`, ) .expectRedirect( - `/jenkins/coverage/api.svg?jobUrl=${encodeURIComponent( - 'https://jenkins.library.illinois.edu/job/OpenSourceProjects/job/Speedwagon/job/master' - )}` + `/jenkins/coverage.svg?jobUrl=https://jenkins.mm12.xyz/jenkins/job/nmfu/job/master`, ) diff --git a/services/jenkins/jenkins-coverage.service.js b/services/jenkins/jenkins-coverage.service.js index 6f053650efb42..c46486ff624ec 100644 --- a/services/jenkins/jenkins-coverage.service.js +++ b/services/jenkins/jenkins-coverage.service.js @@ -1,4 +1,5 @@ import Joi from 'joi' +import { queryParam } from '../index.js' import { coveragePercentage } from '../color-formatters.js' import JenkinsBase from './jenkins-base.js' import { @@ -7,101 +8,41 @@ import { queryParamSchema, } from './jenkins-common.js' -const formatMap = { - jacoco: { - schema: Joi.object({ - instructionCoverage: Joi.object({ - percentage: Joi.number().min(0).max(100).required(), - }).required(), - }).required(), - treeQueryParam: 'instructionCoverage[percentage]', - transform: json => ({ coverage: json.instructionCoverage.percentage }), - pluginSpecificPath: 'jacoco', - }, - cobertura: { - schema: Joi.object({ - results: Joi.object({ - elements: Joi.array() - .items( - Joi.object({ - name: Joi.string().required(), - ratio: Joi.number().min(0).max(100).required(), - }) - ) - .has(Joi.object({ name: 'Lines' })) - .min(1) - .required(), - }).required(), - }).required(), - treeQueryParam: 'results[elements[name,ratio]]', - transform: json => { - const lineCoverage = json.results.elements.find( - element => element.name === 'Lines' - ) - return { coverage: lineCoverage.ratio } - }, - pluginSpecificPath: 'cobertura', - }, - api: { - schema: Joi.object({ - results: Joi.object({ - elements: Joi.array() - .items( - Joi.object({ - name: Joi.string().required(), - ratio: Joi.number().min(0).max(100).required(), - }) - ) - .has(Joi.object({ name: 'Line' })) - .min(1) - .required(), - }).required(), - }).required(), - treeQueryParam: 'results[elements[name,ratio]]', - transform: json => { - const lineCoverage = json.results.elements.find( - element => element.name === 'Line' - ) - return { coverage: lineCoverage.ratio } - }, - pluginSpecificPath: 'coverage/result', - }, -} +const schemaCoverage = Joi.object({ + projectStatistics: Joi.object({ + line: Joi.string() + .pattern(/\d+\.\d+%/) + .required(), + }).required(), +}).required() -const documentation = ` -- We support coverage metrics from a variety of Jenkins plugins: -
- 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.
-
- 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.
-
-
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'>
- 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. - -
Addresses sectionroom addresses (or aliases), which can be easily identified with their starting hash (#) character (ex: #twim:matrix.org)Local addresses for this room#)/matrix/twim:matrix.org.svgexample.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 homeservermatrix.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.
+
+Addresses sectionroom addresses (or aliases), which can be easily identified with their starting hash (#) character (ex: #twim:matrix.org)Local addresses for this room#)/matrix/twim:matrix.org.svgexample.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..402d261c5ee99 100644
--- a/services/maven-central/maven-central.service.js
+++ b/services/maven-central/maven-central.service.js
@@ -1,34 +1,26 @@
-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',
- isDeprecated: false,
+ isRetired: false,
route: {
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.
-
highestVersion - sort versions using Maven's ComparableVersion semantics and pick the highest (default)releaseProperty - use the "release" metadata propertylatestProperty - use the "latest" metadata propertyfilter 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 = `
+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 thepublish 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-version.js b/services/nexus/nexus-version.js deleted file mode 100644 index 0adb7bf42527f..0000000000000 --- a/services/nexus/nexus-version.js +++ /dev/null @@ -1,6 +0,0 @@ -function isSnapshotVersion(version) { - const pattern = /(\d+\.)*[0-9a-f]-SNAPSHOT/ - return version && version.match(pattern) -} - -export { isSnapshotVersion } diff --git a/services/nexus/nexus.service.js b/services/nexus/nexus.service.js index 67451ef0c929e..ac8c5f50741ce 100644 --- a/services/nexus/nexus.service.js +++ b/services/nexus/nexus.service.js @@ -1,56 +1,40 @@ import Joi from 'joi' -import { version as versionColor } from '../color-formatters.js' -import { addv } from '../text-formatters.js' -import { - optionalUrl, - optionalDottedVersionNClausesWithOptionalSuffix, -} from '../validators.js' -import { BaseJsonService, InvalidResponse, NotFound } from '../index.js' -import { isSnapshotVersion } from './nexus-version.js' +import { renderVersionBadge } from '../version.js' +import { url } from '../validators.js' +import { BaseJsonService, NotFound, pathParams, queryParams } from '../index.js' -const nexus2SearchApiSchema = Joi.object({ - data: Joi.array() - .items( - Joi.object({ - latestRelease: optionalDottedVersionNClausesWithOptionalSuffix, - latestSnapshot: optionalDottedVersionNClausesWithOptionalSuffix, - // `version` will almost always follow the same pattern as optionalDottedVersionNClausesWithOptionalSuffix. - // However, there are a couple exceptions where `version` may be a simple string (like `android-SNAPSHOT`) - // This schema is relaxed accordingly since for snapshot/release badges the schema has to validate - // 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() - -const nexus3SearchApiSchema = Joi.object({ +const searchApiSchema = Joi.object({ items: Joi.array() .items( Joi.object({ - // This schema is relaxed similarly to nexux2SearchApiSchema version: Joi.string().required(), - }) + }), ) .required(), }).required() -const nexus2ResolveApiSchema = Joi.object({ - data: Joi.object({ - baseVersion: optionalDottedVersionNClausesWithOptionalSuffix, - version: optionalDottedVersionNClausesWithOptionalSuffix, - }).required(), -}).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://repo.tomkeuper.com', required: true }, + { + 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: Searching for Component +`, + }, +) + export default class Nexus extends BaseJsonService { static category = 'version' @@ -66,103 +50,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', + static openApi = { + '/nexus/r/{groupId}/{artifactId}': { + get: { + summary: 'Sonatype Nexus (Releases)', + parameters: [ + ...pathParams( + { name: 'groupId', example: 'com.google.guava' }, + { name: 'artifactId', example: 'guava' }, + ), + ...openApiQueryParams, + ], }, - 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', - }, - 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: 'maven-central' }, + { 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,67 +104,12 @@ export default class Nexus extends BaseJsonService { const paramParts = keyValuePair.split('=') const paramKey = paramParts[0] const paramValue = paramParts[1] - qs[paramKey] = paramValue + searchParams[paramKey] = paramValue }) } - async fetch({ server, repo, groupId, artifactId, queryOpt, nexusVersion }) { - if (nexusVersion === '3') { - return this.fetch3({ server, repo, groupId, artifactId, queryOpt }) - } - // Most servers still use Nexus 2. Fall back to Nexus 3 if the hitting a - // Nexus 2 endpoint returns a Bad Request (=> InvalidResponse, for path /service/local/artifact/maven/resolve) - // or a Not Found (for path /service/local/artifact/maven/resolve). - try { - return await this.fetch2({ server, repo, groupId, artifactId, queryOpt }) - } catch (e) { - if (e instanceof InvalidResponse || e instanceof NotFound) { - return this.fetch3({ server, repo, groupId, artifactId, queryOpt }) - } - throw e - } - } - - async fetch2({ server, repo, groupId, artifactId, queryOpt }) { - const qs = { - g: groupId, - a: artifactId, - } - let schema - let url = `${server}${server.slice(-1) === '/' ? '' : '/'}` - // API pattern: - // for /nexus/[rs]/... pattern, use the search api of the nexus server, and - // for /nexus/
- ${this.documentation}
- The node version support is retrieved from the engines.node section in package.json.
-
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.
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.
- 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', + ` +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: Example:
- 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:
- Note that only network-accessible packagist.org and other self-hosted Packagist instances are supported.
-
- 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.
-
- To see more details about this badge and obtain your api key, visit
- https://my.pingpong.one/integrations/badge-status/
-
- To see more details about this badge and obtain your api key, visit
- https://my.pingpong.one/integrations/badge-uptime/
-
- You must specify the read-only API token from the POEditor account to which the project belongs.
-
- As per the POEditor API documentation,
- Pub is a package registry for Dart and Flutter. This badge shows a measure of how many developers have downloaded a package monthly. 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. This badge shows a measure of quality. This includes several dimensions of quality such as code style, platform support, and maintainability.
- The Security Headers
- provide an easy mechanism to analyze HTTP response headers and
- give information on how to deploy missing headers.
-https://ore.spongepowered.org/Erigitic/Total-Economy - Here the Plugin ID is totaleconomy.`
-
-const keywords = ['sponge', 'spongemc', 'spongepowered']
+https://ore.spongepowered.org/Erigitic/Total-Economy - Here the Plugin ID is totaleconomy.osslifecycle=active. Other suggested values are
- osslifecycle=maintenance and osslifecycle=archived. A working example
- can be viewed on the OSS Tracker repository.
-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.
-
+${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..db74e5769b4e7
--- /dev/null
+++ b/services/python/python-version-from-toml.tester.js
@@ -0,0 +1,27 @@
+import Joi from 'joi'
+import { validRange } from '@renovatebot/pep440'
+import { createServiceTester } from '../tester.js'
+export const t = await createServiceTester()
+
+const validatePep440 = (value, helpers) => {
+ if (!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/v2.31.0/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..e5a8715da90e8 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,7 @@ 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',
- },
+ httpErrors: { 404: 'unregistered' },
})
}
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\/(?
-`
-
-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 { retiredService } from '../index.js'
+
+export const SecurityHeaders = retiredService({
+ 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..42d35f3e5711d 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: 'retired badge',
+})
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 =
- '
- 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..74bbc77b3cc2e 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/apache_camel.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.
-. For example:-. For example:| URL input | +Badge output | +
|---|---|
Underscore _ or %20 |
+ Space |
+
Double underscore __ |
+ Underscore _ |
+
Double dash -- |
+ Dash - |
+
- 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..489f39a4c07bb 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 { retiredService } from '../index.js'
export default [
- redirector({
+ retiredService({
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',
+ `
- You may change the "passed", "failed" and "skipped" text on this badge by supplying query parameters &passed_label=, &failed_label= and &skipped_label= respectively.
-
&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:
-
- ?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 |.
-
&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..28f534c2763a6 100644
--- a/services/test-validators.js
+++ b/services/test-validators.js
@@ -1,70 +1,134 @@
+/**
+ * Joi validators that are shared across more than one service's tests.
+ * Validators which are only used by one service should be declared in
+ * that service's test file.
+ *
+ * @module
+ */
+
import Joi from 'joi'
import { semver as isSemver } from './validators.js'
-/*
- Note:
- Validators defined in this file are used by more than one service.
- Validators which are only used by one service
- should be declared in that service's test file.
-*/
-
+/**
+ * Creates a Joi string validator that matches the given regular expression.
+ *
+ * @param {RegExp} re Regular expression the string must match
+ * @returns {Joi.StringSchema} Joi string schema validating against the regex
+ */
const withRegex = re => Joi.string().regex(re)
+/**
+ * Validates a version string with three dotted numeric clauses prefixed
+ * by `v`, e.g. `v1.2.3`.
+ *
+ * @type {Joi.StringSchema}
+ */
const isVPlusTripleDottedVersion = withRegex(/^v[0-9]+.[0-9]+.[0-9]+$/)
+/**
+ * Validates a version string prefixed by `v` with at least a major version
+ * and optional minor and patch numbers, e.g. `v1`, `v1.2`, or `v1.2.3`.
+ *
+ * @type {Joi.StringSchema}
+ */
const isVPlusDottedVersionAtLeastOne = withRegex(/^v\d+(\.\d+)?(\.\d+)?$/)
-// matches a version number with N 'clauses' e.g: v1.2 or v1.22.7.392 are valid
+/**
+ * Validates a version number prefixed by `v` with N 'clauses',
+ * e.g. `v1.2` or `v1.22.7.392`.
+ *
+ * @type {Joi.StringSchema}
+ */
const isVPlusDottedVersionNClauses = withRegex(/^v\d+(\.\d+)*$/)
-// matches a version number with N 'clauses'
-// and an optional text suffix
-// e.g: -beta, -preview1, -release-candidate, +beta, ~pre9-12 etc
+/**
+ * Validates a version number prefixed by `v` with N 'clauses' and an
+ * optional text suffix, e.g. `-beta`, `-preview1`, `-release-candidate`,
+ * `+beta`, or `~pre9-12`.
+ *
+ * @type {Joi.StringSchema}
+ */
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
+/**
+ * Same as {@link isVPlusDottedVersionNClausesWithOptionalSuffix}, but also
+ * accepts an optional 'epoch' prefix that can be found e.g. in distro
+ * package versions, like `4:6.3.0-4`.
+ *
+ * @type {Joi.StringSchema}
+ */
const isVPlusDottedVersionNClausesWithOptionalSuffixAndEpoch = withRegex(
- /^v(\d+:)?\d+(\.\d+)*([-+~].*)?$/
-)
-
-// Simple regex for test Composer versions rule
-// https://getcomposer.org/doc/articles/versions.md
-// Examples:
-// 7.1
-// >=5.6
-// >1.0 <2.0
-// !=1.0 <1.1 || >=1.2
-// 7.1.*
-// 7.* || 5.6.*
-// This regex not support branches, minimum-stability, ref and any (*)
-// https://getcomposer.org/doc/04-schema.md#package-links
-// https://getcomposer.org/doc/04-schema.md#minimum-stability
+ /^v(\d+:)?\d+(\.\d+)*([-+~].*)?$/,
+)
+
+/**
+ * Simple regex for testing Composer version rules, e.g. `7.1`, `>=5.6`,
+ * `>1.0 <2.0`, `!=1.0 <1.1 || >=1.2`, `7.1.*`, or `7.* || 5.6.*`.
+ * This regex does not support branches, minimum-stability, ref, or any (`*`).
+ *
+ * @see https://getcomposer.org/doc/articles/versions.md
+ * @see https://getcomposer.org/doc/04-schema.md#package-links
+ * @see https://getcomposer.org/doc/04-schema.md#minimum-stability
+ * @type {Joi.StringSchema}
+ */
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()
-// >= 7
-// >= 7.1
-// 5.4, 5.6, 7.2
-// 5.4 - 7.1, HHVM
+/**
+ * Validates the reduced PHP version string produced by
+ * `php-version.versionReduction()`, e.g. `>= 7`, `>= 7.1`,
+ * `5.4, 5.6, 7.2`, or `5.4 - 7.1, HHVM`.
+ *
+ * @type {Joi.StringSchema}
+ */
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)?$/,
)
+/**
+ * Validates a short or full git commit hash (7 to 40 hexadecimal characters).
+ *
+ * @type {Joi.StringSchema}
+ */
+const isCommitHash = withRegex(/^[a-f0-9]{7,40}$/)
+
+/**
+ * Validates a 5-character star rating string composed of full, fractional,
+ * and empty star glyphs, e.g. `★★★¾☆`.
+ *
+ * @type {Joi.StringSchema}
+ */
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.
+/**
+ * Validates a metric-formatted positive number, e.g. `10`, `1k`, or `2.5M`.
+ * Required to be > 0, because accepting zero masks many problems.
+ *
+ * @type {Joi.StringSchema}
+ */
const isMetric = withRegex(/^([1-9][0-9]*[kMGTPEZY]?|[1-9]\.[1-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.
+ * Same as {@link isMetric}, but also accepts zero and negative numbers,
+ * e.g. `0`, `-1k`, or `-2.5M`.
+ *
+ * @type {Joi.StringSchema}
+ */
+const isMetricAllowNegative = withRegex(
+ /^(0|-?[1-9][0-9]*[kMGTPEZY]?|-?[0-9]\.[0-9][kMGTPEZY])$/,
+)
+
+/**
+ * Creates a validator that matches a metric (see {@link isMetric}) followed
+ * by another pattern, e.g. ` open` or `/year`.
+ *
+ * @param {RegExp} nestedRegexp Pattern that must appear after the metric
+ * @returns {Joi.StringSchema} Joi string schema for the combined pattern
*/
const isMetricWithPattern = nestedRegexp => {
const pattern = `^([1-9][0-9]*[kMGTPEZY]?|[1-9]\\.[1-9][kMGTPEZY])${nestedRegexp.source}$`
@@ -72,82 +136,251 @@ const isMetricWithPattern = nestedRegexp => {
return withRegex(regexp)
}
+/**
+ * Validates a metric followed by ` open`, e.g. `3 open` or `1.2k open`.
+ *
+ * @type {Joi.StringSchema}
+ */
const isMetricOpenIssues = isMetricWithPattern(/ open/)
+/**
+ * Validates a metric followed by ` closed`, e.g. `3 closed` or `1.2k closed`.
+ *
+ * @type {Joi.StringSchema}
+ */
+const isMetricClosedIssues = isMetricWithPattern(/ closed/)
+
+/**
+ * Validates a metric followed by `/` and another metric, e.g. `3/10` or `1.2k/5k`.
+ *
+ * @type {Joi.StringSchema}
+ */
const isMetricOverMetric = isMetricWithPattern(
- /\/([1-9][0-9]*[kMGTPEZY]?|[1-9]\.[1-9][kMGTPEZY])/
+ /\/([1-9][0-9]*[kMGTPEZY]?|[1-9]\.[1-9][kMGTPEZY])/,
)
+/**
+ * Validates a metric followed by `/` and a time period, e.g. `3/day` or `1.2k/month`.
+ *
+ * @type {Joi.StringSchema}
+ */
const isMetricOverTimePeriod = isMetricWithPattern(
- /\/(year|month|four weeks|quarter|week|day)/
+ /\/(year|month|four weeks|quarter|week|day)/,
)
+/**
+ * Validates a literal zero followed by `/` and a time period, e.g. `0/day` or `0/year`.
+ *
+ * @type {Joi.StringSchema}
+ */
const isZeroOverTimePeriod = withRegex(
- /^0\/(year|month|four weeks|quarter|week|day)$/
+ /^0\/(year|month|four weeks|quarter|week|day)$/,
)
+/**
+ * Validates a non-negative integer percentage, e.g. `0%`, `75%`, or `100%`.
+ *
+ * @type {Joi.StringSchema}
+ */
const isIntegerPercentage = withRegex(/^[1-9][0-9]?%|^100%|^0%$/)
+/**
+ * Same as {@link isIntegerPercentage}, but also accepts negative values, e.g. `-25%`.
+ *
+ * @type {Joi.StringSchema}
+ */
+const isIntegerPercentageNegative = withRegex(/^-?[1-9][0-9]?%|^100%|^0%$/)
+/**
+ * Validates a non-negative decimal percentage, e.g. `0.5%` or `12.34%`.
+ *
+ * @type {Joi.StringSchema}
+ */
const isDecimalPercentage = withRegex(/^[0-9]+\.[0-9]*%$/)
+/**
+ * Same as {@link isDecimalPercentage}, but also accepts negative values, e.g. `-0.5%`.
+ *
+ * @type {Joi.StringSchema}
+ */
+const isDecimalPercentageNegative = withRegex(/^-?[0-9]+\.[0-9]*%$/)
+/**
+ * Validates any percentage string by combining {@link isIntegerPercentage},
+ * {@link isDecimalPercentage}, {@link isIntegerPercentageNegative}, and
+ * {@link isDecimalPercentageNegative}.
+ *
+ * @type {Joi.AlternativesSchema}
+ */
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)$/
+/**
+ * Validates a metric (SI) file size, e.g. `1 B`, `12.5 kB`, or `2 MB`.
+ *
+ * @type {Joi.StringSchema}
+ */
+const isMetricFileSize = withRegex(
+ /^[0-9]*[.]?[0-9]+\s(B|kB|KB|MB|GB|TB|PB|EB|ZB|YB)$/,
+)
+/**
+ * Validates an IEC (binary) file size, e.g. `1 B`, `12.5 KiB`, or `2 MiB`.
+ *
+ * @type {Joi.StringSchema}
+ */
+const isIecFileSize = withRegex(
+ /^[0-9]*[.]?[0-9]+\s(B|KiB|MiB|GiB|TiB|PiB|EiB|ZiB|YiB)$/,
)
+/**
+ * Validates a human-readable formatted date, such as `today`, `yesterday`,
+ * `last tuesday`, or `january 2021`.
+ *
+ * @type {Joi.AlternativesSchema}
+ */
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})?$/,
+ ),
)
+/**
+ * Validates a relative formatted date, e.g. `2 days ago`, `in 3 months`,
+ * or `a few seconds ago`.
+ *
+ * @type {Joi.AlternativesSchema}
+ */
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|)$/,
+ ),
)
+/**
+ * Validates a dependency state string, e.g. `up to date`, `3 out of date`,
+ * or `2 deprecated`.
+ *
+ * @type {Joi.StringSchema}
+ */
const isDependencyState = withRegex(
- /^(\d+ out of date|\d+ deprecated|up to date)$/
+ /^(\d+ out of date|\d+ deprecated|up to date)$/,
)
+/**
+ * Creates a validator for test totals in the format
+ * `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"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
- 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.
-
- 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) -// due differences in how the Marketplace tracks metrics for Azure DevOps extensions vs. other extension types. -// See https://github.com/badges/shields/pull/2748 for more information on the discussion and decision. -export default class VisualStudioMarketplaceAzureDevOpsInstalls extends VisualStudioMarketplaceBase { - static category = 'downloads' - - static route = { - base: 'visual-studio-marketplace/azure-devops/installs', - pattern: ':measure(total|onprem|services)/:extensionId', - } - - static examples = [ - { - title: 'Visual Studio Marketplace Installs - Azure DevOps Extension', - namedParams: { - measure: 'total', - extensionId: '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), - } - } - - 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 }) - } - } -} 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 deleted file mode 100644 index f4b209c5460c5..0000000000000 --- a/services/visual-studio-marketplace/visual-studio-marketplace-azure-devops-installs.tester.js +++ /dev/null @@ -1,128 +0,0 @@ -import { createServiceTester } from '../tester.js' -import { isMetric } from '../test-validators.js' -export const t = await createServiceTester() - -const mockResponse = { - results: [ - { - extensions: [ - { - statistics: [ - { - statisticName: 'install', - value: 21, - }, - { - statisticName: 'onpremDownloads', - value: 7, - }, - ], - versions: [ - { - version: '1.0.0', - }, - ], - releaseDate: '2019-04-13T07:50:27.000Z', - lastUpdated: '2019-04-13T07:50:27.000Z', - }, - ], - }, - ], -} - -t.create('Azure DevOps Extension total installs') - .get('/total/swellaby.mirror-git-repository.json') - .expectBadge({ - label: 'installs', - message: isMetric, - }) - -t.create('Azure DevOps Extension services installs') - .get('/services/swellaby.mirror-git-repository.json') - .expectBadge({ - label: 'installs', - message: isMetric, - }) - -t.create('invalid extension id') - .get('/services/badges-shields.json') - .expectBadge({ - label: 'installs', - message: 'invalid extension id', - }) - -t.create('non existent extension') - .get('/total/badges.shields-io-fake.json') - .expectBadge({ - label: 'installs', - message: 'extension not found', - }) - -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) - ) - .expectBadge({ - label: 'installs', - message: '28', - color: 'yellowgreen', - }) - -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) - ) - .expectBadge({ - label: 'installs', - message: '21', - color: 'yellowgreen', - }) - -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) - ) - .expectBadge({ - label: 'installs', - message: '7', - color: 'yellow', - }) - -t.create('zero installs') - .get('/total/swellaby.cobertura-transform.json') - .intercept(nock => - nock('https://marketplace.visualstudio.com/_apis/public/gallery/') - .post(`/extensionquery/`) - .reply(200, { - results: [ - { - extensions: [ - { - statistics: [], - versions: [ - { - version: '1.0.0', - }, - ], - releaseDate: '2019-04-13T07:50:27.000Z', - lastUpdated: '2019-04-13T07:50:27.000Z', - }, - ], - }, - ], - }) - ) - .expectBadge({ - label: 'installs', - message: '0', - color: 'red', - }) diff --git a/services/visual-studio-marketplace/visual-studio-marketplace-base.js b/services/visual-studio-marketplace/visual-studio-marketplace-base.js deleted file mode 100644 index d25e9c11a2fc7..0000000000000 --- a/services/visual-studio-marketplace/visual-studio-marketplace-base.js +++ /dev/null @@ -1,111 +0,0 @@ -import Joi from 'joi' -import validate from '../../core/base-service/validate.js' -import { BaseJsonService, NotFound } from '../index.js' - -const extensionQuerySchema = Joi.object({ - results: Joi.array() - .items( - Joi.object({ - extensions: Joi.array() - .items( - Joi.object({ - statistics: Joi.array() - .items( - Joi.object({ - statisticName: Joi.string().required(), - value: Joi.number().required(), - }) - ) - .required(), - versions: Joi.array() - .items( - Joi.object({ - version: Joi.string().required(), - }) - ) - .min(1) - .required(), - releaseDate: Joi.string().required(), - lastUpdated: Joi.string().required(), - }) - ) - .required(), - }) - ) - .required(), -}).required() - -const statisticSchema = Joi.object().keys({ - install: Joi.number().default(0), - updateCount: Joi.number().default(0), - onpremDownloads: Joi.number().default(0), - averagerating: Joi.number().default(0), - ratingcount: Joi.number().default(0), -}) - -export default class VisualStudioMarketplaceBase extends BaseJsonService { - static keywords = [ - 'vscode', - 'tfs', - 'vsts', - 'visual-studio-marketplace', - 'vs-marketplace', - 'vscode-marketplace', - ] - - static defaultBadgeData = { - label: 'vs marketplace', - color: 'blue', - } - - async fetch({ extensionId }) { - const url = - 'https://marketplace.visualstudio.com/_apis/public/gallery/extensionquery/' - const body = { - filters: [ - { - criteria: [{ filterType: 7, value: extensionId }], - }, - ], - flags: 914, - } - const options = { - method: 'POST', - headers: { - accept: 'application/json;api-version=3.0-preview.1', - 'content-type': 'application/json', - }, - body: JSON.stringify(body), - } - - return this._requestJson({ - schema: extensionQuerySchema, - url, - options, - errorMessages: { - 400: 'invalid extension id', - }, - }) - } - - transformExtension({ json }) { - const extensions = json.results[0].extensions - if (extensions.length === 0) { - throw new NotFound({ prettyMessage: 'extension not found' }) - } - return { extension: extensions[0] } - } - - transformStatistics({ json }) { - const { extension } = this.transformExtension({ json }) - const statistics = {} - - extension.statistics.forEach(({ statisticName, value }) => { - statistics[statisticName] = value - }) - - const value = validate({ ErrorClass: Error }, statistics, statisticSchema) - - return { statistics: value } - } -} diff --git a/services/visual-studio-marketplace/visual-studio-marketplace-downloads.service.js b/services/visual-studio-marketplace/visual-studio-marketplace-downloads.service.js deleted file mode 100644 index 1d90a6275cfa9..0000000000000 --- a/services/visual-studio-marketplace/visual-studio-marketplace-downloads.service.js +++ /dev/null @@ -1,61 +0,0 @@ -import { metric } from '../text-formatters.js' -import { downloadCount } from '../color-formatters.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. -
-` - -export default class VisualStudioMarketplaceDownloads extends VisualStudioMarketplaceBase { - static category = 'downloads' - - static route = { - base: '', - pattern: - '(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, - }, - { - 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, - }, - ] - - static render({ measure, count }) { - const label = measure === 'd' ? 'downloads' : 'installs' - - return { - label, - message: metric(count), - color: downloadCount(count), - } - } - - async handle({ measure, extensionId }) { - const json = await this.fetch({ extensionId }) - const { statistics } = this.transformStatistics({ json }) - const count = - measure === 'i' - ? statistics.install - : statistics.install + statistics.updateCount - return this.constructor.render({ measure, count }) - } -} diff --git a/services/visual-studio-marketplace/visual-studio-marketplace-downloads.tester.js b/services/visual-studio-marketplace/visual-studio-marketplace-downloads.tester.js deleted file mode 100644 index d74b78cb87470..0000000000000 --- a/services/visual-studio-marketplace/visual-studio-marketplace-downloads.tester.js +++ /dev/null @@ -1,129 +0,0 @@ -import { createServiceTester } from '../tester.js' -import { isMetric } from '../test-validators.js' -export const t = await createServiceTester() - -const mockResponse = { - results: [ - { - extensions: [ - { - statistics: [ - { - statisticName: 'install', - value: 3, - }, - { - statisticName: 'updateCount', - value: 7, - }, - ], - versions: [ - { - version: '1.0.0', - }, - ], - releaseDate: '2019-04-13T07:50:27.000Z', - lastUpdated: '2019-04-13T07:50:27.000Z', - }, - ], - }, - ], -} - -t.create('installs') - .get('/visual-studio-marketplace/i/ritwickdey.LiveServer.json') - .expectBadge({ - label: 'installs', - message: isMetric, - }) - -t.create('downloads') - .get('/visual-studio-marketplace/d/ritwickdey.LiveServer.json') - .expectBadge({ - label: 'downloads', - message: isMetric, - }) - -t.create('invalid extension id') - .get('/visual-studio-marketplace/d/badges-shields.json') - .expectBadge({ - label: 'vs marketplace', - message: 'invalid extension id', - }) - -t.create('non existent extension') - .get('/visual-studio-marketplace/d/badges.shields-io-fake.json') - .expectBadge({ - label: 'vs marketplace', - message: 'extension not found', - }) - -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) - ) - .expectBadge({ - label: 'installs', - message: '3', - color: 'yellow', - }) - -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/`) - .reply(200, { - results: [ - { - extensions: [ - { - statistics: [], - versions: [ - { - version: '1.0.0', - }, - ], - releaseDate: '2019-04-13T07:50:27.000Z', - lastUpdated: '2019-04-13T07:50:27.000Z', - }, - ], - }, - ], - }) - ) - .expectBadge({ - label: 'installs', - message: '0', - color: 'red', - }) - -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) - ) - .expectBadge({ - label: 'downloads', - message: '10', - color: 'yellowgreen', - }) - -t.create('installs (legacy)') - .get('/vscode-marketplace/i/ritwickdey.LiveServer.json') - .expectBadge({ - label: 'installs', - message: isMetric, - }) - -t.create('downloads (legacy)') - .get('/vscode-marketplace/d/ritwickdey.LiveServer.json') - .expectBadge({ - label: 'downloads', - message: isMetric, - }) 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 deleted file mode 100644 index 5d7106fa58151..0000000000000 --- a/services/visual-studio-marketplace/visual-studio-marketplace-last-updated.service.js +++ /dev/null @@ -1,46 +0,0 @@ -import { age } from '../color-formatters.js' -import { formatDate } from '../text-formatters.js' -import VisualStudioMarketplaceBase from './visual-studio-marketplace-base.js' - -export default class VisualStudioMarketplaceLastUpdated extends VisualStudioMarketplaceBase { - static category = 'activity' - - static route = { - base: '', - pattern: - '(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 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 - return { lastUpdated } - } - - async handle({ extensionId }) { - const json = await this.fetch({ extensionId }) - const { lastUpdated } = this.transform({ json }) - return this.constructor.render({ 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 deleted file mode 100644 index cd7e40745a4ff..0000000000000 --- a/services/visual-studio-marketplace/visual-studio-marketplace-last-updated.tester.js +++ /dev/null @@ -1,26 +0,0 @@ -import { createServiceTester } from '../tester.js' -import { isFormattedDate } from '../test-validators.js' -export const t = await createServiceTester() - -t.create('date') - .get('/visual-studio-marketplace/last-updated/yasht.terminal-all-in-one.json') - .expectBadge({ - label: 'last updated', - message: isFormattedDate, - }) - -t.create('invalid extension id') - .get('/visual-studio-marketplace/last-updated/yasht-terminal-all-in-one.json') - .expectBadge({ - label: 'last updated', - message: 'invalid extension id', - }) - -t.create('non existent extension') - .get( - '/visual-studio-marketplace/last-updated/yasht.terminal-all-in-one-fake.json' - ) - .expectBadge({ - label: 'last updated', - message: 'extension not found', - }) diff --git a/services/visual-studio-marketplace/visual-studio-marketplace-rating.service.js b/services/visual-studio-marketplace/visual-studio-marketplace-rating.service.js deleted file mode 100644 index 0c3cb2eda2ccf..0000000000000 --- a/services/visual-studio-marketplace/visual-studio-marketplace-rating.service.js +++ /dev/null @@ -1,69 +0,0 @@ -import { starRating } from '../text-formatters.js' -import { floorCount } from '../color-formatters.js' -import VisualStudioMarketplaceBase from './visual-studio-marketplace-base.js' - -export default class VisualStudioMarketplaceRating extends VisualStudioMarketplaceBase { - static category = 'rating' - - static route = { - base: '', - pattern: - '(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, - }, - { - 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', - } - - static render({ format, averageRating, ratingCount }) { - if (ratingCount < 1) { - return { - message: 'no ratings', - color: 'inactive', - } - } - - const message = - format === 'r' - ? `${averageRating.toFixed(1)}/5 (${ratingCount})` - : starRating(averageRating) - return { - message, - color: floorCount(averageRating, 2, 3, 4), - } - } - - async handle({ format, extensionId }) { - const json = await this.fetch({ extensionId }) - const { statistics } = this.transformStatistics({ json }) - return this.constructor.render({ - format, - averageRating: statistics.averagerating, - ratingCount: statistics.ratingcount, - }) - } -} diff --git a/services/visual-studio-marketplace/visual-studio-marketplace-rating.tester.js b/services/visual-studio-marketplace/visual-studio-marketplace-rating.tester.js deleted file mode 100644 index 1d23290707ef2..0000000000000 --- a/services/visual-studio-marketplace/visual-studio-marketplace-rating.tester.js +++ /dev/null @@ -1,141 +0,0 @@ -import { createServiceTester } from '../tester.js' -import { withRegex, isStarRating } from '../test-validators.js' -export const t = await createServiceTester() - -const isVscodeRating = withRegex(/[0-5]\.[0-9]{1}\/5?\s*\([0-9]*\)$/) - -t.create('rating') - .get('/visual-studio-marketplace/r/ritwickdey.LiveServer.json') - .expectBadge({ - label: 'rating', - message: isVscodeRating, - }) - -t.create('stars') - .get('/visual-studio-marketplace/stars/ritwickdey.LiveServer.json') - .expectBadge({ - label: 'rating', - message: isStarRating, - }) - -t.create('rating') - .get('/visual-studio-marketplace/r/ritwickdey.LiveServer.json') - .intercept(nock => - nock('https://marketplace.visualstudio.com/_apis/public/gallery/') - .post(`/extensionquery/`) - .reply(200, { - results: [ - { - extensions: [ - { - statistics: [ - { - statisticName: 'averagerating', - value: 2.5, - }, - { - statisticName: 'ratingcount', - value: 10, - }, - ], - versions: [ - { - version: '1.0.0', - }, - ], - releaseDate: '2019-04-13T07:50:27.000Z', - lastUpdated: '2019-04-13T07:50:27.000Z', - }, - ], - }, - ], - }) - ) - .expectBadge({ - label: 'rating', - message: '2.5/5 (10)', - color: 'yellowgreen', - }) - -t.create('zero rating') - .get('/visual-studio-marketplace/r/ritwickdey.LiveServer.json') - .intercept(nock => - nock('https://marketplace.visualstudio.com/_apis/public/gallery/') - .post(`/extensionquery/`) - .reply(200, { - results: [ - { - extensions: [ - { - statistics: [], - versions: [ - { - version: '1.0.0', - }, - ], - releaseDate: '2019-04-13T07:50:27.000Z', - lastUpdated: '2019-04-13T07:50:27.000Z', - }, - ], - }, - ], - }) - ) - .expectBadge({ - label: 'rating', - message: 'no ratings', - color: 'lightgrey', - }) - -t.create('stars') - .get('/visual-studio-marketplace/stars/ritwickdey.LiveServer.json') - .intercept(nock => - nock('https://marketplace.visualstudio.com/_apis/public/gallery/') - .post(`/extensionquery/`) - .reply(200, { - results: [ - { - extensions: [ - { - statistics: [ - { - statisticName: 'averagerating', - value: 4.7, - }, - { - statisticName: 'ratingcount', - value: 200, - }, - ], - versions: [ - { - version: '1.0.0', - }, - ], - releaseDate: '2019-04-13T07:50:27.000Z', - lastUpdated: '2019-04-13T07:50:27.000Z', - }, - ], - }, - ], - }) - ) - .expectBadge({ - label: 'rating', - message: '★★★★¾', - color: 'brightgreen', - }) - -t.create('rating (legacy)') - .get('/vscode-marketplace/r/ritwickdey.LiveServer.json') - .expectBadge({ - label: 'rating', - message: isVscodeRating, - }) - -t.create('stars (legacy)') - .get('/vscode-marketplace/stars/ritwickdey.LiveServer.json') - .expectBadge({ - label: 'rating', - message: isStarRating, - }) 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 deleted file mode 100644 index d604c425c1a84..0000000000000 --- a/services/visual-studio-marketplace/visual-studio-marketplace-release-date.service.js +++ /dev/null @@ -1,46 +0,0 @@ -import { age } from '../color-formatters.js' -import { formatDate } from '../text-formatters.js' -import VisualStudioMarketplaceBase from './visual-studio-marketplace-base.js' - -export default class VisualStudioMarketplaceReleaseDate extends VisualStudioMarketplaceBase { - static category = 'activity' - - static route = { - base: '', - pattern: - '(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 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 - return { releaseDate } - } - - async handle({ extensionId }) { - const json = await this.fetch({ extensionId }) - const { releaseDate } = this.transform({ json }) - return this.constructor.render({ 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 deleted file mode 100644 index acf9da220bd8f..0000000000000 --- a/services/visual-studio-marketplace/visual-studio-marketplace-release-date.tester.js +++ /dev/null @@ -1,26 +0,0 @@ -import { createServiceTester } from '../tester.js' -import { isFormattedDate } from '../test-validators.js' -export const t = await createServiceTester() - -t.create('date') - .get('/visual-studio-marketplace/release-date/yasht.terminal-all-in-one.json') - .expectBadge({ - label: 'release date', - message: isFormattedDate, - }) - -t.create('invalid extension id') - .get('/visual-studio-marketplace/release-date/yasht-terminal-all-in-one.json') - .expectBadge({ - label: 'release date', - message: 'invalid extension id', - }) - -t.create('non existent extension') - .get( - '/visual-studio-marketplace/release-date/yasht.terminal-all-in-one-fake.json' - ) - .expectBadge({ - label: 'release date', - message: 'extension not found', - }) diff --git a/services/visual-studio-marketplace/visual-studio-marketplace-version.service.js b/services/visual-studio-marketplace/visual-studio-marketplace-version.service.js deleted file mode 100644 index e0e8e2458b31e..0000000000000 --- a/services/visual-studio-marketplace/visual-studio-marketplace-version.service.js +++ /dev/null @@ -1,42 +0,0 @@ -import { renderVersionBadge } from '../version.js' -import VisualStudioMarketplaceBase from './visual-studio-marketplace-base.js' - -export default class VisualStudioMarketplaceVersion extends VisualStudioMarketplaceBase { - static category = 'version' - - static route = { - base: '', - pattern: '(visual-studio-marketplace|vscode-marketplace)/v/:extensionId', - } - - 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 defaultBadgeData = { - label: 'version', - } - - static render({ version }) { - return renderVersionBadge({ version }) - } - - transform({ json }) { - const { extension } = this.transformExtension({ json }) - const version = extension.versions[0].version - return { version } - } - - async handle({ extensionId }) { - const json = await this.fetch({ extensionId }) - const { version } = this.transform({ json }) - - 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 deleted file mode 100644 index 1b44b3e0e0e26..0000000000000 --- a/services/visual-studio-marketplace/visual-studio-marketplace-version.tester.js +++ /dev/null @@ -1,79 +0,0 @@ -import { createServiceTester } from '../tester.js' -import { withRegex } from '../test-validators.js' -export const t = await createServiceTester() - -const isMarketplaceVersion = withRegex(/^v(\d+\.\d+\.\d+)(\.\d+)?$/) - -t.create('rating') - .get('/visual-studio-marketplace/v/ritwickdey.LiveServer.json') - .expectBadge({ - label: 'version', - message: isMarketplaceVersion, - }) - -t.create('version') - .get('/visual-studio-marketplace/v/ritwickdey.LiveServer.json') - .intercept(nock => - nock('https://marketplace.visualstudio.com/_apis/public/gallery/') - .post(`/extensionquery/`) - .reply(200, { - results: [ - { - extensions: [ - { - statistics: [], - versions: [ - { - version: '1.0.0', - }, - ], - releaseDate: '2019-04-13T07:50:27.000Z', - lastUpdated: '2019-04-13T07:50:27.000Z', - }, - ], - }, - ], - }) - ) - .expectBadge({ - label: 'version', - message: 'v1.0.0', - color: 'blue', - }) - -t.create('pre-release version') - .get('/visual-studio-marketplace/v/swellaby.vscode-rust-test-adapter.json') - .intercept(nock => - nock('https://marketplace.visualstudio.com/_apis/public/gallery/') - .post(`/extensionquery/`) - .reply(200, { - results: [ - { - extensions: [ - { - statistics: [], - versions: [ - { - version: '0.3.8', - }, - ], - releaseDate: '2019-04-13T07:50:27.000Z', - lastUpdated: '2019-04-13T07:50:27.000Z', - }, - ], - }, - ], - }) - ) - .expectBadge({ - label: 'version', - message: 'v0.3.8', - color: 'orange', - }) - -t.create('version (legacy)') - .get('/vscode-marketplace/v/ritwickdey.LiveServer.json') - .expectBadge({ - label: 'version', - message: isMarketplaceVersion, - }) diff --git a/services/visual-studio-marketplace/visual-studio-marketplace.service.js b/services/visual-studio-marketplace/visual-studio-marketplace.service.js new file mode 100644 index 0000000000000..13dde92c1911e --- /dev/null +++ b/services/visual-studio-marketplace/visual-studio-marketplace.service.js @@ -0,0 +1,107 @@ +import { retiredService } from '../index.js' + +const dateAdded = new Date('2026-04-09') +const label = 'visual-studio-marketplace' +const pattern = ':various+' + +export default [ + // Downloads / Installs + retiredService({ + category: 'downloads', + route: { base: 'visual-studio-marketplace/d', pattern }, + label, + dateAdded, + }), + retiredService({ + category: 'downloads', + route: { base: 'visual-studio-marketplace/i', pattern }, + label, + dateAdded, + }), + retiredService({ + category: 'downloads', + route: { base: 'vscode-marketplace/d', pattern }, + label, + dateAdded, + }), + retiredService({ + category: 'downloads', + route: { base: 'vscode-marketplace/i', pattern }, + label, + dateAdded, + }), + + // Version + retiredService({ + category: 'version', + route: { base: 'visual-studio-marketplace/v', pattern }, + label, + dateAdded, + }), + retiredService({ + category: 'version', + route: { base: 'vscode-marketplace/v', pattern }, + label, + dateAdded, + }), + + // Rating / Stars + retiredService({ + category: 'rating', + route: { base: 'visual-studio-marketplace/r', pattern }, + label, + dateAdded, + }), + retiredService({ + category: 'rating', + route: { base: 'visual-studio-marketplace/stars', pattern }, + label, + dateAdded, + }), + retiredService({ + category: 'rating', + route: { base: 'vscode-marketplace/r', pattern }, + label, + dateAdded, + }), + retiredService({ + category: 'rating', + route: { base: 'vscode-marketplace/stars', pattern }, + label, + dateAdded, + }), + + // Release date / Last updated + retiredService({ + category: 'activity', + route: { base: 'visual-studio-marketplace/release-date', pattern }, + label, + dateAdded, + }), + retiredService({ + category: 'activity', + route: { base: 'vscode-marketplace/release-date', pattern }, + label, + dateAdded, + }), + retiredService({ + category: 'activity', + route: { base: 'visual-studio-marketplace/last-updated', pattern }, + label, + dateAdded, + }), + retiredService({ + category: 'activity', + route: { base: 'vscode-marketplace/last-updated', pattern }, + label, + dateAdded, + }), + + // Azure DevOps installs (covers measure/extensionId segments) + retiredService({ + category: 'downloads', + route: { base: 'visual-studio-marketplace/azure-devops/installs', pattern }, + label, + dateAdded, + }), +] diff --git a/services/visual-studio-marketplace/visual-studio-marketplace.tester.js b/services/visual-studio-marketplace/visual-studio-marketplace.tester.js new file mode 100644 index 0000000000000..7f7d70d5fe89a --- /dev/null +++ b/services/visual-studio-marketplace/visual-studio-marketplace.tester.js @@ -0,0 +1,134 @@ +import { ServiceTester } from '../tester.js' + +export const t = new ServiceTester({ + id: 'visualstudiomarketplace', + title: 'Visual Studio Marketplace (retired)', + pathPrefix: '/visual-studio-marketplace', +}) + +export const tLegacy = new ServiceTester({ + id: 'visualstudiomarketplacelegacy', + title: 'VSCode Marketplace (retired)', + pathPrefix: '/vscode-marketplace', +}) + +// Downloads / Installs (visual-studio-marketplace) +t.create('retired badge (visual-studio-marketplace downloads)') + .get('/d/ritwickdey.LiveServer.json') + .expectBadge({ + label: 'visual-studio-marketplace', + message: 'retired badge', + }) + +t.create('retired badge (visual-studio-marketplace installs)') + .get('/i/ritwickdey.LiveServer.json') + .expectBadge({ + label: 'visual-studio-marketplace', + message: 'retired badge', + }) + +// Downloads / Installs (vscode-marketplace - legacy) +tLegacy + .create('retired badge (vscode-marketplace downloads)') + .get('/d/ritwickdey.LiveServer.json') + .expectBadge({ + label: 'visual-studio-marketplace', + message: 'retired badge', + }) + +tLegacy + .create('retired badge (vscode-marketplace installs)') + .get('/i/ritwickdey.LiveServer.json') + .expectBadge({ + label: 'visual-studio-marketplace', + message: 'retired badge', + }) + +// Version (visual-studio-marketplace) +t.create('retired badge (version)') + .get('/v/lextudio.restructuredtext.json') + .expectBadge({ + label: 'visual-studio-marketplace', + message: 'retired badge', + }) + +// Version (vscode-marketplace - legacy) +tLegacy + .create('retired badge (version legacy)') + .get('/v/lextudio.restructuredtext.json') + .expectBadge({ + label: 'visual-studio-marketplace', + message: 'retired badge', + }) + +// Rating / Stars (visual-studio-marketplace) +t.create('retired badge (rating)') + .get('/r/ritwickdey.LiveServer.json') + .expectBadge({ + label: 'visual-studio-marketplace', + message: 'retired badge', + }) + +t.create('retired badge (stars)') + .get('/stars/ritwickdey.LiveServer.json') + .expectBadge({ + label: 'visual-studio-marketplace', + message: 'retired badge', + }) + +// Rating / Stars (vscode-marketplace - legacy) +tLegacy + .create('retired badge (rating legacy)') + .get('/r/ritwickdey.LiveServer.json') + .expectBadge({ + label: 'visual-studio-marketplace', + message: 'retired badge', + }) + +tLegacy + .create('retired badge (stars legacy)') + .get('/stars/ritwickdey.LiveServer.json') + .expectBadge({ + label: 'visual-studio-marketplace', + message: 'retired badge', + }) + +// Release date / Last updated (visual-studio-marketplace) +t.create('retired badge (release date)') + .get('/release-date/yasht.terminal-all-in-one.json') + .expectBadge({ + label: 'visual-studio-marketplace', + message: 'retired badge', + }) + +t.create('retired badge (last updated)') + .get('/last-updated/yasht.terminal-all-in-one.json') + .expectBadge({ + label: 'visual-studio-marketplace', + message: 'retired badge', + }) + +// Release date / Last updated (vscode-marketplace - legacy) +tLegacy + .create('retired badge (release date legacy)') + .get('/release-date/yasht.terminal-all-in-one.json') + .expectBadge({ + label: 'visual-studio-marketplace', + message: 'retired badge', + }) + +tLegacy + .create('retired badge (last updated legacy)') + .get('/last-updated/yasht.terminal-all-in-one.json') + .expectBadge({ + label: 'visual-studio-marketplace', + message: 'retired badge', + }) + +// Azure DevOps installs (visual-studio-marketplace) +t.create('retired badge (azure devops installs total)') + .get('/azure-devops/installs/total/swellaby.mirror-git-repository.json') + .expectBadge({ + label: 'visual-studio-marketplace', + message: 'retired badge', + }) diff --git a/services/voxelshop/voxelshop-base.js b/services/voxelshop/voxelshop-base.js new file mode 100644 index 0000000000000..18a956f9b1d31 --- /dev/null +++ b/services/voxelshop/voxelshop-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 product page.
+Example: https://voxel.shop/product/323/polymart-plugin - Here the Resource ID is 323.
- 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: -
- 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: + +
- 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.
-
- The name of an extension is case-sensitive excluding the first character. -
-
- For example, in the case of ParserFunctions, the following are
- valid:
-
ParserFunctionsparserFunctionsparserfunctionsParserfunctionspARSERfUNCTIONS/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..7f342c6c94876 100644
--- a/services/wordpress/wordpress-version-color.js
+++ b/services/wordpress/wordpress-version-color.js
@@ -1,30 +1,32 @@
-import { promisify } from 'util'
+import Joi from 'joi'
import semver from 'semver'
-import { regularUpdate } from '../../core/legacy/regular-update.js'
+import { getCachedResource } from '../../core/base-service/resource-cache.js'
+import { optionalDottedVersionNClausesWithOptionalSuffix } from '../validators.js'
-// TODO: Incorporate this schema.
-// const schema = Joi.object()
-// .keys({
-// offers: Joi.array()
-// .items(
-// Joi.object()
-// .keys({
-// version: Joi.string()
-// .regex(/^\d+(\.\d+)?(\.\d+)?$/)
-// .required(),
-// })
-// .required()
-// )
-// .required(),
-// })
-// .required()
+const schema = Joi.object()
+ .keys({
+ offers: Joi.array()
+ .items(
+ Joi.object()
+ .keys({
+ version: optionalDottedVersionNClausesWithOptionalSuffix,
+ })
+ .required(),
+ )
+ .required(),
+ })
+ .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),
+ scraper: json => {
+ const { error, value } = schema.validate(json, { allowUnknown: true })
+ if (error) {
+ throw Error(`WordPress version API response: ${error.message}`)
+ }
+ return value.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
- When enabling the withDislikes option, 👍 corresponds to the number
- of likes of a given video, 👎 corresponds to the number of dislikes.
-