Compare commits
117 Commits
server-202
...
server-202
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
606ea0ad54 | ||
|
|
00e37a6888 | ||
|
|
c3d08f7f8b | ||
|
|
8be87554d6 | ||
|
|
df4c6d14dd | ||
|
|
5830810451 | ||
|
|
ac2ae0c180 | ||
|
|
efd707eb65 | ||
|
|
f67251a9ca | ||
|
|
00f35c67e8 | ||
|
|
7a38cfe099 | ||
|
|
b32dc3e894 | ||
|
|
e8798a437a | ||
|
|
80ffafc422 | ||
|
|
8808e0dd7b | ||
|
|
146598f5f4 | ||
|
|
41c53c73c2 | ||
|
|
dd3e2df00a | ||
|
|
271547d2c6 | ||
|
|
300871ac65 | ||
|
|
af25802b68 | ||
|
|
5caab6724f | ||
|
|
73b8e78143 | ||
|
|
022fe54141 | ||
|
|
06cdf34c3d | ||
|
|
6049ef64c8 | ||
|
|
a276770cdb | ||
|
|
61dd6c0443 | ||
|
|
2d254acd20 | ||
|
|
2e18afc062 | ||
|
|
ddd25e0d47 | ||
|
|
7d2d930486 | ||
|
|
c26b90f4e7 | ||
|
|
eef6057c78 | ||
|
|
a67dda19bc | ||
|
|
e1622e800c | ||
|
|
5e78eccf56 | ||
|
|
7207df085d | ||
|
|
367b83b0b6 | ||
|
|
908cc8de93 | ||
|
|
4d785cd459 | ||
|
|
8dcd0d1ffd | ||
|
|
8754ac3798 | ||
|
|
93016b04ff | ||
|
|
b6be37d277 | ||
|
|
4da9e7d58f | ||
|
|
affae9c521 | ||
|
|
73bbe80eaa | ||
|
|
9cd16402db | ||
|
|
c49ee023dd | ||
|
|
e2fcd1787b | ||
|
|
c008dcfbb6 | ||
|
|
d769de309e | ||
|
|
478c0545ac | ||
|
|
ac1a4d4587 | ||
|
|
882fb9d267 | ||
|
|
2cdd89e8a2 | ||
|
|
eefb16a6ec | ||
|
|
53c5cfa94d | ||
|
|
438677b6f0 | ||
|
|
bcc39200ae | ||
|
|
fe2b3dfff0 | ||
|
|
f1cc2f1906 | ||
|
|
ca6778983b | ||
|
|
76c1ef912e | ||
|
|
4496ab575d | ||
|
|
3e6d6f9380 | ||
|
|
1d1600cd04 | ||
|
|
5618d52ab1 | ||
|
|
7483739172 | ||
|
|
d00bd3cfd4 | ||
|
|
c5476b4dd5 | ||
|
|
1c1b03344a | ||
|
|
b95e60bace | ||
|
|
3efe143edc | ||
|
|
93b8978e4a | ||
|
|
d6bc0feafa | ||
|
|
c2d1cc2874 | ||
|
|
a2751af0b1 | ||
|
|
1baa84683a | ||
|
|
8e92da69bb | ||
|
|
f851948178 | ||
|
|
dd43621a48 | ||
|
|
7c27fb02f6 | ||
|
|
b6f1aad2f0 | ||
|
|
3d8fa47010 | ||
|
|
664f2c15d6 | ||
|
|
dfa38a4bb7 | ||
|
|
4560a229e8 | ||
|
|
2a590fce99 | ||
|
|
755af712ae | ||
|
|
6303ddc015 | ||
|
|
5dd382bb7a | ||
|
|
de2464c56d | ||
|
|
b5b6298e14 | ||
|
|
4fae08f82e | ||
|
|
31faec2d6b | ||
|
|
59336e2a64 | ||
|
|
62adb3df4d | ||
|
|
cb79fbd3c2 | ||
|
|
c423eb6c54 | ||
|
|
5305e11f79 | ||
|
|
025a49b715 | ||
|
|
c214ad8de8 | ||
|
|
809853ada1 | ||
|
|
a95ad6c631 | ||
|
|
795c73294a | ||
|
|
b72e49074c | ||
|
|
5d0bda22a9 | ||
|
|
58d9a98054 | ||
|
|
9bbc333af5 | ||
|
|
d4d0fd0f81 | ||
|
|
525794b9e6 | ||
|
|
b1fdcace0f | ||
|
|
c8a0a8660e | ||
|
|
e223f45bce | ||
|
|
b3a9dd7119 |
@@ -46,44 +46,6 @@ jobs:
|
||||
NODE_ENV: test
|
||||
command: npm run danger ci
|
||||
|
||||
frontend:
|
||||
docker:
|
||||
- image: cimg/node:16.15
|
||||
|
||||
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
|
||||
|
||||
services:
|
||||
docker:
|
||||
- image: cimg/node:16.15
|
||||
@@ -98,59 +60,11 @@ jobs:
|
||||
|
||||
<<: *services_steps
|
||||
|
||||
e2e:
|
||||
docker:
|
||||
- image: cypress/base:16.14.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:
|
||||
- frontend:
|
||||
filters:
|
||||
branches:
|
||||
ignore: gh-pages
|
||||
- services:
|
||||
filters:
|
||||
branches:
|
||||
@@ -170,19 +84,12 @@ workflows:
|
||||
- master
|
||||
- gh-pages
|
||||
- /dependabot\/.*/
|
||||
- e2e:
|
||||
filters:
|
||||
branches:
|
||||
ignore: gh-pages
|
||||
# on-commit-with-cache:
|
||||
# jobs:
|
||||
# - npm-install:
|
||||
# filters:
|
||||
# branches:
|
||||
# ignore: gh-pages
|
||||
# - frontend:
|
||||
# requires:
|
||||
# - npm-install
|
||||
# - services:
|
||||
# requires:
|
||||
# - npm-install
|
||||
|
||||
37
.github/ISSUE_TEMPLATE/3_Badge_request.md
vendored
37
.github/ISSUE_TEMPLATE/3_Badge_request.md
vendored
@@ -1,37 +0,0 @@
|
||||
---
|
||||
name: 💡 Badge Request
|
||||
about: Ideas for new badges
|
||||
labels: 'service-badge'
|
||||
---
|
||||
|
||||
:clipboard: **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)
|
||||
-->
|
||||
|
||||
:link: **Data**
|
||||
|
||||
<!--
|
||||
Where can we get the data from?
|
||||
|
||||
- Is there a public API?
|
||||
- Does the API require an API key?
|
||||
- Link to the API documentation.
|
||||
-->
|
||||
|
||||
:microphone: **Motivation**
|
||||
|
||||
<!--
|
||||
Please explain why this feature should be implemented and how it would be used.
|
||||
|
||||
- What is the specific use case?
|
||||
-->
|
||||
|
||||
<!-- Love Shields? Please consider donating $10 to sustain our activities:
|
||||
👉 https://opencollective.com/shields -->
|
||||
62
.github/ISSUE_TEMPLATE/3_Badge_request.yml
vendored
Normal file
62
.github/ISSUE_TEMPLATE/3_Badge_request.yml
vendored
Normal file
@@ -0,0 +1,62 @@
|
||||
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://github.com/badges/shields/blob/master/doc/static-badges.md).
|
||||
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/#your-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 $10 to sustain our activities: [https://opencollective.com/shields](https://opencollective.com/shields)
|
||||
2
.github/actions/close-bot/action.yml
vendored
2
.github/actions/close-bot/action.yml
vendored
@@ -8,5 +8,5 @@ inputs:
|
||||
description: 'The GITHUB_TOKEN secret'
|
||||
required: true
|
||||
runs:
|
||||
using: 'node12'
|
||||
using: 'node16'
|
||||
main: 'index.js'
|
||||
|
||||
31
.github/actions/frontend-tests/action.yml
vendored
Normal file
31
.github/actions/frontend-tests/action.yml
vendored
Normal file
@@ -0,0 +1,31 @@
|
||||
name: 'Frontend tests'
|
||||
description: 'Run frontend tests and check types'
|
||||
runs:
|
||||
using: 'composite'
|
||||
steps:
|
||||
- name: Prepare frontend tests
|
||||
if: always()
|
||||
run: npm run defs && npm run features
|
||||
shell: bash
|
||||
|
||||
- name: Tests
|
||||
if: always()
|
||||
run: npm run test:frontend -- --reporter json --reporter-option 'output=reports/frontend-tests.json'
|
||||
shell: bash
|
||||
|
||||
- name: Type Checks
|
||||
if: always()
|
||||
run: |
|
||||
set -o pipefail
|
||||
npm run check-types:frontend 2>&1 | tee reports/frontend-types.txt
|
||||
shell: bash
|
||||
|
||||
- name: Write Markdown Summary
|
||||
if: always()
|
||||
run: |
|
||||
node scripts/mocha2md.js 'Frontend Tests' reports/frontend-tests.json >> $GITHUB_STEP_SUMMARY
|
||||
echo '# Frontend Types' >> $GITHUB_STEP_SUMMARY
|
||||
echo '```' >> $GITHUB_STEP_SUMMARY
|
||||
cat reports/frontend-types.txt >> $GITHUB_STEP_SUMMARY
|
||||
echo '```' >> $GITHUB_STEP_SUMMARY
|
||||
shell: bash
|
||||
21
.github/actions/setup/action.yml
vendored
21
.github/actions/setup/action.yml
vendored
@@ -5,21 +5,32 @@ inputs:
|
||||
description: 'Version Spec of the version to use. Examples: 12.x, 10.15.1, >=10.15.0.'
|
||||
required: true
|
||||
cypress:
|
||||
description: 'Install Cypress binary: 0 or 1'
|
||||
description: 'Install Cypress binary (boolean)'
|
||||
type: boolean
|
||||
# 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.
|
||||
required: false
|
||||
default: 0
|
||||
default: false
|
||||
runs:
|
||||
using: 'composite'
|
||||
steps:
|
||||
- name: Install Node JS ${{ inputs.node-version }}
|
||||
uses: actions/setup-node@v2
|
||||
uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: ${{ inputs.node-version }}
|
||||
|
||||
- name: Install dependencies
|
||||
if: ${{ inputs.cypress == 'false' }}
|
||||
env:
|
||||
CYPRESS_INSTALL_BINARY: ${{ inputs.cypress }}
|
||||
run: npm ci
|
||||
CYPRESS_INSTALL_BINARY: 0
|
||||
run: |
|
||||
echo "skipping cypress binary"
|
||||
npm ci
|
||||
shell: bash
|
||||
|
||||
- name: Install dependencies (including cypress binary)
|
||||
if: ${{ inputs.cypress == 'true' }}
|
||||
run: |
|
||||
echo "installing cypress binary"
|
||||
npm ci
|
||||
shell: bash
|
||||
|
||||
@@ -10,4 +10,4 @@ jobs:
|
||||
- name: 'Checkout Repository'
|
||||
uses: actions/checkout@v3
|
||||
- name: 'Dependency Review'
|
||||
uses: actions/dependency-review-action@v2
|
||||
uses: actions/dependency-review-action@v3
|
||||
|
||||
52
.github/workflows/test-e2e.yml
vendored
Normal file
52
.github/workflows/test-e2e.yml
vendored
Normal file
@@ -0,0 +1,52 @@
|
||||
name: E2E
|
||||
on:
|
||||
pull_request:
|
||||
types: [opened, edited, reopened, synchronize]
|
||||
push:
|
||||
branches-ignore:
|
||||
- 'gh-pages'
|
||||
- 'dependabot/**'
|
||||
|
||||
jobs:
|
||||
test-e2e:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v3
|
||||
|
||||
- name: Cache Cypress binary
|
||||
id: cache-cypress
|
||||
uses: actions/cache@v3
|
||||
env:
|
||||
cache-name: cache-cypress
|
||||
with:
|
||||
path: ~/.cache/Cypress
|
||||
key: ${{ runner.os }}-build-${{ env.cache-name }}-${{ hashFiles('**/package-lock.json') }}
|
||||
|
||||
- name: Setup
|
||||
uses: ./.github/actions/setup
|
||||
with:
|
||||
node-version: 16
|
||||
cypress: true
|
||||
|
||||
- name: Frontend build
|
||||
run: GATSBY_BASE_URL=http://localhost:8080 npm run build
|
||||
|
||||
- name: Run tests
|
||||
env:
|
||||
GH_TOKEN: '${{ secrets.GITHUB_TOKEN }}'
|
||||
run: npm run e2e-on-build
|
||||
|
||||
- name: Archive videos
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: videos
|
||||
path: cypress/videos
|
||||
|
||||
- name: Archive screenshots
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: screenshots
|
||||
path: cypress/screenshots
|
||||
26
.github/workflows/test-frontend.yml
vendored
Normal file
26
.github/workflows/test-frontend.yml
vendored
Normal file
@@ -0,0 +1,26 @@
|
||||
name: Frontend
|
||||
on:
|
||||
pull_request:
|
||||
types: [opened, edited, reopened, synchronize]
|
||||
push:
|
||||
branches-ignore:
|
||||
- 'gh-pages'
|
||||
- 'dependabot/**'
|
||||
|
||||
jobs:
|
||||
test-frontend:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v3
|
||||
|
||||
- name: Setup
|
||||
uses: ./.github/actions/setup
|
||||
with:
|
||||
node-version: 16
|
||||
|
||||
- name: Frontend tests
|
||||
uses: ./.github/actions/frontend-tests
|
||||
|
||||
- name: Frontend build
|
||||
run: npm run build
|
||||
33
.github/workflows/update-github-api.yml
vendored
Normal file
33
.github/workflows/update-github-api.yml
vendored
Normal file
@@ -0,0 +1,33 @@
|
||||
name: Update GitHub API Version
|
||||
on:
|
||||
schedule:
|
||||
- cron: '0 7 * * 6'
|
||||
# At 07:00 on Saturday
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
pull-requests: write
|
||||
contents: write
|
||||
|
||||
jobs:
|
||||
update-github-api:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v3
|
||||
|
||||
- name: Setup
|
||||
uses: ./.github/actions/setup
|
||||
with:
|
||||
node-version: 16
|
||||
|
||||
- name: Check for new GitHub API version
|
||||
run: node scripts/update-github-api.js
|
||||
|
||||
- name: Create Pull Request if config has changed
|
||||
uses: peter-evans/create-pull-request@v4
|
||||
with:
|
||||
token: '${{ secrets.GITHUB_TOKEN }}'
|
||||
commit-message: Update GitHub API Version
|
||||
title: Update [GitHub] API Version
|
||||
branch-suffix: random
|
||||
31
CHANGELOG.md
31
CHANGELOG.md
@@ -4,6 +4,37 @@ Note: this changelog is for the shields.io server. The changelog for the badge-m
|
||||
|
||||
---
|
||||
|
||||
## server-2023-01-01
|
||||
|
||||
- Breaking change: Routes for GitHub workflows badge have changed. See https://github.com/badges/shields/issues/8671 for more details
|
||||
- Behaviour change: In this release we fixed a long standing bug. GitHub badges were previously not reading the base URL from the `config.service.baseUri`.
|
||||
This release fixes that bug, bringing the code into line with the documented behaviour. This should not cause a behaviour change for most users,
|
||||
but users who had previously set a value in `config.service.baseUri` which was previously ignored could see this now have an effect.
|
||||
Users who configure their instance using env vars rather than yaml should see no change.
|
||||
- Send `X-GitHub-Api-Version` when calling [GitHub] v3 API [#8669](https://github.com/badges/shields/issues/8669)
|
||||
- add [VpmVersion] badge [#8755](https://github.com/badges/shields/issues/8755)
|
||||
- Add [modrinth] game versions [#8673](https://github.com/badges/shields/issues/8673)
|
||||
- fix debug logging of undefined query params [#8540](https://github.com/badges/shields/issues/8540), [#8757](https://github.com/badges/shields/issues/8757)
|
||||
- fall back to classifiers if [pypi] license text is really long [#8690](https://github.com/badges/shields/issues/8690)
|
||||
- allow passing key to [stackexchange] [#8539](https://github.com/badges/shields/issues/8539)
|
||||
- Dependency updates
|
||||
|
||||
## server-2022-12-01
|
||||
|
||||
- fix: support logoColor to shield icons. [#8263](https://github.com/badges/shields/issues/8263)
|
||||
- handle missing properties array in [VisualStudioMarketplaceVersion] [#8603](https://github.com/badges/shields/issues/8603)
|
||||
- deprecate [wercker] service [#8642](https://github.com/badges/shields/issues/8642)
|
||||
- Add [Coincap] Cryptocurrency badges [#8623](https://github.com/badges/shields/issues/8623)
|
||||
- Add [modrinth] version [#8604](https://github.com/badges/shields/issues/8604)
|
||||
- [factorio-mod-portal] services [#8625](https://github.com/badges/shields/issues/8625)
|
||||
- [Coveralls] for GitLab [#8584](https://github.com/badges/shields/issues/8584), [#8644](https://github.com/badges/shields/issues/8644)
|
||||
- Remove 'suggest badges' feature [#8311](https://github.com/badges/shields/issues/8311)
|
||||
- Add [modrinth] followers [#8601](https://github.com/badges/shields/issues/8601)
|
||||
- Update the [modrinth] API to v2 [#8600](https://github.com/badges/shields/issues/8600)
|
||||
- tidy up [GitHubGist] routes [#8510](https://github.com/badges/shields/issues/8510)
|
||||
- fix [flathub] version error handling [#8500](https://github.com/badges/shields/issues/8500)
|
||||
- Dependency updates
|
||||
|
||||
## server-2022-11-01
|
||||
|
||||
- [Ansible] Add collection badge [#8578](https://github.com/badges/shields/issues/8578)
|
||||
|
||||
@@ -98,6 +98,7 @@ private:
|
||||
sl_insight_userUuid: 'SL_INSIGHT_USER_UUID'
|
||||
sl_insight_apiToken: 'SL_INSIGHT_API_TOKEN'
|
||||
sonarqube_token: 'SONARQUBE_TOKEN'
|
||||
stackapps_api_key: 'STACKAPPS_API_KEY'
|
||||
teamcity_user: 'TEAMCITY_USER'
|
||||
teamcity_pass: 'TEAMCITY_PASS'
|
||||
twitch_client_id: 'TWITCH_CLIENT_ID'
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
public:
|
||||
bind:
|
||||
address: '::'
|
||||
|
||||
metrics:
|
||||
prometheus:
|
||||
enabled: false
|
||||
@@ -12,33 +11,26 @@ public:
|
||||
intervalSeconds: 15
|
||||
ssl:
|
||||
isSecure: false
|
||||
|
||||
cors:
|
||||
allowedOrigin: []
|
||||
|
||||
services:
|
||||
github:
|
||||
baseUri: 'https://api.github.com/'
|
||||
baseUri: 'https://api.github.com'
|
||||
debug:
|
||||
enabled: false
|
||||
intervalSeconds: 200
|
||||
restApiVersion: '2022-11-28'
|
||||
obs:
|
||||
authorizedOrigins: 'https://api.opensuse.org'
|
||||
weblate:
|
||||
authorizedOrigins: 'https://hosted.weblate.org'
|
||||
trace: false
|
||||
|
||||
cacheHeaders:
|
||||
defaultCacheLengthSeconds: 120
|
||||
|
||||
handleInternalErrors: true
|
||||
|
||||
fetchLimit: '10MB'
|
||||
userAgentBase: 'shields (self-hosted)'
|
||||
|
||||
requestTimeoutSeconds: 120
|
||||
requestTimeoutMaxAgeSeconds: 30
|
||||
|
||||
requireCloudflare: false
|
||||
|
||||
private: {}
|
||||
|
||||
18
core/badge-urls/make-badge-url.d.ts
vendored
18
core/badge-urls/make-badge-url.d.ts
vendored
@@ -14,24 +14,6 @@ export function badgeUrlFromPath({
|
||||
longCache?: boolean
|
||||
}): string
|
||||
|
||||
export function badgeUrlFromPattern({
|
||||
baseUrl,
|
||||
pattern,
|
||||
namedParams,
|
||||
queryParams,
|
||||
style,
|
||||
format,
|
||||
longCache,
|
||||
}: {
|
||||
baseUrl?: string
|
||||
pattern: string
|
||||
namedParams: { [k: string]: string }
|
||||
queryParams: { [k: string]: string | number | boolean }
|
||||
style?: string
|
||||
format?: string
|
||||
longCache?: boolean
|
||||
}): string
|
||||
|
||||
export function encodeField(s: string): string
|
||||
|
||||
export function staticBadgeUrl({
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
// Avoid "Attempted import error: 'URL' is not exported from 'url'" in frontend.
|
||||
import url from 'url'
|
||||
import queryString from 'query-string'
|
||||
import { compile } from 'path-to-regexp'
|
||||
|
||||
function badgeUrlFromPath({
|
||||
baseUrl = '',
|
||||
@@ -23,33 +22,6 @@ function badgeUrlFromPath({
|
||||
return `${baseUrl}${path}${outExt}${suffix}`
|
||||
}
|
||||
|
||||
function badgeUrlFromPattern({
|
||||
baseUrl = '',
|
||||
pattern,
|
||||
namedParams,
|
||||
queryParams,
|
||||
style,
|
||||
format = '',
|
||||
longCache = false,
|
||||
}) {
|
||||
const toPath = compile(pattern, {
|
||||
strict: true,
|
||||
sensitive: true,
|
||||
encode: encodeURIComponent,
|
||||
})
|
||||
|
||||
const path = toPath(namedParams)
|
||||
|
||||
return badgeUrlFromPath({
|
||||
baseUrl,
|
||||
path,
|
||||
queryParams,
|
||||
style,
|
||||
format,
|
||||
longCache,
|
||||
})
|
||||
}
|
||||
|
||||
function encodeField(s) {
|
||||
return encodeURIComponent(s.replace(/-/g, '--').replace(/_/g, '__'))
|
||||
}
|
||||
@@ -154,7 +126,6 @@ function rasterRedirectUrl({ rasterUrl }, badgeUrl) {
|
||||
|
||||
export {
|
||||
badgeUrlFromPath,
|
||||
badgeUrlFromPattern,
|
||||
encodeField,
|
||||
staticBadgeUrl,
|
||||
queryStringStaticBadgeUrl,
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { test, given } from 'sazerac'
|
||||
import {
|
||||
badgeUrlFromPath,
|
||||
badgeUrlFromPattern,
|
||||
encodeField,
|
||||
staticBadgeUrl,
|
||||
queryStringStaticBadgeUrl,
|
||||
@@ -20,18 +19,6 @@ describe('Badge URL generation functions', function () {
|
||||
)
|
||||
})
|
||||
|
||||
test(badgeUrlFromPattern, () => {
|
||||
given({
|
||||
baseUrl: 'http://example.com',
|
||||
pattern: '/npm/v/:packageName',
|
||||
namedParams: { packageName: 'gh-badges' },
|
||||
style: 'flat-square',
|
||||
longCache: true,
|
||||
}).expect(
|
||||
'http://example.com/npm/v/gh-badges?cacheSeconds=2592000&style=flat-square'
|
||||
)
|
||||
})
|
||||
|
||||
test(encodeField, () => {
|
||||
given('foo').expect('foo')
|
||||
given('').expect('')
|
||||
|
||||
@@ -221,8 +221,14 @@ class BaseService {
|
||||
const logTrace = (...args) => trace.logTrace('fetch', ...args)
|
||||
let logUrl = url
|
||||
const logOptions = Object.assign({}, options)
|
||||
if ('searchParams' in options) {
|
||||
const params = new URLSearchParams(options.searchParams)
|
||||
if ('searchParams' in options && options.searchParams != null) {
|
||||
const params = new URLSearchParams(
|
||||
Object.fromEntries(
|
||||
Object.entries(options.searchParams).filter(
|
||||
([k, v]) => v !== undefined
|
||||
)
|
||||
)
|
||||
)
|
||||
logUrl = `${url}?${params.toString()}`
|
||||
delete logOptions.searchParams
|
||||
}
|
||||
|
||||
@@ -440,14 +440,21 @@ describe('BaseService', function () {
|
||||
)
|
||||
|
||||
const url = 'some-url'
|
||||
const options = { headers: { Cookie: 'some-cookie' } }
|
||||
const options = {
|
||||
headers: { Cookie: 'some-cookie' },
|
||||
searchParams: { param1: 'foobar', param2: undefined },
|
||||
}
|
||||
await serviceInstance._request({ url, options })
|
||||
|
||||
expect(trace.logTrace).to.be.calledWithMatch(
|
||||
'fetch',
|
||||
sinon.match.string,
|
||||
'Request',
|
||||
`${url}\n${JSON.stringify(options, null, 2)}`
|
||||
`${url}?param1=foobar\n${JSON.stringify(
|
||||
{ headers: options.headers },
|
||||
null,
|
||||
2
|
||||
)}`
|
||||
)
|
||||
expect(trace.logTrace).to.be.calledWithMatch(
|
||||
'fetch',
|
||||
|
||||
@@ -16,16 +16,15 @@ import toArray from './to-array.js'
|
||||
//
|
||||
// Logos are resolved in this manner:
|
||||
//
|
||||
// 1. When `?logo=` contains the name of one of the Shields logos, or contains
|
||||
// base64-encoded SVG, that logo is used. In the case of a named logo, when
|
||||
// a `&logoColor=` is specified, that color is used. Otherwise the default
|
||||
// color is used. `logoColor` will not be applied to a custom
|
||||
// (base64-encoded) logo; if a custom color is desired the logo should be
|
||||
// recolored prior to making the request. The appearance of the logo can be
|
||||
// customized using `logoWidth`, and in the case of the popout badge,
|
||||
// `logoPosition`. When `?logo=` is specified, any logo-related parameters
|
||||
// specified dynamically by the service, or by default in the service, are
|
||||
// ignored.
|
||||
// 1. When `?logo=` contains a named logo or the name of one of the Shields
|
||||
// logos or contains base64-encoded SVG, that logo is used. When a
|
||||
// `&logoColor=` is specified, that color is used (except for the
|
||||
// base64-encoded logos). Otherwise the default color is used. If the color
|
||||
// is specified for a multicolor Shield logo, the named logo will be used and
|
||||
// colored. The appearance of the logo can be customized using `logoWidth`,
|
||||
// and in the case of the popout badge, `logoPosition`. When `?logo=` is
|
||||
// specified, any logo-related parameters specified dynamically by the
|
||||
// service, or by default in the service, are ignored.
|
||||
// 2. The second precedence is the dynamic logo returned by a service. This is
|
||||
// used only by the Endpoint badge. The `logoColor` can be overridden by the
|
||||
// query string.
|
||||
|
||||
@@ -153,10 +153,18 @@ describe('coalesceBadge', function () {
|
||||
).and.not.to.be.empty
|
||||
})
|
||||
|
||||
it('applies the named logo with color', function () {
|
||||
it('applies the named monochrome logo with color', function () {
|
||||
expect(
|
||||
coalesceBadge({}, { namedLogo: 'dependabot', logoColor: 'blue' }, {})
|
||||
.logo
|
||||
).to.equal(getShieldsIcon({ name: 'dependabot', color: 'blue' })).and.not
|
||||
.to.be.empty
|
||||
})
|
||||
|
||||
it('applies the named multicolored logo with color', function () {
|
||||
expect(
|
||||
coalesceBadge({}, { namedLogo: 'npm', logoColor: 'blue' }, {}).logo
|
||||
).to.equal(getShieldsIcon({ name: 'npm', color: 'blue' })).and.not.to.be
|
||||
).to.equal(getSimpleIcon({ name: 'npm', color: 'blue' })).and.not.to.be
|
||||
.empty
|
||||
})
|
||||
|
||||
@@ -166,15 +174,25 @@ describe('coalesceBadge', function () {
|
||||
).to.equal(getShieldsIcon({ name: 'npm' })).and.not.be.empty
|
||||
})
|
||||
|
||||
it('overrides the logo with a color', function () {
|
||||
it('overrides the monochrome logo with a color', function () {
|
||||
expect(
|
||||
coalesceBadge(
|
||||
{ logo: 'dependabot', logoColor: 'blue' },
|
||||
{ namedLogo: 'appveyor' },
|
||||
{}
|
||||
).logo
|
||||
).to.equal(getShieldsIcon({ name: 'dependabot', color: 'blue' })).and.not
|
||||
.be.empty
|
||||
})
|
||||
|
||||
it('overrides multicolored logo with a color', function () {
|
||||
expect(
|
||||
coalesceBadge(
|
||||
{ logo: 'npm', logoColor: 'blue' },
|
||||
{ namedLogo: 'appveyor' },
|
||||
{}
|
||||
).logo
|
||||
).to.equal(getShieldsIcon({ name: 'npm', color: 'blue' })).and.not.be
|
||||
.empty
|
||||
).to.equal(getSimpleIcon({ name: 'npm', color: 'blue' })).and.not.be.empty
|
||||
})
|
||||
|
||||
it("when the logo is overridden, it ignores the service's logo color, position, and width", function () {
|
||||
@@ -192,15 +210,25 @@ describe('coalesceBadge', function () {
|
||||
).to.equal(getShieldsIcon({ name: 'npm' })).and.not.be.empty
|
||||
})
|
||||
|
||||
it("overrides the service logo's color", function () {
|
||||
it("overrides the service monochome logo's color", function () {
|
||||
expect(
|
||||
coalesceBadge(
|
||||
{ logoColor: 'blue' },
|
||||
{ namedLogo: 'dependabot', logoColor: 'red' },
|
||||
{}
|
||||
).logo
|
||||
).to.equal(getShieldsIcon({ name: 'dependabot', color: 'blue' })).and.not
|
||||
.be.empty
|
||||
})
|
||||
|
||||
it("overrides the service multicolored logo's color", function () {
|
||||
expect(
|
||||
coalesceBadge(
|
||||
{ logoColor: 'blue' },
|
||||
{ namedLogo: 'npm', logoColor: 'red' },
|
||||
{}
|
||||
).logo
|
||||
).to.equal(getShieldsIcon({ name: 'npm', color: 'blue' })).and.not.be
|
||||
.empty
|
||||
).to.equal(getSimpleIcon({ name: 'npm', color: 'blue' })).and.not.be.empty
|
||||
})
|
||||
|
||||
// https://github.com/badges/shields/issues/2998
|
||||
|
||||
@@ -11,7 +11,6 @@ import originalJoi from 'joi'
|
||||
import makeBadge from '../../badge-maker/lib/make-badge.js'
|
||||
import GithubConstellation from '../../services/github/github-constellation.js'
|
||||
import LibrariesIoConstellation from '../../services/librariesio/librariesio-constellation.js'
|
||||
import { setRoutes } from '../../services/suggest.js'
|
||||
import { loadServiceClasses } from '../base-service/loader.js'
|
||||
import { makeSend } from '../base-service/legacy-result-sender.js'
|
||||
import { handleRequest } from '../base-service/legacy-request-handler.js'
|
||||
@@ -113,6 +112,9 @@ const publicConfigSchema = Joi.object({
|
||||
redirectUrl: optionalUrl,
|
||||
rasterUrl: optionalUrl,
|
||||
cors: {
|
||||
// This doesn't actually do anything
|
||||
// TODO: maybe remove in future?
|
||||
// https://github.com/badges/shields/pull/8311#discussion_r945337530
|
||||
allowedOrigin: Joi.array().items(optionalUrl).required(),
|
||||
},
|
||||
services: Joi.object({
|
||||
@@ -124,6 +126,7 @@ const publicConfigSchema = Joi.object({
|
||||
enabled: Joi.boolean().required(),
|
||||
intervalSeconds: Joi.number().integer().min(1).required(),
|
||||
},
|
||||
restApiVersion: Joi.date().raw().required(),
|
||||
},
|
||||
gitlab: defaultService,
|
||||
jira: defaultService,
|
||||
@@ -184,6 +187,7 @@ const privateConfigSchema = Joi.object({
|
||||
sl_insight_userUuid: Joi.string(),
|
||||
sl_insight_apiToken: Joi.string(),
|
||||
sonarqube_token: Joi.string(),
|
||||
stackapps_api_key: Joi.string(),
|
||||
teamcity_user: Joi.string(),
|
||||
teamcity_pass: Joi.string(),
|
||||
twitch_client_id: Joi.string(),
|
||||
@@ -488,7 +492,6 @@ class Server {
|
||||
const {
|
||||
bind: { port, address: hostname },
|
||||
ssl: { isSecure: secure, cert, key },
|
||||
cors: { allowedOrigin },
|
||||
requireCloudflare,
|
||||
} = this.config.public
|
||||
|
||||
@@ -521,9 +524,6 @@ class Server {
|
||||
}
|
||||
}
|
||||
|
||||
const { apiProvider: githubApiProvider } = this.githubConstellation
|
||||
setRoutes(allowedOrigin, githubApiProvider, camp)
|
||||
|
||||
// https://github.com/badges/shields/issues/3273
|
||||
camp.handle((req, res, next) => {
|
||||
res.setHeader('Access-Control-Allow-Origin', '*')
|
||||
|
||||
@@ -60,12 +60,14 @@ describe('The server', function () {
|
||||
})
|
||||
|
||||
it('should serve badges with custom maxAge', async function () {
|
||||
const { headers } = await got(`${baseUrl}npm/l/express`)
|
||||
expect(headers['cache-control']).to.equal('max-age=3600, s-maxage=3600')
|
||||
const { headers } = await got(`${baseUrl}badge/foo-bar-blue`)
|
||||
expect(headers['cache-control']).to.equal('max-age=86400, s-maxage=86400')
|
||||
})
|
||||
|
||||
it('should return cors header for the request', async function () {
|
||||
const { statusCode, headers } = await got(`${baseUrl}npm/v/express.svg`)
|
||||
const { statusCode, headers } = await got(
|
||||
`${baseUrl}badge/foo-bar-blue.svg`
|
||||
)
|
||||
expect(statusCode).to.equal(200)
|
||||
expect(headers['access-control-allow-origin']).to.equal('*')
|
||||
})
|
||||
@@ -84,12 +86,15 @@ describe('The server', function () {
|
||||
})
|
||||
|
||||
it('should redirect modern PNG badges as configured', async function () {
|
||||
const { statusCode, headers } = await got(`${baseUrl}npm/v/express.png`, {
|
||||
followRedirect: false,
|
||||
})
|
||||
const { statusCode, headers } = await got(
|
||||
`${baseUrl}badge/foo-bar-blue.png`,
|
||||
{
|
||||
followRedirect: false,
|
||||
}
|
||||
)
|
||||
expect(statusCode).to.equal(301)
|
||||
expect(headers.location).to.equal(
|
||||
'http://raster.example.test/npm/v/express.png'
|
||||
'http://raster.example.test/badge/foo-bar-blue.png'
|
||||
)
|
||||
})
|
||||
|
||||
@@ -197,9 +202,12 @@ describe('The server', function () {
|
||||
})
|
||||
|
||||
it('should return the 410 badge for obsolete formats', async function () {
|
||||
const { statusCode, body } = await got(`${baseUrl}npm/v/express.jpg`, {
|
||||
throwHttpErrors: false,
|
||||
})
|
||||
const { statusCode, body } = await got(
|
||||
`${baseUrl}badge/foo-bar-blue.jpg`,
|
||||
{
|
||||
throwHttpErrors: false,
|
||||
}
|
||||
)
|
||||
// TODO It would be nice if this were 404 or 410.
|
||||
expect(statusCode).to.equal(200)
|
||||
expect(body)
|
||||
@@ -207,12 +215,6 @@ describe('The server', function () {
|
||||
.and.to.include('410')
|
||||
.and.to.include('jpg no longer available')
|
||||
})
|
||||
|
||||
it('should return cors header for the request', async function () {
|
||||
const { statusCode, headers } = await got(`${baseUrl}npm/v/express.svg`)
|
||||
expect(statusCode).to.equal(200)
|
||||
expect(headers['access-control-allow-origin']).to.equal('*')
|
||||
})
|
||||
})
|
||||
|
||||
context('`requireCloudflare` is enabled', function () {
|
||||
|
||||
@@ -4,7 +4,7 @@ registerCommand()
|
||||
|
||||
describe('Main page', function () {
|
||||
const backendUrl = Cypress.env('backend_url')
|
||||
const SEARCH_INPUT = 'input[placeholder="search / project URL"]'
|
||||
const SEARCH_INPUT = 'input[placeholder="search"]'
|
||||
|
||||
function expectBadgeExample(title, previewUrl, pattern) {
|
||||
cy.contains('tr', `${title}:`).find('code').should('have.text', pattern)
|
||||
@@ -36,35 +36,15 @@ describe('Main page', function () {
|
||||
)
|
||||
})
|
||||
|
||||
it('Suggest badges', function () {
|
||||
const badgeUrl = `${backendUrl}/github/issues/badges/shields`
|
||||
it('Customizate badges', function () {
|
||||
visitAndWait('/')
|
||||
|
||||
cy.get(SEARCH_INPUT).type('https://github.com/badges/shields')
|
||||
cy.contains('Suggest badges').click()
|
||||
cy.get(SEARCH_INPUT).type('issues')
|
||||
|
||||
expectBadgeExample('GitHub issues', badgeUrl, badgeUrl)
|
||||
})
|
||||
|
||||
it('Customization form is filled with suggested badge details', function () {
|
||||
const badgeUrl = `${backendUrl}/github/issues/badges/shields`
|
||||
visitAndWait('/')
|
||||
cy.get(SEARCH_INPUT).type('https://github.com/badges/shields')
|
||||
cy.contains('Suggest badges').click()
|
||||
|
||||
cy.contains(badgeUrl).click()
|
||||
|
||||
cy.get('input[name="user"]').should('have.value', 'badges')
|
||||
cy.get('input[name="repo"]').should('have.value', 'shields')
|
||||
})
|
||||
|
||||
it('Customizate suggested badge', function () {
|
||||
const badgeUrl = `${backendUrl}/github/issues/badges/shields`
|
||||
visitAndWait('/')
|
||||
cy.get(SEARCH_INPUT).type('https://github.com/badges/shields')
|
||||
cy.contains('Suggest badges').click()
|
||||
cy.contains(badgeUrl).click()
|
||||
cy.contains('/github/issues/:user/:repo').click()
|
||||
|
||||
cy.get('input[name="user"]').type('badges')
|
||||
cy.get('input[name="repo"]').type('shields')
|
||||
cy.get('table input[name="color"]').type('orange')
|
||||
|
||||
cy.get(`img[src='${backendUrl}/github/issues/badges/shields?color=orange']`)
|
||||
|
||||
8
doc/authentication.md
Normal file
8
doc/authentication.md
Normal file
@@ -0,0 +1,8 @@
|
||||
# Badges Requiring Authentication
|
||||
|
||||
There are two patterns for how shields.io can interact with APIs that require auth:
|
||||
|
||||
1. We can store one token at the service level which allows us to read public data for everyone's projects, or lift a rate limit. If you are looking for information on configuring credentials for a self-hosted instance see https://github.com/badges/shields/blob/master/doc/server-secrets.md
|
||||
2. If every user needs to provide their own token, that has to be a token which can be passed to us as a query param in the badge URL. This means it must be possible to generate a key or token that can be exposed in a public github README public with no negative consequences. (i.e: that key or token only exposes public metrics).
|
||||
|
||||
If every user would need to supply their own token for some particular service and it is only possible to generate a key or token which allows access to sensitive data or allows write access to resources, we can't provide an integration for this service.
|
||||
@@ -20,8 +20,6 @@ The Shields codebase is divided into several parts:
|
||||
1. `*.js` in the root of [`services`][services]
|
||||
7. The services themselves (about 80% of the code)
|
||||
1. `*.js` in the folders of [`services`][services]
|
||||
8. The badge suggestion endpoint (Note: it's tested as if it’s a service.)
|
||||
1. [`lib/suggest.js`][suggest]
|
||||
|
||||
[frontend]: https://github.com/badges/shields/tree/master/frontend
|
||||
[badge-maker]: https://github.com/badges/shields/tree/master/badge-maker
|
||||
@@ -29,7 +27,6 @@ The Shields codebase is divided into several parts:
|
||||
[server]: https://github.com/badges/shields/tree/master/core/server
|
||||
[token-pooling]: https://github.com/badges/shields/tree/master/core/token-pooling
|
||||
[services]: https://github.com/badges/shields/tree/master/services
|
||||
[suggest]: https://github.com/badges/shields/tree/master/lib/suggest.js
|
||||
|
||||
The tests are also divided into several parts:
|
||||
|
||||
|
||||
12
doc/logos.md
12
doc/logos.md
@@ -22,6 +22,18 @@ Any custom logo can be passed in a URL parameter by base64 encoding it. e.g:
|
||||
|
||||
 - https://img.shields.io/badge/play-station-blue.svg?logo=data:image/svg%2bxml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZlcnNpb249IjEiIHdpZHRoPSI2MDAiIGhlaWdodD0iNjAwIj48cGF0aCBkPSJNMTI5IDExMWMtNTUgNC05MyA2Ni05MyA3OEwwIDM5OGMtMiA3MCAzNiA5MiA2OSA5MWgxYzc5IDAgODctNTcgMTMwLTEyOGgyMDFjNDMgNzEgNTAgMTI4IDEyOSAxMjhoMWMzMyAxIDcxLTIxIDY5LTkxbC0zNi0yMDljMC0xMi00MC03OC05OC03OGgtMTBjLTYzIDAtOTIgMzUtOTIgNDJIMjM2YzAtNy0yOS00Mi05Mi00MmgtMTV6IiBmaWxsPSIjZmZmIi8+PC9zdmc+
|
||||
|
||||
### logoColor parameter
|
||||
|
||||
The `logoColor` param can be used to set the color of the logo. Hex, rgb, rgba, hsl, hsla and css named colors can all be used. For SimpleIcons named logos (which are monochrome), the color will be applied to the SimpleIcons logo.
|
||||
|
||||
-  - https://img.shields.io/badge/logo-javascript-blue?logo=javascript
|
||||
-  - https://img.shields.io/badge/logo-javascript-blue?logo=javascript&logoColor=f5f5f5
|
||||
|
||||
In the case where Shields hosts a custom multi-colored logo, if the `logoColor` param is passed, the corresponding SimpleIcons logo will be substituted and colored.
|
||||
|
||||
-  - https://img.shields.io/badge/logo-gitlab-blue?logo=gitlab
|
||||
-  - https://img.shields.io/badge/logo-gitlab-blue?logo=gitlab&logoColor=white
|
||||
|
||||
## Contributing Logos
|
||||
|
||||
Our preferred way to consume icons is via the SimpleIcons logo. As a first port of call, we encourage you to contribute logos to [the SimpleIcons project][simple-icons github]. Please review their [guidance](https://github.com/simple-icons/simple-icons/blob/develop/CONTRIBUTING.md) before contributing.
|
||||
|
||||
@@ -153,15 +153,6 @@ Then copy the contents of the `build/` folder to your static hosting / CDN.
|
||||
|
||||
There are also a couple settings you should configure on the server.
|
||||
|
||||
If you want to use server suggestions, you should also set `ALLOWED_ORIGIN`:
|
||||
|
||||
```sh
|
||||
ALLOWED_ORIGIN=http://my-custom-shields.s3.amazonaws.com,https://my-custom-shields.s3.amazonaws.com
|
||||
```
|
||||
|
||||
This should be a comma-separated list of allowed origin headers. They should
|
||||
not have paths or trailing slashes.
|
||||
|
||||
To help out users, you can make the Shields server redirect the server root.
|
||||
Set the `REDIRECT_URI` environment variable:
|
||||
|
||||
|
||||
@@ -244,6 +244,17 @@ Create an account, sign in and obtain a uuid and token from your
|
||||
to give your self-hosted Shields installation access to a
|
||||
private SonarQube instance or private project on a public instance.
|
||||
|
||||
### StackApps (for StackExchange and StackOverflow)
|
||||
|
||||
- `STACKAPPS_API_KEY`: (yml: `private.stackapps_api_key`)
|
||||
|
||||
Anonymous requests to the stackexchange API are limited to 300 calls per day.
|
||||
To increase your quota to 10,000 calls per day, create an account at
|
||||
[StackApps](https://stackapps.com/) and
|
||||
[register an OAuth app](https://stackapps.com/apps/oauth/register). Having registered
|
||||
an OAuth app, you'll be granted a key which can be used to increase your request quota.
|
||||
It is not necessary to performa full OAuth Flow to gain an access token.
|
||||
|
||||
### TeamCity
|
||||
|
||||
- `TEAMCITY_ORIGINS` (yml: `public.services.teamcity.authorizedOrigins`)
|
||||
|
||||
12
doc/static-badges.md
Normal file
12
doc/static-badges.md
Normal file
@@ -0,0 +1,12 @@
|
||||
# Static Badges
|
||||
|
||||
It is possible to use shields.io to make a wide variety of badges displaying static text and/or logos. For example:
|
||||
|
||||
-  - https://img.shields.io/badge/any%20text-you%20like-blue
|
||||
-  - https://img.shields.io/badge/just%20the%20message-8A2BE2
|
||||
-  - https://img.shields.io/badge/%27for%20the%20badge%27%20style-20B2AA?style=for-the-badge
|
||||
-  - https://img.shields.io/badge/with%20a%20logo-grey?style=for-the-badge&logo=javascript
|
||||
|
||||
Full documentation of styles and parameters: https://shields.io/#styles
|
||||
|
||||
More documentation on logos: https://github.com/badges/shields/blob/master/doc/logos.md
|
||||
@@ -2,13 +2,11 @@ import React from 'react'
|
||||
import styled from 'styled-components'
|
||||
import {
|
||||
badgeUrlFromPath,
|
||||
badgeUrlFromPattern,
|
||||
staticBadgeUrl,
|
||||
} from '../../core/badge-urls/make-badge-url'
|
||||
import { removeRegexpFromPattern } from '../lib/pattern-helpers'
|
||||
import {
|
||||
Example as ExampleData,
|
||||
Suggestion,
|
||||
RenderableExample,
|
||||
} from '../lib/service-definitions'
|
||||
import { Badge } from './common'
|
||||
@@ -36,49 +34,34 @@ function Example({
|
||||
baseUrl,
|
||||
onClick,
|
||||
exampleData,
|
||||
isBadgeSuggestion,
|
||||
}: {
|
||||
baseUrl?: string
|
||||
onClick: (example: RenderableExample, isSuggestion: boolean) => void
|
||||
onClick: (example: RenderableExample) => void
|
||||
exampleData: RenderableExample
|
||||
isBadgeSuggestion: boolean
|
||||
}): JSX.Element {
|
||||
const handleClick = React.useCallback(
|
||||
function (): void {
|
||||
onClick(exampleData, isBadgeSuggestion)
|
||||
onClick(exampleData)
|
||||
},
|
||||
[exampleData, isBadgeSuggestion, onClick]
|
||||
[exampleData, onClick]
|
||||
)
|
||||
|
||||
let exampleUrl, previewUrl
|
||||
if (isBadgeSuggestion) {
|
||||
const {
|
||||
example: { pattern, namedParams, queryParams },
|
||||
} = exampleData as Suggestion
|
||||
exampleUrl = previewUrl = badgeUrlFromPattern({
|
||||
baseUrl,
|
||||
pattern,
|
||||
namedParams,
|
||||
queryParams,
|
||||
})
|
||||
} else {
|
||||
const {
|
||||
example: { pattern, queryParams },
|
||||
preview: { label, message, color, style, namedLogo },
|
||||
} = exampleData as ExampleData
|
||||
previewUrl = staticBadgeUrl({
|
||||
baseUrl,
|
||||
label: label || '',
|
||||
message,
|
||||
color,
|
||||
style,
|
||||
namedLogo,
|
||||
})
|
||||
exampleUrl = badgeUrlFromPath({
|
||||
path: removeRegexpFromPattern(pattern),
|
||||
queryParams,
|
||||
})
|
||||
}
|
||||
const {
|
||||
example: { pattern, queryParams },
|
||||
preview: { label, message, color, style, namedLogo },
|
||||
} = exampleData as ExampleData
|
||||
const previewUrl = staticBadgeUrl({
|
||||
baseUrl,
|
||||
label: label || '',
|
||||
message,
|
||||
color,
|
||||
style,
|
||||
namedLogo,
|
||||
})
|
||||
const exampleUrl = badgeUrlFromPath({
|
||||
path: removeRegexpFromPattern(pattern),
|
||||
queryParams,
|
||||
})
|
||||
|
||||
const { title } = exampleData
|
||||
return (
|
||||
@@ -101,14 +84,12 @@ function Example({
|
||||
|
||||
export function BadgeExamples({
|
||||
examples,
|
||||
areBadgeSuggestions,
|
||||
baseUrl,
|
||||
onClick,
|
||||
}: {
|
||||
examples: RenderableExample[]
|
||||
areBadgeSuggestions: boolean
|
||||
baseUrl?: string
|
||||
onClick: (exampleData: RenderableExample, isSuggestion: boolean) => void
|
||||
onClick: (exampleData: RenderableExample) => void
|
||||
}): JSX.Element {
|
||||
return (
|
||||
<ExampleTable>
|
||||
@@ -117,7 +98,6 @@ export function BadgeExamples({
|
||||
<Example
|
||||
baseUrl={baseUrl}
|
||||
exampleData={exampleData}
|
||||
isBadgeSuggestion={areBadgeSuggestions}
|
||||
key={`${exampleData.title} ${exampleData.example.pattern}`}
|
||||
onClick={onClick}
|
||||
/>
|
||||
|
||||
@@ -18,8 +18,6 @@ export default function Customizer({
|
||||
exampleNamedParams,
|
||||
exampleQueryParams,
|
||||
initialStyle,
|
||||
isPrefilled,
|
||||
link = '',
|
||||
}: {
|
||||
baseUrl: string
|
||||
title: string
|
||||
@@ -27,8 +25,6 @@ export default function Customizer({
|
||||
exampleNamedParams: { [k: string]: string }
|
||||
exampleQueryParams: { [k: string]: string }
|
||||
initialStyle?: string
|
||||
isPrefilled: boolean
|
||||
link?: string
|
||||
}): JSX.Element {
|
||||
// https://github.com/DefinitelyTyped/DefinitelyTyped/issues/35572
|
||||
// https://github.com/DefinitelyTyped/DefinitelyTyped/issues/28884#issuecomment-471341041
|
||||
@@ -75,7 +71,6 @@ export default function Customizer({
|
||||
const builtBadgeUrl = generateBuiltBadgeUrl()
|
||||
const markup = generateMarkup({
|
||||
badgeUrl: builtBadgeUrl,
|
||||
link,
|
||||
title,
|
||||
markupFormat,
|
||||
})
|
||||
@@ -93,7 +88,7 @@ export default function Customizer({
|
||||
indicatorRef.current.trigger()
|
||||
}
|
||||
},
|
||||
[generateBuiltBadgeUrl, link, title, setMessage, setMarkup]
|
||||
[generateBuiltBadgeUrl, title, setMessage, setMarkup]
|
||||
)
|
||||
|
||||
function renderMarkupAndLivePreview(): JSX.Element {
|
||||
@@ -147,7 +142,6 @@ export default function Customizer({
|
||||
<form action="">
|
||||
<PathBuilder
|
||||
exampleParams={exampleNamedParams}
|
||||
isPrefilled={isPrefilled}
|
||||
onChange={handlePathChange}
|
||||
pattern={pattern}
|
||||
/>
|
||||
|
||||
@@ -112,7 +112,6 @@ export default function PathBuilder({
|
||||
pattern,
|
||||
exampleParams,
|
||||
onChange,
|
||||
isPrefilled,
|
||||
}: {
|
||||
pattern: string
|
||||
exampleParams: { [k: string]: string }
|
||||
@@ -123,22 +122,19 @@ export default function PathBuilder({
|
||||
path: string
|
||||
isComplete: boolean
|
||||
}) => void
|
||||
isPrefilled: boolean
|
||||
}): JSX.Element {
|
||||
const [tokens] = useState(() => parse(pattern))
|
||||
const [namedParams, setNamedParams] = useState(() =>
|
||||
isPrefilled
|
||||
? exampleParams
|
||||
: // `pathToRegexp.parse()` returns a mixed array of strings for literals
|
||||
// and objects for parameters. Filter out the literals and work with the
|
||||
// objects.
|
||||
tokens
|
||||
.filter(t => typeof t !== 'string')
|
||||
.map(t => t as Key)
|
||||
.reduce((accum, { name }) => {
|
||||
accum[name] = ''
|
||||
return accum
|
||||
}, {} as { [k: string]: string })
|
||||
// `pathToRegexp.parse()` returns a mixed array of strings for literals
|
||||
// and objects for parameters. Filter out the literals and work with the
|
||||
// objects.
|
||||
tokens
|
||||
.filter(t => typeof t !== 'string')
|
||||
.map(t => t as Key)
|
||||
.reduce((accum, { name }) => {
|
||||
accum[name] = ''
|
||||
return accum
|
||||
}, {} as { [k: string]: string })
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
@@ -195,11 +191,11 @@ export default function PathBuilder({
|
||||
onChange={handleTokenChange}
|
||||
value={value}
|
||||
>
|
||||
<option disabled={isPrefilled} key="empty" value="">
|
||||
<option key="empty" value="">
|
||||
{' '}
|
||||
</option>
|
||||
{options.map(option => (
|
||||
<option disabled={isPrefilled} key={option} value={option}>
|
||||
<option key={option} value={option}>
|
||||
{option}
|
||||
</option>
|
||||
))}
|
||||
@@ -208,7 +204,6 @@ export default function PathBuilder({
|
||||
} else {
|
||||
return (
|
||||
<NamedParamInput
|
||||
disabled={isPrefilled}
|
||||
name={name}
|
||||
onChange={handleTokenChange}
|
||||
type="text"
|
||||
@@ -239,11 +234,9 @@ export default function PathBuilder({
|
||||
{optional ? <BuilderLabel>(optional)</BuilderLabel> : null}
|
||||
</NamedParamLabelContainer>
|
||||
{renderNamedParamInput(token)}
|
||||
{!isPrefilled && (
|
||||
<NamedParamCaption>
|
||||
{namedParamIndex === 0 ? `e.g. ${exampleValue}` : exampleValue}
|
||||
</NamedParamCaption>
|
||||
)}
|
||||
<NamedParamCaption>
|
||||
{namedParamIndex === 0 ? `e.g. ${exampleValue}` : exampleValue}
|
||||
</NamedParamCaption>
|
||||
</PathBuilderColumn>
|
||||
</React.Fragment>
|
||||
)
|
||||
|
||||
@@ -6,7 +6,7 @@ import React, {
|
||||
} from 'react'
|
||||
import styled from 'styled-components'
|
||||
import humanizeString from 'humanize-string'
|
||||
import { stringify as stringifyQueryString } from 'query-string'
|
||||
import qs from 'query-string'
|
||||
import { advertisedStyles } from '../../lib/supported-features'
|
||||
import { noAutocorrect, StyledInput } from '../common'
|
||||
import {
|
||||
@@ -94,7 +94,7 @@ function getQueryString({
|
||||
}
|
||||
})
|
||||
|
||||
const queryString = stringifyQueryString(outQuery)
|
||||
const queryString = qs.stringify(outQuery)
|
||||
|
||||
return { queryString, isComplete }
|
||||
}
|
||||
|
||||
@@ -14,7 +14,7 @@ import ServiceDefinitionSetHelper from '../lib/service-definitions/service-defin
|
||||
import { getBaseUrl } from '../constants'
|
||||
import Meta from './meta'
|
||||
import Header from './header'
|
||||
import SuggestionAndSearch from './suggestion-and-search'
|
||||
import Search from './search'
|
||||
import DonateBox from './donate'
|
||||
import { MarkupModal } from './markup-modal'
|
||||
import Usage from './usage'
|
||||
@@ -49,8 +49,6 @@ export default function Main({
|
||||
[k: string]: ServiceDefinition[]
|
||||
}>()
|
||||
const [selectedExample, setSelectedExample] = useState<RenderableExample>()
|
||||
const [selectedExampleIsSuggestion, setSelectedExampleIsSuggestion] =
|
||||
useState(false)
|
||||
const searchTimeout = useRef(0)
|
||||
const baseUrl = getBaseUrl()
|
||||
|
||||
@@ -92,14 +90,6 @@ export default function Main({
|
||||
[setSearchIsInProgress, performSearch]
|
||||
)
|
||||
|
||||
const exampleClicked = React.useCallback(
|
||||
function (example: RenderableExample, isSuggestion: boolean): void {
|
||||
setSelectedExample(example)
|
||||
setSelectedExampleIsSuggestion(isSuggestion)
|
||||
},
|
||||
[setSelectedExample, setSelectedExampleIsSuggestion]
|
||||
)
|
||||
|
||||
const dismissMarkupModal = React.useCallback(
|
||||
function (): void {
|
||||
setSelectedExample(undefined)
|
||||
@@ -123,7 +113,6 @@ export default function Main({
|
||||
<div>
|
||||
<CategoryHeading category={category} />
|
||||
<BadgeExamples
|
||||
areBadgeSuggestions={false}
|
||||
baseUrl={baseUrl}
|
||||
examples={flattened}
|
||||
onClick={setSelectedExample}
|
||||
@@ -182,15 +171,10 @@ export default function Main({
|
||||
<MarkupModal
|
||||
baseUrl={baseUrl}
|
||||
example={selectedExample}
|
||||
isBadgeSuggestion={selectedExampleIsSuggestion}
|
||||
onRequestClose={dismissMarkupModal}
|
||||
/>
|
||||
<section>
|
||||
<SuggestionAndSearch
|
||||
baseUrl={baseUrl}
|
||||
onBadgeClick={exampleClicked}
|
||||
queryChanged={searchQueryChanged}
|
||||
/>
|
||||
<Search queryChanged={searchQueryChanged} />
|
||||
<DonateBox />
|
||||
</section>
|
||||
{renderMain()}
|
||||
|
||||
@@ -11,12 +11,10 @@ const ContentContainer = styled(BaseFont)`
|
||||
|
||||
export function MarkupModal({
|
||||
example,
|
||||
isBadgeSuggestion,
|
||||
baseUrl,
|
||||
onRequestClose,
|
||||
}: {
|
||||
example: RenderableExample | undefined
|
||||
isBadgeSuggestion: boolean
|
||||
baseUrl: string
|
||||
onRequestClose: () => void
|
||||
}): JSX.Element {
|
||||
@@ -29,11 +27,7 @@ export function MarkupModal({
|
||||
>
|
||||
{example !== undefined && (
|
||||
<ContentContainer>
|
||||
<MarkupModalContent
|
||||
baseUrl={baseUrl}
|
||||
example={example}
|
||||
isBadgeSuggestion={isBadgeSuggestion}
|
||||
/>
|
||||
<MarkupModalContent baseUrl={baseUrl} example={example} />
|
||||
</ContentContainer>
|
||||
)}
|
||||
</Modal>
|
||||
|
||||
@@ -1,10 +1,6 @@
|
||||
import React from 'react'
|
||||
import styled from 'styled-components'
|
||||
import {
|
||||
Example,
|
||||
Suggestion,
|
||||
RenderableExample,
|
||||
} from '../../lib/service-definitions'
|
||||
import { Example, RenderableExample } from '../../lib/service-definitions'
|
||||
import { H3 } from '../common'
|
||||
import Customizer from '../customizer/customizer'
|
||||
|
||||
@@ -16,20 +12,12 @@ const Documentation = styled.div`
|
||||
|
||||
export function MarkupModalContent({
|
||||
example,
|
||||
isBadgeSuggestion,
|
||||
baseUrl,
|
||||
}: {
|
||||
example: RenderableExample
|
||||
isBadgeSuggestion: boolean
|
||||
baseUrl: string
|
||||
}): JSX.Element {
|
||||
let documentation: { __html: string } | undefined
|
||||
let link: string | undefined
|
||||
if (isBadgeSuggestion) {
|
||||
;({ link } = example as Suggestion)
|
||||
} else {
|
||||
;({ documentation } = example as Example)
|
||||
}
|
||||
const { documentation } = example as Example
|
||||
|
||||
const {
|
||||
title,
|
||||
@@ -48,8 +36,6 @@ export function MarkupModalContent({
|
||||
exampleNamedParams={namedParams}
|
||||
exampleQueryParams={queryParams}
|
||||
initialStyle={initialStyle}
|
||||
isPrefilled={isBadgeSuggestion}
|
||||
link={link}
|
||||
pattern={pattern}
|
||||
title={title}
|
||||
/>
|
||||
|
||||
37
frontend/components/search.tsx
Normal file
37
frontend/components/search.tsx
Normal file
@@ -0,0 +1,37 @@
|
||||
import React, { useRef, ChangeEvent } from 'react'
|
||||
import debounce from 'lodash.debounce'
|
||||
import { BlockInput } from './common'
|
||||
|
||||
export default function Search({
|
||||
queryChanged,
|
||||
}: {
|
||||
queryChanged: (query: string) => void
|
||||
}): JSX.Element {
|
||||
const queryChangedDebounced = useRef(
|
||||
debounce(queryChanged, 50, { leading: true })
|
||||
)
|
||||
|
||||
const onQueryChanged = React.useCallback(
|
||||
function ({
|
||||
target: { value: query },
|
||||
}: ChangeEvent<HTMLInputElement>): void {
|
||||
queryChangedDebounced.current(query)
|
||||
},
|
||||
[queryChangedDebounced]
|
||||
)
|
||||
|
||||
// TODO: Warning: A future version of React will block javascript: URLs as a security precaution
|
||||
// how else to do this?
|
||||
return (
|
||||
<section>
|
||||
<form action="javascript:void 0" autoComplete="off">
|
||||
<BlockInput
|
||||
autoComplete="off"
|
||||
autoFocus
|
||||
onChange={onQueryChanged}
|
||||
placeholder="search"
|
||||
/>
|
||||
</form>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -1,133 +0,0 @@
|
||||
import React, { useRef, useState, ChangeEvent } from 'react'
|
||||
import fetchPonyfill from 'fetch-ponyfill'
|
||||
import debounce from 'lodash.debounce'
|
||||
import { RenderableExample } from '../lib/service-definitions'
|
||||
import { BadgeExamples } from './badge-examples'
|
||||
import { BlockInput } from './common'
|
||||
|
||||
interface SuggestionItem {
|
||||
title: string
|
||||
link: string
|
||||
example: {
|
||||
pattern: string
|
||||
namedParams: { [k: string]: string }
|
||||
queryParams?: { [k: string]: string }
|
||||
}
|
||||
preview:
|
||||
| {
|
||||
style?: string
|
||||
}
|
||||
| undefined
|
||||
}
|
||||
|
||||
interface SuggestionResponse {
|
||||
suggestions: SuggestionItem[]
|
||||
}
|
||||
|
||||
export default function SuggestionAndSearch({
|
||||
queryChanged,
|
||||
onBadgeClick,
|
||||
baseUrl,
|
||||
}: {
|
||||
queryChanged: (query: string) => void
|
||||
onBadgeClick: (example: RenderableExample, isSuggestion: boolean) => void
|
||||
baseUrl: string
|
||||
}): JSX.Element {
|
||||
const queryChangedDebounced = useRef(
|
||||
debounce(queryChanged, 50, { leading: true })
|
||||
)
|
||||
const [isUrl, setIsUrl] = useState(false)
|
||||
const [inProgress, setInProgress] = useState(false)
|
||||
const [projectUrl, setProjectUrl] = useState<string>()
|
||||
const [suggestions, setSuggestions] = useState<SuggestionItem[]>([])
|
||||
|
||||
const onQueryChanged = React.useCallback(
|
||||
function ({
|
||||
target: { value: query },
|
||||
}: ChangeEvent<HTMLInputElement>): void {
|
||||
const isUrl = query.startsWith('https://') || query.startsWith('http://')
|
||||
setIsUrl(isUrl)
|
||||
setProjectUrl(isUrl ? query : undefined)
|
||||
|
||||
queryChangedDebounced.current(query)
|
||||
},
|
||||
[setIsUrl, setProjectUrl, queryChangedDebounced]
|
||||
)
|
||||
|
||||
const getSuggestions = React.useCallback(
|
||||
async function (): Promise<void> {
|
||||
if (!projectUrl) {
|
||||
setSuggestions([])
|
||||
return
|
||||
}
|
||||
|
||||
setInProgress(true)
|
||||
|
||||
const fetch = window.fetch || fetchPonyfill
|
||||
const res = await fetch(
|
||||
`${baseUrl}/$suggest/v1?url=${encodeURIComponent(projectUrl)}`
|
||||
)
|
||||
let suggestions = [] as SuggestionItem[]
|
||||
try {
|
||||
const json = (await res.json()) as SuggestionResponse
|
||||
// This doesn't validate the response. The default value here prevents
|
||||
// a crash if the server returns {"err":"Disallowed"}.
|
||||
suggestions = json.suggestions || []
|
||||
} catch (e) {
|
||||
suggestions = []
|
||||
}
|
||||
|
||||
setInProgress(false)
|
||||
setSuggestions(suggestions)
|
||||
},
|
||||
[setSuggestions, setInProgress, baseUrl, projectUrl]
|
||||
)
|
||||
|
||||
function renderSuggestions(): JSX.Element | null {
|
||||
if (suggestions.length === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
const transformed = suggestions.map(
|
||||
({ title, link, example, preview }) => ({
|
||||
title,
|
||||
link,
|
||||
example: {
|
||||
...example,
|
||||
queryParams: example.queryParams || {},
|
||||
},
|
||||
preview: preview || {},
|
||||
isBadgeSuggestion: true,
|
||||
})
|
||||
)
|
||||
|
||||
return (
|
||||
<BadgeExamples
|
||||
areBadgeSuggestions
|
||||
baseUrl={baseUrl}
|
||||
examples={transformed}
|
||||
onClick={onBadgeClick}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
// TODO: Warning: A future version of React will block javascript: URLs as a security precaution
|
||||
// how else to do this?
|
||||
return (
|
||||
<section>
|
||||
<form action="javascript:void 0" autoComplete="off">
|
||||
<BlockInput
|
||||
autoComplete="off"
|
||||
autoFocus
|
||||
onChange={onQueryChanged}
|
||||
placeholder="search / project URL"
|
||||
/>
|
||||
<br />
|
||||
<button disabled={inProgress} hidden={!isUrl} onClick={getSuggestions}>
|
||||
Suggest badges
|
||||
</button>
|
||||
</form>
|
||||
{renderSuggestions()}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -363,8 +363,9 @@ export default function Usage({ baseUrl }: { baseUrl: string }): JSX.Element {
|
||||
documentation={
|
||||
<span>
|
||||
Set the color of the logo (hex, rgb, rgba, hsl, hsla and css
|
||||
named colors supported). Supported for named logos but not for
|
||||
custom logos.
|
||||
named colors supported). Supported for named logos and Shields
|
||||
logos but not for custom logos. For multicolor Shields logos,
|
||||
the corresponding named logo will be used and colored.
|
||||
</span>
|
||||
}
|
||||
key="logoColor"
|
||||
|
||||
@@ -18,48 +18,30 @@ test(bareLink, () => {
|
||||
})
|
||||
|
||||
test(html, () => {
|
||||
given(
|
||||
'https://img.shields.io/badge',
|
||||
'https://example.com/example',
|
||||
'Example'
|
||||
).expect(
|
||||
'<a href="https://example.com/example"><img alt="Example" src="https://img.shields.io/badge"></a>'
|
||||
given('https://img.shields.io/badge', 'Example').expect(
|
||||
'<img alt="Example" src="https://img.shields.io/badge">'
|
||||
)
|
||||
given('https://img.shields.io/badge', undefined, undefined).expect(
|
||||
given('https://img.shields.io/badge', undefined).expect(
|
||||
'<img src="https://img.shields.io/badge">'
|
||||
)
|
||||
})
|
||||
|
||||
test(markdown, () => {
|
||||
given('https://img.shields.io/badge', undefined, 'Example').expect(
|
||||
given('https://img.shields.io/badge', 'Example').expect(
|
||||
''
|
||||
)
|
||||
given(
|
||||
'https://img.shields.io/badge',
|
||||
'https://example.com/example',
|
||||
'Example'
|
||||
).expect(
|
||||
'[](https://example.com/example)'
|
||||
)
|
||||
given('https://img.shields.io/badge', undefined, undefined).expect(
|
||||
given('https://img.shields.io/badge', undefined).expect(
|
||||
''
|
||||
)
|
||||
})
|
||||
|
||||
test(reStructuredText, () => {
|
||||
given('https://img.shields.io/badge', undefined, undefined).expect(
|
||||
given('https://img.shields.io/badge', undefined).expect(
|
||||
'.. image:: https://img.shields.io/badge'
|
||||
)
|
||||
given('https://img.shields.io/badge', undefined, 'Example').expect(
|
||||
given('https://img.shields.io/badge', 'Example').expect(
|
||||
'.. image:: https://img.shields.io/badge\n :alt: Example'
|
||||
)
|
||||
given(
|
||||
'https://img.shields.io/badge',
|
||||
'https://example.com/example',
|
||||
'Example'
|
||||
).expect(
|
||||
'.. image:: https://img.shields.io/badge\n :alt: Example\n :target: https://example.com/example'
|
||||
)
|
||||
})
|
||||
|
||||
test(renderAsciiDocAttributes, () => {
|
||||
@@ -70,33 +52,21 @@ test(renderAsciiDocAttributes, () => {
|
||||
})
|
||||
|
||||
test(asciiDoc, () => {
|
||||
given('https://img.shields.io/badge', undefined, undefined).expect(
|
||||
given('https://img.shields.io/badge', undefined).expect(
|
||||
'image:https://img.shields.io/badge[]'
|
||||
)
|
||||
given('https://img.shields.io/badge', undefined, 'Example').expect(
|
||||
given('https://img.shields.io/badge', 'Example').expect(
|
||||
'image:https://img.shields.io/badge[Example]'
|
||||
)
|
||||
given(
|
||||
'https://img.shields.io/badge',
|
||||
undefined,
|
||||
'Example, with comma'
|
||||
).expect('image:https://img.shields.io/badge["Example, with comma"]')
|
||||
given(
|
||||
'https://img.shields.io/badge',
|
||||
'https://example.com/example',
|
||||
'Example'
|
||||
).expect(
|
||||
'image:https://img.shields.io/badge["Example",link="https://example.com/example"]'
|
||||
given('https://img.shields.io/badge', 'Example, with comma').expect(
|
||||
'image:https://img.shields.io/badge["Example, with comma"]'
|
||||
)
|
||||
})
|
||||
|
||||
test(generateMarkup, () => {
|
||||
given({
|
||||
badgeUrl: 'https://img.shields.io/badge',
|
||||
link: 'https://example.com/example',
|
||||
title: 'Example',
|
||||
markupFormat: 'markdown',
|
||||
}).expect(
|
||||
'[](https://example.com/example)'
|
||||
)
|
||||
}).expect('')
|
||||
})
|
||||
|
||||
@@ -2,42 +2,21 @@ export function bareLink(badgeUrl: string, link?: string, title = ''): string {
|
||||
return badgeUrl
|
||||
}
|
||||
|
||||
export function html(badgeUrl: string, link?: string, title?: string): string {
|
||||
export function html(badgeUrl: string, title?: string): string {
|
||||
// To be more robust, this should escape the title.
|
||||
const alt = title ? ` alt="${title}"` : ''
|
||||
const img = `<img${alt} src="${badgeUrl}">`
|
||||
if (link) {
|
||||
return `<a href="${link}">${img}</a>`
|
||||
} else {
|
||||
return img
|
||||
}
|
||||
return `<img${alt} src="${badgeUrl}">`
|
||||
}
|
||||
|
||||
export function markdown(
|
||||
badgeUrl: string,
|
||||
link?: string,
|
||||
title?: string
|
||||
): string {
|
||||
const withoutLink = ``
|
||||
if (link) {
|
||||
return `[${withoutLink}](${link})`
|
||||
} else {
|
||||
return withoutLink
|
||||
}
|
||||
export function markdown(badgeUrl: string, title?: string): string {
|
||||
return ``
|
||||
}
|
||||
|
||||
export function reStructuredText(
|
||||
badgeUrl: string,
|
||||
link?: string,
|
||||
title?: string
|
||||
): string {
|
||||
export function reStructuredText(badgeUrl: string, title?: string): string {
|
||||
let result = `.. image:: ${badgeUrl}`
|
||||
if (title) {
|
||||
result += `\n :alt: ${title}`
|
||||
}
|
||||
if (link) {
|
||||
result += `\n :target: ${link}`
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
@@ -91,13 +70,9 @@ export function renderAsciiDocAttributes(
|
||||
}
|
||||
}
|
||||
|
||||
export function asciiDoc(
|
||||
badgeUrl: string,
|
||||
link?: string,
|
||||
title?: string
|
||||
): string {
|
||||
export function asciiDoc(badgeUrl: string, title?: string): string {
|
||||
const positional = title ? [title] : []
|
||||
const named = link ? { link } : ({} as { [k: string]: string })
|
||||
const named = {} as { [k: string]: string }
|
||||
const attrs = renderAsciiDocAttributes(positional, named)
|
||||
return `image:${badgeUrl}${attrs}`
|
||||
}
|
||||
@@ -106,12 +81,10 @@ export type MarkupFormat = 'markdown' | 'rst' | 'asciidoc' | 'link' | 'html'
|
||||
|
||||
export function generateMarkup({
|
||||
badgeUrl,
|
||||
link,
|
||||
title,
|
||||
markupFormat,
|
||||
}: {
|
||||
badgeUrl: string
|
||||
link?: string
|
||||
title?: string
|
||||
markupFormat: MarkupFormat
|
||||
}): string {
|
||||
@@ -122,5 +95,5 @@ export function generateMarkup({
|
||||
link: bareLink,
|
||||
html,
|
||||
}[markupFormat]
|
||||
return generatorFn(badgeUrl, link, title)
|
||||
return generatorFn(badgeUrl, title)
|
||||
}
|
||||
|
||||
@@ -64,13 +64,4 @@ export function getDefinitionsForCategory(
|
||||
return byCategory[category] || []
|
||||
}
|
||||
|
||||
export interface Suggestion {
|
||||
title: string
|
||||
link: string
|
||||
example: ExampleSignature
|
||||
preview: {
|
||||
style?: string
|
||||
}
|
||||
}
|
||||
|
||||
export type RenderableExample = Example | Suggestion
|
||||
export type RenderableExample = Example
|
||||
|
||||
@@ -210,7 +210,9 @@ export default function EndpointPage(): JSX.Element {
|
||||
<dt>logoColor</dt>
|
||||
<dd>
|
||||
Default: none. Same meaning as the query string. Can be overridden by
|
||||
the query string. Only works for named logos.
|
||||
the query string. Only works for named logos and Shields logos. If you
|
||||
override the color of a multicolor Shield logo, the corresponding
|
||||
named logo will be used and colored.
|
||||
</dd>
|
||||
<dt>logoWidth</dt>
|
||||
<dd>
|
||||
@@ -244,7 +246,6 @@ export default function EndpointPage(): JSX.Element {
|
||||
exampleQueryParams={{
|
||||
url: 'https://shields.redsparr0w.com/2473/monday',
|
||||
}}
|
||||
isPrefilled={false}
|
||||
pattern="/endpoint"
|
||||
title="Custom badge"
|
||||
/>
|
||||
|
||||
10
lib/logos.js
10
lib/logos.js
@@ -66,8 +66,12 @@ function getShieldsIcon({ name, color }) {
|
||||
|
||||
const { svg, base64, isMonochrome } = logos[name]
|
||||
const svgColor = toSvgColor(color)
|
||||
if (svgColor && isMonochrome) {
|
||||
return svg2base64(svg.replace(/fill="(.+?)"/g, `fill="${svgColor}"`))
|
||||
if (svgColor) {
|
||||
if (isMonochrome) {
|
||||
return svg2base64(svg.replace(/fill="(.+?)"/g, `fill="${svgColor}"`))
|
||||
} else {
|
||||
return undefined
|
||||
}
|
||||
} else {
|
||||
return base64
|
||||
}
|
||||
@@ -85,7 +89,7 @@ function getSimpleIconStyle({ icon, style }) {
|
||||
}
|
||||
|
||||
function getSimpleIcon({ name, color, style }) {
|
||||
const key = name.replace(/ /g, '-')
|
||||
const key = name === 'travis' ? 'travis-ci' : name.replace(/ /g, '-')
|
||||
|
||||
if (!(key in simpleIcons)) {
|
||||
return undefined
|
||||
|
||||
File diff suppressed because one or more lines are too long
3202
package-lock.json
generated
3202
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
78
package.json
78
package.json
@@ -24,29 +24,29 @@
|
||||
"@fontsource/lato": "^4.5.10",
|
||||
"@fontsource/lekton": "^4.5.11",
|
||||
"@renovate/pep440": "^1.0.0",
|
||||
"@renovatebot/ruby-semver": "^1.1.6",
|
||||
"@sentry/node": "^7.17.2",
|
||||
"@renovatebot/ruby-semver": "^1.1.7",
|
||||
"@sentry/node": "^7.28.1",
|
||||
"@shields_io/camp": "^18.1.1",
|
||||
"badge-maker": "file:badge-maker",
|
||||
"bytes": "^3.1.2",
|
||||
"camelcase": "^7.0.0",
|
||||
"chalk": "^5.1.2",
|
||||
"camelcase": "^7.0.1",
|
||||
"chalk": "^5.2.0",
|
||||
"check-node-version": "^4.2.1",
|
||||
"cloudflare-middleware": "^1.0.4",
|
||||
"config": "^3.3.8",
|
||||
"cross-env": "^7.0.3",
|
||||
"dayjs": "^1.11.6",
|
||||
"dayjs": "^1.11.7",
|
||||
"decamelize": "^3.2.0",
|
||||
"emojic": "^1.1.17",
|
||||
"escape-string-regexp": "^4.0.0",
|
||||
"fast-xml-parser": "^4.0.11",
|
||||
"fast-xml-parser": "^4.0.12",
|
||||
"glob": "^8.0.3",
|
||||
"global-agent": "^3.0.0",
|
||||
"got": "^12.5.2",
|
||||
"got": "^12.5.3",
|
||||
"graphql": "^15.6.1",
|
||||
"graphql-tag": "^2.12.6",
|
||||
"ioredis": "5.2.3",
|
||||
"joi": "17.6.4",
|
||||
"ioredis": "5.2.4",
|
||||
"joi": "17.7.0",
|
||||
"joi-extension-semver": "5.0.0",
|
||||
"js-yaml": "^4.1.0",
|
||||
"jsonpath": "~1.1.1",
|
||||
@@ -60,9 +60,9 @@
|
||||
"priorityqueuejs": "^2.0.0",
|
||||
"prom-client": "^14.1.0",
|
||||
"qs": "^6.11.0",
|
||||
"query-string": "^7.1.1",
|
||||
"query-string": "^8.1.0",
|
||||
"semver": "~7.3.8",
|
||||
"simple-icons": "7.17.0",
|
||||
"simple-icons": "8.2.0",
|
||||
"webextension-store-meta": "^1.0.5",
|
||||
"xmldom": "~0.6.0",
|
||||
"xpath": "~0.0.32"
|
||||
@@ -142,36 +142,36 @@
|
||||
]
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/core": "^7.19.6",
|
||||
"@babel/core": "^7.20.7",
|
||||
"@babel/polyfill": "^7.12.1",
|
||||
"@babel/register": "7.18.9",
|
||||
"@istanbuljs/schema": "^0.1.3",
|
||||
"@mapbox/react-click-to-select": "^2.2.1",
|
||||
"@types/chai": "^4.3.3",
|
||||
"@types/chai": "^4.3.4",
|
||||
"@types/lodash.debounce": "^4.0.7",
|
||||
"@types/lodash.groupby": "^4.6.7",
|
||||
"@types/mocha": "^10.0.0",
|
||||
"@types/mocha": "^10.0.1",
|
||||
"@types/node": "^16.7.10",
|
||||
"@types/react-helmet": "^6.1.5",
|
||||
"@types/react-helmet": "^6.1.6",
|
||||
"@types/react-modal": "^3.13.1",
|
||||
"@types/react-select": "^4.0.17",
|
||||
"@types/styled-components": "5.1.26",
|
||||
"@typescript-eslint/eslint-plugin": "^5.41.0",
|
||||
"@typescript-eslint/parser": "^5.30.7",
|
||||
"@typescript-eslint/eslint-plugin": "^5.47.1",
|
||||
"@typescript-eslint/parser": "^5.46.0",
|
||||
"babel-plugin-inline-react-svg": "^2.0.1",
|
||||
"babel-preset-gatsby": "^2.22.0",
|
||||
"c8": "^7.12.0",
|
||||
"caller": "^1.1.0",
|
||||
"chai": "^4.3.6",
|
||||
"chai": "^4.3.7",
|
||||
"chai-as-promised": "^7.1.1",
|
||||
"chai-datetime": "^1.8.0",
|
||||
"chai-string": "^1.4.0",
|
||||
"child-process-promise": "^2.2.1",
|
||||
"clipboard-copy": "^4.0.1",
|
||||
"concurrently": "^7.5.0",
|
||||
"cypress": "^10.11.0",
|
||||
"concurrently": "^7.6.0",
|
||||
"cypress": "^12.2.0",
|
||||
"cypress-wait-for-stable-dom": "^0.1.0",
|
||||
"danger": "^11.1.4",
|
||||
"danger": "^11.2.0",
|
||||
"danger-plugin-no-test-shortcuts": "^2.0.0",
|
||||
"deepmerge": "^4.2.2",
|
||||
"eslint": "^7.32.0",
|
||||
@@ -182,44 +182,44 @@
|
||||
"eslint-plugin-chai-friendly": "^0.7.2",
|
||||
"eslint-plugin-cypress": "^2.12.1",
|
||||
"eslint-plugin-import": "^2.26.0",
|
||||
"eslint-plugin-jsdoc": "^39.3.25",
|
||||
"eslint-plugin-jsdoc": "^39.6.4",
|
||||
"eslint-plugin-mocha": "^10.1.0",
|
||||
"eslint-plugin-no-extension-in-require": "^0.2.0",
|
||||
"eslint-plugin-node": "^11.1.0",
|
||||
"eslint-plugin-promise": "^5.2.0",
|
||||
"eslint-plugin-react": "^7.31.10",
|
||||
"eslint-plugin-react": "^7.31.11",
|
||||
"eslint-plugin-react-hooks": "^4.6.0",
|
||||
"eslint-plugin-sort-class-members": "^1.15.2",
|
||||
"eslint-plugin-sort-class-members": "^1.16.0",
|
||||
"fetch-ponyfill": "^7.1.0",
|
||||
"form-data": "^4.0.0",
|
||||
"gatsby": "4.23.1",
|
||||
"gatsby-plugin-catch-links": "^4.19.0",
|
||||
"gatsby-plugin-page-creator": "^4.24.0",
|
||||
"gatsby-plugin-react-helmet": "^5.22.0",
|
||||
"gatsby-plugin-catch-links": "^4.25.0",
|
||||
"gatsby-plugin-page-creator": "^4.25.0",
|
||||
"gatsby-plugin-react-helmet": "^5.25.0",
|
||||
"gatsby-plugin-remove-trailing-slashes": "^4.9.0",
|
||||
"gatsby-plugin-styled-components": "^5.24.0",
|
||||
"gatsby-plugin-typescript": "^4.22.0",
|
||||
"gatsby-plugin-typescript": "^4.25.0",
|
||||
"humanize-string": "^2.1.0",
|
||||
"icedfrisby": "4.0.0",
|
||||
"icedfrisby-nock": "^2.1.0",
|
||||
"is-svg": "^4.3.2",
|
||||
"js-yaml-loader": "^1.2.2",
|
||||
"jsdoc": "^3.6.11",
|
||||
"lint-staged": "^13.0.3",
|
||||
"jsdoc": "^4.0.0",
|
||||
"lint-staged": "^13.1.0",
|
||||
"lodash.debounce": "^4.0.8",
|
||||
"lodash.difference": "^4.5.0",
|
||||
"minimist": "^1.2.7",
|
||||
"mocha": "^10.1.0",
|
||||
"mocha": "^10.2.0",
|
||||
"mocha-env-reporter": "^4.0.0",
|
||||
"mocha-junit-reporter": "^2.1.1",
|
||||
"mocha-junit-reporter": "^2.2.0",
|
||||
"mocha-yaml-loader": "^1.0.3",
|
||||
"nock": "13.2.9",
|
||||
"node-mocks-http": "^1.11.0",
|
||||
"node-mocks-http": "^1.12.1",
|
||||
"nodemon": "^2.0.20",
|
||||
"npm-run-all": "^4.1.5",
|
||||
"open-cli": "^7.1.0",
|
||||
"portfinder": "^1.0.32",
|
||||
"prettier": "2.7.1",
|
||||
"prettier": "2.8.1",
|
||||
"react": "^17.0.2",
|
||||
"react-dom": "^17.0.2",
|
||||
"react-error-overlay": "^6.0.11",
|
||||
@@ -232,14 +232,14 @@
|
||||
"rimraf": "^3.0.2",
|
||||
"sazerac": "^2.0.0",
|
||||
"simple-git-hooks": "^2.8.1",
|
||||
"sinon": "^14.0.1",
|
||||
"sinon": "^15.0.1",
|
||||
"sinon-chai": "^3.7.0",
|
||||
"snap-shot-it": "^7.9.6",
|
||||
"start-server-and-test": "1.14.0",
|
||||
"snap-shot-it": "^7.9.10",
|
||||
"start-server-and-test": "1.15.2",
|
||||
"styled-components": "^5.3.6",
|
||||
"ts-mocha": "^10.0.0",
|
||||
"tsd": "^0.24.1",
|
||||
"typescript": "^4.8.4",
|
||||
"tsd": "^0.25.0",
|
||||
"typescript": "^4.9.4",
|
||||
"url": "^0.11.0"
|
||||
},
|
||||
"engines": {
|
||||
|
||||
19
scripts/update-github-api.js
Normal file
19
scripts/update-github-api.js
Normal file
@@ -0,0 +1,19 @@
|
||||
import fs from 'fs/promises'
|
||||
import got from 'got'
|
||||
import yaml from 'js-yaml'
|
||||
|
||||
const resp = await got('https://api.github.com/versions').json()
|
||||
const latestDate = resp.sort()[resp.length - 1]
|
||||
|
||||
const config = yaml.load(await fs.readFile('./config/default.yml', 'utf8'))
|
||||
|
||||
if (latestDate === config.public.services.github.restApiVersion) {
|
||||
console.log("We're already using the latest version. No change needed.")
|
||||
process.exit(0)
|
||||
}
|
||||
|
||||
config.public.services.github.restApiVersion = latestDate
|
||||
await fs.writeFile(
|
||||
'./config/default.yml',
|
||||
yaml.dump(config, { forceQuotes: true })
|
||||
)
|
||||
22
services/coincap/coincap-base.js
Normal file
22
services/coincap/coincap-base.js
Normal file
@@ -0,0 +1,22 @@
|
||||
import { BaseJsonService } from '../index.js'
|
||||
|
||||
export default class BaseCoincapService extends BaseJsonService {
|
||||
static category = 'other'
|
||||
|
||||
static defaultBadgeData = { label: 'coincap' }
|
||||
|
||||
// Doc this API. From https://docs.coincap.io/
|
||||
// example: https://api.coincap.io/v2/assets/bitcoin
|
||||
|
||||
async fetch({ assetId, schema }) {
|
||||
return this._requestJson({
|
||||
schema,
|
||||
url: `https://api.coincap.io/v2/assets/${assetId}`,
|
||||
errorMessages: {
|
||||
404: 'asset not found',
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
export { BaseCoincapService }
|
||||
44
services/coincap/coincap-changepercent24hr.service.js
Normal file
44
services/coincap/coincap-changepercent24hr.service.js
Normal file
@@ -0,0 +1,44 @@
|
||||
import Joi from 'joi'
|
||||
import { floorCount } from '../color-formatters.js'
|
||||
import BaseCoincapService from './coincap-base.js'
|
||||
|
||||
const schema = Joi.object({
|
||||
data: Joi.object({
|
||||
changePercent24Hr: Joi.string()
|
||||
.pattern(/[0-9]*\.[0-9]+/i)
|
||||
.required(),
|
||||
name: Joi.string().required(),
|
||||
}).required(),
|
||||
}).required()
|
||||
|
||||
export default class CoincapChangePercent24HrUsd extends BaseCoincapService {
|
||||
static route = { base: 'coincap/change-percent-24hr', pattern: ':assetId' }
|
||||
|
||||
static examples = [
|
||||
{
|
||||
title: 'Coincap (Change Percent 24Hr)',
|
||||
namedParams: { assetId: 'bitcoin' },
|
||||
staticPreview: this.render({
|
||||
asset: { name: 'bitcoin', changePercent24Hr: '2.0670573674501840"' },
|
||||
}),
|
||||
keywords: ['bitcoin', 'crypto', 'cryptocurrency'],
|
||||
},
|
||||
]
|
||||
|
||||
static percentFormat(changePercent24Hr) {
|
||||
return `${parseInt(changePercent24Hr).toFixed(2)}%`
|
||||
}
|
||||
|
||||
static render({ asset }) {
|
||||
return {
|
||||
label: `${asset.name}`.toLowerCase(),
|
||||
message: this.percentFormat(asset.changePercent24Hr),
|
||||
color: floorCount(asset.changePercent24Hr),
|
||||
}
|
||||
}
|
||||
|
||||
async handle({ assetId }) {
|
||||
const { data: asset } = await this.fetch({ assetId, schema })
|
||||
return this.constructor.render({ asset })
|
||||
}
|
||||
}
|
||||
43
services/coincap/coincap-changepercent24hr.tester.js
Normal file
43
services/coincap/coincap-changepercent24hr.tester.js
Normal file
@@ -0,0 +1,43 @@
|
||||
import { isPercentage } from '../test-validators.js'
|
||||
import { createServiceTester } from '../tester.js'
|
||||
export const t = await createServiceTester()
|
||||
|
||||
t.create('request for existing asset with positive')
|
||||
.get('/bitcoin.json')
|
||||
.intercept(nock =>
|
||||
nock('https://api.coincap.io')
|
||||
.get('/v2/assets/bitcoin')
|
||||
.reply(200, {
|
||||
data: { changePercent24Hr: '1.4767080598737783', name: 'Bitcoin' },
|
||||
})
|
||||
)
|
||||
.expectBadge({
|
||||
label: 'bitcoin',
|
||||
message: '1.00%',
|
||||
color: 'brightgreen',
|
||||
})
|
||||
|
||||
t.create('request for existing asset with negative')
|
||||
.get('/bitcoin.json')
|
||||
.intercept(nock =>
|
||||
nock('https://api.coincap.io')
|
||||
.get('/v2/assets/bitcoin')
|
||||
.reply(200, {
|
||||
data: { changePercent24Hr: '-1.4767080598737783', name: 'Bitcoin' },
|
||||
})
|
||||
)
|
||||
.expectBadge({
|
||||
label: 'bitcoin',
|
||||
message: '-1.00%',
|
||||
color: 'red',
|
||||
})
|
||||
|
||||
t.create('change percent 24hr').get('/bitcoin.json').expectBadge({
|
||||
label: 'bitcoin',
|
||||
message: isPercentage,
|
||||
})
|
||||
|
||||
t.create('asset not found').get('/not-a-valid-asset.json').expectBadge({
|
||||
label: 'coincap',
|
||||
message: 'asset not found',
|
||||
})
|
||||
45
services/coincap/coincap-priceusd.service.js
Normal file
45
services/coincap/coincap-priceusd.service.js
Normal file
@@ -0,0 +1,45 @@
|
||||
import Joi from 'joi'
|
||||
import BaseCoincapService from './coincap-base.js'
|
||||
|
||||
const schema = Joi.object({
|
||||
data: Joi.object({
|
||||
priceUsd: Joi.string()
|
||||
.pattern(/[0-9]*\.[0-9]+/i)
|
||||
.required(),
|
||||
name: Joi.string().required(),
|
||||
}).required(),
|
||||
}).required()
|
||||
|
||||
export default class CoincapPriceUsd extends BaseCoincapService {
|
||||
static route = { base: 'coincap/price-usd', pattern: ':assetId' }
|
||||
|
||||
static examples = [
|
||||
{
|
||||
title: 'Coincap (Price USD)',
|
||||
namedParams: { assetId: 'bitcoin' },
|
||||
staticPreview: this.render({
|
||||
asset: { name: 'bitcoin', priceUsd: '19116.0479117336250772' },
|
||||
}),
|
||||
keywords: ['bitcoin', 'crypto', 'cryptocurrency'],
|
||||
},
|
||||
]
|
||||
|
||||
static priceFormat(price) {
|
||||
return `$${parseFloat(price)
|
||||
.toFixed(2)
|
||||
.replace(/\d(?=(\d{3})+\.)/g, '$&,')}`
|
||||
}
|
||||
|
||||
static render({ asset }) {
|
||||
return {
|
||||
label: `${asset.name}`.toLowerCase(),
|
||||
message: this.priceFormat(asset.priceUsd),
|
||||
color: 'blue',
|
||||
}
|
||||
}
|
||||
|
||||
async handle({ assetId }) {
|
||||
const { data: asset } = await this.fetch({ assetId, schema })
|
||||
return this.constructor.render({ asset })
|
||||
}
|
||||
}
|
||||
16
services/coincap/coincap-priceusd.spec.js
Normal file
16
services/coincap/coincap-priceusd.spec.js
Normal file
@@ -0,0 +1,16 @@
|
||||
import { test, given } from 'sazerac'
|
||||
import CoincapPriceUsd from './coincap-priceusd.service.js'
|
||||
|
||||
describe('PriceUsd Format', function () {
|
||||
test(CoincapPriceUsd.priceFormat, () => {
|
||||
given('3').expect('$3.00')
|
||||
given('33').expect('$33.00')
|
||||
given('332').expect('$332.00')
|
||||
given('3324').expect('$3,324.00')
|
||||
given('332432').expect('$332,432.00')
|
||||
given('332432.2').expect('$332,432.20')
|
||||
given('332432.25').expect('$332,432.25')
|
||||
given('332432432').expect('$332,432,432.00')
|
||||
given('332432432.3432432').expect('$332,432,432.34')
|
||||
})
|
||||
})
|
||||
29
services/coincap/coincap-priceusd.tester.js
Normal file
29
services/coincap/coincap-priceusd.tester.js
Normal file
@@ -0,0 +1,29 @@
|
||||
import { isCurrency } from '../test-validators.js'
|
||||
import { createServiceTester } from '../tester.js'
|
||||
export const t = await createServiceTester()
|
||||
|
||||
t.create('request for existing asset')
|
||||
.get('/bitcoin.json')
|
||||
.intercept(nock =>
|
||||
nock('https://api.coincap.io')
|
||||
.get('/v2/assets/bitcoin')
|
||||
.reply(200, {
|
||||
data: { priceUsd: '16417.7176754790740415', name: 'Bitcoin' },
|
||||
})
|
||||
)
|
||||
.expectBadge({
|
||||
label: 'bitcoin',
|
||||
message: '$16,417.72',
|
||||
color: 'blue',
|
||||
})
|
||||
|
||||
t.create('price usd').get('/bitcoin.json').expectBadge({
|
||||
label: 'bitcoin',
|
||||
message: isCurrency,
|
||||
color: 'blue',
|
||||
})
|
||||
|
||||
t.create('asset not found').get('/not-a-valid-asset.json').expectBadge({
|
||||
label: 'coincap',
|
||||
message: 'asset not found',
|
||||
})
|
||||
37
services/coincap/coincap-rank.service.js
Normal file
37
services/coincap/coincap-rank.service.js
Normal file
@@ -0,0 +1,37 @@
|
||||
import Joi from 'joi'
|
||||
import BaseCoincapService from './coincap-base.js'
|
||||
|
||||
const schema = Joi.object({
|
||||
data: Joi.object({
|
||||
rank: Joi.string()
|
||||
.pattern(/^[0-9]+$/)
|
||||
.required(),
|
||||
name: Joi.string().required(),
|
||||
}).required(),
|
||||
}).required()
|
||||
|
||||
export default class CoincapRank extends BaseCoincapService {
|
||||
static route = { base: 'coincap/rank', pattern: ':assetId' }
|
||||
|
||||
static examples = [
|
||||
{
|
||||
title: 'Coincap (Rank)',
|
||||
namedParams: { assetId: 'bitcoin' },
|
||||
staticPreview: this.render({ asset: { name: 'bitcoin', rank: '1' } }),
|
||||
keywords: ['bitcoin', 'crypto', 'cryptocurrency'],
|
||||
},
|
||||
]
|
||||
|
||||
static render({ asset }) {
|
||||
return {
|
||||
label: `${asset.name}`.toLowerCase(),
|
||||
message: asset.rank,
|
||||
color: 'blue',
|
||||
}
|
||||
}
|
||||
|
||||
async handle({ assetId }) {
|
||||
const { data: asset } = await this.fetch({ assetId, schema })
|
||||
return this.constructor.render({ asset })
|
||||
}
|
||||
}
|
||||
29
services/coincap/coincap-rank.tester.js
Normal file
29
services/coincap/coincap-rank.tester.js
Normal file
@@ -0,0 +1,29 @@
|
||||
import Joi from 'joi'
|
||||
import { createServiceTester } from '../tester.js'
|
||||
export const t = await createServiceTester()
|
||||
|
||||
t.create('request for existing asset')
|
||||
.get('/bitcoin.json')
|
||||
.intercept(nock =>
|
||||
nock('https://api.coincap.io')
|
||||
.get('/v2/assets/bitcoin')
|
||||
.reply(200, { data: { rank: '1', name: 'Bitcoin' } })
|
||||
)
|
||||
.expectBadge({
|
||||
label: 'bitcoin',
|
||||
message: '1',
|
||||
color: 'blue',
|
||||
})
|
||||
|
||||
t.create('rank')
|
||||
.get('/bitcoin.json')
|
||||
.expectBadge({
|
||||
label: 'bitcoin',
|
||||
message: Joi.number().integer().min(1).required(),
|
||||
color: 'blue',
|
||||
})
|
||||
|
||||
t.create('asset not found').get('/not-a-valid-asset.json').expectBadge({
|
||||
label: 'coincap',
|
||||
message: 'asset not found',
|
||||
})
|
||||
@@ -2,14 +2,49 @@ import { redirector } from '../index.js'
|
||||
|
||||
export default [
|
||||
redirector({
|
||||
name: 'CoverallsGitHubRedirect',
|
||||
name: 'CoverallsGitHubRedirectWithBranch',
|
||||
category: 'coverage',
|
||||
route: {
|
||||
base: 'coveralls',
|
||||
pattern: ':user((?!github|bitbucket).*)/:repo/:branch*',
|
||||
pattern: ':user((?!github|bitbucket).*)/:repo/:branch+',
|
||||
},
|
||||
transformPath: ({ user, repo, branch }) =>
|
||||
`/coveralls/github/${user}/${repo}${branch ? `/${branch}` : ''}`,
|
||||
transformPath: ({ user, repo }) =>
|
||||
`/coverallsCoverage/github/${user}/${repo}`,
|
||||
transformQueryParams: ({ branch }) => ({ branch }),
|
||||
dateAdded: new Date('2022-11-10'),
|
||||
}),
|
||||
redirector({
|
||||
name: 'CoverallsGitHubRedirectWithoutBranch',
|
||||
category: 'coverage',
|
||||
route: {
|
||||
base: 'coveralls',
|
||||
pattern: ':user((?!github|bitbucket).*)/:repo',
|
||||
},
|
||||
transformPath: ({ user, repo }) =>
|
||||
`/coverallsCoverage/github/${user}/${repo}`,
|
||||
dateAdded: new Date('2021-02-23'),
|
||||
}),
|
||||
redirector({
|
||||
name: 'CoverallsPreGitlabRedirectWithBranch',
|
||||
category: 'coverage',
|
||||
route: {
|
||||
base: 'coveralls',
|
||||
pattern: ':vcsType(github|bitbucket)/:user/:repo/:branch+',
|
||||
},
|
||||
transformPath: ({ vcsType, user, repo }) =>
|
||||
`/coverallsCoverage/${vcsType}/${user}/${repo}`,
|
||||
transformQueryParams: ({ branch }) => ({ branch }),
|
||||
dateAdded: new Date('2022-11-10'),
|
||||
}),
|
||||
redirector({
|
||||
name: 'CoverallsPreGitlabRedirectWithoutBranch',
|
||||
category: 'coverage',
|
||||
route: {
|
||||
base: 'coveralls',
|
||||
pattern: ':vcsType(github|bitbucket)/:user/:repo',
|
||||
},
|
||||
transformPath: ({ vcsType, user, repo }) =>
|
||||
`/coverallsCoverage/${vcsType}/${user}/${repo}`,
|
||||
dateAdded: new Date('2022-11-20'),
|
||||
}),
|
||||
]
|
||||
|
||||
@@ -1,11 +1,41 @@
|
||||
import { ServiceTester } from '../tester.js'
|
||||
|
||||
export const t = new ServiceTester({
|
||||
id: 'CoverallsGitHubRedirect',
|
||||
title: 'Coveralls GitHub Redirector',
|
||||
id: 'CoverallsTestsRedirector',
|
||||
title: 'CoverallsTestsRedirector',
|
||||
pathPrefix: '/coveralls',
|
||||
})
|
||||
|
||||
t.create('Coveralls VCS type missing')
|
||||
.get('/lemurheavy/coveralls-ruby.svg')
|
||||
.expectRedirect('/coveralls/github/lemurheavy/coveralls-ruby.svg')
|
||||
.expectRedirect('/coverallsCoverage/github/lemurheavy/coveralls-ruby.svg')
|
||||
|
||||
t.create('Coveralls VCS type missing + specified branch')
|
||||
.get('/jekyll/jekyll/master.svg')
|
||||
.expectRedirect('/coverallsCoverage/github/jekyll/jekyll.svg?branch=master')
|
||||
|
||||
t.create(
|
||||
'Redirect from before branch was a query param - github, with specified branch'
|
||||
)
|
||||
.get('/github/jekyll/jekyll/master.svg')
|
||||
.expectRedirect('/coverallsCoverage/github/jekyll/jekyll.svg?branch=master')
|
||||
|
||||
t.create(
|
||||
'Redirect from before branch was a query param - github, without specified branch'
|
||||
)
|
||||
.get('/github/badges/shields')
|
||||
.expectRedirect('/coverallsCoverage/github/badges/shields.svg')
|
||||
|
||||
t.create(
|
||||
'Redirect from before branch was a query param - bitbucket, with specified branch'
|
||||
)
|
||||
.get('/bitbucket/pyKLIP/pyklip/master.svg')
|
||||
.expectRedirect(
|
||||
'/coverallsCoverage/bitbucket/pyKLIP/pyklip.svg?branch=master'
|
||||
)
|
||||
|
||||
t.create(
|
||||
'Redirect from before branch was a query param - bitbucket, without specified branch'
|
||||
)
|
||||
.get('/bitbucket/pyKLIP/pyklip.svg')
|
||||
.expectRedirect('/coverallsCoverage/bitbucket/pyKLIP/pyklip.svg')
|
||||
|
||||
@@ -6,18 +6,22 @@ const schema = Joi.object({
|
||||
covered_percent: Joi.number().min(0).max(100).required(),
|
||||
}).required()
|
||||
|
||||
const queryParamSchema = Joi.object({
|
||||
branch: Joi.string(),
|
||||
}).required()
|
||||
|
||||
export default class Coveralls extends BaseJsonService {
|
||||
static category = 'coverage'
|
||||
static route = {
|
||||
base: 'coveralls',
|
||||
pattern: ':vcsType(github|bitbucket)/:user/:repo/:branch*',
|
||||
base: 'coverallsCoverage',
|
||||
pattern: ':vcsType(github|bitbucket|gitlab)/:user/:repo+',
|
||||
queryParamSchema,
|
||||
}
|
||||
|
||||
static examples = [
|
||||
{
|
||||
title: 'Coveralls',
|
||||
namedParams: { vcsType: 'github', user: 'jekyll', repo: 'jekyll' },
|
||||
pattern: ':vcsType(github|bitbucket)/:user/:repo',
|
||||
staticPreview: this.render({ coverage: 86 }),
|
||||
},
|
||||
{
|
||||
@@ -26,9 +30,8 @@ export default class Coveralls extends BaseJsonService {
|
||||
vcsType: 'bitbucket',
|
||||
user: 'pyKLIP',
|
||||
repo: 'pyklip',
|
||||
branch: 'master',
|
||||
},
|
||||
pattern: ':vcsType(github|bitbucket)/:user/:repo/:branch',
|
||||
queryParams: { branch: 'master' },
|
||||
staticPreview: this.render({ coverage: 96 }),
|
||||
},
|
||||
]
|
||||
@@ -69,7 +72,7 @@ export default class Coveralls extends BaseJsonService {
|
||||
})
|
||||
}
|
||||
|
||||
async handle({ vcsType, user, repo, branch }) {
|
||||
async handle({ vcsType, user, repo }, { branch }) {
|
||||
const json = await this.fetch({ vcsType, user, repo, branch })
|
||||
return this.constructor.render({ coverage: json.covered_percent })
|
||||
}
|
||||
|
||||
@@ -11,9 +11,21 @@ t.create('nonexistent project')
|
||||
.expectBadge({ label: 'coverage', message: 'repository not found' })
|
||||
|
||||
t.create('github branch coverage')
|
||||
.get('/github/lemurheavy/coveralls-ruby/master.json')
|
||||
.get('/github/lemurheavy/coveralls-ruby.json?branch=master')
|
||||
.expectBadge({ label: 'coverage', message: isIntegerPercentage })
|
||||
|
||||
t.create('bitbucket coverage')
|
||||
.get('/bitbucket/pyKLIP/pyklip.json')
|
||||
.expectBadge({ label: 'coverage', message: isIntegerPercentage })
|
||||
|
||||
t.create('bitbucket branch coverage')
|
||||
.get('/bitbucket/pyKLIP/pyklip.json?branch=master')
|
||||
.expectBadge({ label: 'coverage', message: isIntegerPercentage })
|
||||
|
||||
t.create('gitlab coverage')
|
||||
.get('/gitlab/selcouth/wsrouter.json')
|
||||
.expectBadge({ label: 'coverage', message: isIntegerPercentage })
|
||||
|
||||
t.create('gitlab branch coverage')
|
||||
.get('/gitlab/selcouth/wsrouter.json?branch=master')
|
||||
.expectBadge({ label: 'coverage', message: isIntegerPercentage })
|
||||
|
||||
173
services/factorio-mod-portal/factorio-mod-portal.service.js
Normal file
173
services/factorio-mod-portal/factorio-mod-portal.service.js
Normal file
@@ -0,0 +1,173 @@
|
||||
import Joi from 'joi'
|
||||
import { BaseJsonService } from '../index.js'
|
||||
import { age } from '../color-formatters.js'
|
||||
import { formatDate } from '../text-formatters.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}`,
|
||||
errorMessages: {
|
||||
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 examples = [
|
||||
{
|
||||
title: 'Factorio Mod Portal mod version',
|
||||
namedParams: { modName: 'rso-mod' },
|
||||
staticPreview: this.render({ version: '6.2.20' }),
|
||||
},
|
||||
]
|
||||
|
||||
static defaultBadgeData = { label: 'latest version' }
|
||||
|
||||
static render({ version }) {
|
||||
return renderVersionBadge({ version })
|
||||
}
|
||||
|
||||
async handle({ modName }) {
|
||||
const { latest_release } = await this.fetch({ modName })
|
||||
return this.constructor.render({ version: 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 examples = [
|
||||
{
|
||||
title: 'Factorio Mod Portal factorio versions',
|
||||
namedParams: { modName: 'rso-mod' },
|
||||
staticPreview: this.render({ version: '1.1' }),
|
||||
},
|
||||
]
|
||||
|
||||
static defaultBadgeData = { label: 'factorio version' }
|
||||
|
||||
static render({ version }) {
|
||||
return renderVersionBadge({ version })
|
||||
}
|
||||
|
||||
async handle({ modName }) {
|
||||
const { latest_release } = await this.fetch({ modName })
|
||||
const version = 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 examples = [
|
||||
{
|
||||
title: 'Factorio Mod Portal mod',
|
||||
namedParams: { modName: 'rso-mod' },
|
||||
staticPreview: this.render({
|
||||
last_updated: new Date(),
|
||||
}),
|
||||
},
|
||||
]
|
||||
|
||||
static defaultBadgeData = { label: 'last updated' }
|
||||
|
||||
static render({ last_updated }) {
|
||||
return {
|
||||
message: formatDate(last_updated),
|
||||
color: age(last_updated),
|
||||
}
|
||||
}
|
||||
|
||||
async handle({ modName }) {
|
||||
const { latest_release } = await this.fetch({ modName })
|
||||
return this.constructor.render({ last_updated: 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 examples = [
|
||||
{
|
||||
title: 'Factorio Mod Portal mod downloads',
|
||||
namedParams: { modName: 'rso-mod' },
|
||||
staticPreview: this.render({
|
||||
downloads_count: 1694763,
|
||||
}),
|
||||
},
|
||||
]
|
||||
|
||||
static defaultBadgeData = { label: 'downloads' }
|
||||
|
||||
static render({ downloads_count }) {
|
||||
return renderDownloadsBadge({ downloads: downloads_count })
|
||||
}
|
||||
|
||||
async handle({ modName }) {
|
||||
const { downloads_count } = await this.fetch({ modName })
|
||||
return this.constructor.render({ downloads_count })
|
||||
}
|
||||
}
|
||||
|
||||
export {
|
||||
FactorioModPortalLatestVersion,
|
||||
FactorioModPortalLastUpdated,
|
||||
FactorioModPortalFactorioVersion,
|
||||
FactorioModPortalDownloads,
|
||||
}
|
||||
47
services/factorio-mod-portal/factorio-mod-portal.tester.js
Normal file
47
services/factorio-mod-portal/factorio-mod-portal.tester.js
Normal file
@@ -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' })
|
||||
@@ -1,10 +1,15 @@
|
||||
import Joi from 'joi'
|
||||
import { renderVersionBadge } from '../version.js'
|
||||
import { BaseJsonService } from '../index.js'
|
||||
import { BaseJsonService, NotFound } from '../index.js'
|
||||
|
||||
const schema = Joi.object({
|
||||
currentReleaseVersion: Joi.string().required(),
|
||||
}).required()
|
||||
const schema = Joi.alternatives()
|
||||
.try(
|
||||
Joi.object({
|
||||
currentReleaseVersion: Joi.string().required(),
|
||||
}).required(),
|
||||
Joi.valid(null).required()
|
||||
)
|
||||
.required()
|
||||
|
||||
export default class Flathub extends BaseJsonService {
|
||||
static category = 'version'
|
||||
@@ -26,6 +31,13 @@ export default class Flathub extends BaseJsonService {
|
||||
schema,
|
||||
url: `https://flathub.org/api/v1/apps/${encodeURIComponent(packageName)}`,
|
||||
})
|
||||
|
||||
// the upstream API indicates "not found"
|
||||
// by returning a 200 OK with a null body
|
||||
if (data === null) {
|
||||
throw new NotFound()
|
||||
}
|
||||
|
||||
return renderVersionBadge({ version: data.currentReleaseVersion })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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'),
|
||||
})
|
||||
@@ -0,0 +1,16 @@
|
||||
import { ServiceTester } from '../../tester.js'
|
||||
export const t = new ServiceTester({
|
||||
id: 'GithubGistLastCommitRedirect',
|
||||
title: 'Github Gist Last Commit Redirect',
|
||||
pathPrefix: '/github-gist',
|
||||
})
|
||||
|
||||
t.create('Last Commit redirect')
|
||||
.get('/last-commit/a8b8c979d200ffde13cc08505f7a6436', {
|
||||
followRedirect: false,
|
||||
})
|
||||
.expectStatus(301)
|
||||
.expectHeader(
|
||||
'Location',
|
||||
'/github/gist/last-commit/a8b8c979d200ffde13cc08505f7a6436.svg'
|
||||
)
|
||||
@@ -1,8 +1,8 @@
|
||||
import Joi from 'joi'
|
||||
import { formatDate } from '../text-formatters.js'
|
||||
import { age as ageColor } from '../color-formatters.js'
|
||||
import { GithubAuthV3Service } from './github-auth-service.js'
|
||||
import { documentation, errorMessagesFor } from './github-helpers.js'
|
||||
import { formatDate } from '../../text-formatters.js'
|
||||
import { age as ageColor } from '../../color-formatters.js'
|
||||
import { GithubAuthV3Service } from '../github-auth-service.js'
|
||||
import { documentation, errorMessagesFor } from '../github-helpers.js'
|
||||
|
||||
const schema = Joi.object({
|
||||
updated_at: Joi.string().required(),
|
||||
@@ -10,7 +10,7 @@ const schema = Joi.object({
|
||||
|
||||
export default class GithubGistLastCommit extends GithubAuthV3Service {
|
||||
static category = 'activity'
|
||||
static route = { base: 'github-gist/last-commit', pattern: ':gistId' }
|
||||
static route = { base: 'github/gist/last-commit', pattern: ':gistId' }
|
||||
static examples = [
|
||||
{
|
||||
title: 'GitHub Gist last commit',
|
||||
@@ -1,4 +1,4 @@
|
||||
import { createServiceTester } from '../tester.js'
|
||||
import { createServiceTester } from '../../tester.js'
|
||||
export const t = await createServiceTester()
|
||||
|
||||
t.create('last commit in gist (ancient)').get('/871064.json').expectBadge({
|
||||
@@ -0,0 +1,8 @@
|
||||
import { redirector } from '../../index.js'
|
||||
|
||||
export default redirector({
|
||||
category: 'social',
|
||||
route: { base: 'github/stars/gists', pattern: ':gistId' },
|
||||
transformPath: ({ gistId }) => `/github/gist/stars/${gistId}`,
|
||||
dateAdded: new Date('2022-10-09'),
|
||||
})
|
||||
16
services/github/gist/github-gist-stars-redirect.tester.js
Normal file
16
services/github/gist/github-gist-stars-redirect.tester.js
Normal file
@@ -0,0 +1,16 @@
|
||||
import { ServiceTester } from '../../tester.js'
|
||||
export const t = new ServiceTester({
|
||||
id: 'GithubGistStarsRedirect',
|
||||
title: 'Github Gist Stars Redirect',
|
||||
pathPrefix: '/github',
|
||||
})
|
||||
|
||||
t.create('Stars redirect')
|
||||
.get('/stars/gists/a8b8c979d200ffde13cc08505f7a6436', {
|
||||
followRedirect: false,
|
||||
})
|
||||
.expectStatus(301)
|
||||
.expectHeader(
|
||||
'Location',
|
||||
'/github/gist/stars/a8b8c979d200ffde13cc08505f7a6436.svg'
|
||||
)
|
||||
@@ -1,9 +1,9 @@
|
||||
import gql from 'graphql-tag'
|
||||
import Joi from 'joi'
|
||||
import { metric } from '../text-formatters.js'
|
||||
import { NotFound } from '../index.js'
|
||||
import { GithubAuthV4Service } from './github-auth-service.js'
|
||||
import { documentation as commonDocumentation } from './github-helpers.js'
|
||||
import { metric } from '../../text-formatters.js'
|
||||
import { NotFound } 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({
|
||||
@@ -28,7 +28,7 @@ export default class GithubGistStars extends GithubAuthV4Service {
|
||||
static category = 'social'
|
||||
|
||||
static route = {
|
||||
base: 'github/stars/gists',
|
||||
base: 'github/gist/stars',
|
||||
pattern: ':gistId',
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { createServiceTester } from '../tester.js'
|
||||
import { isMetric } from '../test-validators.js'
|
||||
import { createServiceTester } from '../../tester.js'
|
||||
import { isMetric } from '../../test-validators.js'
|
||||
|
||||
export const t = await createServiceTester()
|
||||
|
||||
100
services/github/github-actions-workflow-status.service.js
Normal file
100
services/github/github-actions-workflow-status.service.js
Normal file
@@ -0,0 +1,100 @@
|
||||
import Joi from 'joi'
|
||||
import { isBuildStatus, renderBuildStatusBadge } from '../build-status.js'
|
||||
import { BaseSvgScrapingService } 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()
|
||||
|
||||
const keywords = ['action', 'actions']
|
||||
|
||||
export default class GithubActionsWorkflowStatus extends BaseSvgScrapingService {
|
||||
static category = 'build'
|
||||
|
||||
static route = {
|
||||
base: 'github/actions/workflow/status',
|
||||
pattern: ':user/:repo/:workflow+',
|
||||
queryParamSchema,
|
||||
}
|
||||
|
||||
static examples = [
|
||||
{
|
||||
title: 'GitHub Workflow Status',
|
||||
namedParams: {
|
||||
user: 'actions',
|
||||
repo: 'toolkit',
|
||||
workflow: 'unit-tests.yml',
|
||||
},
|
||||
staticPreview: renderBuildStatusBadge({
|
||||
status: 'passing',
|
||||
}),
|
||||
documentation,
|
||||
keywords,
|
||||
},
|
||||
{
|
||||
title: 'GitHub Workflow Status (with branch)',
|
||||
namedParams: {
|
||||
user: 'actions',
|
||||
repo: 'toolkit',
|
||||
workflow: 'unit-tests.yml',
|
||||
},
|
||||
queryParams: {
|
||||
branch: 'main',
|
||||
},
|
||||
staticPreview: renderBuildStatusBadge({
|
||||
status: 'passing',
|
||||
}),
|
||||
documentation,
|
||||
keywords,
|
||||
},
|
||||
{
|
||||
title: 'GitHub Workflow Status (with event)',
|
||||
namedParams: {
|
||||
user: 'actions',
|
||||
repo: 'toolkit',
|
||||
workflow: 'unit-tests.yml',
|
||||
},
|
||||
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}/actions/workflows/${encodeURIComponent(
|
||||
workflow
|
||||
)}/badge.svg`,
|
||||
options: { searchParams: { branch, event } },
|
||||
valueMatcher: />([^<>]+)<\/tspan><\/text><\/g><path/,
|
||||
errorMessages: {
|
||||
404: 'repo or workflow not found',
|
||||
},
|
||||
})
|
||||
|
||||
return { status }
|
||||
}
|
||||
|
||||
async handle({ user, repo, workflow }, { branch, event }) {
|
||||
const { status } = await this.fetch({ user, repo, workflow, branch, event })
|
||||
return renderBuildStatusBadge({ status })
|
||||
}
|
||||
}
|
||||
66
services/github/github-actions-workflow-status.tester.js
Normal file
66
services/github/github-actions-workflow-status.tester.js
Normal file
@@ -0,0 +1,66 @@
|
||||
import Joi from 'joi'
|
||||
import { isBuildStatus } from '../build-status.js'
|
||||
import { createServiceTester } from '../tester.js'
|
||||
export const t = await createServiceTester()
|
||||
|
||||
const isWorkflowStatus = Joi.alternatives()
|
||||
.try(isBuildStatus, Joi.equal('no status'))
|
||||
.required()
|
||||
|
||||
t.create('nonexistent repo')
|
||||
.get('/badges/shields-fakeness/fake.yml.json')
|
||||
.expectBadge({
|
||||
label: 'build',
|
||||
message: 'repo or workflow not found',
|
||||
})
|
||||
|
||||
t.create('nonexistent workflow')
|
||||
.get('/actions/toolkit/not-a-real-workflow.yml.json')
|
||||
.expectBadge({
|
||||
label: 'build',
|
||||
message: 'repo or workflow not found',
|
||||
})
|
||||
|
||||
t.create('nonexistent branch')
|
||||
.get('/actions/toolkit/unit-tests.yml.json?branch=not-a-real-branch')
|
||||
.expectBadge({
|
||||
label: 'build',
|
||||
message: 'no status',
|
||||
})
|
||||
|
||||
t.create('nonexistent event')
|
||||
.get('/actions/toolkit/unit-tests.yml.json?event=not-a-real-event')
|
||||
.expectBadge({
|
||||
label: 'build',
|
||||
message: 'no status',
|
||||
})
|
||||
|
||||
t.create('numeric branch name')
|
||||
.get('/actions/toolkit/unit-tests.yml.json?branch=9999')
|
||||
.expectBadge({
|
||||
label: 'build',
|
||||
// the key thing we're testing here is that this doesn't fail with
|
||||
// "invalid query parameter: branch"
|
||||
message: 'no status',
|
||||
})
|
||||
|
||||
t.create('valid workflow')
|
||||
.get('/actions/toolkit/unit-tests.yml.json')
|
||||
.expectBadge({
|
||||
label: 'build',
|
||||
message: isWorkflowStatus,
|
||||
})
|
||||
|
||||
t.create('valid workflow (with branch)')
|
||||
.get('/actions/toolkit/unit-tests.yml.json?branch=main')
|
||||
.expectBadge({
|
||||
label: 'build',
|
||||
message: isWorkflowStatus,
|
||||
})
|
||||
|
||||
t.create('valid workflow (with event)')
|
||||
.get('/actions/toolkit/unit-tests.yml.json?event=push')
|
||||
.expectBadge({
|
||||
label: 'build',
|
||||
message: isWorkflowStatus,
|
||||
})
|
||||
@@ -41,6 +41,7 @@ class GithubApiProvider {
|
||||
onTokenInvalidated = tokenString => {},
|
||||
globalToken,
|
||||
reserveFraction = 0.25,
|
||||
restApiVersion,
|
||||
}) {
|
||||
Object.assign(this, {
|
||||
baseUrl,
|
||||
@@ -55,6 +56,7 @@ class GithubApiProvider {
|
||||
this.searchTokens = new TokenPool({ batchSize: 5 })
|
||||
this.graphqlTokens = new TokenPool({ batchSize: 25 })
|
||||
}
|
||||
this.restApiVersion = restApiVersion
|
||||
}
|
||||
|
||||
addToken(tokenString) {
|
||||
@@ -175,6 +177,7 @@ class GithubApiProvider {
|
||||
headers: {
|
||||
'User-Agent': userAgent,
|
||||
Authorization: `token ${tokenString}`,
|
||||
'X-GitHub-Api-Version': this.restApiVersion,
|
||||
...options.headers,
|
||||
},
|
||||
},
|
||||
|
||||
@@ -41,6 +41,7 @@ describe('GithubAuthV3Service', function () {
|
||||
)
|
||||
const githubApiProvider = new GithubApiProvider({
|
||||
baseUrl: 'https://github-api.example.com',
|
||||
restApiVersion: '2022-11-28',
|
||||
})
|
||||
const mockToken = { update: sinon.mock(), invalidate: sinon.mock() }
|
||||
sinon.stub(githubApiProvider.standardTokens, 'next').returns(mockToken)
|
||||
@@ -57,6 +58,7 @@ describe('GithubAuthV3Service', function () {
|
||||
'User-Agent': 'shields (self-hosted)/dev',
|
||||
Accept: 'application/vnd.github.antiope-preview+json',
|
||||
Authorization: 'token undefined',
|
||||
'X-GitHub-Api-Version': '2022-11-28',
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
@@ -33,10 +33,11 @@ class GithubConstellation {
|
||||
}
|
||||
|
||||
this.apiProvider = new GithubApiProvider({
|
||||
baseUrl: process.env.GITHUB_URL || 'https://api.github.com',
|
||||
baseUrl: config.service.baseUri,
|
||||
globalToken,
|
||||
withPooling: !globalToken,
|
||||
onTokenInvalidated: tokenString => this.onTokenInvalidated(tokenString),
|
||||
restApiVersion: config.service.restApiVersion,
|
||||
})
|
||||
|
||||
this.oauthHelper = this.constructor._createOauthHelper(config)
|
||||
|
||||
@@ -1,100 +1,28 @@
|
||||
import Joi from 'joi'
|
||||
import { isBuildStatus, renderBuildStatusBadge } from '../build-status.js'
|
||||
import { BaseSvgScrapingService } from '../index.js'
|
||||
import { documentation } from './github-helpers.js'
|
||||
import { BaseService } 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 {
|
||||
export default class DeprecatedGithubWorkflowStatus extends BaseService {
|
||||
static category = 'build'
|
||||
|
||||
static route = {
|
||||
base: 'github/workflow/status',
|
||||
pattern: ':user/:repo/:workflow/:branch*',
|
||||
queryParamSchema,
|
||||
pattern: ':various+',
|
||||
}
|
||||
|
||||
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 examples = []
|
||||
|
||||
static defaultBadgeData = {
|
||||
label: 'build',
|
||||
}
|
||||
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: { searchParams: { branch, event } },
|
||||
valueMatcher: />([^<>]+)<\/tspan><\/text><\/g><path/,
|
||||
errorMessages: {
|
||||
404: 'repo, branch, or workflow not found',
|
||||
},
|
||||
})
|
||||
|
||||
return { status }
|
||||
}
|
||||
|
||||
async handle({ user, repo, workflow, branch }, { event }) {
|
||||
const { status } = await this.fetch({ user, repo, workflow, branch, event })
|
||||
return renderBuildStatusBadge({ status })
|
||||
async handle() {
|
||||
return {
|
||||
label: 'build',
|
||||
message: 'https://github.com/badges/shields/issues/8671',
|
||||
/*
|
||||
This is a 'special' deprecation because we are making a breaking change
|
||||
We've implemented it as a custom class instead of a normal
|
||||
deprecatedService so that we can include link.
|
||||
*/
|
||||
link: ['https://github.com/badges/shields/issues/8671'],
|
||||
color: 'red',
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,43 +1,42 @@
|
||||
import Joi from 'joi'
|
||||
import { isBuildStatus } from '../build-status.js'
|
||||
import { createServiceTester } from '../tester.js'
|
||||
export const t = await createServiceTester()
|
||||
import { ServiceTester } from '../tester.js'
|
||||
|
||||
const isWorkflowStatus = Joi.alternatives()
|
||||
.try(isBuildStatus, Joi.equal('no status'))
|
||||
.required()
|
||||
export const t = new ServiceTester({
|
||||
id: 'GithubWorkflowStatus',
|
||||
title: 'Github Workflow Status',
|
||||
pathPrefix: '/github/workflow/status',
|
||||
})
|
||||
|
||||
t.create('nonexistent repo')
|
||||
t.create('no longer available (previously nonexistent repo)')
|
||||
.get('/badges/shields-fakeness/fake.json')
|
||||
.expectBadge({
|
||||
label: 'build',
|
||||
message: 'repo, branch, or workflow not found',
|
||||
message: 'https://github.com/badges/shields/issues/8671',
|
||||
})
|
||||
|
||||
t.create('nonexistent workflow')
|
||||
t.create('no longer available (previously nonexistent workflow)')
|
||||
.get('/actions/toolkit/not-a-real-workflow.json')
|
||||
.expectBadge({
|
||||
label: 'build',
|
||||
message: 'repo, branch, or workflow not found',
|
||||
message: 'https://github.com/badges/shields/issues/8671',
|
||||
})
|
||||
|
||||
t.create('valid workflow')
|
||||
t.create('no longer available (previously valid workflow)')
|
||||
.get('/actions/toolkit/toolkit-unit-tests.json')
|
||||
.expectBadge({
|
||||
label: 'build',
|
||||
message: isWorkflowStatus,
|
||||
message: 'https://github.com/badges/shields/issues/8671',
|
||||
})
|
||||
|
||||
t.create('valid workflow (branch)')
|
||||
t.create('no longer available (previously valid workflow - branch)')
|
||||
.get('/actions/toolkit/toolkit-unit-tests/master.json')
|
||||
.expectBadge({
|
||||
label: 'build',
|
||||
message: isWorkflowStatus,
|
||||
message: 'https://github.com/badges/shields/issues/8671',
|
||||
})
|
||||
|
||||
t.create('valid workflow (event)')
|
||||
t.create('no longer available (previously valid workflow - event)')
|
||||
.get('/actions/toolkit/toolkit-unit-tests.json?event=push')
|
||||
.expectBadge({
|
||||
label: 'build',
|
||||
message: isWorkflowStatus,
|
||||
message: 'https://github.com/badges/shields/issues/8671',
|
||||
})
|
||||
|
||||
39
services/modrinth/modrinth-base.js
Normal file
39
services/modrinth/modrinth-base.js
Normal file
@@ -0,0 +1,39 @@
|
||||
import Joi from 'joi'
|
||||
import { BaseJsonService } from '../index.js'
|
||||
import { nonNegativeInteger } from '../validators.js'
|
||||
|
||||
const projectSchema = Joi.object({
|
||||
downloads: nonNegativeInteger,
|
||||
followers: nonNegativeInteger,
|
||||
}).required()
|
||||
|
||||
const versionSchema = Joi.array()
|
||||
.items(
|
||||
Joi.object({
|
||||
version_number: Joi.string().required(),
|
||||
game_versions: Joi.array().items(Joi.string()).min(1).required(),
|
||||
}).required()
|
||||
)
|
||||
.required()
|
||||
|
||||
const documentation =
|
||||
"<p>You can use your project slug, or the project ID. The ID can be found in the 'Technical information' section of your Modrinth page.</p>"
|
||||
|
||||
class BaseModrinthService extends BaseJsonService {
|
||||
async fetchVersions({ projectId }) {
|
||||
const bruh = {
|
||||
schema: versionSchema,
|
||||
url: `https://api.modrinth.com/v2/project/${projectId}/version`,
|
||||
}
|
||||
return this._requestJson(bruh)
|
||||
}
|
||||
|
||||
async fetchProject({ projectId }) {
|
||||
return this._requestJson({
|
||||
schema: projectSchema,
|
||||
url: `https://api.modrinth.com/v2/project/${projectId}`,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
export { BaseModrinthService, documentation }
|
||||
27
services/modrinth/modrinth-downloads.service.js
Normal file
27
services/modrinth/modrinth-downloads.service.js
Normal file
@@ -0,0 +1,27 @@
|
||||
import { renderDownloadsBadge } from '../downloads.js'
|
||||
import { BaseModrinthService, documentation } from './modrinth-base.js'
|
||||
|
||||
export default class ModrinthDownloads extends BaseModrinthService {
|
||||
static category = 'downloads'
|
||||
|
||||
static route = {
|
||||
base: 'modrinth/dt',
|
||||
pattern: ':projectId',
|
||||
}
|
||||
|
||||
static examples = [
|
||||
{
|
||||
title: 'Modrinth Downloads',
|
||||
namedParams: { projectId: 'AANobbMI' },
|
||||
staticPreview: renderDownloadsBadge({ downloads: 120000 }),
|
||||
documentation,
|
||||
},
|
||||
]
|
||||
|
||||
static defaultBadgeData = { label: 'downloads' }
|
||||
|
||||
async handle({ projectId }) {
|
||||
const { downloads } = await this.fetchProject({ projectId })
|
||||
return renderDownloadsBadge({ downloads })
|
||||
}
|
||||
}
|
||||
37
services/modrinth/modrinth-followers.service.js
Normal file
37
services/modrinth/modrinth-followers.service.js
Normal file
@@ -0,0 +1,37 @@
|
||||
import { metric } from '../text-formatters.js'
|
||||
import { BaseModrinthService, documentation } from './modrinth-base.js'
|
||||
|
||||
export default class ModrinthFollowers extends BaseModrinthService {
|
||||
static category = 'social'
|
||||
|
||||
static route = {
|
||||
base: 'modrinth/followers',
|
||||
pattern: ':projectId',
|
||||
}
|
||||
|
||||
static examples = [
|
||||
{
|
||||
title: 'Modrinth Followers',
|
||||
namedParams: { projectId: 'AANobbMI' },
|
||||
staticPreview: Object.assign(this.render({ followers: 176 }), {
|
||||
label: 'Followers',
|
||||
style: 'social',
|
||||
}),
|
||||
documentation,
|
||||
},
|
||||
]
|
||||
|
||||
static defaultBadgeData = { label: 'followers' }
|
||||
|
||||
static render({ followers }) {
|
||||
return {
|
||||
message: metric(followers),
|
||||
color: 'blue',
|
||||
}
|
||||
}
|
||||
|
||||
async handle({ projectId }) {
|
||||
const { followers } = await this.fetchProject({ projectId })
|
||||
return this.constructor.render({ followers })
|
||||
}
|
||||
}
|
||||
12
services/modrinth/modrinth-followers.tester.js
Normal file
12
services/modrinth/modrinth-followers.tester.js
Normal file
@@ -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' })
|
||||
34
services/modrinth/modrinth-game-versions.service.js
Normal file
34
services/modrinth/modrinth-game-versions.service.js
Normal file
@@ -0,0 +1,34 @@
|
||||
import { BaseModrinthService, documentation } from './modrinth-base.js'
|
||||
|
||||
export default class ModrinthGameVersions extends BaseModrinthService {
|
||||
static category = 'platform-support'
|
||||
|
||||
static route = {
|
||||
base: 'modrinth/game-versions',
|
||||
pattern: ':projectId',
|
||||
}
|
||||
|
||||
static examples = [
|
||||
{
|
||||
title: 'Modrinth Game Versions',
|
||||
namedParams: { projectId: 'AANobbMI' },
|
||||
staticPreview: this.render({ versions: ['1.19.2', '1.19.1', '1.19'] }),
|
||||
documentation,
|
||||
},
|
||||
]
|
||||
|
||||
static defaultBadgeData = { label: 'game versions' }
|
||||
|
||||
static render({ versions }) {
|
||||
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 })
|
||||
}
|
||||
}
|
||||
15
services/modrinth/modrinth-game-versions.tester.js
Normal file
15
services/modrinth/modrinth-game-versions.tester.js
Normal file
@@ -0,0 +1,15 @@
|
||||
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: withRegex(/\d+\.\d+(\.\d+)?( \| )?/),
|
||||
})
|
||||
|
||||
t.create('Game Versions (not found)')
|
||||
.get('/not-existing.json')
|
||||
.expectBadge({ label: 'game versions', message: 'not found', color: 'red' })
|
||||
28
services/modrinth/modrinth-version.service.js
Normal file
28
services/modrinth/modrinth-version.service.js
Normal file
@@ -0,0 +1,28 @@
|
||||
import { renderVersionBadge } from '../version.js'
|
||||
import { BaseModrinthService, documentation } from './modrinth-base.js'
|
||||
|
||||
export default class ModrinthVersion extends BaseModrinthService {
|
||||
static category = 'version'
|
||||
|
||||
static route = {
|
||||
base: 'modrinth/v',
|
||||
pattern: ':projectId',
|
||||
}
|
||||
|
||||
static examples = [
|
||||
{
|
||||
title: 'Modrinth Version',
|
||||
namedParams: { projectId: 'AANobbMI' },
|
||||
staticPreview: renderVersionBadge({ version: '0.4.4' }),
|
||||
documentation,
|
||||
},
|
||||
]
|
||||
|
||||
static defaultBadgeData = { label: 'version' }
|
||||
|
||||
async handle({ projectId }) {
|
||||
const { 0: latest } = await this.fetchVersions({ projectId })
|
||||
const version = latest.version_number
|
||||
return renderVersionBadge({ version })
|
||||
}
|
||||
}
|
||||
12
services/modrinth/modrinth-version.tester.js
Normal file
12
services/modrinth/modrinth-version.tester.js
Normal file
@@ -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' })
|
||||
@@ -1,39 +0,0 @@
|
||||
import Joi from 'joi'
|
||||
import { BaseJsonService } from '../index.js'
|
||||
import { renderDownloadsBadge } from '../downloads.js'
|
||||
import { nonNegativeInteger } from '../validators.js'
|
||||
|
||||
const schema = Joi.object({
|
||||
downloads: nonNegativeInteger,
|
||||
}).required()
|
||||
|
||||
export default class Modrinth extends BaseJsonService {
|
||||
static category = 'downloads'
|
||||
|
||||
static route = {
|
||||
base: 'modrinth/dt',
|
||||
pattern: ':modId',
|
||||
}
|
||||
|
||||
static examples = [
|
||||
{
|
||||
title: 'Modrinth',
|
||||
namedParams: { modId: 'AANobbMI' },
|
||||
staticPreview: renderDownloadsBadge({ downloads: 120000 }),
|
||||
},
|
||||
]
|
||||
|
||||
static defaultBadgeData = { label: 'downloads' }
|
||||
|
||||
async fetch({ modId }) {
|
||||
return this._requestJson({
|
||||
schema,
|
||||
url: `https://api.modrinth.com/api/v1/mod/${modId}`,
|
||||
})
|
||||
}
|
||||
|
||||
async handle({ modId }) {
|
||||
const { downloads } = await this.fetch({ modId })
|
||||
return renderDownloadsBadge({ downloads })
|
||||
}
|
||||
}
|
||||
@@ -50,7 +50,17 @@ function getLicenses(packageData) {
|
||||
const {
|
||||
info: { license },
|
||||
} = packageData
|
||||
if (license) {
|
||||
|
||||
/*
|
||||
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.
|
||||
*/
|
||||
if (license && license.length < 40) {
|
||||
return [license]
|
||||
} else {
|
||||
const parenthesizedAcronymRegex = /\(([^)]+)\)/
|
||||
|
||||
@@ -116,6 +116,13 @@ describe('PyPI helpers', function () {
|
||||
classifiers: ['License :: OSI Approved :: MIT License'],
|
||||
},
|
||||
}),
|
||||
given({
|
||||
info: {
|
||||
license:
|
||||
'this text is really really really really really really long',
|
||||
classifiers: ['License :: OSI Approved :: MIT License'],
|
||||
},
|
||||
}),
|
||||
given({
|
||||
info: {
|
||||
license: '',
|
||||
|
||||
37
services/stackexchange/stackexchange-base.js
Normal file
37
services/stackexchange/stackexchange-base.js
Normal file
@@ -0,0 +1,37 @@
|
||||
import { BaseJsonService } from '../index.js'
|
||||
import { metric } from '../text-formatters.js'
|
||||
import { floorCount as floorCountColor } from '../color-formatters.js'
|
||||
|
||||
export function renderQuestionsBadge({
|
||||
suffix,
|
||||
stackexchangesite,
|
||||
query,
|
||||
numValue,
|
||||
}) {
|
||||
const label = `${stackexchangesite} ${query} questions`
|
||||
return {
|
||||
label,
|
||||
message: `${metric(numValue)}${suffix}`,
|
||||
color: floorCountColor(numValue, 1000, 10000, 20000),
|
||||
}
|
||||
}
|
||||
|
||||
export class StackExchangeBase extends BaseJsonService {
|
||||
static category = 'chat'
|
||||
|
||||
static auth = {
|
||||
passKey: 'stackapps_api_key',
|
||||
authorizedOrigins: ['https://api.stackexchange.com'],
|
||||
isRequired: false,
|
||||
}
|
||||
|
||||
static defaultBadgeData = {
|
||||
label: 'stackoverflow',
|
||||
}
|
||||
|
||||
async fetch(params) {
|
||||
return this._requestJson(
|
||||
this.authHelper.withQueryStringAuth({ passKey: 'key' }, params)
|
||||
)
|
||||
}
|
||||
}
|
||||
38
services/stackexchange/stackexchange-base.spec.js
Normal file
38
services/stackexchange/stackexchange-base.spec.js
Normal file
@@ -0,0 +1,38 @@
|
||||
import Joi from 'joi'
|
||||
import { expect } from 'chai'
|
||||
import nock from 'nock'
|
||||
import { cleanUpNockAfterEach, defaultContext } from '../test-helpers.js'
|
||||
import { StackExchangeBase } from './stackexchange-base.js'
|
||||
|
||||
class DummyStackExchangeService extends StackExchangeBase {
|
||||
static route = { base: 'fake-base' }
|
||||
|
||||
async handle() {
|
||||
const data = await this.fetch({
|
||||
schema: Joi.any(),
|
||||
url: 'https://api.stackexchange.com/2.2/tags/python/info',
|
||||
})
|
||||
return { message: data.message }
|
||||
}
|
||||
}
|
||||
|
||||
describe('StackExchangeBase', function () {
|
||||
describe('auth', function () {
|
||||
cleanUpNockAfterEach()
|
||||
|
||||
const config = { private: { stackapps_api_key: 'fake-key' } }
|
||||
|
||||
it('sends the auth information as configured', async function () {
|
||||
const scope = nock('https://api.stackexchange.com')
|
||||
.get('/2.2/tags/python/info')
|
||||
.query({ key: 'fake-key' })
|
||||
.reply(200, { message: 'fake message' })
|
||||
|
||||
expect(
|
||||
await DummyStackExchangeService.invoke(defaultContext, config, {})
|
||||
).to.deep.equal({ message: 'fake message' })
|
||||
|
||||
scope.done()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,16 +0,0 @@
|
||||
import { metric } from '../text-formatters.js'
|
||||
import { floorCount as floorCountColor } from '../color-formatters.js'
|
||||
|
||||
export default function renderQuestionsBadge({
|
||||
suffix,
|
||||
stackexchangesite,
|
||||
query,
|
||||
numValue,
|
||||
}) {
|
||||
const label = `${stackexchangesite} ${query} questions`
|
||||
return {
|
||||
label,
|
||||
message: `${metric(numValue)}${suffix}`,
|
||||
color: floorCountColor(numValue, 1000, 10000, 20000),
|
||||
}
|
||||
}
|
||||
@@ -1,16 +1,16 @@
|
||||
import dayjs from 'dayjs'
|
||||
import Joi from 'joi'
|
||||
import { nonNegativeInteger } from '../validators.js'
|
||||
import { BaseJsonService } from '../index.js'
|
||||
import renderQuestionsBadge from './stackexchange-helpers.js'
|
||||
import {
|
||||
renderQuestionsBadge,
|
||||
StackExchangeBase,
|
||||
} from './stackexchange-base.js'
|
||||
|
||||
const tagSchema = Joi.object({
|
||||
total: nonNegativeInteger,
|
||||
}).required()
|
||||
|
||||
export default class StackExchangeMonthlyQuestions extends BaseJsonService {
|
||||
static category = 'chat'
|
||||
|
||||
export default class StackExchangeMonthlyQuestions extends StackExchangeBase {
|
||||
static route = {
|
||||
base: 'stackexchange',
|
||||
pattern: ':stackexchangesite/qm/:query',
|
||||
@@ -29,10 +29,6 @@ export default class StackExchangeMonthlyQuestions extends BaseJsonService {
|
||||
},
|
||||
]
|
||||
|
||||
static defaultBadgeData = {
|
||||
label: 'stackoverflow',
|
||||
}
|
||||
|
||||
static render(props) {
|
||||
return renderQuestionsBadge({
|
||||
suffix: '/month',
|
||||
@@ -51,7 +47,7 @@ export default class StackExchangeMonthlyQuestions extends BaseJsonService {
|
||||
.endOf('month')
|
||||
.unix()
|
||||
|
||||
const parsedData = await this._requestJson({
|
||||
const parsedData = await this.fetch({
|
||||
schema: tagSchema,
|
||||
options: {
|
||||
decompress: true,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import Joi from 'joi'
|
||||
import { metric } from '../text-formatters.js'
|
||||
import { floorCount as floorCountColor } from '../color-formatters.js'
|
||||
import { BaseJsonService } from '../index.js'
|
||||
import { StackExchangeBase } from './stackexchange-base.js'
|
||||
|
||||
const reputationSchema = Joi.object({
|
||||
items: Joi.array()
|
||||
@@ -14,9 +14,7 @@ const reputationSchema = Joi.object({
|
||||
.required(),
|
||||
}).required()
|
||||
|
||||
export default class StackExchangeReputation extends BaseJsonService {
|
||||
static category = 'chat'
|
||||
|
||||
export default class StackExchangeReputation extends StackExchangeBase {
|
||||
static route = {
|
||||
base: 'stackexchange',
|
||||
pattern: ':stackexchangesite/r/:query',
|
||||
@@ -34,10 +32,6 @@ export default class StackExchangeReputation extends BaseJsonService {
|
||||
},
|
||||
]
|
||||
|
||||
static defaultBadgeData = {
|
||||
label: 'stackoverflow',
|
||||
}
|
||||
|
||||
static render({ stackexchangesite, numValue }) {
|
||||
const label = `${stackexchangesite} reputation`
|
||||
|
||||
@@ -51,7 +45,7 @@ export default class StackExchangeReputation extends BaseJsonService {
|
||||
async handle({ stackexchangesite, query }) {
|
||||
const path = `users/${query}`
|
||||
|
||||
const parsedData = await this._requestJson({
|
||||
const parsedData = await this.fetch({
|
||||
schema: reputationSchema,
|
||||
options: { decompress: true, searchParams: { site: stackexchangesite } },
|
||||
url: `https://api.stackexchange.com/2.2/${path}`,
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import Joi from 'joi'
|
||||
import { BaseJsonService } from '../index.js'
|
||||
import renderQuestionsBadge from './stackexchange-helpers.js'
|
||||
import {
|
||||
renderQuestionsBadge,
|
||||
StackExchangeBase,
|
||||
} from './stackexchange-base.js'
|
||||
|
||||
const tagSchema = Joi.object({
|
||||
items: Joi.array()
|
||||
@@ -13,9 +15,7 @@ const tagSchema = Joi.object({
|
||||
.required(),
|
||||
}).required()
|
||||
|
||||
export default class StackExchangeQuestions extends BaseJsonService {
|
||||
static category = 'chat'
|
||||
|
||||
export default class StackExchangeQuestions extends StackExchangeBase {
|
||||
static route = {
|
||||
base: 'stackexchange',
|
||||
pattern: ':stackexchangesite/t/:query',
|
||||
@@ -34,10 +34,6 @@ export default class StackExchangeQuestions extends BaseJsonService {
|
||||
},
|
||||
]
|
||||
|
||||
static defaultBadgeData = {
|
||||
label: 'stackoverflow',
|
||||
}
|
||||
|
||||
static render(props) {
|
||||
return renderQuestionsBadge({
|
||||
suffix: '',
|
||||
@@ -48,7 +44,7 @@ export default class StackExchangeQuestions extends BaseJsonService {
|
||||
async handle({ stackexchangesite, query }) {
|
||||
const path = `tags/${query}/info`
|
||||
|
||||
const parsedData = await this._requestJson({
|
||||
const parsedData = await this.fetch({
|
||||
schema: tagSchema,
|
||||
options: { decompress: true, searchParams: { site: stackexchangesite } },
|
||||
url: `https://api.stackexchange.com/2.2/${path}`,
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
},
|
||||
],
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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 { fetch } from '../core/base-service/got.js'
|
||||
|
||||
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.fetch(
|
||||
fetch,
|
||||
`/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 }
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -45,6 +45,16 @@ t.create('Test status on project that does not exist')
|
||||
|
||||
t.create('Test status on private project')
|
||||
.get('/github/tasdemo/nexe-private.json')
|
||||
.intercept(nock =>
|
||||
nock('https://api.tas.lambdatest.com')
|
||||
.get('/repo/badge')
|
||||
.query({
|
||||
git_provider: 'github',
|
||||
org: 'tasdemo',
|
||||
repo: 'nexe-private',
|
||||
})
|
||||
.reply(401)
|
||||
)
|
||||
.expectBadge({
|
||||
label: 'tests',
|
||||
message: 'private project not supported',
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user