diff --git a/core/server/server.js b/core/server/server.js index d6c6256a1165e..99484ef92f6cc 100644 --- a/core/server/server.js +++ b/core/server/server.js @@ -142,6 +142,7 @@ const publicConfigSchema = Joi.object({ sonar: defaultService, teamcity: defaultService, weblate: defaultService, + youtrack: defaultService, trace: Joi.boolean().required(), }).required(), cacheHeaders: { defaultCacheLengthSeconds: nonNegativeInteger }, @@ -205,6 +206,7 @@ const privateConfigSchema = Joi.object({ influx_password: Joi.string(), weblate_api_key: Joi.string(), youtube_api_key: Joi.string(), + youtrack_token: Joi.string(), }).required() const privateMetricsInfluxConfigSchema = privateConfigSchema.append({ influx_username: Joi.string().required(), diff --git a/doc/server-secrets.md b/doc/server-secrets.md index f7406b6d8bb89..6efadc0183805 100644 --- a/doc/server-secrets.md +++ b/doc/server-secrets.md @@ -364,6 +364,15 @@ and create an API key for the YouTube Data API v3. [youtube credentials]: https://console.developers.google.com/apis/credentials +### YouTrack + +- `YOUTRACK_ORIGINS` (yml: `public.services.youtrack.authorizedOrigins`) +- `YOUTRACK_TOKEN` (yml: `private.youtrack_token`) + +A Youtrack [Permanent Access Token][youtrack-pat] is required for accessing private content. If you need a Youtrack token for your self-hosted Shields server then we recommend limiting the scopes to the minimal set necessary for the badges you are using. + +[youtrack-pat]: https://www.jetbrains.com/help/youtrack/devportal/Manage-Permanent-Token.html + ## Error reporting - `SENTRY_DSN` (yml: `private.sentry_dsn`) diff --git a/services/youtrack/youtrack-base.js b/services/youtrack/youtrack-base.js new file mode 100644 index 0000000000000..c99d5321093df --- /dev/null +++ b/services/youtrack/youtrack-base.js @@ -0,0 +1,22 @@ +import { BaseJsonService } from '../index.js' + +export default class YoutrackBase extends BaseJsonService { + static auth = { + passKey: 'youtrack_token', + serviceKey: 'youtrack', + } + + async fetch({ url, options, schema, httpErrors }) { + return this._requestJson( + this.authHelper.withBearerAuthHeader({ + schema, + url, + options, + httpErrors: { 500: 'invalid query', ...httpErrors }, + systemErrors: { + ETIMEOUT: { prettyMessage: 'timeout', cacheSeconds: 10 }, + }, + }), + ) + } +} diff --git a/services/youtrack/youtrack-base.spec.js b/services/youtrack/youtrack-base.spec.js new file mode 100644 index 0000000000000..33361cf08a38a --- /dev/null +++ b/services/youtrack/youtrack-base.spec.js @@ -0,0 +1,48 @@ +import Joi from 'joi' +import { expect } from 'chai' +import nock from 'nock' +import { cleanUpNockAfterEach, defaultContext } from '../test-helpers.js' +import YoutrackBase from './youtrack-base.js' + +class DummyYoutrackService extends YoutrackBase { + static route = { base: 'fake-base' } + + async handle() { + const data = await this.fetch({ + schema: Joi.any(), + url: 'https://shields.youtrack.cloud/api/issuesGetter/count?fields=count', + }) + return { message: data.message } + } +} + +describe('YoutrackBase', function () { + describe('auth', function () { + cleanUpNockAfterEach() + + const config = { + public: { + services: { + youtrack: { + authorizedOrigins: ['https://shields.youtrack.cloud'], + }, + }, + }, + private: { + youtrack_token: 'fake-key', + }, + } + + it('sends the auth information as configured', async function () { + const scope = nock('https://shields.youtrack.cloud') + .get('/api/issuesGetter/count?fields=count') + .matchHeader('Authorization', 'Bearer fake-key') + .reply(200, { message: 'fake message' }) + expect( + await DummyYoutrackService.invoke(defaultContext, config, {}), + ).to.not.have.property('isError') + + scope.done() + }) + }) +}) diff --git a/services/youtrack/youtrack-helper.js b/services/youtrack/youtrack-helper.js new file mode 100644 index 0000000000000..deaab538c4488 --- /dev/null +++ b/services/youtrack/youtrack-helper.js @@ -0,0 +1,7 @@ +const description = ` +Returns the number of issues for the specified project based on the \`query\` parameter defined. + +NOTE: The \`youtrack_url\` query param is required. +` + +export { description } diff --git a/services/youtrack/youtrack-issues.service.js b/services/youtrack/youtrack-issues.service.js new file mode 100644 index 0000000000000..2c6d7e4a58082 --- /dev/null +++ b/services/youtrack/youtrack-issues.service.js @@ -0,0 +1,99 @@ +import Joi from 'joi' +import { InvalidResponse, pathParam, queryParam } from '../index.js' +import { optionalUrl } from '../validators.js' +import { metric } from '../text-formatters.js' +import { description } from './youtrack-helper.js' +import YoutrackBase from './youtrack-base.js' + +function sleep(ms) { + return new Promise(resolve => setTimeout(resolve, ms)) +} + +const schema = Joi.object({ + count: Joi.number().required(), + $type: Joi.equal('IssueCountResponse'), +}) + +const queryParamSchema = Joi.object({ + query: Joi.string(), + youtrack_url: optionalUrl, +}).required() + +export default class YoutrackIssues extends YoutrackBase { + static category = 'issue-tracking' + + static route = { + base: 'youtrack/issues', + pattern: ':project+', + queryParamSchema, + } + + static openApi = { + '/youtrack/issues/{project}': { + get: { + summary: 'Youtrack Issues', + description, + parameters: [ + pathParam({ + name: 'project', + example: 'DEMO', + }), + queryParam({ + name: 'youtrack_url', + example: 'https://shields.youtrack.cloud', + required: true, + }), + queryParam({ + name: 'query', + example: 'manage state: Unresolved', + description: 'A valid YouTrack search query.', + }), + ], + }, + }, + } + + static defaultBadgeData = { label: 'issues', color: 'informational' } + + static render({ count }) { + return { + label: 'issues', + message: metric(count), + color: count > 0 ? 'yellow' : 'brightgreen', + } + } + + async fetch({ baseUrl, query }) { + // https://www.jetbrains.com.cn/en-us/help/youtrack/devportal/resource-api-issuesGetter-count.html + return super.fetch({ + schema, + options: { + method: 'POST', + json: { query }, + }, + url: `${baseUrl}/api/issuesGetter/count?fields=count`, + }) + } + + async handle({ project }, { youtrack_url: baseUrl, query = '' }) { + for (let i = 0; i < 6; i++) { + // 6 trials + const data = await this.fetch({ + baseUrl, + query: `project: ${project} ${query}`, + }) + + if (data.count === -1) { + await sleep(500) + continue + } + + return this.constructor.render({ count: data.count }) + } + + throw new InvalidResponse({ + prettyMessage: 'invalid', + cacheSeconds: 10, + }) + } +} diff --git a/services/youtrack/youtrack-issues.tester.js b/services/youtrack/youtrack-issues.tester.js new file mode 100644 index 0000000000000..60aeecf3e7f6b --- /dev/null +++ b/services/youtrack/youtrack-issues.tester.js @@ -0,0 +1,38 @@ +import { createServiceTester } from '../tester.js' +import { isMetric } from '../test-validators.js' + +export const t = await createServiceTester() + +t.create('Issues (DEMO) (Cloud)') + .get( + '/DEMO.json?youtrack_url=https://shields.youtrack.cloud&query=manage%20state%3A%20Unresolved', + ) + .expectBadge({ + label: 'issues', + message: isMetric, + }) + +t.create('Issues (DEMO) (Empty Query) (Cloud)') + .get('/DEMO.json?youtrack_url=https://shields.youtrack.cloud') + .expectBadge({ + label: 'issues', + message: isMetric, + }) + +t.create('Issues (DEMO) (Invalid State) (Cloud Hosted)') + .get( + '/DEMO.json?youtrack_url=https://shields.youtrack.cloud&query=%23ABCDEFG', + ) + .expectBadge({ + label: 'issues', + message: 'invalid', + }) + +t.create('Issues (DOESNOTEXIST) (Invalid Project) (Cloud Hosted)') + .get( + '/DOESNOTEXIST.json?youtrack_url=https://shields.youtrack.cloud&query=state%3A%20Unresolved', + ) + .expectBadge({ + label: 'issues', + message: 'invalid', + })