From 0be9fb918a4c3fb7eb8e7074ebcd4a1f4c71a440 Mon Sep 17 00:00:00 2001 From: Neemys <36508659+Neemys@users.noreply.github.com> Date: Wed, 8 Jun 2022 13:48:40 +0200 Subject: [PATCH 01/30] create pull request Signed-off-by: Neemys <36508659+Neemys@users.noreply.github.com> From 4d64f22c9d6727f8fd03007e9535dfb28bae1a21 Mon Sep 17 00:00:00 2001 From: Neemys <36508659+Neemys@users.noreply.github.com> Date: Mon, 13 Jun 2022 15:37:30 +0200 Subject: [PATCH 02/30] Add new backend bare plugin intended for sonarqube Signed-off-by: Neemys <36508659+Neemys@users.noreply.github.com> --- plugins/sonarqube-backend/.eslintrc.js | 1 + plugins/sonarqube-backend/README.md | 13 +++++ plugins/sonarqube-backend/package.json | 44 ++++++++++++++++ plugins/sonarqube-backend/src/index.ts | 17 +++++++ plugins/sonarqube-backend/src/run.ts | 33 ++++++++++++ .../src/service/router.test.ts | 45 +++++++++++++++++ .../sonarqube-backend/src/service/router.ts | 40 +++++++++++++++ .../src/service/standaloneServer.ts | 50 +++++++++++++++++++ plugins/sonarqube-backend/src/setupTests.ts | 17 +++++++ 9 files changed, 260 insertions(+) create mode 100644 plugins/sonarqube-backend/.eslintrc.js create mode 100644 plugins/sonarqube-backend/README.md create mode 100644 plugins/sonarqube-backend/package.json create mode 100644 plugins/sonarqube-backend/src/index.ts create mode 100644 plugins/sonarqube-backend/src/run.ts create mode 100644 plugins/sonarqube-backend/src/service/router.test.ts create mode 100644 plugins/sonarqube-backend/src/service/router.ts create mode 100644 plugins/sonarqube-backend/src/service/standaloneServer.ts create mode 100644 plugins/sonarqube-backend/src/setupTests.ts diff --git a/plugins/sonarqube-backend/.eslintrc.js b/plugins/sonarqube-backend/.eslintrc.js new file mode 100644 index 0000000000..e2a53a6ad2 --- /dev/null +++ b/plugins/sonarqube-backend/.eslintrc.js @@ -0,0 +1 @@ +module.exports = require('@backstage/cli/config/eslint-factory')(__dirname); diff --git a/plugins/sonarqube-backend/README.md b/plugins/sonarqube-backend/README.md new file mode 100644 index 0000000000..0fad3b67f3 --- /dev/null +++ b/plugins/sonarqube-backend/README.md @@ -0,0 +1,13 @@ +# sonarqube-backend + +Welcome to the sonarqube-backend backend plugin! + +_This plugin was created through the Backstage CLI_ + +## Getting started + +Your plugin has been added to the example app in this repository, meaning you'll be able to access it by running `yarn start` in the root directory, and then navigating to [/sonarqube-backend](http://localhost:3000/sonarqube-backend). + +You can also serve the plugin in isolation by running `yarn start` in the plugin directory. +This method of serving the plugin provides quicker iteration speed and a faster startup and hot reloads. +It is only meant for local development, and the setup for it can be found inside the [/dev](/dev) directory. diff --git a/plugins/sonarqube-backend/package.json b/plugins/sonarqube-backend/package.json new file mode 100644 index 0000000000..ca2b98c10e --- /dev/null +++ b/plugins/sonarqube-backend/package.json @@ -0,0 +1,44 @@ +{ + "name": "plugin-sonarqube-backend", + "version": "0.0.0", + "main": "src/index.ts", + "types": "src/index.ts", + "license": "Apache-2.0", + "private": true, + "publishConfig": { + "access": "public", + "main": "dist/index.cjs.js", + "types": "dist/index.d.ts" + }, + "backstage": { + "role": "backend-plugin" + }, + "scripts": { + "start": "backstage-cli package start", + "build": "backstage-cli package build", + "lint": "backstage-cli package lint", + "test": "backstage-cli package test", + "clean": "backstage-cli package clean", + "prepack": "backstage-cli package prepack", + "postpack": "backstage-cli package postpack" + }, + "dependencies": { + "@backstage/backend-common": "^0.13.6-next.1", + "@backstage/config": "^1.0.1", + "@types/express": "*", + "express": "^4.17.1", + "express-promise-router": "^4.1.0", + "winston": "^3.2.1", + "node-fetch": "^2.6.7", + "yn": "^4.0.0" + }, + "devDependencies": { + "@backstage/cli": "^0.17.2-next.1", + "@types/supertest": "^2.0.8", + "supertest": "^4.0.2", + "msw": "^0.42.0" + }, + "files": [ + "dist" + ] +} diff --git a/plugins/sonarqube-backend/src/index.ts b/plugins/sonarqube-backend/src/index.ts new file mode 100644 index 0000000000..ca73cb27ba --- /dev/null +++ b/plugins/sonarqube-backend/src/index.ts @@ -0,0 +1,17 @@ +/* + * Copyright 2020 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export * from './service/router'; diff --git a/plugins/sonarqube-backend/src/run.ts b/plugins/sonarqube-backend/src/run.ts new file mode 100644 index 0000000000..0a3ed2b7f0 --- /dev/null +++ b/plugins/sonarqube-backend/src/run.ts @@ -0,0 +1,33 @@ +/* + * Copyright 2020 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { getRootLogger } from '@backstage/backend-common'; +import yn from 'yn'; +import { startStandaloneServer } from './service/standaloneServer'; + +const port = process.env.PLUGIN_PORT ? Number(process.env.PLUGIN_PORT) : 7007; +const enableCors = yn(process.env.PLUGIN_CORS, { default: false }); +const logger = getRootLogger(); + +startStandaloneServer({ port, enableCors, logger }).catch(err => { + logger.error(err); + process.exit(1); +}); + +process.on('SIGINT', () => { + logger.info('CTRL+C pressed; exiting.'); + process.exit(0); +}); diff --git a/plugins/sonarqube-backend/src/service/router.test.ts b/plugins/sonarqube-backend/src/service/router.test.ts new file mode 100644 index 0000000000..8b77a04348 --- /dev/null +++ b/plugins/sonarqube-backend/src/service/router.test.ts @@ -0,0 +1,45 @@ +/* + * Copyright 2020 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { getVoidLogger } from '@backstage/backend-common'; +import express from 'express'; +import request from 'supertest'; + +import { createRouter } from './router'; + +describe('createRouter', () => { + let app: express.Express; + + beforeAll(async () => { + const router = await createRouter({ + logger: getVoidLogger(), + }); + app = express().use(router); + }); + + beforeEach(() => { + jest.resetAllMocks(); + }); + + describe('GET /health', () => { + it('returns ok', async () => { + const response = await request(app).get('/health'); + + expect(response.status).toEqual(200); + expect(response.body).toEqual({ status: 'ok' }); + }); + }); +}); diff --git a/plugins/sonarqube-backend/src/service/router.ts b/plugins/sonarqube-backend/src/service/router.ts new file mode 100644 index 0000000000..9ceaa47627 --- /dev/null +++ b/plugins/sonarqube-backend/src/service/router.ts @@ -0,0 +1,40 @@ +/* + * Copyright 2020 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { errorHandler } from '@backstage/backend-common'; +import express from 'express'; +import Router from 'express-promise-router'; +import { Logger } from 'winston'; + +export interface RouterOptions { + logger: Logger; +} + +export async function createRouter( + options: RouterOptions, +): Promise { + const { logger } = options; + + const router = Router(); + router.use(express.json()); + + router.get('/health', (_, response) => { + logger.info('PONG!'); + response.send({ status: 'ok' }); + }); + router.use(errorHandler()); + return router; +} diff --git a/plugins/sonarqube-backend/src/service/standaloneServer.ts b/plugins/sonarqube-backend/src/service/standaloneServer.ts new file mode 100644 index 0000000000..78f39b2680 --- /dev/null +++ b/plugins/sonarqube-backend/src/service/standaloneServer.ts @@ -0,0 +1,50 @@ +/* + * Copyright 2020 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { createServiceBuilder } from '@backstage/backend-common'; +import { Server } from 'http'; +import { Logger } from 'winston'; +import { createRouter } from './router'; + +export interface ServerOptions { + port: number; + enableCors: boolean; + logger: Logger; +} + +export async function startStandaloneServer( + options: ServerOptions, +): Promise { + const logger = options.logger.child({ service: 'sonarqube-backend-backend' }); + logger.debug('Starting application server...'); + const router = await createRouter({ + logger, + }); + + let service = createServiceBuilder(module) + .setPort(options.port) + .addRouter('/sonarqube-backend', router); + if (options.enableCors) { + service = service.enableCors({ origin: 'http://localhost:3000' }); + } + + return await service.start().catch(err => { + logger.error(err); + process.exit(1); + }); +} + +module.hot?.accept(); diff --git a/plugins/sonarqube-backend/src/setupTests.ts b/plugins/sonarqube-backend/src/setupTests.ts new file mode 100644 index 0000000000..d3232290a7 --- /dev/null +++ b/plugins/sonarqube-backend/src/setupTests.ts @@ -0,0 +1,17 @@ +/* + * Copyright 2020 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export {}; From 619b5151721ad879cb608efef861f65f76644d3f Mon Sep 17 00:00:00 2001 From: Neemys <36508659+Neemys@users.noreply.github.com> Date: Thu, 16 Jun 2022 17:50:24 +0200 Subject: [PATCH 03/30] Modify sonarqube frontend plugin to call the new sonarqube backend backend only have mock API for now Signed-off-by: Neemys <36508659+Neemys@users.noreply.github.com> --- .changeset/forty-lobsters-guess.md | 9 ++ .../src/service/router.test.ts | 12 +- .../sonarqube-backend/src/service/router.ts | 11 +- .../sonarqube/src/api/SonarQubeClient.test.ts | 138 +++--------------- plugins/sonarqube/src/api/SonarQubeClient.ts | 55 ++----- plugins/sonarqube/src/api/types.ts | 10 +- 6 files changed, 57 insertions(+), 178 deletions(-) create mode 100644 .changeset/forty-lobsters-guess.md diff --git a/.changeset/forty-lobsters-guess.md b/.changeset/forty-lobsters-guess.md new file mode 100644 index 0000000000..50dd22627f --- /dev/null +++ b/.changeset/forty-lobsters-guess.md @@ -0,0 +1,9 @@ +--- +'@backstage/plugin-sonarqube': minor +--- + +**BREAKING** This plugin now call the sonarqube-backend plugin instead of relying on the proxy plugin + +The whole proxy's `'/sonarqube':` key can be removed from your configuration files. + +Then head to the sonarqube-backend plugin page to learn how to set-up the link to your sonarqube instances. diff --git a/plugins/sonarqube-backend/src/service/router.test.ts b/plugins/sonarqube-backend/src/service/router.test.ts index 8b77a04348..24e482896b 100644 --- a/plugins/sonarqube-backend/src/service/router.test.ts +++ b/plugins/sonarqube-backend/src/service/router.test.ts @@ -34,12 +34,18 @@ describe('createRouter', () => { jest.resetAllMocks(); }); - describe('GET /health', () => { + describe('GET /findings', () => { it('returns ok', async () => { - const response = await request(app).get('/health'); + const response = await request(app) + .get('/findings') + .set('componentKey', 'my:app') + .send(); expect(response.status).toEqual(200); - expect(response.body).toEqual({ status: 'ok' }); + expect(response.body).toEqual({ + analysisDate: '2022-10-22T04:55:23Z', + measures: [{ metric: 'coverage', value: '50' }], + }); }); }); }); diff --git a/plugins/sonarqube-backend/src/service/router.ts b/plugins/sonarqube-backend/src/service/router.ts index 9ceaa47627..9d13104d82 100644 --- a/plugins/sonarqube-backend/src/service/router.ts +++ b/plugins/sonarqube-backend/src/service/router.ts @@ -30,10 +30,13 @@ export async function createRouter( const router = Router(); router.use(express.json()); - - router.get('/health', (_, response) => { - logger.info('PONG!'); - response.send({ status: 'ok' }); + // mock api for now + router.get('/findings', (request, response) => { + logger.info(request.params); + response.send({ + analysisDate: '2022-10-22T04:55:23Z', + measures: [{ metric: 'coverage', value: '50' }], + }); }); router.use(errorHandler()); return router; diff --git a/plugins/sonarqube/src/api/SonarQubeClient.test.ts b/plugins/sonarqube/src/api/SonarQubeClient.test.ts index b592953434..1727e73e93 100644 --- a/plugins/sonarqube/src/api/SonarQubeClient.test.ts +++ b/plugins/sonarqube/src/api/SonarQubeClient.test.ts @@ -18,7 +18,7 @@ import { setupRequestMockHandlers } from '@backstage/test-utils'; import { rest } from 'msw'; import { setupServer } from 'msw/node'; import { FindingSummary, SonarQubeClient } from './index'; -import { ComponentWrapper, MeasuresWrapper } from './types'; +import { FindingsWrapper } from './types'; import { UrlPatternDiscovery } from '@backstage/core-app-api'; import { IdentityApi } from '@backstage/core-plugin-api'; @@ -40,133 +40,66 @@ const identityApiGuest: IdentityApi = { describe('SonarQubeClient', () => { setupRequestMockHandlers(server); - const mockBaseUrl = 'http://backstage:9191/api/proxy'; + const mockBaseUrl = 'http://backstage:9191/api/sonarqube'; const discoveryApi = UrlPatternDiscovery.compile(mockBaseUrl); - const setupHandlers = ( - metricKeys = [ - 'alert_status', - 'bugs', - 'reliability_rating', - 'vulnerabilities', - 'security_rating', - 'security_hotspots_reviewed', - 'security_review_rating', - 'code_smells', - 'sqale_rating', - 'coverage', - 'duplicated_lines_density', - ], - ) => { + const setupHandlers = () => { server.use( - rest.get(`${mockBaseUrl}/sonarqube/metrics/search`, (req, res, ctx) => { - expect(req.url.searchParams.get('ps')).toBe('500'); - - // emulate paging to check if everything is requested - if (req.url.searchParams.get('p') === '1') { - return res( - ctx.json({ - metrics: metricKeys.slice(0, 5).map(k => ({ key: k })), - total: metricKeys.length, - }), - ); - } - - // make sure this is only called twice - expect(req.url.searchParams.get('p')).toBe('2'); - return res( - ctx.json({ - metrics: metricKeys.slice(5).map(k => ({ key: k })), - total: metricKeys.length, - }), - ); - }), - ); - - server.use( - rest.get(`${mockBaseUrl}/sonarqube/components/show`, (req, res, ctx) => { - expect(req.url.searchParams.toString()).toBe('component=our%3Aservice'); - return res( - ctx.json({ - component: { - analysisDate: '2020-01-01T00:00:00Z', - }, - } as ComponentWrapper), - ); - }), - ); - - server.use( - rest.get(`${mockBaseUrl}/sonarqube/measures/search`, (req, res, ctx) => { + rest.get(`${mockBaseUrl}/findings`, (req, res, ctx) => { expect(req.url.searchParams.toString()).toBe( - `projectKeys=our%3Aservice&metricKeys=${metricKeys.join('%2C')}`, + 'componentKey=our%3Aservice', ); return res( ctx.json({ + analysisDate: '2020-01-01T00:00:00Z', measures: [ { metric: 'alert_status', value: 'OK', - component: 'our:service', - }, - { - metric: 'alert_status', - value: 'ERROR', - component: 'other-service', }, { metric: 'bugs', value: '2', - component: 'our:service', }, { metric: 'reliability_rating', value: '3.0', - component: 'our:service', }, { metric: 'vulnerabilities', value: '4', - component: 'our:service', }, { metric: 'security_rating', value: '1.0', - component: 'our:service', }, { metric: 'security_hotspots_reviewed', value: '100', - component: 'our:service', }, { metric: 'security_review_rating', value: '1.0', - component: 'our:service', }, { metric: 'code_smells', value: '100', - component: 'our:service', }, { metric: 'sqale_rating', value: '2.0', - component: 'our:service', }, { metric: 'coverage', value: '55.5', - component: 'our:service', }, { metric: 'duplicated_lines_density', value: '1.0', - component: 'our:service', }, - ].filter(m => metricKeys.includes(m.metric)), - } as MeasuresWrapper), + ], + } as FindingsWrapper), ); }), ); @@ -246,47 +179,19 @@ describe('SonarQubeClient', () => { ); }); - it('should only request selected metrics', async () => { - setupHandlers(['alert_status', 'bugs']); - - const client = new SonarQubeClient({ - discoveryApi, - baseUrl: 'http://a.instance.local', - identityApi: identityApiAuthenticated, - }); - - const summary = await client.getFindingSummary('our:service'); - - expect(summary).toEqual( - expect.objectContaining({ - lastAnalysis: '2020-01-01T00:00:00Z', - metrics: { - alert_status: 'OK', - bugs: '2', - }, - projectUrl: 'http://a.instance.local/dashboard?id=our%3Aservice', - }) as FindingSummary, - ); - expect(summary?.getIssuesUrl('CODE_SMELL')).toEqual( - 'http://a.instance.local/project/issues?id=our%3Aservice&types=CODE_SMELL&resolved=false', - ); - expect(summary?.getComponentMeasuresUrl('COVERAGE')).toEqual( - 'http://a.instance.local/component_measures?id=our%3Aservice&metric=coverage&resolved=false&view=list', - ); - }); - it('should add identity token for logged in users', async () => { setupHandlers(); server.use( - rest.get(`${mockBaseUrl}/sonarqube/components/show`, (req, res, ctx) => { - expect(req.url.searchParams.toString()).toBe('component=our%3Aservice'); + rest.get(`${mockBaseUrl}/findings`, (req, res, ctx) => { + expect(req.url.searchParams.toString()).toBe( + 'componentKey=our%3Aservice', + ); expect(req.headers.get('Authorization')).toBe('Bearer fake-id-token'); return res( ctx.json({ - component: { - analysisDate: '2020-01-01T00:00:00Z', - }, - } as ComponentWrapper), + analysisDate: '2020-01-01T00:00:00Z', + measures: [], + } as FindingsWrapper), ); }), ); @@ -304,15 +209,16 @@ describe('SonarQubeClient', () => { it('should omit identity token for guest users', async () => { setupHandlers(); server.use( - rest.get(`${mockBaseUrl}/sonarqube/components/show`, (req, res, ctx) => { - expect(req.url.searchParams.toString()).toBe('component=our%3Aservice'); + rest.get(`${mockBaseUrl}/findings`, (req, res, ctx) => { + expect(req.url.searchParams.toString()).toBe( + 'componentKey=our%3Aservice', + ); expect(req.headers.has('Authorization')).toBeFalsy(); return res( ctx.json({ - component: { - analysisDate: '2020-01-01T00:00:00Z', - }, - } as ComponentWrapper), + analysisDate: '2020-01-01T00:00:00Z', + measures: [], + } as FindingsWrapper), ); }), ); diff --git a/plugins/sonarqube/src/api/SonarQubeClient.ts b/plugins/sonarqube/src/api/SonarQubeClient.ts index 39d0f11369..ed0b7dc3a7 100644 --- a/plugins/sonarqube/src/api/SonarQubeClient.ts +++ b/plugins/sonarqube/src/api/SonarQubeClient.ts @@ -16,7 +16,7 @@ import fetch from 'cross-fetch'; import { FindingSummary, Metrics, SonarQubeApi } from './SonarQubeApi'; -import { ComponentWrapper, MeasuresWrapper } from './types'; +import { FindingsWrapper } from './types'; import { DiscoveryApi, IdentityApi } from '@backstage/core-plugin-api'; export class SonarQubeClient implements SonarQubeApi { @@ -44,7 +44,7 @@ export class SonarQubeClient implements SonarQubeApi { ): Promise { const { token: idToken } = await this.identityApi.getCredentials(); - const apiUrl = `${await this.discoveryApi.getBaseUrl('proxy')}/sonarqube`; + const apiUrl = `${await this.discoveryApi.getBaseUrl('sonarqube')}`; const response = await fetch( `${apiUrl}/${path}?${new URLSearchParams(query).toString()}`, { @@ -60,27 +60,6 @@ export class SonarQubeClient implements SonarQubeApi { return undefined; } - private async getSupportedMetrics(): Promise { - const metrics: string[] = []; - let nextPage: number = 1; - - for (;;) { - const result = await this.callApi<{ - metrics: Array<{ key: string }>; - total: number; - }>('metrics/search', { ps: 500, p: nextPage }); - - metrics.push(...(result?.metrics?.map(m => m.key) ?? [])); - - if (result && metrics.length < result.total) { - nextPage++; - continue; - } - - return metrics; - } - } - async getFindingSummary( componentKey?: string, ): Promise { @@ -88,13 +67,6 @@ export class SonarQubeClient implements SonarQubeApi { return undefined; } - const component = await this.callApi('components/show', { - component: componentKey, - }); - if (!component) { - return undefined; - } - const metrics: Metrics = { alert_status: undefined, bugs: undefined, @@ -109,28 +81,19 @@ export class SonarQubeClient implements SonarQubeApi { duplicated_lines_density: undefined, }; - // select the metrics that are supported by the SonarQube instance - const supportedMetrics = await this.getSupportedMetrics(); - const metricKeys = Object.keys(metrics).filter(m => - supportedMetrics.includes(m), - ); - - const measures = await this.callApi('measures/search', { - projectKeys: componentKey, - metricKeys: metricKeys.join(','), + const findings = await this.callApi('findings', { + componentKey: componentKey, }); - if (!measures) { + if (!findings) { return undefined; } - measures.measures - .filter(m => m.component === componentKey) - .forEach(m => { - metrics[m.metric] = m.value; - }); + findings.measures.forEach(m => { + metrics[m.metric] = m.value; + }); return { - lastAnalysis: component.component.analysisDate, + lastAnalysis: findings.analysisDate, metrics, projectUrl: `${this.baseUrl}dashboard?id=${encodeURIComponent( componentKey, diff --git a/plugins/sonarqube/src/api/types.ts b/plugins/sonarqube/src/api/types.ts index 6e951f9d8b..27f065d36a 100644 --- a/plugins/sonarqube/src/api/types.ts +++ b/plugins/sonarqube/src/api/types.ts @@ -14,15 +14,8 @@ * limitations under the License. */ -export interface ComponentWrapper { - component: Component; -} - -export interface Component { +export interface FindingsWrapper { analysisDate: string; -} - -export interface MeasuresWrapper { measures: Measure[]; } @@ -55,7 +48,6 @@ export type MetricKey = export interface Measure { metric: MetricKey; value: string; - component: string; } export type SonarUrlProcessorFunc = (identifier: string) => string; From f9c310a4395cf79c5c4b2d3c2b33b03bfca8b96c Mon Sep 17 00:00:00 2001 From: Neemys <36508659+Neemys@users.noreply.github.com> Date: Fri, 17 Jun 2022 17:43:40 +0200 Subject: [PATCH 04/30] Modify sonarqube frontend plugin to handle multiple sonarqube instances The instance name should be provided into the annotation in the `catalog-info.yaml` Care has been taken to provide backward compatibility of previous annotation into the default sonarqube instance Signed-off-by: Neemys <36508659+Neemys@users.noreply.github.com> --- .changeset/ten-roses-walk.md | 5 ++ .../sonarqube-backend/src/service/router.ts | 8 ++ plugins/sonarqube/src/api/SonarQubeApi.ts | 5 +- .../sonarqube/src/api/SonarQubeClient.test.ts | 79 ++++++++++++++----- plugins/sonarqube/src/api/SonarQubeClient.ts | 37 ++++++--- plugins/sonarqube/src/api/types.ts | 4 + .../SonarQubeCard/SonarQubeCard.tsx | 6 +- .../sonarqube/src/components/useProjectKey.ts | 32 +++++++- plugins/sonarqube/src/plugin.ts | 5 +- 9 files changed, 139 insertions(+), 42 deletions(-) create mode 100644 .changeset/ten-roses-walk.md diff --git a/.changeset/ten-roses-walk.md b/.changeset/ten-roses-walk.md new file mode 100644 index 0000000000..14d0819c38 --- /dev/null +++ b/.changeset/ten-roses-walk.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-sonarqube': patch +--- + +Add ability to provide an optional sonarqube instance into the annotation in the `catalog-info.yaml` file diff --git a/plugins/sonarqube-backend/src/service/router.ts b/plugins/sonarqube-backend/src/service/router.ts index 9d13104d82..54f0038ca9 100644 --- a/plugins/sonarqube-backend/src/service/router.ts +++ b/plugins/sonarqube-backend/src/service/router.ts @@ -38,6 +38,14 @@ export async function createRouter( measures: [{ metric: 'coverage', value: '50' }], }); }); + router.get('/instanceUrl', (request, response) => { + logger.info(request.params); + response.send({ + instanceUrl: `https://instance.local?${encodeURI( + request.query.instanceKey as string, + )}`, + }); + }); router.use(errorHandler()); return router; } diff --git a/plugins/sonarqube/src/api/SonarQubeApi.ts b/plugins/sonarqube/src/api/SonarQubeApi.ts index 3b39b30eb0..2232a00d9f 100644 --- a/plugins/sonarqube/src/api/SonarQubeApi.ts +++ b/plugins/sonarqube/src/api/SonarQubeApi.ts @@ -38,5 +38,8 @@ export const sonarQubeApiRef = createApiRef({ }); export type SonarQubeApi = { - getFindingSummary(componentKey?: string): Promise; + getFindingSummary( + projectInstance?: string, + componentKey?: string, + ): Promise; }; diff --git a/plugins/sonarqube/src/api/SonarQubeClient.test.ts b/plugins/sonarqube/src/api/SonarQubeClient.test.ts index 1727e73e93..87137ea93f 100644 --- a/plugins/sonarqube/src/api/SonarQubeClient.test.ts +++ b/plugins/sonarqube/src/api/SonarQubeClient.test.ts @@ -18,7 +18,7 @@ import { setupRequestMockHandlers } from '@backstage/test-utils'; import { rest } from 'msw'; import { setupServer } from 'msw/node'; import { FindingSummary, SonarQubeClient } from './index'; -import { FindingsWrapper } from './types'; +import { InstanceUrlWrapper, FindingsWrapper } from './types'; import { UrlPatternDiscovery } from '@backstage/core-app-api'; import { IdentityApi } from '@backstage/core-plugin-api'; @@ -47,7 +47,7 @@ describe('SonarQubeClient', () => { server.use( rest.get(`${mockBaseUrl}/findings`, (req, res, ctx) => { expect(req.url.searchParams.toString()).toBe( - 'componentKey=our%3Aservice', + 'componentKey=our%3Aservice&instanceKey=', ); return res( @@ -103,6 +103,17 @@ describe('SonarQubeClient', () => { ); }), ); + server.use( + rest.get(`${mockBaseUrl}/instanceUrl`, (req, res, ctx) => { + expect(req.url.searchParams.toString()).toBe('instanceKey='); + + return res( + ctx.json({ + instanceUrl: 'https://sonarcloud.io', + } as InstanceUrlWrapper), + ); + }), + ); }; it('should report finding summary', async () => { @@ -143,30 +154,60 @@ describe('SonarQubeClient', () => { it('should report finding summary (custom baseUrl)', async () => { setupHandlers(); + server.use( + rest.get(`${mockBaseUrl}/instanceUrl`, (req, res, ctx) => { + expect(req.url.searchParams.toString()).toBe('instanceKey=custom'); + + return res( + ctx.json({ + instanceUrl: 'http://a.instance.local', + } as InstanceUrlWrapper), + ); + }), + ); + + server.use( + rest.get(`${mockBaseUrl}/findings`, (req, res, ctx) => { + expect(req.url.searchParams.toString()).toBe( + 'componentKey=our%3Aservice&instanceKey=custom', + ); + + return res( + ctx.json({ + analysisDate: '2020-01-03T00:00:00Z', + measures: [ + { + metric: 'alert_status', + value: 'ERROR', + }, + { + metric: 'bugs', + value: '45', + }, + { + metric: 'reliability_rating', + value: '5.0', + }, + ], + } as FindingsWrapper), + ); + }), + ); const client = new SonarQubeClient({ discoveryApi, - baseUrl: 'http://a.instance.local', identityApi: identityApiAuthenticated, }); - const summary = await client.getFindingSummary('our:service'); + const summary = await client.getFindingSummary('our:service', 'custom'); expect(summary).toEqual( expect.objectContaining({ - lastAnalysis: '2020-01-01T00:00:00Z', + lastAnalysis: '2020-01-03T00:00:00Z', metrics: { - alert_status: 'OK', - bugs: '2', - reliability_rating: '3.0', - vulnerabilities: '4', - security_rating: '1.0', - security_hotspots_reviewed: '100', - security_review_rating: '1.0', - code_smells: '100', - sqale_rating: '2.0', - coverage: '55.5', - duplicated_lines_density: '1.0', + alert_status: 'ERROR', + bugs: '45', + reliability_rating: '5.0', }, projectUrl: 'http://a.instance.local/dashboard?id=our%3Aservice', }) as FindingSummary, @@ -184,7 +225,7 @@ describe('SonarQubeClient', () => { server.use( rest.get(`${mockBaseUrl}/findings`, (req, res, ctx) => { expect(req.url.searchParams.toString()).toBe( - 'componentKey=our%3Aservice', + 'componentKey=our%3Aservice&instanceKey=', ); expect(req.headers.get('Authorization')).toBe('Bearer fake-id-token'); return res( @@ -198,7 +239,6 @@ describe('SonarQubeClient', () => { const client = new SonarQubeClient({ discoveryApi, - baseUrl: 'http://a.instance.local', identityApi: identityApiAuthenticated, }); const summary = await client.getFindingSummary('our:service'); @@ -211,7 +251,7 @@ describe('SonarQubeClient', () => { server.use( rest.get(`${mockBaseUrl}/findings`, (req, res, ctx) => { expect(req.url.searchParams.toString()).toBe( - 'componentKey=our%3Aservice', + 'componentKey=our%3Aservice&instanceKey=', ); expect(req.headers.has('Authorization')).toBeFalsy(); return res( @@ -225,7 +265,6 @@ describe('SonarQubeClient', () => { const client = new SonarQubeClient({ discoveryApi, - baseUrl: 'http://a.instance.local', identityApi: identityApiGuest, }); const summary = await client.getFindingSummary('our:service'); diff --git a/plugins/sonarqube/src/api/SonarQubeClient.ts b/plugins/sonarqube/src/api/SonarQubeClient.ts index ed0b7dc3a7..59182d917f 100644 --- a/plugins/sonarqube/src/api/SonarQubeClient.ts +++ b/plugins/sonarqube/src/api/SonarQubeClient.ts @@ -16,26 +16,22 @@ import fetch from 'cross-fetch'; import { FindingSummary, Metrics, SonarQubeApi } from './SonarQubeApi'; -import { FindingsWrapper } from './types'; +import { InstanceUrlWrapper, FindingsWrapper } from './types'; import { DiscoveryApi, IdentityApi } from '@backstage/core-plugin-api'; export class SonarQubeClient implements SonarQubeApi { discoveryApi: DiscoveryApi; - baseUrl: string; identityApi: IdentityApi; constructor({ discoveryApi, identityApi, - baseUrl = 'https://sonarcloud.io/', }: { discoveryApi: DiscoveryApi; identityApi: IdentityApi; - baseUrl?: string; }) { this.discoveryApi = discoveryApi; this.identityApi = identityApi; - this.baseUrl = baseUrl.endsWith('/') ? baseUrl : `${baseUrl}/`; } private async callApi( @@ -62,11 +58,14 @@ export class SonarQubeClient implements SonarQubeApi { async getFindingSummary( componentKey?: string, + projectInstance?: string, ): Promise { if (!componentKey) { return undefined; } + const instanceKey = projectInstance || ''; + const metrics: Metrics = { alert_status: undefined, bugs: undefined, @@ -81,8 +80,24 @@ export class SonarQubeClient implements SonarQubeApi { duplicated_lines_density: undefined, }; + const baseUrlWrapper = await this.callApi( + 'instanceUrl', + { + instanceKey, + }, + ); + let baseUrl = baseUrlWrapper?.instanceUrl; + if (!baseUrl) { + return undefined; + } + // ensure trailing slash for later on + if (!baseUrl.endsWith('/')) { + baseUrl += '/'; + } + const findings = await this.callApi('findings', { - componentKey: componentKey, + componentKey, + instanceKey, }); if (!findings) { return undefined; @@ -95,21 +110,19 @@ export class SonarQubeClient implements SonarQubeApi { return { lastAnalysis: findings.analysisDate, metrics, - projectUrl: `${this.baseUrl}dashboard?id=${encodeURIComponent( - componentKey, - )}`, + projectUrl: `${baseUrl}dashboard?id=${encodeURIComponent(componentKey)}`, getIssuesUrl: identifier => - `${this.baseUrl}project/issues?id=${encodeURIComponent( + `${baseUrl}project/issues?id=${encodeURIComponent( componentKey, )}&types=${identifier.toLocaleUpperCase('en-US')}&resolved=false`, getComponentMeasuresUrl: identifier => - `${this.baseUrl}component_measures?id=${encodeURIComponent( + `${baseUrl}component_measures?id=${encodeURIComponent( componentKey, )}&metric=${identifier.toLocaleLowerCase( 'en-US', )}&resolved=false&view=list`, getSecurityHotspotsUrl: () => - `${this.baseUrl}project/security_hotspots?id=${encodeURIComponent( + `${baseUrl}project/security_hotspots?id=${encodeURIComponent( componentKey, )}`, }; diff --git a/plugins/sonarqube/src/api/types.ts b/plugins/sonarqube/src/api/types.ts index 27f065d36a..348986149d 100644 --- a/plugins/sonarqube/src/api/types.ts +++ b/plugins/sonarqube/src/api/types.ts @@ -14,6 +14,10 @@ * limitations under the License. */ +export interface InstanceUrlWrapper { + instanceUrl: string; +} + export interface FindingsWrapper { analysisDate: string; measures: Measure[]; diff --git a/plugins/sonarqube/src/components/SonarQubeCard/SonarQubeCard.tsx b/plugins/sonarqube/src/components/SonarQubeCard/SonarQubeCard.tsx index 4527c2a3e3..d8aaec04f2 100644 --- a/plugins/sonarqube/src/components/SonarQubeCard/SonarQubeCard.tsx +++ b/plugins/sonarqube/src/components/SonarQubeCard/SonarQubeCard.tsx @@ -26,7 +26,7 @@ import useAsync from 'react-use/lib/useAsync'; import { sonarQubeApiRef } from '../../api'; import { SONARQUBE_PROJECT_KEY_ANNOTATION, - useProjectKey, + useProjectInfo, } from '../useProjectKey'; import { Percentage } from './Percentage'; import { Rating } from './Rating'; @@ -95,10 +95,10 @@ export const SonarQubeCard = ({ const { entity } = useEntity(); const sonarQubeApi = useApi(sonarQubeApiRef); - const projectTitle = useProjectKey(entity); + const { projectKey: projectTitle, projectInstance } = useProjectInfo(entity); const { value, loading } = useAsync( - async () => sonarQubeApi.getFindingSummary(projectTitle), + async () => sonarQubeApi.getFindingSummary(projectTitle, projectInstance), [sonarQubeApi, projectTitle], ); diff --git a/plugins/sonarqube/src/components/useProjectKey.ts b/plugins/sonarqube/src/components/useProjectKey.ts index 18596a8171..b091ea597c 100644 --- a/plugins/sonarqube/src/components/useProjectKey.ts +++ b/plugins/sonarqube/src/components/useProjectKey.ts @@ -16,11 +16,39 @@ import { Entity } from '@backstage/catalog-model'; +export interface ProjectInfo { + projectInstance: string; + projectKey: string; +} + export const SONARQUBE_PROJECT_KEY_ANNOTATION = 'sonarqube.org/project-key'; +export const SONARQUBE_PROJECT_INSTANCE_SEPARATOR = '/'; export const isSonarQubeAvailable = (entity: Entity) => Boolean(entity.metadata.annotations?.[SONARQUBE_PROJECT_KEY_ANNOTATION]); -export const useProjectKey = (entity: Entity) => { - return entity?.metadata.annotations?.[SONARQUBE_PROJECT_KEY_ANNOTATION] ?? ''; +/** + * Try to parse sonarqube information from an entity. + * + * If part are all info are not found, they will default to an empty string + * + * @param entity entity to find the sonarqube information from. + * @return a ProjectInfo properly populated. + */ +export const useProjectInfo = (entity: Entity): ProjectInfo => { + let projectInstance = ''; + let projectKey = ''; + const annotation = + entity?.metadata.annotations?.[SONARQUBE_PROJECT_KEY_ANNOTATION]; + if (annotation) { + if (annotation.indexOf(SONARQUBE_PROJECT_INSTANCE_SEPARATOR) > -1) { + [projectInstance, projectKey] = annotation.split( + SONARQUBE_PROJECT_INSTANCE_SEPARATOR, + 2, + ); + } else { + projectKey = annotation; + } + } + return { projectInstance, projectKey }; }; diff --git a/plugins/sonarqube/src/plugin.ts b/plugins/sonarqube/src/plugin.ts index 5662a41f2f..4ac49b2350 100644 --- a/plugins/sonarqube/src/plugin.ts +++ b/plugins/sonarqube/src/plugin.ts @@ -16,7 +16,6 @@ import { sonarQubeApiRef, SonarQubeClient } from './api'; import { - configApiRef, createApiFactory, createComponentExtension, createPlugin, @@ -30,14 +29,12 @@ export const sonarQubePlugin = createPlugin({ createApiFactory({ api: sonarQubeApiRef, deps: { - configApi: configApiRef, discoveryApi: discoveryApiRef, identityApi: identityApiRef, }, - factory: ({ configApi, discoveryApi, identityApi }) => + factory: ({ discoveryApi, identityApi }) => new SonarQubeClient({ discoveryApi, - baseUrl: configApi.getOptionalString('sonarQube.baseUrl'), identityApi, }), }), From 4d0fc08d4b0ed36802e0a9263ab050739cd41319 Mon Sep 17 00:00:00 2001 From: Neemys <36508659+Neemys@users.noreply.github.com> Date: Fri, 17 Jun 2022 17:46:52 +0200 Subject: [PATCH 05/30] Update sonarqube plugin's README.md to reflect recent changes No more use for proxy configuration Optional instance name in project annotations Signed-off-by: Neemys <36508659+Neemys@users.noreply.github.com> --- plugins/sonarqube/README.md | 44 +++++-------------------------------- 1 file changed, 5 insertions(+), 39 deletions(-) diff --git a/plugins/sonarqube/README.md b/plugins/sonarqube/README.md index febce4ecfc..d8ed6dfb8d 100644 --- a/plugins/sonarqube/README.md +++ b/plugins/sonarqube/README.md @@ -33,50 +33,14 @@ yarn add --cwd packages/app @backstage/plugin-sonarqube ); ``` -3. Add the proxy config: - - Provide a method for your Backstage backend to get to your SonarQube API end point. Add configuration to your `app-config.yaml` file depending on the product you use. Make sure to keep the trailing colon after the `SONARQUBE_TOKEN`, it is required to call - the Web API (see [docs](https://docs.sonarqube.org/latest/extend/web-api/)). - -**SonarCloud** - -```yaml -proxy: - '/sonarqube': - target: https://sonarcloud.io/api - allowedMethods: ['GET'] - # note that the colon after the token is required - auth: '${SONARQUBE_TOKEN}:' - # Environmental variable: SONARQUBE_TOKEN - # Fetch the sonar-auth-token from https://sonarcloud.io/account/security/ -``` - -**SonarQube** - -```yaml -proxy: - '/sonarqube': - target: https://your.sonarqube.instance.com/api - allowedMethods: ['GET'] - # note that the colon after the token is required - auth: '${SONARQUBE_TOKEN}:' - # Environmental variable: SONARQUBE_TOKEN - # Fetch the sonar-auth-token from https://sonarcloud.io/account/security/ - -sonarQube: - baseUrl: https://your.sonarqube.instance.com -``` - -4. Get and provide `SONARQUBE_TOKEN` as an env variable (https://sonarcloud.io/account/security or https://docs.sonarqube.org/latest/user-guide/user-token/). - -5. Run the following commands in the root folder of the project to install and compile the changes. +3. Run the following commands in the root folder of the project to install and compile the changes. ```yaml yarn install yarn tsc ``` -6. Add the `sonarqube.org/project-key` annotation to the `catalog-info.yaml` file of the target repo for which code quality analysis is needed. +4. Add the `sonarqube.org/project-key` annotation to the `catalog-info.yaml` file of the target repo for which code quality analysis is needed. ```yaml apiVersion: backstage.io/v1alpha1 @@ -86,9 +50,11 @@ metadata: description: | Backstage is an open-source developer portal that puts the developer experience first. annotations: - sonarqube.org/project-key: YOUR_PROJECT_KEY + sonarqube.org/project-key: YOUR_INSTANCE_NAME/YOUR_PROJECT_KEY spec: type: library owner: CNCF lifecycle: experimental ``` + +`YOUR_INSTANCE_NAME/` is optional and will query the default instance if not provided. From 55a31cc7538d8fa7b318fd3a95f8ac016e2d5694 Mon Sep 17 00:00:00 2001 From: Neemys <36508659+Neemys@users.noreply.github.com> Date: Thu, 21 Jul 2022 13:19:55 +0200 Subject: [PATCH 06/30] Add sonarqubeInfoProvider in sonarqube-backend plugin Handle config and sonarqube api call Heavily inspired from the jenkinsInfoProvider in the jenkins-backend plugin Signed-off-by: Neemys <36508659+Neemys@users.noreply.github.com> --- plugins/sonarqube-backend/src/index.ts | 1 + .../src/service/sonarqubeInfoProvider.test.ts | 504 ++++++++++++++++++ .../src/service/sonarqubeInfoProvider.ts | 371 +++++++++++++ 3 files changed, 876 insertions(+) create mode 100644 plugins/sonarqube-backend/src/service/sonarqubeInfoProvider.test.ts create mode 100644 plugins/sonarqube-backend/src/service/sonarqubeInfoProvider.ts diff --git a/plugins/sonarqube-backend/src/index.ts b/plugins/sonarqube-backend/src/index.ts index ca73cb27ba..c91aef09f8 100644 --- a/plugins/sonarqube-backend/src/index.ts +++ b/plugins/sonarqube-backend/src/index.ts @@ -15,3 +15,4 @@ */ export * from './service/router'; +export * from './service/sonarqubeInfoProvider'; diff --git a/plugins/sonarqube-backend/src/service/sonarqubeInfoProvider.test.ts b/plugins/sonarqube-backend/src/service/sonarqubeInfoProvider.test.ts new file mode 100644 index 0000000000..fcb73da926 --- /dev/null +++ b/plugins/sonarqube-backend/src/service/sonarqubeInfoProvider.test.ts @@ -0,0 +1,504 @@ +/* + * Copyright 2020 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { ConfigReader } from '../../../../packages/config'; +import { + DefaultSonarqubeInfoProvider, + SonarqubeConfig, +} from './sonarqubeInfoProvider'; +import { setupServer } from 'msw/node'; +import { setupRequestMockHandlers } from '../../../../packages/test-utils'; +import { rest, RestRequest } from 'msw'; + +describe('SonarqubeConfig', () => { + const SONARQUBE_DEFAULT_INSTANCE_NAME = 'default'; + const DUMMY_SONAR_URL = 'https://sonarqube.example.com'; + const DUMMY_SONAR_APIKEY = '123456789abcdef0123456789abcedf012'; + const DUMMY_SIMPLE_OBJECT_FOR_DEFAULT_SONARQUBE_CONFIG = { + name: SONARQUBE_DEFAULT_INSTANCE_NAME, + baseUrl: DUMMY_SONAR_URL, + apiKey: DUMMY_SONAR_APIKEY, + }; + const DUMMY_SIMPLE_CONFIG = { + sonarqube: { + baseUrl: DUMMY_SONAR_URL, + apiKey: DUMMY_SONAR_APIKEY, + }, + }; + const DUMMY_NAMED_CONFIG = { + sonarqube: { + instances: [ + { + name: SONARQUBE_DEFAULT_INSTANCE_NAME, + baseUrl: DUMMY_SONAR_URL, + apiKey: DUMMY_SONAR_APIKEY, + }, + ], + }, + }; + + describe('fromConfig', () => { + it('Reads simple config and annotation', async () => { + const config = SonarqubeConfig.fromConfig( + new ConfigReader(DUMMY_SIMPLE_CONFIG), + ); + + expect(config.instances).toEqual([ + { + name: SONARQUBE_DEFAULT_INSTANCE_NAME, + baseUrl: DUMMY_SONAR_URL, + apiKey: DUMMY_SONAR_APIKEY, + }, + ]); + }); + + it('Reads named default config and annotation', async () => { + const config = SonarqubeConfig.fromConfig( + new ConfigReader(DUMMY_NAMED_CONFIG), + ); + + expect(config.instances).toEqual([ + { + name: SONARQUBE_DEFAULT_INSTANCE_NAME, + baseUrl: DUMMY_SONAR_URL, + apiKey: DUMMY_SONAR_APIKEY, + }, + ]); + }); + + it('Parses named default config (amongst named other configs)', async () => { + const config = SonarqubeConfig.fromConfig( + new ConfigReader({ + sonarqube: { + instances: [ + { + name: SONARQUBE_DEFAULT_INSTANCE_NAME, + baseUrl: DUMMY_SONAR_URL, + apiKey: DUMMY_SONAR_APIKEY, + }, + { + name: 'other', + baseUrl: 'https://sonarqube-other.example.com', + apiKey: 'abcdef0123456789abcedf0123456789abc', + }, + ], + }, + }), + ); + + expect(config.instances).toEqual([ + { + name: SONARQUBE_DEFAULT_INSTANCE_NAME, + baseUrl: DUMMY_SONAR_URL, + apiKey: DUMMY_SONAR_APIKEY, + }, + { + name: 'other', + baseUrl: 'https://sonarqube-other.example.com', + apiKey: 'abcdef0123456789abcedf0123456789abc', + }, + ]); + }); + it('Throw an error if both a named default config and top level config', async () => { + expect(() => + SonarqubeConfig.fromConfig( + new ConfigReader({ + sonarqube: { + baseUrl: DUMMY_SONAR_URL, + apiKey: DUMMY_SONAR_APIKEY, + instances: [ + { + name: SONARQUBE_DEFAULT_INSTANCE_NAME, + baseUrl: DUMMY_SONAR_URL, + apiKey: DUMMY_SONAR_APIKEY, + }, + ], + }, + }), + ), + ).toThrowError(Error); + }); + + it('Throw an error if default config is partially provided', async () => { + expect(() => + SonarqubeConfig.fromConfig( + new ConfigReader({ + sonarqube: { + baseUrl: DUMMY_SONAR_URL, + }, + }), + ), + ).toThrowError(Error); + }); + }); + + describe('getInstanceConfig', () => { + it('Gets default instance when no parameter given', async () => { + const config = new SonarqubeConfig([ + DUMMY_SIMPLE_OBJECT_FOR_DEFAULT_SONARQUBE_CONFIG, + { + name: 'other', + baseUrl: 'https://sonarqube-other.example.com', + apiKey: '123456789abcdef0123456789abcedf012', + }, + ]); + + expect(config.getInstanceConfig()).toEqual({ + name: SONARQUBE_DEFAULT_INSTANCE_NAME, + baseUrl: DUMMY_SONAR_URL, + apiKey: DUMMY_SONAR_APIKEY, + }); + }); + + it('Gets default instance when "default" given', async () => { + const config = new SonarqubeConfig([ + DUMMY_SIMPLE_OBJECT_FOR_DEFAULT_SONARQUBE_CONFIG, + { + name: 'other', + baseUrl: 'https://sonarqube-other.example.com', + apiKey: 'abcdef0123456789abcedf0123456789abc', + }, + ]); + + expect(config.getInstanceConfig('default')).toEqual({ + name: 'default', + baseUrl: 'https://sonarqube.example.com', + apiKey: '123456789abcdef0123456789abcedf012', + }); + }); + + it('Gets named instance', async () => { + const config = new SonarqubeConfig([ + DUMMY_SIMPLE_OBJECT_FOR_DEFAULT_SONARQUBE_CONFIG, + { + name: 'other', + baseUrl: 'https://sonarqube-other.example.com', + apiKey: '123456789abcdef0123456789abcedf012', + }, + ]); + + expect(config.getInstanceConfig('other')).toEqual({ + name: 'other', + baseUrl: 'https://sonarqube-other.example.com', + apiKey: '123456789abcdef0123456789abcedf012', + }); + }); + + it('Throw an error if default instance could not be found', async () => { + const config = new SonarqubeConfig([ + { + name: 'other', + baseUrl: 'https://sonarqube-other.example.com', + apiKey: '123456789abcdef0123456789abcedf012', + }, + ]); + + expect(() => config.getInstanceConfig('default')).toThrowError(Error); + }); + + it('Throw an error if named instance could not be found', async () => { + const config = new SonarqubeConfig([ + DUMMY_SIMPLE_OBJECT_FOR_DEFAULT_SONARQUBE_CONFIG, + ]); + + expect(() => config.getInstanceConfig('other')).toThrowError(Error); + }); + }); +}); + +const server = setupServer(); + +describe('DefaultSonarqubeInfoProvider', () => { + function configureProvider(configData: any) { + const config = new ConfigReader(configData); + + return DefaultSonarqubeInfoProvider.fromConfig(config); + } + + describe('getBaseUrl', () => { + it('Provide base url for default from simple config and empty string', async () => { + const provider = configureProvider({ + sonarqube: { + baseUrl: 'https://sonarqube.example.com', + apiKey: '123456789abcdef0123456789abcedf012', + }, + }); + + expect(provider.getBaseUrl('')).toEqual('https://sonarqube.example.com'); + }); + + it('Provide base url for named default config and "default" string', async () => { + const provider = configureProvider({ + sonarqube: { + instances: [ + { + name: 'default', + baseUrl: 'https://sonarqube.example.com', + apiKey: '123456789abcdef0123456789abcedf012', + }, + ], + }, + }); + + expect(provider.getBaseUrl('default')).toEqual( + 'https://sonarqube.example.com', + ); + }); + + it('Provide base url for named config', async () => { + const provider = configureProvider({ + sonarqube: { + instances: [ + { + name: 'default', + baseUrl: 'https://sonarqube.example.com', + apiKey: '123456789abcdef0123456789abcedf012', + }, + { + name: 'other', + baseUrl: 'https://sonarqube-other.example.com', + apiKey: '123456789abcdef0123456789abcedf012', + }, + ], + }, + }); + + expect(provider.getBaseUrl('other')).toEqual( + 'https://sonarqube-other.example.com', + ); + }); + }); + + describe('getFindings', () => { + setupRequestMockHandlers(server); + const MOCK_BASE_URL = 'http://backstage:9191'; + const DUMMY_COMPONENT_KEY = 'dummyComponentKey'; + const DUMMY_ANALYSIS_DATE = '2022-01-01T00:00:00Z'; + const DUMMY_API_KEY = '123456789abcdef0123456789abcedf012'; + + const checkBasicAuthToken = (req: RestRequest) => { + if (req.headers && req.headers.has('Authorization')) { + expect(req.headers.get('Authorization')).toEqual( + `Basic MTIzNDU2Nzg5YWJjZGVmMDEyMzQ1Njc4OWFiY2VkZjAxMjo=`, + ); + } else { + throw new Error('Basic auth token not provided'); + } + }; + + const setupComponentHandler = () => { + server.use( + rest.get(`${MOCK_BASE_URL}/api/components/show`, (req, res, ctx) => { + checkBasicAuthToken(req); + expect(req.url.searchParams.toString()).toBe( + `component=${DUMMY_COMPONENT_KEY}`, + ); + return res( + ctx.json({ + component: { analysisDate: DUMMY_ANALYSIS_DATE }, + }), + ); + }), + ); + }; + const setupMetricsHandler = () => { + server.use( + rest.get(`${MOCK_BASE_URL}/api/metrics/search`, (req, res, ctx) => { + checkBasicAuthToken(req); + return res( + ctx.json({ + total: 4, + metrics: [ + { key: 'coverage' }, + { key: 'code_smells' }, + { key: 'vulnerabilities' }, + { key: 'unused_metric' }, + ], + }), + ); + }), + ); + }; + const setupMeasureHandler = () => { + server.use( + rest.get(`${MOCK_BASE_URL}/api/measures/component`, (req, res, ctx) => { + checkBasicAuthToken(req); + expect(req.url.searchParams.toString()).toBe( + `component=${DUMMY_COMPONENT_KEY}&metricKeys=vulnerabilities%2Ccode_smells%2Ccoverage`, + ); + return res( + ctx.json({ + component: { + measures: [ + { metric: 'coverage', value: '86' }, + { metric: 'code_smells', value: '40' }, + { metric: 'vulnerabilities', value: '3' }, + ], + }, + }), + ); + }), + ); + }; + + const setupHandlers = () => { + setupComponentHandler(); + setupMetricsHandler(); + setupMeasureHandler(); + }; + + const DUMMY_SIMPLE_CONFIG_FOR_PROVIDER = { + sonarqube: { + baseUrl: MOCK_BASE_URL, + apiKey: DUMMY_API_KEY, + }, + }; + it('Provide findings when everything is ok', async () => { + setupHandlers(); + const provider = configureProvider(DUMMY_SIMPLE_CONFIG_FOR_PROVIDER); + expect( + await provider.getFindings(DUMMY_COMPONENT_KEY, 'default'), + ).toEqual({ + analysisDate: DUMMY_ANALYSIS_DATE, + measures: [ + { metric: 'coverage', value: '86' }, + { metric: 'code_smells', value: '40' }, + { metric: 'vulnerabilities', value: '3' }, + ], + }); + }); + + it('Provide undefined as finding if component API answer code is not 200', async () => { + server.use( + rest.get(`${MOCK_BASE_URL}/api/components/show`, (req, res, ctx) => { + checkBasicAuthToken(req); + expect(req.url.searchParams.toString()).toBe( + `component=${DUMMY_COMPONENT_KEY}`, + ); + return res(ctx.status(500)); + }), + ); + + const provider = configureProvider({ + sonarqube: { + baseUrl: MOCK_BASE_URL, + apiKey: '123456789abcdef0123456789abcedf012', + }, + }); + expect( + await provider.getFindings(DUMMY_COMPONENT_KEY, 'default'), + ).toBeUndefined(); + }); + it('Provide undefined as finding if component API answer incorrectly', async () => { + server.use( + rest.get(`${MOCK_BASE_URL}/api/components/show`, (req, res, ctx) => { + checkBasicAuthToken(req); + expect(req.url.searchParams.toString()).toBe( + `component=${DUMMY_COMPONENT_KEY}`, + ); + return res( + ctx.json({ + invalid: true, + }), + ); + }), + ); + + const provider = configureProvider(DUMMY_SIMPLE_CONFIG_FOR_PROVIDER); + expect( + await provider.getFindings(DUMMY_COMPONENT_KEY, 'default'), + ).toBeUndefined(); + }); + it('Provide findings when metrics API uses pages', async () => { + setupComponentHandler(); + setupMeasureHandler(); + // custom metrics handler that provide two pages + server.use( + rest.get(`${MOCK_BASE_URL}/api/metrics/search`, (req, res, ctx) => { + checkBasicAuthToken(req); + if (req.url.searchParams.get('p') === '1') + return res( + ctx.json({ + total: 4, + metrics: [{ key: 'coverage' }, { key: 'code_smells' }], + }), + ); + return res( + ctx.json({ + total: 4, + metrics: [{ key: 'vulnerabilities' }, { key: 'unused_metric' }], + }), + ); + }), + ); + + const provider = configureProvider(DUMMY_SIMPLE_CONFIG_FOR_PROVIDER); + expect( + await provider.getFindings(DUMMY_COMPONENT_KEY, 'default'), + ).toEqual({ + analysisDate: DUMMY_ANALYSIS_DATE, + measures: [ + { metric: 'coverage', value: '86' }, + { metric: 'code_smells', value: '40' }, + { metric: 'vulnerabilities', value: '3' }, + ], + }); + }); + + it('Provide undefined as findings when measure API answer code is not 200', async () => { + setupComponentHandler(); + setupMetricsHandler(); + // custom metrics handler that provide two pages + server.use( + rest.get(`${MOCK_BASE_URL}/api/measures/component`, (req, res, ctx) => { + checkBasicAuthToken(req); + expect(req.url.searchParams.toString()).toBe( + `component=${DUMMY_COMPONENT_KEY}&metricKeys=vulnerabilities%2Ccode_smells%2Ccoverage`, + ); + return res(ctx.status(500)); + }), + ); + + const provider = configureProvider(DUMMY_SIMPLE_CONFIG_FOR_PROVIDER); + expect( + await provider.getFindings(DUMMY_COMPONENT_KEY, 'default'), + ).toBeUndefined(); + }); + + it('Provide findings with empty measures when metrics API answer incorrectly', async () => { + setupComponentHandler(); + setupMetricsHandler(); + // custom metrics handler that provide two pages + server.use( + rest.get(`${MOCK_BASE_URL}/api/measures/component`, (req, res, ctx) => { + checkBasicAuthToken(req); + expect(req.url.searchParams.toString()).toBe( + `component=${DUMMY_COMPONENT_KEY}&metricKeys=vulnerabilities%2Ccode_smells%2Ccoverage`, + ); + return res(ctx.json({})); + }), + ); + + const provider = configureProvider(DUMMY_SIMPLE_CONFIG_FOR_PROVIDER); + expect( + await provider.getFindings(DUMMY_COMPONENT_KEY, 'default'), + ).toEqual({ + analysisDate: DUMMY_ANALYSIS_DATE, + measures: [], + }); + }); + }); +}); diff --git a/plugins/sonarqube-backend/src/service/sonarqubeInfoProvider.ts b/plugins/sonarqube-backend/src/service/sonarqubeInfoProvider.ts new file mode 100644 index 0000000000..0939abaed0 --- /dev/null +++ b/plugins/sonarqube-backend/src/service/sonarqubeInfoProvider.ts @@ -0,0 +1,371 @@ +/* + * Copyright 2020 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { Config } from '@backstage/config'; +import fetch from 'node-fetch'; + +/** + * Provide information about sonarqube instances and projects contained within + * @public + */ +export interface SonarqubeInfoProvider { + /** + * Get the sonarqube URL in configuration from a provided name. + * + * If name is omitted, default sonarqube instance is queried in config + * + * @param instanceName - Name of the sonarqube instance to get the info from + * @returns the url of the instance + */ + getBaseUrl(instanceName: string): string; + + /** + * Query the sonarqube instance corresponding to the instanceName to get all + * measures for the component of key componentKey. + * @param componentKey - component key of the project we want to get measure from. + * @param instanceName - name of the instance (in config) where the project is hosted. + * @returns All measures with the analysis date. Will return undefined if we + * can't provide the full response + */ + getFindings( + componentKey: string, + instanceName: string, + ): Promise; +} + +/** + * Information retrieved for a specific project in Sonarqube + * @public + */ +export interface SonarqubeFindings { + /** + * Last date of the analysis that have generated this finding + */ + analysisDate: string; + /** + * All measures pertaining to the findings + */ + measures: Measure[]; +} + +interface MeasuresWrapper { + component: { measures: Measure[] }; +} + +/** + * A specific measure on a project in Sonarqube + * @public + */ +export interface Measure { + /** + * Name of the measure + */ + metric: string; + /** + * Value of the measure + */ + value: string; +} + +/** + * Information about a Sonarqube instance. + * @public + */ +export interface SonarqubeInstanceConfig { + /** + * Name of the instance. An instance name in configuration and catalog should match. + */ + name: string; + /** + * Base url to access the instance + */ + baseUrl: string; + /** + * Access token to access the sonarqube instance as generated in user profile. + */ + apiKey: string; +} + +interface ComponentWrapper { + component: { analysisDate: string }; +} + +/** + * Holds multiple Sonarqube configurations. + * @public + */ +export class SonarqubeConfig { + /** + * + * @param instances - All information on all sonarqube instance from the config file + */ + constructor(public readonly instances: SonarqubeInstanceConfig[]) {} + + /** + * Read all Sonarqube instance configurations. + * @param config - Root configuration + * @returns A SonarqubeConfig that contains all configured Sonarqube instances. + */ + static fromConfig(config: Config): SonarqubeConfig { + const DEFAULT_SONARQUBE_NAME = 'default'; + + const sonarqubeConfig = config.getConfig('sonarqube'); + + // load all named instance config + const namedInstanceConfig = + sonarqubeConfig.getOptionalConfigArray('instances')?.map(c => ({ + name: c.getString('name'), + baseUrl: c.getString('baseUrl'), + apiKey: c.getString('apiKey'), + })) || []; + + // load unnamed default config + const hasNamedDefault = namedInstanceConfig.some( + x => x.name === DEFAULT_SONARQUBE_NAME, + ); + + // Get these as optional strings and check to give a better error message + const baseUrl = sonarqubeConfig.getOptionalString('baseUrl'); + const apiKey = sonarqubeConfig.getOptionalString('apiKey'); + + if (hasNamedDefault && (baseUrl || apiKey)) { + throw new Error( + `Found both a named sonarqube instance with name ${DEFAULT_SONARQUBE_NAME} and top level baseUrl or apiKey config. Use only one style of config.`, + ); + } + + const unnamedNonePresent = !baseUrl && !apiKey; + const unnamedAllPresent = baseUrl && apiKey; + if (!(unnamedAllPresent || unnamedNonePresent)) { + throw new Error( + `Found partial default sonarqube config. All (or none) of baseUrl and apiKey must be provided.`, + ); + } + + if (unnamedAllPresent) { + const unnamedInstanceConfig = [ + { name: DEFAULT_SONARQUBE_NAME, baseUrl, apiKey }, + ] as { + name: string; + baseUrl: string; + apiKey: string; + }[]; + + return new SonarqubeConfig([ + ...namedInstanceConfig, + ...unnamedInstanceConfig, + ]); + } + + return new SonarqubeConfig(namedInstanceConfig); + } + + /** + * Gets a Sonarqube instance configuration by name, or the default one if no name is provided. + * @param sonarqubeName - Optional name of the Sonarqube instance. + * @returns The requested Sonarqube instance. + * @throws Error when no default config could be found or the requested name couldn't be found in config. + */ + getInstanceConfig(sonarqubeName?: string): SonarqubeInstanceConfig { + const DEFAULT_SONARQUBE_NAME = 'default'; + + if (!sonarqubeName || sonarqubeName === DEFAULT_SONARQUBE_NAME) { + // no name provided, use default + const instanceConfig = this.instances.find( + c => c.name === DEFAULT_SONARQUBE_NAME, + ); + + if (!instanceConfig) { + throw new Error( + `Couldn't find a default sonarqube instance in the config. Either configure an instance with name ${DEFAULT_SONARQUBE_NAME} or add a prefix to your annotation value.`, + ); + } + + return instanceConfig; + } + + // A name is provided, look it up. + const instanceConfig = this.instances.find(c => c.name === sonarqubeName); + + if (!instanceConfig) { + throw new Error( + `Couldn't find a sonarqube instance in the config with name ${sonarqubeName}`, + ); + } + return instanceConfig; + } +} + +/** + * @public + * + * Use default config and annotations, build using fromConfig static function. + */ +export class DefaultSonarqubeInfoProvider implements SonarqubeInfoProvider { + private constructor(private readonly config: SonarqubeConfig) {} + + /** + * Generate an instance from a Config instance + * @param config - Backend configuration + */ + static fromConfig(config: Config): DefaultSonarqubeInfoProvider { + return new DefaultSonarqubeInfoProvider(SonarqubeConfig.fromConfig(config)); + } + + /** + * Retrieve all supported metrics from a sonarqube instance. + * + * @param instanceUrl - URL of the sonarqube instance + * @param token - token to access the sonarqube instance + * @returns The list of supported metrics, if no metrics are supported an empty list is provided in the promise + * @private + */ + private static async getSupportedMetrics( + instanceUrl: string, + token: string, + ): Promise { + const metrics: string[] = []; + let nextPage: number = 1; + + for (;;) { + const result = await DefaultSonarqubeInfoProvider.callApi<{ + metrics: Array<{ key: string }>; + total: number; + }>(instanceUrl, 'api/metrics/search', token, { ps: 500, p: nextPage }); + metrics.push(...(result?.metrics?.map(m => m.key) ?? [])); + + if (result && metrics.length < result.total) { + nextPage++; + continue; + } + + return metrics; + } + } + + /** + * Call an API with provided arguments + * @param url - URL of the API to call + * @param path - path to call + * @param authToken - token used as basic auth user without password + * @param query - parameters to provide to the call + * @returns A promise on the answer to the API call if the answer status code is 200, undefined otherwise. + * @private + */ + private static async callApi( + url: string, + path: string, + authToken: string, + query: { [key in string]: any }, + ): Promise { + // Sonarqube auth use basic with token as username and no password + // but standard dictate the colon (separator) need to stay here despite the + // lack of password + const encodedAuthToken = Buffer.from(`${authToken}:`).toString('base64'); + + const response = await fetch( + `${url}/${path}?${new URLSearchParams(query).toString()}`, + { + headers: { + 'Content-Type': 'application/json', + Authorization: `Basic ${encodedAuthToken}`, + }, + }, + ); + if (response.status === 200) { + return (await response.json()) as T; + } + return undefined; + } + + /** + * {@inheritDoc SonarqubeInfoProvider.getBaseUrl} + * @throws Error If configuration can't be retrieved. + */ + getBaseUrl(instanceName: string): string { + const instanceConfig = this.config.getInstanceConfig(instanceName ?? ''); + return instanceConfig.baseUrl; + } + + /** + * {@inheritDoc SonarqubeInfoProvider.getFindings} + * @throws Error If configuration can't be retrieved. + */ + async getFindings( + componentKey: string, + instanceName: string, + ): Promise { + const { baseUrl, apiKey } = this.config.getInstanceConfig( + instanceName ?? '', + ); + + // get component info to retrieve analysis date + const component = + await DefaultSonarqubeInfoProvider.callApi( + baseUrl, + 'api/components/show', + apiKey, + { + component: componentKey, + }, + ); + if (!component || !component.component) { + return undefined; + } + + // select the metrics that are supported by the SonarQube instance + const supportedMetrics = + await DefaultSonarqubeInfoProvider.getSupportedMetrics(baseUrl, apiKey); + const wantedMetrics: string[] = [ + 'alert_status', + 'bugs', + 'reliability_rating', + 'vulnerabilities', + 'security_rating', + 'security_hotspots_reviewed', + 'security_review_rating', + 'code_smells', + 'sqale_rating', + 'coverage', + 'duplicated_lines_density', + ]; + + // only retrieve wanted metrics that are supported + const metricsToQuery = wantedMetrics.filter(el => + supportedMetrics.includes(el), + ); + + // get all measures + const measures = + await DefaultSonarqubeInfoProvider.callApi( + baseUrl, + 'api/measures/component', + apiKey, + { + component: componentKey, + metricKeys: metricsToQuery.join(','), + }, + ); + if (!measures) { + return undefined; + } + + return { + analysisDate: component.component.analysisDate, + measures: measures.component?.measures ?? [], + }; + } +} From e9dcea3c92345d93fbc16d020a77f462ec1e0e05 Mon Sep 17 00:00:00 2001 From: Neemys <36508659+Neemys@users.noreply.github.com> Date: Fri, 22 Jul 2022 14:33:50 +0200 Subject: [PATCH 07/30] Implement APIs of sonarqube-backend plugin's router Also update the standalone server to add router dependencies Signed-off-by: Neemys <36508659+Neemys@users.noreply.github.com> --- .../src/service/router.test.ts | 79 ++++++++++++++++-- .../sonarqube-backend/src/service/router.ts | 80 +++++++++++++++---- .../src/service/standaloneServer.ts | 10 ++- 3 files changed, 147 insertions(+), 22 deletions(-) diff --git a/plugins/sonarqube-backend/src/service/router.test.ts b/plugins/sonarqube-backend/src/service/router.test.ts index 24e482896b..756fba6164 100644 --- a/plugins/sonarqube-backend/src/service/router.test.ts +++ b/plugins/sonarqube-backend/src/service/router.test.ts @@ -19,13 +19,23 @@ import express from 'express'; import request from 'supertest'; import { createRouter } from './router'; +import { SonarqubeFindings } from './sonarqubeInfoProvider'; describe('createRouter', () => { let app: express.Express; + const getBaseUrlMock: jest.Mock = jest.fn(); + const getFindingsMock: jest.Mock< + Promise, + [string, string] + > = jest.fn(); beforeAll(async () => { const router = await createRouter({ logger: getVoidLogger(), + sonarqubeInfoProvider: { + getBaseUrl: getBaseUrlMock, + getFindings: getFindingsMock, + }, }); app = express().use(router); }); @@ -35,17 +45,76 @@ describe('createRouter', () => { }); describe('GET /findings', () => { + const DUMMY_COMPONENT_KEY = 'my:component'; + const DUMMY_INSTANCE_KEY = 'myInstance'; it('returns ok', async () => { + const measures = { + analysisDate: '2022-01-01T00:00:00Z', + measures: [{ metric: 'vulnerabilities', value: '54' }], + }; + + getFindingsMock.mockReturnValue(Promise.resolve(measures)); const response = await request(app) .get('/findings') - .set('componentKey', 'my:app') + .query({ + componentKey: DUMMY_COMPONENT_KEY, + instanceKey: DUMMY_INSTANCE_KEY, + }) + .send(); + expect(getFindingsMock).toBeCalledTimes(1); + expect(getFindingsMock).toBeCalledWith( + DUMMY_COMPONENT_KEY, + DUMMY_INSTANCE_KEY, + ); + expect(response.status).toEqual(200); + expect(response.body).toEqual(measures); + }); + it('returns an error when component key is not defined', async () => { + const response = await request(app) + .get('/findings') + .query({ + instanceKey: DUMMY_INSTANCE_KEY, + }) .send(); + expect(response.status).toEqual(400); + }); + + it('use an empty string as instance name when instance key not provided', async () => { + const measures = { + analysisDate: '2021-04-08', + measures: [{ metric: 'vulnerabilities', value: '54' }], + }; + + getFindingsMock.mockReturnValue(Promise.resolve(measures)); + const response = await request(app) + .get('/findings') + .query({ + componentKey: DUMMY_COMPONENT_KEY, + }) + .send(); + + expect(getFindingsMock).toBeCalledTimes(1); + expect(getFindingsMock).toBeCalledWith(DUMMY_COMPONENT_KEY, ''); expect(response.status).toEqual(200); - expect(response.body).toEqual({ - analysisDate: '2022-10-22T04:55:23Z', - measures: [{ metric: 'coverage', value: '50' }], - }); + expect(response.body).toEqual(measures); + }); + }); + describe('GET /instanceUrl', () => { + const DUMMY_INSTANCE_KEY = 'myInstance'; + const DUMMY_INSTANCE_URL = 'http://sonarqube.example.com'; + it('returns ok', async () => { + getBaseUrlMock.mockReturnValue(DUMMY_INSTANCE_URL); + const response = await request(app) + .get('/instanceUrl') + .query({ + instanceKey: DUMMY_INSTANCE_KEY, + }) + .send(); + expect(getBaseUrlMock).toBeCalledTimes(1); + expect(getBaseUrlMock).toBeCalledWith(DUMMY_INSTANCE_KEY); + expect(response.status).toEqual(200); + expect(response.body).toEqual({ instanceUrl: DUMMY_INSTANCE_URL }); }); }); }); diff --git a/plugins/sonarqube-backend/src/service/router.ts b/plugins/sonarqube-backend/src/service/router.ts index 54f0038ca9..c1f672a2c4 100644 --- a/plugins/sonarqube-backend/src/service/router.ts +++ b/plugins/sonarqube-backend/src/service/router.ts @@ -15,37 +15,87 @@ */ import { errorHandler } from '@backstage/backend-common'; -import express from 'express'; +import express, { RequestHandler } from 'express'; import Router from 'express-promise-router'; import { Logger } from 'winston'; +import { + SonarqubeFindings, + SonarqubeInfoProvider, +} from './sonarqubeInfoProvider'; +import { InputError } from '../../../../packages/errors'; +/** + * Dependencies needed by the router + * @public + */ export interface RouterOptions { + /** + * Logger for logging purposes + */ logger: Logger; + /** + * Info provider to be able to get all necessary information for the APIs + */ + sonarqubeInfoProvider: SonarqubeInfoProvider; } +/** + * @public + * + * Constructs a sonarqube router. + * + * Expose endpoint to get information on or for a sonarqube instance. + * + * @param options - Dependencies of the router + */ export async function createRouter( options: RouterOptions, ): Promise { - const { logger } = options; + const { logger, sonarqubeInfoProvider } = options; const router = Router(); router.use(express.json()); // mock api for now - router.get('/findings', (request, response) => { - logger.info(request.params); + router.get('/findings', (async (request, response) => { + const componentKey = request.query.componentKey; + let instanceKey = request.query.instanceKey; + + if (!componentKey) + throw new InputError('ComponentKey must be provided as a single string.'); + + if (!instanceKey) { + instanceKey = ''; + logger.info( + `Retrieving findings for component ${componentKey} in default sonarqube instance`, + ); + } else { + logger.info( + `Retrieving findings for component ${componentKey} in sonarqube instance name ${instanceKey}`, + ); + } + + response.send( + await sonarqubeInfoProvider.getFindings(componentKey, instanceKey), + ); + }) as RequestHandler); + + router.get('/instanceUrl', ((request, response) => { + let requestedInstanceKey = request.query.instanceKey; + if (requestedInstanceKey) { + logger.info( + `Retrieving sonarqube instance URL for key ${requestedInstanceKey}`, + ); + } else { + requestedInstanceKey = ''; + logger.info( + `Retrieving default sonarqube instance URL as parameter is inexistant, empty or malformed`, + ); + } response.send({ - analysisDate: '2022-10-22T04:55:23Z', - measures: [{ metric: 'coverage', value: '50' }], + instanceUrl: sonarqubeInfoProvider.getBaseUrl(requestedInstanceKey), }); - }); - router.get('/instanceUrl', (request, response) => { - logger.info(request.params); - response.send({ - instanceUrl: `https://instance.local?${encodeURI( - request.query.instanceKey as string, - )}`, - }); - }); + }) as RequestHandler); + router.use(errorHandler()); return router; } diff --git a/plugins/sonarqube-backend/src/service/standaloneServer.ts b/plugins/sonarqube-backend/src/service/standaloneServer.ts index 78f39b2680..06a8d4ffb2 100644 --- a/plugins/sonarqube-backend/src/service/standaloneServer.ts +++ b/plugins/sonarqube-backend/src/service/standaloneServer.ts @@ -14,10 +14,14 @@ * limitations under the License. */ -import { createServiceBuilder } from '@backstage/backend-common'; +import { + createServiceBuilder, + loadBackendConfig, +} from '@backstage/backend-common'; import { Server } from 'http'; import { Logger } from 'winston'; import { createRouter } from './router'; +import { DefaultSonarqubeInfoProvider } from './sonarqubeInfoProvider'; export interface ServerOptions { port: number; @@ -30,13 +34,15 @@ export async function startStandaloneServer( ): Promise { const logger = options.logger.child({ service: 'sonarqube-backend-backend' }); logger.debug('Starting application server...'); + const config = await loadBackendConfig({ logger, argv: process.argv }); const router = await createRouter({ logger, + sonarqubeInfoProvider: DefaultSonarqubeInfoProvider.fromConfig(config), }); let service = createServiceBuilder(module) .setPort(options.port) - .addRouter('/sonarqube-backend', router); + .addRouter('/sonarqube', router); if (options.enableCors) { service = service.enableCors({ origin: 'http://localhost:3000' }); } From 035ea31f2cc140333423c461f24e20f03bca6580 Mon Sep 17 00:00:00 2001 From: Neemys <36508659+Neemys@users.noreply.github.com> Date: Fri, 22 Jul 2022 14:37:50 +0200 Subject: [PATCH 08/30] Add api-report.md for plugin sonarqube-backend Signed-off-by: Neemys <36508659+Neemys@users.noreply.github.com> --- plugins/sonarqube-backend/api-report.md | 67 +++++++++++++++++++++++++ 1 file changed, 67 insertions(+) create mode 100644 plugins/sonarqube-backend/api-report.md diff --git a/plugins/sonarqube-backend/api-report.md b/plugins/sonarqube-backend/api-report.md new file mode 100644 index 0000000000..c163ff3bc5 --- /dev/null +++ b/plugins/sonarqube-backend/api-report.md @@ -0,0 +1,67 @@ +## API Report File for "@backstage/plugin-sonarqube-backend" + +> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). + +```ts +import { Config } from '@backstage/config'; +import express from 'express'; +import { Logger } from 'winston'; + +// @public +export function createRouter(options: RouterOptions): Promise; + +// @public +export class DefaultSonarqubeInfoProvider implements SonarqubeInfoProvider { + static fromConfig(config: Config): DefaultSonarqubeInfoProvider; + getBaseUrl(instanceName: string): string; + getFindings( + componentKey: string, + instanceName: string, + ): Promise; +} + +// @public +export interface Measure { + metric: string; + value: string; +} + +// @public +export interface RouterOptions { + logger: Logger; + sonarqubeInfoProvider: SonarqubeInfoProvider; +} + +// @public +export class SonarqubeConfig { + constructor(instances: SonarqubeInstanceConfig[]); + static fromConfig(config: Config): SonarqubeConfig; + getInstanceConfig(sonarqubeName?: string): SonarqubeInstanceConfig; + // (undocumented) + readonly instances: SonarqubeInstanceConfig[]; +} + +// @public +export interface SonarqubeFindings { + analysisDate: string; + measures: Measure[]; +} + +// @public +export interface SonarqubeInfoProvider { + getBaseUrl(instanceName: string): string; + getFindings( + componentKey: string, + instanceName: string, + ): Promise; +} + +// @public +export interface SonarqubeInstanceConfig { + apiKey: string; + baseUrl: string; + name: string; +} + +// (No @packageDocumentation comment for this package) +``` From f1768c82e13861d3f536cab34e026332bb922209 Mon Sep 17 00:00:00 2001 From: Neemys <36508659+Neemys@users.noreply.github.com> Date: Fri, 22 Jul 2022 14:39:35 +0200 Subject: [PATCH 09/30] Update package.json for plugin sonarqube-backend Set the plugin to non private and prefix the name with "@backstage/" Signed-off-by: Neemys <36508659+Neemys@users.noreply.github.com> --- plugins/sonarqube-backend/package.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/plugins/sonarqube-backend/package.json b/plugins/sonarqube-backend/package.json index ca2b98c10e..d8d21e86d1 100644 --- a/plugins/sonarqube-backend/package.json +++ b/plugins/sonarqube-backend/package.json @@ -1,10 +1,10 @@ { - "name": "plugin-sonarqube-backend", + "name": "@backstage/plugin-sonarqube-backend", "version": "0.0.0", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", - "private": true, + "private": false, "publishConfig": { "access": "public", "main": "dist/index.cjs.js", From ddd47ff7fba20f7cb431405da27c758a38a8ddbe Mon Sep 17 00:00:00 2001 From: Neemys <36508659+Neemys@users.noreply.github.com> Date: Fri, 22 Jul 2022 14:47:25 +0200 Subject: [PATCH 10/30] Fix typo in comment in sonarqube plugin Signed-off-by: Neemys <36508659+Neemys@users.noreply.github.com> --- plugins/sonarqube/src/components/useProjectKey.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugins/sonarqube/src/components/useProjectKey.ts b/plugins/sonarqube/src/components/useProjectKey.ts index b091ea597c..acf28c7802 100644 --- a/plugins/sonarqube/src/components/useProjectKey.ts +++ b/plugins/sonarqube/src/components/useProjectKey.ts @@ -30,7 +30,7 @@ export const isSonarQubeAvailable = (entity: Entity) => /** * Try to parse sonarqube information from an entity. * - * If part are all info are not found, they will default to an empty string + * If part or all info are not found, they will default to an empty string * * @param entity entity to find the sonarqube information from. * @return a ProjectInfo properly populated. From 648190c0b4da3e430d16d92b93037d2ba14c4a9d Mon Sep 17 00:00:00 2001 From: Neemys <36508659+Neemys@users.noreply.github.com> Date: Fri, 22 Jul 2022 16:45:27 +0200 Subject: [PATCH 11/30] Update plugin sonarqube-backend's README with proper information Signed-off-by: Neemys <36508659+Neemys@users.noreply.github.com> --- plugins/sonarqube-backend/README.md | 138 ++++++++++++++++++++++++++-- 1 file changed, 132 insertions(+), 6 deletions(-) diff --git a/plugins/sonarqube-backend/README.md b/plugins/sonarqube-backend/README.md index 0fad3b67f3..0db23ade6c 100644 --- a/plugins/sonarqube-backend/README.md +++ b/plugins/sonarqube-backend/README.md @@ -2,12 +2,138 @@ Welcome to the sonarqube-backend backend plugin! -_This plugin was created through the Backstage CLI_ +## Integrating into a backstage instance -## Getting started +This plugin needs to be added to an existing backstage instance. -Your plugin has been added to the example app in this repository, meaning you'll be able to access it by running `yarn start` in the root directory, and then navigating to [/sonarqube-backend](http://localhost:3000/sonarqube-backend). +```bash +# From your Backstage root directory +yarn add --cwd packages/backend @backstage/plugin-sonarqube-backend +``` -You can also serve the plugin in isolation by running `yarn start` in the plugin directory. -This method of serving the plugin provides quicker iteration speed and a faster startup and hot reloads. -It is only meant for local development, and the setup for it can be found inside the [/dev](/dev) directory. +Typically, this means creating a `src/plugins/sonarqube.ts` file and adding a reference to it to `src/index.ts` in the backend package. + +### sonarqube.ts + +```typescript +import { + createRouter, + DefaultSonarqubeInfoProvider, +} from '@backstage/plugin-sonarqube-backend'; +import { Router } from 'express'; +import { PluginEnvironment } from '../types'; + +export default async function createPlugin( + env: PluginEnvironment, +): Promise { + return await createRouter({ + logger: env.logger, + sonarqubeInfoProvider: DefaultSonarqubeInfoProvider.fromConfig(env.config), + }); +} +``` + +### src/index.ts + +```diff +diff --git a/packages/backend/src/index.ts b/packages/backend/src/index.ts +index 1942c36ad1..7fdc48ba24 100644 +--- a/packages/backend/src/index.ts ++++ b/packages/backend/src/index.ts +@@ -50,6 +50,7 @@ import scaffolder from './plugins/scaffolder'; + import proxy from './plugins/proxy'; + import search from './plugins/search'; + import techdocs from './plugins/techdocs'; ++import sonarqube from './plugins/sonarqube'; + import techInsights from './plugins/techInsights'; + import todo from './plugins/todo'; + import graphql from './plugins/graphql'; +@@ -133,6 +134,7 @@ async function main() { + createEnv('tech-insights'), + ); + const permissionEnv = useHotMemoize(module, () => createEnv('permission')); ++ const sonarqubeEnv = useHotMemoize(module, () => createEnv('sonarqube')); + + const apiRouter = Router(); + apiRouter.use('/catalog', await catalog(catalogEnv)); +@@ -152,6 +154,7 @@ async function main() { + apiRouter.use('/badges', await badges(badgesEnv)); + apiRouter.use('/jenkins', await jenkins(jenkinsEnv)); + apiRouter.use('/permission', await permission(permissionEnv)); ++ apiRouter.use('/sonarqube', await sonarqube(sonarqubeEnv)); + apiRouter.use(notFoundHandler()); + + const service = createServiceBuilder(module) + +``` + +This plugin must be provided with a `SonarqubeInfoProvider`, this is a strategy object for finding sonarqube instances in configuration and retrieving data from an instance. + +There is a standard one provided (`DefaultSonarqubeInfoProvider`), but the Integrator is free to build their own. + +### DefaultSonarqubeInfoProvider + +Allows configuration of either a single or multiple global Sonarqube instances and annotating entities with the instance name. This instance name in the entities is optional, if not provided the default instance in configuration will be used. That allow to keep configuration from before multiple instances capability to keep working without changes. + +#### Example - Single global instance + +##### Config + +```yaml +sonarqube: + baseUrl: https://sonarqube.example.com + apiKey: 123456789abcdef0123456789abcedf012 +``` + +##### Catalog + +```yaml +apiVersion: backstage.io/v1alpha1 +kind: Component +metadata: + name: backstage + annotations: + sonarqube.org/project-key: YOUR_INSTANCE_NAME/YOUR_PROJECT_KEY +``` + +#### Example - Multiple global instance + +The following will look for findings at `https://special-project-sonarqube.example.com` for the project of key `YOUR_PROJECT_KEY`. + +##### Config + +```yaml +sonarqube: + instances: + - name: default + baseUrl: https://default-sonarqube.example.com + apiKey: 123456789abcdef0123456789abcedf012 + - name: specialProject + baseUrl: https://special-project-sonarqube.example.com + apiKey: abcdef0123456789abcedf0123456789ab +``` + +##### Catalog + +```yaml +apiVersion: backstage.io/v1alpha1 +kind: Component +metadata: + name: backstage + annotations: + sonarqube.org/project-key: specialProject/YOUR_PROJECT_KEY +``` + +If the `specialProject/` part is omitted (or replaced with `default/`), the sonarqube instance of name `default` will be used. + +The following config is an equivalent (but less clear) version of the above: + +```yaml +sonarqube: + baseUrl: https://default-sonarqube.example.com + apiKey: 123456789abcdef0123456789abcedf012 + instances: + - name: specialProject + baseUrl: https://special-project-sonarqube.example.com + apiKey: abcdef0123456789abcedf0123456789ab +``` From 4bec71fe13ad5fadc7e1e839af3410d4e8062a55 Mon Sep 17 00:00:00 2001 From: Neemys <36508659+Neemys@users.noreply.github.com> Date: Fri, 22 Jul 2022 17:38:30 +0200 Subject: [PATCH 12/30] Update plugin sonarqube-backend's dependencies Signed-off-by: Neemys <36508659+Neemys@users.noreply.github.com> --- plugins/sonarqube-backend/package.json | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/plugins/sonarqube-backend/package.json b/plugins/sonarqube-backend/package.json index d8d21e86d1..22dbe1e637 100644 --- a/plugins/sonarqube-backend/package.json +++ b/plugins/sonarqube-backend/package.json @@ -23,20 +23,20 @@ "postpack": "backstage-cli package postpack" }, "dependencies": { - "@backstage/backend-common": "^0.13.6-next.1", + "@backstage/backend-common": "^0.14.1", "@backstage/config": "^1.0.1", "@types/express": "*", - "express": "^4.17.1", + "express": "^4.18.1", "express-promise-router": "^4.1.0", - "winston": "^3.2.1", "node-fetch": "^2.6.7", - "yn": "^4.0.0" + "winston": "^3.8.1", + "yn": "^5.0.0" }, "devDependencies": { - "@backstage/cli": "^0.17.2-next.1", - "@types/supertest": "^2.0.8", - "supertest": "^4.0.2", - "msw": "^0.42.0" + "@backstage/cli": "^0.18.0", + "@types/supertest": "^2.0.12", + "msw": "^0.44.2", + "supertest": "^6.2.4" }, "files": [ "dist" From e2be9ab3a48aa0b23d1a3e58691053755dd8eb63 Mon Sep 17 00:00:00 2001 From: Neemys <36508659+Neemys@users.noreply.github.com> Date: Fri, 22 Jul 2022 17:44:50 +0200 Subject: [PATCH 13/30] Add plugin sonarqube-backend's changeset Signed-off-by: Neemys <36508659+Neemys@users.noreply.github.com> --- .changeset/eighty-radios-look.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/eighty-radios-look.md diff --git a/.changeset/eighty-radios-look.md b/.changeset/eighty-radios-look.md new file mode 100644 index 0000000000..de178c1111 --- /dev/null +++ b/.changeset/eighty-radios-look.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-sonarqube-backend': patch +--- + +Initial creation of the plugin From b2b62ab5b672304c3e3bfbd0e2b74c58adf860f4 Mon Sep 17 00:00:00 2001 From: Neemys <36508659+Neemys@users.noreply.github.com> Date: Fri, 22 Jul 2022 18:01:48 +0200 Subject: [PATCH 14/30] Correct usage of 'sonarqube' with 'Sonarqube' Also add 'Sonarqube' into vale vocab Signed-off-by: Neemys <36508659+Neemys@users.noreply.github.com> --- .changeset/forty-lobsters-guess.md | 2 +- .changeset/ten-roses-walk.md | 2 +- .github/vale/Vocab/Backstage/accept.txt | 1 + plugins/sonarqube-backend/README.md | 4 ++-- 4 files changed, 5 insertions(+), 4 deletions(-) diff --git a/.changeset/forty-lobsters-guess.md b/.changeset/forty-lobsters-guess.md index 50dd22627f..c7ab852e08 100644 --- a/.changeset/forty-lobsters-guess.md +++ b/.changeset/forty-lobsters-guess.md @@ -6,4 +6,4 @@ The whole proxy's `'/sonarqube':` key can be removed from your configuration files. -Then head to the sonarqube-backend plugin page to learn how to set-up the link to your sonarqube instances. +Then head to the sonarqube-backend plugin page to learn how to set-up the link to your Sonarqube instances. diff --git a/.changeset/ten-roses-walk.md b/.changeset/ten-roses-walk.md index 14d0819c38..87b711db2a 100644 --- a/.changeset/ten-roses-walk.md +++ b/.changeset/ten-roses-walk.md @@ -2,4 +2,4 @@ '@backstage/plugin-sonarqube': patch --- -Add ability to provide an optional sonarqube instance into the annotation in the `catalog-info.yaml` file +Add ability to provide an optional Sonarqube instance into the annotation in the `catalog-info.yaml` file diff --git a/.github/vale/Vocab/Backstage/accept.txt b/.github/vale/Vocab/Backstage/accept.txt index cd01425a5e..5324ce2b2a 100644 --- a/.github/vale/Vocab/Backstage/accept.txt +++ b/.github/vale/Vocab/Backstage/accept.txt @@ -283,6 +283,7 @@ shoutout siloed Sinon Snyk +Sonarqube sourcemaps sparklines Splunk diff --git a/plugins/sonarqube-backend/README.md b/plugins/sonarqube-backend/README.md index 0db23ade6c..ac3973a5c0 100644 --- a/plugins/sonarqube-backend/README.md +++ b/plugins/sonarqube-backend/README.md @@ -67,7 +67,7 @@ index 1942c36ad1..7fdc48ba24 100644 ``` -This plugin must be provided with a `SonarqubeInfoProvider`, this is a strategy object for finding sonarqube instances in configuration and retrieving data from an instance. +This plugin must be provided with a `SonarqubeInfoProvider`, this is a strategy object for finding Sonarqube instances in configuration and retrieving data from an instance. There is a standard one provided (`DefaultSonarqubeInfoProvider`), but the Integrator is free to build their own. @@ -124,7 +124,7 @@ metadata: sonarqube.org/project-key: specialProject/YOUR_PROJECT_KEY ``` -If the `specialProject/` part is omitted (or replaced with `default/`), the sonarqube instance of name `default` will be used. +If the `specialProject/` part is omitted (or replaced with `default/`), the Sonarqube instance of name `default` will be used. The following config is an equivalent (but less clear) version of the above: From 932a31ade7cbc9954f1ebec2decbeacf4128c8aa Mon Sep 17 00:00:00 2001 From: Neemys <36508659+Neemys@users.noreply.github.com> Date: Thu, 28 Jul 2022 15:53:18 +0200 Subject: [PATCH 15/30] Improve changeset after review comment Signed-off-by: Neemys <36508659+Neemys@users.noreply.github.com> --- .changeset/eighty-radios-look.md | 2 +- .changeset/forty-lobsters-guess.md | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.changeset/eighty-radios-look.md b/.changeset/eighty-radios-look.md index de178c1111..2db51d47d7 100644 --- a/.changeset/eighty-radios-look.md +++ b/.changeset/eighty-radios-look.md @@ -1,5 +1,5 @@ --- -'@backstage/plugin-sonarqube-backend': patch +'@backstage/plugin-sonarqube-backend': minor --- Initial creation of the plugin diff --git a/.changeset/forty-lobsters-guess.md b/.changeset/forty-lobsters-guess.md index c7ab852e08..1a722fa04d 100644 --- a/.changeset/forty-lobsters-guess.md +++ b/.changeset/forty-lobsters-guess.md @@ -2,8 +2,8 @@ '@backstage/plugin-sonarqube': minor --- -**BREAKING** This plugin now call the sonarqube-backend plugin instead of relying on the proxy plugin +**BREAKING** This plugin now call the `sonarqube-backend` plugin instead of relying on the proxy plugin The whole proxy's `'/sonarqube':` key can be removed from your configuration files. -Then head to the sonarqube-backend plugin page to learn how to set-up the link to your Sonarqube instances. +Then head to the [README in sonarqube-backend plugin page](https://github.com/backstage/backstage/tree/master/plugins/sonarqube-backend/README.md) to learn how to set-up the link to your Sonarqube instances. From ae0e115dd1e58e3f2b6a5c13f179bd55e675241e Mon Sep 17 00:00:00 2001 From: Neemys <36508659+Neemys@users.noreply.github.com> Date: Thu, 28 Jul 2022 16:50:43 +0200 Subject: [PATCH 16/30] Inline a type in useProjectKey in sonarqube plugin Type used only used and referenced in one place Signed-off-by: Neemys <36508659+Neemys@users.noreply.github.com> --- plugins/sonarqube/src/components/useProjectKey.ts | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/plugins/sonarqube/src/components/useProjectKey.ts b/plugins/sonarqube/src/components/useProjectKey.ts index acf28c7802..fbe12b5b5d 100644 --- a/plugins/sonarqube/src/components/useProjectKey.ts +++ b/plugins/sonarqube/src/components/useProjectKey.ts @@ -16,11 +16,6 @@ import { Entity } from '@backstage/catalog-model'; -export interface ProjectInfo { - projectInstance: string; - projectKey: string; -} - export const SONARQUBE_PROJECT_KEY_ANNOTATION = 'sonarqube.org/project-key'; export const SONARQUBE_PROJECT_INSTANCE_SEPARATOR = '/'; @@ -35,7 +30,12 @@ export const isSonarQubeAvailable = (entity: Entity) => * @param entity entity to find the sonarqube information from. * @return a ProjectInfo properly populated. */ -export const useProjectInfo = (entity: Entity): ProjectInfo => { +export const useProjectInfo = ( + entity: Entity, +): { + projectInstance: string; + projectKey: string; +} => { let projectInstance = ''; let projectKey = ''; const annotation = From 41d11ca579c8281c02896fded41633b58c9c78c1 Mon Sep 17 00:00:00 2001 From: Neemys <36508659+Neemys@users.noreply.github.com> Date: Thu, 28 Jul 2022 17:45:36 +0200 Subject: [PATCH 17/30] Change `SonarqubeInfoProvider.getBaseUrl`'s signature To make future changes to the API easier Signed-off-by: Neemys <36508659+Neemys@users.noreply.github.com> --- plugins/sonarqube-backend/src/service/router.ts | 4 +++- .../sonarqube-backend/src/service/sonarqubeInfoProvider.ts | 6 +++--- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/plugins/sonarqube-backend/src/service/router.ts b/plugins/sonarqube-backend/src/service/router.ts index c1f672a2c4..2d15f3d0aa 100644 --- a/plugins/sonarqube-backend/src/service/router.ts +++ b/plugins/sonarqube-backend/src/service/router.ts @@ -92,7 +92,9 @@ export async function createRouter( ); } response.send({ - instanceUrl: sonarqubeInfoProvider.getBaseUrl(requestedInstanceKey), + instanceUrl: sonarqubeInfoProvider.getBaseUrl({ + instanceName: requestedInstanceKey, + }).baseUrl, }); }) as RequestHandler); diff --git a/plugins/sonarqube-backend/src/service/sonarqubeInfoProvider.ts b/plugins/sonarqube-backend/src/service/sonarqubeInfoProvider.ts index 0939abaed0..17cd08472b 100644 --- a/plugins/sonarqube-backend/src/service/sonarqubeInfoProvider.ts +++ b/plugins/sonarqube-backend/src/service/sonarqubeInfoProvider.ts @@ -30,7 +30,7 @@ export interface SonarqubeInfoProvider { * @param instanceName - Name of the sonarqube instance to get the info from * @returns the url of the instance */ - getBaseUrl(instanceName: string): string; + getBaseUrl({ instanceName }: { instanceName: string }): { baseUrl: string }; /** * Query the sonarqube instance corresponding to the instanceName to get all @@ -295,9 +295,9 @@ export class DefaultSonarqubeInfoProvider implements SonarqubeInfoProvider { * {@inheritDoc SonarqubeInfoProvider.getBaseUrl} * @throws Error If configuration can't be retrieved. */ - getBaseUrl(instanceName: string): string { + getBaseUrl({ instanceName }: { instanceName: string }): { baseUrl: string } { const instanceConfig = this.config.getInstanceConfig(instanceName ?? ''); - return instanceConfig.baseUrl; + return { baseUrl: instanceConfig.baseUrl }; } /** From abdc8e2927b3271e23aef249bf0e4a2374c73dd6 Mon Sep 17 00:00:00 2001 From: Neemys <36508659+Neemys@users.noreply.github.com> Date: Thu, 28 Jul 2022 17:47:49 +0200 Subject: [PATCH 18/30] Rename `Measure` type to `SonarqubeMeasure` to properly explicit that it refer to Sonarqube Signed-off-by: Neemys <36508659+Neemys@users.noreply.github.com> --- .../sonarqube-backend/src/service/sonarqubeInfoProvider.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/plugins/sonarqube-backend/src/service/sonarqubeInfoProvider.ts b/plugins/sonarqube-backend/src/service/sonarqubeInfoProvider.ts index 17cd08472b..6f75171f65 100644 --- a/plugins/sonarqube-backend/src/service/sonarqubeInfoProvider.ts +++ b/plugins/sonarqube-backend/src/service/sonarqubeInfoProvider.ts @@ -58,18 +58,18 @@ export interface SonarqubeFindings { /** * All measures pertaining to the findings */ - measures: Measure[]; + measures: SonarqubeMeasure[]; } interface MeasuresWrapper { - component: { measures: Measure[] }; + component: { measures: SonarqubeMeasure[] }; } /** * A specific measure on a project in Sonarqube * @public */ -export interface Measure { +export interface SonarqubeMeasure { /** * Name of the measure */ From dad2f3d908208f7ec66c101f9d457adcfdc8faa3 Mon Sep 17 00:00:00 2001 From: Neemys <36508659+Neemys@users.noreply.github.com> Date: Thu, 28 Jul 2022 17:10:41 +0200 Subject: [PATCH 19/30] Remove no longer relevant comment Signed-off-by: Neemys <36508659+Neemys@users.noreply.github.com> --- plugins/sonarqube-backend/src/service/router.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/plugins/sonarqube-backend/src/service/router.ts b/plugins/sonarqube-backend/src/service/router.ts index 2d15f3d0aa..c0a9cb88d1 100644 --- a/plugins/sonarqube-backend/src/service/router.ts +++ b/plugins/sonarqube-backend/src/service/router.ts @@ -55,7 +55,6 @@ export async function createRouter( const router = Router(); router.use(express.json()); - // mock api for now router.get('/findings', (async (request, response) => { const componentKey = request.query.componentKey; let instanceKey = request.query.instanceKey; From a8374f3e7045432d1cc190317ff439cef19567e8 Mon Sep 17 00:00:00 2001 From: Neemys <36508659+Neemys@users.noreply.github.com> Date: Thu, 28 Jul 2022 18:22:51 +0200 Subject: [PATCH 20/30] Fix failing UT in `sonarqube-backend` plugin Signed-off-by: Neemys <36508659+Neemys@users.noreply.github.com> --- .../sonarqube-backend/src/service/router.test.ts | 11 ++++++++--- .../src/service/sonarqubeInfoProvider.test.ts | 16 +++++++++------- 2 files changed, 17 insertions(+), 10 deletions(-) diff --git a/plugins/sonarqube-backend/src/service/router.test.ts b/plugins/sonarqube-backend/src/service/router.test.ts index 756fba6164..719db6787e 100644 --- a/plugins/sonarqube-backend/src/service/router.test.ts +++ b/plugins/sonarqube-backend/src/service/router.test.ts @@ -23,7 +23,10 @@ import { SonarqubeFindings } from './sonarqubeInfoProvider'; describe('createRouter', () => { let app: express.Express; - const getBaseUrlMock: jest.Mock = jest.fn(); + const getBaseUrlMock: jest.Mock< + { baseUrl: string }, + [{ instanceName: string }] + > = jest.fn(); const getFindingsMock: jest.Mock< Promise, [string, string] @@ -104,7 +107,7 @@ describe('createRouter', () => { const DUMMY_INSTANCE_KEY = 'myInstance'; const DUMMY_INSTANCE_URL = 'http://sonarqube.example.com'; it('returns ok', async () => { - getBaseUrlMock.mockReturnValue(DUMMY_INSTANCE_URL); + getBaseUrlMock.mockReturnValue({ baseUrl: DUMMY_INSTANCE_URL }); const response = await request(app) .get('/instanceUrl') .query({ @@ -112,7 +115,9 @@ describe('createRouter', () => { }) .send(); expect(getBaseUrlMock).toBeCalledTimes(1); - expect(getBaseUrlMock).toBeCalledWith(DUMMY_INSTANCE_KEY); + expect(getBaseUrlMock).toBeCalledWith({ + instanceName: DUMMY_INSTANCE_KEY, + }); expect(response.status).toEqual(200); expect(response.body).toEqual({ instanceUrl: DUMMY_INSTANCE_URL }); }); diff --git a/plugins/sonarqube-backend/src/service/sonarqubeInfoProvider.test.ts b/plugins/sonarqube-backend/src/service/sonarqubeInfoProvider.test.ts index fcb73da926..774fdca238 100644 --- a/plugins/sonarqube-backend/src/service/sonarqubeInfoProvider.test.ts +++ b/plugins/sonarqube-backend/src/service/sonarqubeInfoProvider.test.ts @@ -237,7 +237,9 @@ describe('DefaultSonarqubeInfoProvider', () => { }, }); - expect(provider.getBaseUrl('')).toEqual('https://sonarqube.example.com'); + expect(provider.getBaseUrl({ instanceName: '' })).toEqual({ + baseUrl: 'https://sonarqube.example.com', + }); }); it('Provide base url for named default config and "default" string', async () => { @@ -253,9 +255,9 @@ describe('DefaultSonarqubeInfoProvider', () => { }, }); - expect(provider.getBaseUrl('default')).toEqual( - 'https://sonarqube.example.com', - ); + expect(provider.getBaseUrl({ instanceName: 'default' })).toEqual({ + baseUrl: 'https://sonarqube.example.com', + }); }); it('Provide base url for named config', async () => { @@ -276,9 +278,9 @@ describe('DefaultSonarqubeInfoProvider', () => { }, }); - expect(provider.getBaseUrl('other')).toEqual( - 'https://sonarqube-other.example.com', - ); + expect(provider.getBaseUrl({ instanceName: 'other' })).toEqual({ + baseUrl: 'https://sonarqube-other.example.com', + }); }); }); From 67fd9417d1c8ee46fceaae2397215df34c274bf5 Mon Sep 17 00:00:00 2001 From: Neemys <36508659+Neemys@users.noreply.github.com> Date: Thu, 28 Jul 2022 18:26:49 +0200 Subject: [PATCH 21/30] Update `sonarqube-backend` plugin's dependencies Signed-off-by: Neemys <36508659+Neemys@users.noreply.github.com> --- plugins/sonarqube-backend/package.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/plugins/sonarqube-backend/package.json b/plugins/sonarqube-backend/package.json index 22dbe1e637..5d3b8f852e 100644 --- a/plugins/sonarqube-backend/package.json +++ b/plugins/sonarqube-backend/package.json @@ -23,7 +23,7 @@ "postpack": "backstage-cli package postpack" }, "dependencies": { - "@backstage/backend-common": "^0.14.1", + "@backstage/backend-common": "^0.15.0-next.0", "@backstage/config": "^1.0.1", "@types/express": "*", "express": "^4.18.1", @@ -33,7 +33,7 @@ "yn": "^5.0.0" }, "devDependencies": { - "@backstage/cli": "^0.18.0", + "@backstage/cli": "^0.18.1-next.0", "@types/supertest": "^2.0.12", "msw": "^0.44.2", "supertest": "^6.2.4" From e53b32bc26303f6366f7a206c337c6ae4336028d Mon Sep 17 00:00:00 2001 From: Neemys <36508659+Neemys@users.noreply.github.com> Date: Thu, 28 Jul 2022 19:10:07 +0200 Subject: [PATCH 22/30] Update `sonarqube-backend` plugin's api-report.md Signed-off-by: Neemys <36508659+Neemys@users.noreply.github.com> --- plugins/sonarqube-backend/api-report.md | 22 +++++++++++++--------- 1 file changed, 13 insertions(+), 9 deletions(-) diff --git a/plugins/sonarqube-backend/api-report.md b/plugins/sonarqube-backend/api-report.md index c163ff3bc5..b4f1ad4589 100644 --- a/plugins/sonarqube-backend/api-report.md +++ b/plugins/sonarqube-backend/api-report.md @@ -13,19 +13,15 @@ export function createRouter(options: RouterOptions): Promise; // @public export class DefaultSonarqubeInfoProvider implements SonarqubeInfoProvider { static fromConfig(config: Config): DefaultSonarqubeInfoProvider; - getBaseUrl(instanceName: string): string; + getBaseUrl({ instanceName }: { instanceName: string }): { + baseUrl: string; + }; getFindings( componentKey: string, instanceName: string, ): Promise; } -// @public -export interface Measure { - metric: string; - value: string; -} - // @public export interface RouterOptions { logger: Logger; @@ -44,12 +40,14 @@ export class SonarqubeConfig { // @public export interface SonarqubeFindings { analysisDate: string; - measures: Measure[]; + measures: SonarqubeMeasure[]; } // @public export interface SonarqubeInfoProvider { - getBaseUrl(instanceName: string): string; + getBaseUrl({ instanceName }: { instanceName: string }): { + baseUrl: string; + }; getFindings( componentKey: string, instanceName: string, @@ -63,5 +61,11 @@ export interface SonarqubeInstanceConfig { name: string; } +// @public +export interface SonarqubeMeasure { + metric: string; + value: string; +} + // (No @packageDocumentation comment for this package) ``` From 88dae32a7e93d6e2f674a85e74c34378a419b9f7 Mon Sep 17 00:00:00 2001 From: Neemys <36508659+Neemys@users.noreply.github.com> Date: Fri, 29 Jul 2022 17:22:35 +0200 Subject: [PATCH 23/30] Take into account PR comments on `sonarqube-backend` plugin's `router.ts` Simplify some code and change response method call to be `json` instead of plain `send`. Signed-off-by: Neemys <36508659+Neemys@users.noreply.github.com> --- .../sonarqube-backend/src/service/router.ts | 32 +++++++++---------- 1 file changed, 15 insertions(+), 17 deletions(-) diff --git a/plugins/sonarqube-backend/src/service/router.ts b/plugins/sonarqube-backend/src/service/router.ts index c0a9cb88d1..d6f5da7691 100644 --- a/plugins/sonarqube-backend/src/service/router.ts +++ b/plugins/sonarqube-backend/src/service/router.ts @@ -15,13 +15,10 @@ */ import { errorHandler } from '@backstage/backend-common'; -import express, { RequestHandler } from 'express'; +import express from 'express'; import Router from 'express-promise-router'; import { Logger } from 'winston'; -import { - SonarqubeFindings, - SonarqubeInfoProvider, -} from './sonarqubeInfoProvider'; +import { SonarqubeInfoProvider } from './sonarqubeInfoProvider'; import { InputError } from '../../../../packages/errors'; /** @@ -55,9 +52,9 @@ export async function createRouter( const router = Router(); router.use(express.json()); - router.get('/findings', (async (request, response) => { - const componentKey = request.query.componentKey; - let instanceKey = request.query.instanceKey; + router.get('/findings', async (request, response) => { + const componentKey = request.query.componentKey as string; + let instanceKey = request.query.instanceKey as string; if (!componentKey) throw new InputError('ComponentKey must be provided as a single string.'); @@ -73,13 +70,13 @@ export async function createRouter( ); } - response.send( + response.json( await sonarqubeInfoProvider.getFindings(componentKey, instanceKey), ); - }) as RequestHandler); + }); - router.get('/instanceUrl', ((request, response) => { - let requestedInstanceKey = request.query.instanceKey; + router.get('/instanceUrl', (request, response) => { + let requestedInstanceKey = request.query.instanceKey as string; if (requestedInstanceKey) { logger.info( `Retrieving sonarqube instance URL for key ${requestedInstanceKey}`, @@ -90,12 +87,13 @@ export async function createRouter( `Retrieving default sonarqube instance URL as parameter is inexistant, empty or malformed`, ); } - response.send({ - instanceUrl: sonarqubeInfoProvider.getBaseUrl({ - instanceName: requestedInstanceKey, - }).baseUrl, + const { baseUrl } = sonarqubeInfoProvider.getBaseUrl({ + instanceName: requestedInstanceKey, }); - }) as RequestHandler); + response.json({ + instanceUrl: baseUrl, + }); + }); router.use(errorHandler()); return router; From 1b2d1ff431ab8c917a6dc378cd748ce4e1a5a781 Mon Sep 17 00:00:00 2001 From: Neemys <36508659+Neemys@users.noreply.github.com> Date: Mon, 1 Aug 2022 10:42:43 +0200 Subject: [PATCH 24/30] Make instanceName an optional parameter in plugin `sonarqube-backend` APIs Signed-off-by: Neemys <36508659+Neemys@users.noreply.github.com> --- plugins/sonarqube-backend/api-report.md | 8 ++-- .../src/service/router.test.ts | 16 +++++++- .../sonarqube-backend/src/service/router.ts | 37 +++++++------------ .../src/service/sonarqubeInfoProvider.test.ts | 13 +++++++ .../src/service/sonarqubeInfoProvider.ts | 23 +++++++----- 5 files changed, 58 insertions(+), 39 deletions(-) diff --git a/plugins/sonarqube-backend/api-report.md b/plugins/sonarqube-backend/api-report.md index b4f1ad4589..8f0c672c6c 100644 --- a/plugins/sonarqube-backend/api-report.md +++ b/plugins/sonarqube-backend/api-report.md @@ -13,12 +13,12 @@ export function createRouter(options: RouterOptions): Promise; // @public export class DefaultSonarqubeInfoProvider implements SonarqubeInfoProvider { static fromConfig(config: Config): DefaultSonarqubeInfoProvider; - getBaseUrl({ instanceName }: { instanceName: string }): { + getBaseUrl({ instanceName }?: { instanceName?: string }): { baseUrl: string; }; getFindings( componentKey: string, - instanceName: string, + instanceName?: string, ): Promise; } @@ -45,12 +45,12 @@ export interface SonarqubeFindings { // @public export interface SonarqubeInfoProvider { - getBaseUrl({ instanceName }: { instanceName: string }): { + getBaseUrl({ instanceName }?: { instanceName?: string }): { baseUrl: string; }; getFindings( componentKey: string, - instanceName: string, + instanceName?: string, ): Promise; } diff --git a/plugins/sonarqube-backend/src/service/router.test.ts b/plugins/sonarqube-backend/src/service/router.test.ts index 719db6787e..e082215abc 100644 --- a/plugins/sonarqube-backend/src/service/router.test.ts +++ b/plugins/sonarqube-backend/src/service/router.test.ts @@ -83,7 +83,7 @@ describe('createRouter', () => { expect(response.status).toEqual(400); }); - it('use an empty string as instance name when instance key not provided', async () => { + it('use the value as instance name when instance key not provided', async () => { const measures = { analysisDate: '2021-04-08', measures: [{ metric: 'vulnerabilities', value: '54' }], @@ -94,11 +94,12 @@ describe('createRouter', () => { .get('/findings') .query({ componentKey: DUMMY_COMPONENT_KEY, + instanceKey: undefined, }) .send(); expect(getFindingsMock).toBeCalledTimes(1); - expect(getFindingsMock).toBeCalledWith(DUMMY_COMPONENT_KEY, ''); + expect(getFindingsMock).toBeCalledWith(DUMMY_COMPONENT_KEY, undefined); expect(response.status).toEqual(200); expect(response.body).toEqual(measures); }); @@ -121,5 +122,16 @@ describe('createRouter', () => { expect(response.status).toEqual(200); expect(response.body).toEqual({ instanceUrl: DUMMY_INSTANCE_URL }); }); + + it('query default instance when instanceKey not provided', async () => { + getBaseUrlMock.mockReturnValue({ baseUrl: DUMMY_INSTANCE_URL }); + const response = await request(app).get('/instanceUrl').send(); + expect(getBaseUrlMock).toBeCalledTimes(1); + expect(getBaseUrlMock).toBeCalledWith({ + instanceName: undefined, + }); + expect(response.status).toEqual(200); + expect(response.body).toEqual({ instanceUrl: DUMMY_INSTANCE_URL }); + }); }); }); diff --git a/plugins/sonarqube-backend/src/service/router.ts b/plugins/sonarqube-backend/src/service/router.ts index d6f5da7691..674045121f 100644 --- a/plugins/sonarqube-backend/src/service/router.ts +++ b/plugins/sonarqube-backend/src/service/router.ts @@ -54,21 +54,16 @@ export async function createRouter( router.use(express.json()); router.get('/findings', async (request, response) => { const componentKey = request.query.componentKey as string; - let instanceKey = request.query.instanceKey as string; + const instanceKey = request.query.instanceKey as string; if (!componentKey) throw new InputError('ComponentKey must be provided as a single string.'); - if (!instanceKey) { - instanceKey = ''; - logger.info( - `Retrieving findings for component ${componentKey} in default sonarqube instance`, - ); - } else { - logger.info( - `Retrieving findings for component ${componentKey} in sonarqube instance name ${instanceKey}`, - ); - } + logger.info( + instanceKey + ? `Retrieving findings for component ${componentKey} in sonarqube instance name ${instanceKey}` + : `Retrieving findings for component ${componentKey} in default sonarqube instance`, + ); response.json( await sonarqubeInfoProvider.getFindings(componentKey, instanceKey), @@ -76,19 +71,15 @@ export async function createRouter( }); router.get('/instanceUrl', (request, response) => { - let requestedInstanceKey = request.query.instanceKey as string; - if (requestedInstanceKey) { - logger.info( - `Retrieving sonarqube instance URL for key ${requestedInstanceKey}`, - ); - } else { - requestedInstanceKey = ''; - logger.info( - `Retrieving default sonarqube instance URL as parameter is inexistant, empty or malformed`, - ); - } + const instanceKey = request.query.instanceKey as string; + + logger.info( + instanceKey + ? `Retrieving sonarqube instance URL for key ${instanceKey}` + : `Retrieving default sonarqube instance URL as instanceKey is not provided`, + ); const { baseUrl } = sonarqubeInfoProvider.getBaseUrl({ - instanceName: requestedInstanceKey, + instanceName: instanceKey, }); response.json({ instanceUrl: baseUrl, diff --git a/plugins/sonarqube-backend/src/service/sonarqubeInfoProvider.test.ts b/plugins/sonarqube-backend/src/service/sonarqubeInfoProvider.test.ts index 774fdca238..f7298888fe 100644 --- a/plugins/sonarqube-backend/src/service/sonarqubeInfoProvider.test.ts +++ b/plugins/sonarqube-backend/src/service/sonarqubeInfoProvider.test.ts @@ -229,6 +229,19 @@ describe('DefaultSonarqubeInfoProvider', () => { } describe('getBaseUrl', () => { + it('Provide base url for default from simple config and non provided instanceName', async () => { + const provider = configureProvider({ + sonarqube: { + baseUrl: 'https://sonarqube.example.com', + apiKey: '123456789abcdef0123456789abcedf012', + }, + }); + + expect(provider.getBaseUrl()).toEqual({ + baseUrl: 'https://sonarqube.example.com', + }); + }); + it('Provide base url for default from simple config and empty string', async () => { const provider = configureProvider({ sonarqube: { diff --git a/plugins/sonarqube-backend/src/service/sonarqubeInfoProvider.ts b/plugins/sonarqube-backend/src/service/sonarqubeInfoProvider.ts index 6f75171f65..f03593b012 100644 --- a/plugins/sonarqube-backend/src/service/sonarqubeInfoProvider.ts +++ b/plugins/sonarqube-backend/src/service/sonarqubeInfoProvider.ts @@ -23,18 +23,21 @@ import fetch from 'node-fetch'; */ export interface SonarqubeInfoProvider { /** - * Get the sonarqube URL in configuration from a provided name. + * Get the sonarqube URL in configuration from a provided instanceName. * - * If name is omitted, default sonarqube instance is queried in config + * If instanceName is omitted, default sonarqube instance is queried in config * * @param instanceName - Name of the sonarqube instance to get the info from * @returns the url of the instance */ - getBaseUrl({ instanceName }: { instanceName: string }): { baseUrl: string }; + getBaseUrl({ instanceName }?: { instanceName?: string }): { baseUrl: string }; /** * Query the sonarqube instance corresponding to the instanceName to get all * measures for the component of key componentKey. + * + * If instanceName is omitted, default sonarqube instance is queried in config + * * @param componentKey - component key of the project we want to get measure from. * @param instanceName - name of the instance (in config) where the project is hosted. * @returns All measures with the analysis date. Will return undefined if we @@ -42,7 +45,7 @@ export interface SonarqubeInfoProvider { */ getFindings( componentKey: string, - instanceName: string, + instanceName?: string, ): Promise; } @@ -295,8 +298,10 @@ export class DefaultSonarqubeInfoProvider implements SonarqubeInfoProvider { * {@inheritDoc SonarqubeInfoProvider.getBaseUrl} * @throws Error If configuration can't be retrieved. */ - getBaseUrl({ instanceName }: { instanceName: string }): { baseUrl: string } { - const instanceConfig = this.config.getInstanceConfig(instanceName ?? ''); + getBaseUrl({ instanceName }: { instanceName?: string } = {}): { + baseUrl: string; + } { + const instanceConfig = this.config.getInstanceConfig(instanceName); return { baseUrl: instanceConfig.baseUrl }; } @@ -306,11 +311,9 @@ export class DefaultSonarqubeInfoProvider implements SonarqubeInfoProvider { */ async getFindings( componentKey: string, - instanceName: string, + instanceName?: string, ): Promise { - const { baseUrl, apiKey } = this.config.getInstanceConfig( - instanceName ?? '', - ); + const { baseUrl, apiKey } = this.config.getInstanceConfig(instanceName); // get component info to retrieve analysis date const component = From 6b7214547f86f7d15b69ea72fa794d2b7c1b5869 Mon Sep 17 00:00:00 2001 From: Neemys <36508659+Neemys@users.noreply.github.com> Date: Mon, 1 Aug 2022 16:42:05 +0200 Subject: [PATCH 25/30] Make plugin `sonarqube-backend` APIs use object as parameters To be easily modified in the future Signed-off-by: Neemys <36508659+Neemys@users.noreply.github.com> --- .../src/service/sonarqubeInfoProvider.test.ts | 42 ++++++++++++++----- .../src/service/sonarqubeInfoProvider.ts | 34 ++++++++++----- 2 files changed, 55 insertions(+), 21 deletions(-) diff --git a/plugins/sonarqube-backend/src/service/sonarqubeInfoProvider.test.ts b/plugins/sonarqube-backend/src/service/sonarqubeInfoProvider.test.ts index f7298888fe..e617324555 100644 --- a/plugins/sonarqube-backend/src/service/sonarqubeInfoProvider.test.ts +++ b/plugins/sonarqube-backend/src/service/sonarqubeInfoProvider.test.ts @@ -173,7 +173,7 @@ describe('SonarqubeConfig', () => { }, ]); - expect(config.getInstanceConfig('default')).toEqual({ + expect(config.getInstanceConfig({ sonarqubeName: 'default' })).toEqual({ name: 'default', baseUrl: 'https://sonarqube.example.com', apiKey: '123456789abcdef0123456789abcedf012', @@ -190,7 +190,7 @@ describe('SonarqubeConfig', () => { }, ]); - expect(config.getInstanceConfig('other')).toEqual({ + expect(config.getInstanceConfig({ sonarqubeName: 'other' })).toEqual({ name: 'other', baseUrl: 'https://sonarqube-other.example.com', apiKey: '123456789abcdef0123456789abcedf012', @@ -206,7 +206,9 @@ describe('SonarqubeConfig', () => { }, ]); - expect(() => config.getInstanceConfig('default')).toThrowError(Error); + expect(() => + config.getInstanceConfig({ sonarqubeName: 'default' }), + ).toThrowError(Error); }); it('Throw an error if named instance could not be found', async () => { @@ -214,7 +216,9 @@ describe('SonarqubeConfig', () => { DUMMY_SIMPLE_OBJECT_FOR_DEFAULT_SONARQUBE_CONFIG, ]); - expect(() => config.getInstanceConfig('other')).toThrowError(Error); + expect(() => + config.getInstanceConfig({ sonarqubeName: 'other' }), + ).toThrowError(Error); }); }); }); @@ -385,7 +389,10 @@ describe('DefaultSonarqubeInfoProvider', () => { setupHandlers(); const provider = configureProvider(DUMMY_SIMPLE_CONFIG_FOR_PROVIDER); expect( - await provider.getFindings(DUMMY_COMPONENT_KEY, 'default'), + await provider.getFindings({ + componentKey: DUMMY_COMPONENT_KEY, + instanceName: 'default', + }), ).toEqual({ analysisDate: DUMMY_ANALYSIS_DATE, measures: [ @@ -414,7 +421,10 @@ describe('DefaultSonarqubeInfoProvider', () => { }, }); expect( - await provider.getFindings(DUMMY_COMPONENT_KEY, 'default'), + await provider.getFindings({ + componentKey: DUMMY_COMPONENT_KEY, + instanceName: 'default', + }), ).toBeUndefined(); }); it('Provide undefined as finding if component API answer incorrectly', async () => { @@ -434,7 +444,10 @@ describe('DefaultSonarqubeInfoProvider', () => { const provider = configureProvider(DUMMY_SIMPLE_CONFIG_FOR_PROVIDER); expect( - await provider.getFindings(DUMMY_COMPONENT_KEY, 'default'), + await provider.getFindings({ + componentKey: DUMMY_COMPONENT_KEY, + instanceName: 'default', + }), ).toBeUndefined(); }); it('Provide findings when metrics API uses pages', async () => { @@ -462,7 +475,10 @@ describe('DefaultSonarqubeInfoProvider', () => { const provider = configureProvider(DUMMY_SIMPLE_CONFIG_FOR_PROVIDER); expect( - await provider.getFindings(DUMMY_COMPONENT_KEY, 'default'), + await provider.getFindings({ + componentKey: DUMMY_COMPONENT_KEY, + instanceName: 'default', + }), ).toEqual({ analysisDate: DUMMY_ANALYSIS_DATE, measures: [ @@ -489,7 +505,10 @@ describe('DefaultSonarqubeInfoProvider', () => { const provider = configureProvider(DUMMY_SIMPLE_CONFIG_FOR_PROVIDER); expect( - await provider.getFindings(DUMMY_COMPONENT_KEY, 'default'), + await provider.getFindings({ + componentKey: DUMMY_COMPONENT_KEY, + instanceName: 'default', + }), ).toBeUndefined(); }); @@ -509,7 +528,10 @@ describe('DefaultSonarqubeInfoProvider', () => { const provider = configureProvider(DUMMY_SIMPLE_CONFIG_FOR_PROVIDER); expect( - await provider.getFindings(DUMMY_COMPONENT_KEY, 'default'), + await provider.getFindings({ + componentKey: DUMMY_COMPONENT_KEY, + instanceName: 'default', + }), ).toEqual({ analysisDate: DUMMY_ANALYSIS_DATE, measures: [], diff --git a/plugins/sonarqube-backend/src/service/sonarqubeInfoProvider.ts b/plugins/sonarqube-backend/src/service/sonarqubeInfoProvider.ts index f03593b012..7027738d2c 100644 --- a/plugins/sonarqube-backend/src/service/sonarqubeInfoProvider.ts +++ b/plugins/sonarqube-backend/src/service/sonarqubeInfoProvider.ts @@ -43,10 +43,13 @@ export interface SonarqubeInfoProvider { * @returns All measures with the analysis date. Will return undefined if we * can't provide the full response */ - getFindings( - componentKey: string, - instanceName?: string, - ): Promise; + getFindings({ + componentKey, + instanceName, + }: { + componentKey: string; + instanceName?: string; + }): Promise; } /** @@ -182,7 +185,9 @@ export class SonarqubeConfig { * @returns The requested Sonarqube instance. * @throws Error when no default config could be found or the requested name couldn't be found in config. */ - getInstanceConfig(sonarqubeName?: string): SonarqubeInstanceConfig { + getInstanceConfig({ + sonarqubeName, + }: { sonarqubeName?: string } = {}): SonarqubeInstanceConfig { const DEFAULT_SONARQUBE_NAME = 'default'; if (!sonarqubeName || sonarqubeName === DEFAULT_SONARQUBE_NAME) { @@ -301,7 +306,9 @@ export class DefaultSonarqubeInfoProvider implements SonarqubeInfoProvider { getBaseUrl({ instanceName }: { instanceName?: string } = {}): { baseUrl: string; } { - const instanceConfig = this.config.getInstanceConfig(instanceName); + const instanceConfig = this.config.getInstanceConfig({ + sonarqubeName: instanceName, + }); return { baseUrl: instanceConfig.baseUrl }; } @@ -309,11 +316,16 @@ export class DefaultSonarqubeInfoProvider implements SonarqubeInfoProvider { * {@inheritDoc SonarqubeInfoProvider.getFindings} * @throws Error If configuration can't be retrieved. */ - async getFindings( - componentKey: string, - instanceName?: string, - ): Promise { - const { baseUrl, apiKey } = this.config.getInstanceConfig(instanceName); + async getFindings({ + componentKey, + instanceName, + }: { + componentKey: string; + instanceName?: string; + }): Promise { + const { baseUrl, apiKey } = this.config.getInstanceConfig({ + sonarqubeName: instanceName, + }); // get component info to retrieve analysis date const component = From 063ca9713772d28704ebafd8d5c9b832f720d269 Mon Sep 17 00:00:00 2001 From: Neemys <36508659+Neemys@users.noreply.github.com> Date: Mon, 1 Aug 2022 16:54:24 +0200 Subject: [PATCH 26/30] Change behavior of method `useProjectInfo` in plugin `sonarqube` Will now return undefined instead of empty string. To be consistent with how the rest of the API works. Also provide unit test for this method and the other one in the same file `isSonarQubeAvailable` Signed-off-by: Neemys <36508659+Neemys@users.noreply.github.com> --- .../SonarQubeCard/SonarQubeCard.tsx | 6 +- .../src/components/useProjectKey.test.ts | 100 ++++++++++++++++++ .../sonarqube/src/components/useProjectKey.ts | 10 +- 3 files changed, 110 insertions(+), 6 deletions(-) create mode 100644 plugins/sonarqube/src/components/useProjectKey.test.ts diff --git a/plugins/sonarqube/src/components/SonarQubeCard/SonarQubeCard.tsx b/plugins/sonarqube/src/components/SonarQubeCard/SonarQubeCard.tsx index d8aaec04f2..af91bc5469 100644 --- a/plugins/sonarqube/src/components/SonarQubeCard/SonarQubeCard.tsx +++ b/plugins/sonarqube/src/components/SonarQubeCard/SonarQubeCard.tsx @@ -98,7 +98,11 @@ export const SonarQubeCard = ({ const { projectKey: projectTitle, projectInstance } = useProjectInfo(entity); const { value, loading } = useAsync( - async () => sonarQubeApi.getFindingSummary(projectTitle, projectInstance), + async () => + sonarQubeApi.getFindingSummary({ + componentKey: projectTitle, + projectInstance: projectInstance, + }), [sonarQubeApi, projectTitle], ); diff --git a/plugins/sonarqube/src/components/useProjectKey.test.ts b/plugins/sonarqube/src/components/useProjectKey.test.ts new file mode 100644 index 0000000000..be83c236df --- /dev/null +++ b/plugins/sonarqube/src/components/useProjectKey.test.ts @@ -0,0 +1,100 @@ +/* + * Copyright 2022 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import { + isSonarQubeAvailable, + SONARQUBE_PROJECT_INSTANCE_SEPARATOR, + SONARQUBE_PROJECT_KEY_ANNOTATION, + useProjectInfo, +} from './useProjectKey'; +import { Entity } from '../../../../packages/catalog-model'; + +const createDummyEntity = (sonarqubeAnnotationValue: string): Entity => { + return { + apiVersion: '', + kind: '', + metadata: { + name: 'dummy', + annotations: { + [SONARQUBE_PROJECT_KEY_ANNOTATION]: sonarqubeAnnotationValue, + }, + }, + }; +}; + +describe('isSonarQubeAvailable', () => { + it('returns true if sonarqube annotation defined', () => { + const entity = createDummyEntity('dummy'); + expect(isSonarQubeAvailable(entity)).toBe(true); + }); + it('returns false if sonarqube annotation empty', () => { + const entity = createDummyEntity(''); + expect(isSonarQubeAvailable(entity)).toBe(false); + }); + it('returns false if sonarqube annotation not defined', () => { + const entity = { + apiVersion: '', + kind: '', + metadata: { + name: 'dummy', + annotations: {}, + }, + }; + expect(isSonarQubeAvailable(entity)).toBe(false); + }); +}); + +describe('useProjectInfo', () => { + const DUMMY_INSTANCE = 'dummyInstance'; + const DUMMY_KEY = 'dummyKey'; + it('parse annotation with key and instance', () => { + const entity = createDummyEntity( + DUMMY_INSTANCE + SONARQUBE_PROJECT_INSTANCE_SEPARATOR + DUMMY_KEY, + ); + expect(useProjectInfo(entity)).toEqual({ + projectInstance: DUMMY_INSTANCE, + projectKey: DUMMY_KEY, + }); + }); + // compatibility with previous mono-instance sonarqube config + it('parse annotation with only key', () => { + const entity = createDummyEntity(DUMMY_KEY); + expect(useProjectInfo(entity)).toEqual({ + projectInstance: undefined, + projectKey: DUMMY_KEY, + }); + }); + it('handle empty annotation', () => { + const entity = createDummyEntity(''); + expect(useProjectInfo(entity)).toEqual({ + projectInstance: undefined, + projectKey: undefined, + }); + }); + it('handle non-existent annotation', () => { + const entity = { + apiVersion: '', + kind: '', + metadata: { + name: 'dummy', + annotations: {}, + }, + }; + expect(useProjectInfo(entity)).toEqual({ + projectInstance: undefined, + projectKey: undefined, + }); + }); +}); diff --git a/plugins/sonarqube/src/components/useProjectKey.ts b/plugins/sonarqube/src/components/useProjectKey.ts index fbe12b5b5d..7a5a428f49 100644 --- a/plugins/sonarqube/src/components/useProjectKey.ts +++ b/plugins/sonarqube/src/components/useProjectKey.ts @@ -25,7 +25,7 @@ export const isSonarQubeAvailable = (entity: Entity) => /** * Try to parse sonarqube information from an entity. * - * If part or all info are not found, they will default to an empty string + * If part or all info are not found, they will default to undefined * * @param entity entity to find the sonarqube information from. * @return a ProjectInfo properly populated. @@ -33,11 +33,11 @@ export const isSonarQubeAvailable = (entity: Entity) => export const useProjectInfo = ( entity: Entity, ): { - projectInstance: string; - projectKey: string; + projectInstance: string | undefined; + projectKey: string | undefined; } => { - let projectInstance = ''; - let projectKey = ''; + let projectInstance = undefined; + let projectKey = undefined; const annotation = entity?.metadata.annotations?.[SONARQUBE_PROJECT_KEY_ANNOTATION]; if (annotation) { From e598739487d641e673563c725e7b68eb8cc311f8 Mon Sep 17 00:00:00 2001 From: Neemys <36508659+Neemys@users.noreply.github.com> Date: Mon, 1 Aug 2022 17:06:57 +0200 Subject: [PATCH 27/30] Modify plugin `sonarqube`'s APIs to take object as parameter to make the API more easy to extend. Signed-off-by: Neemys <36508659+Neemys@users.noreply.github.com> --- plugins/sonarqube/src/api/SonarQubeApi.ts | 11 +++++++---- .../sonarqube/src/api/SonarQubeClient.test.ts | 17 +++++++++++++---- plugins/sonarqube/src/api/SonarQubeClient.ts | 11 +++++++---- 3 files changed, 27 insertions(+), 12 deletions(-) diff --git a/plugins/sonarqube/src/api/SonarQubeApi.ts b/plugins/sonarqube/src/api/SonarQubeApi.ts index 2232a00d9f..271f1d8e8e 100644 --- a/plugins/sonarqube/src/api/SonarQubeApi.ts +++ b/plugins/sonarqube/src/api/SonarQubeApi.ts @@ -38,8 +38,11 @@ export const sonarQubeApiRef = createApiRef({ }); export type SonarQubeApi = { - getFindingSummary( - projectInstance?: string, - componentKey?: string, - ): Promise; + getFindingSummary({ + componentKey, + projectInstance, + }: { + componentKey?: string; + projectInstance?: string; + }): Promise; }; diff --git a/plugins/sonarqube/src/api/SonarQubeClient.test.ts b/plugins/sonarqube/src/api/SonarQubeClient.test.ts index 87137ea93f..dc5d7f077e 100644 --- a/plugins/sonarqube/src/api/SonarQubeClient.test.ts +++ b/plugins/sonarqube/src/api/SonarQubeClient.test.ts @@ -124,7 +124,9 @@ describe('SonarQubeClient', () => { identityApi: identityApiAuthenticated, }); - const summary = await client.getFindingSummary('our:service'); + const summary = await client.getFindingSummary({ + componentKey: 'our:service', + }); expect(summary).toEqual( expect.objectContaining({ lastAnalysis: '2020-01-01T00:00:00Z', @@ -199,7 +201,10 @@ describe('SonarQubeClient', () => { identityApi: identityApiAuthenticated, }); - const summary = await client.getFindingSummary('our:service', 'custom'); + const summary = await client.getFindingSummary({ + componentKey: 'our:service', + projectInstance: 'custom', + }); expect(summary).toEqual( expect.objectContaining({ @@ -241,7 +246,9 @@ describe('SonarQubeClient', () => { discoveryApi, identityApi: identityApiAuthenticated, }); - const summary = await client.getFindingSummary('our:service'); + const summary = await client.getFindingSummary({ + componentKey: 'our:service', + }); expect(summary?.lastAnalysis).toBe('2020-01-01T00:00:00Z'); }); @@ -267,7 +274,9 @@ describe('SonarQubeClient', () => { discoveryApi, identityApi: identityApiGuest, }); - const summary = await client.getFindingSummary('our:service'); + const summary = await client.getFindingSummary({ + componentKey: 'our:service', + }); expect(summary?.lastAnalysis).toBe('2020-01-01T00:00:00Z'); }); diff --git a/plugins/sonarqube/src/api/SonarQubeClient.ts b/plugins/sonarqube/src/api/SonarQubeClient.ts index 59182d917f..9ec40e8420 100644 --- a/plugins/sonarqube/src/api/SonarQubeClient.ts +++ b/plugins/sonarqube/src/api/SonarQubeClient.ts @@ -56,10 +56,13 @@ export class SonarQubeClient implements SonarQubeApi { return undefined; } - async getFindingSummary( - componentKey?: string, - projectInstance?: string, - ): Promise { + async getFindingSummary({ + componentKey, + projectInstance, + }: { + componentKey?: string; + projectInstance?: string; + } = {}): Promise { if (!componentKey) { return undefined; } From 65f3172896fc9effb20d6c11e215e73169ab7a7d Mon Sep 17 00:00:00 2001 From: Neemys <36508659+Neemys@users.noreply.github.com> Date: Mon, 1 Aug 2022 17:27:52 +0200 Subject: [PATCH 28/30] Fix forgotten use of recent API change in `sonarqube-backend` plugin Signed-off-by: Neemys <36508659+Neemys@users.noreply.github.com> --- plugins/sonarqube-backend/src/service/router.test.ts | 7 ++++++- plugins/sonarqube-backend/src/service/router.ts | 5 ++++- 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/plugins/sonarqube-backend/src/service/router.test.ts b/plugins/sonarqube-backend/src/service/router.test.ts index e082215abc..6b42e745be 100644 --- a/plugins/sonarqube-backend/src/service/router.test.ts +++ b/plugins/sonarqube-backend/src/service/router.test.ts @@ -29,7 +29,12 @@ describe('createRouter', () => { > = jest.fn(); const getFindingsMock: jest.Mock< Promise, - [string, string] + [ + { + componentKey: string; + instanceName: string; + }, + ] > = jest.fn(); beforeAll(async () => { diff --git a/plugins/sonarqube-backend/src/service/router.ts b/plugins/sonarqube-backend/src/service/router.ts index 674045121f..45cdcb64c3 100644 --- a/plugins/sonarqube-backend/src/service/router.ts +++ b/plugins/sonarqube-backend/src/service/router.ts @@ -66,7 +66,10 @@ export async function createRouter( ); response.json( - await sonarqubeInfoProvider.getFindings(componentKey, instanceKey), + await sonarqubeInfoProvider.getFindings({ + componentKey, + instanceName: instanceKey, + }), ); }); From 4c9193c384e407d50008be8ce30bf65cd629173a Mon Sep 17 00:00:00 2001 From: Neemys <36508659+Neemys@users.noreply.github.com> Date: Mon, 1 Aug 2022 17:37:24 +0200 Subject: [PATCH 29/30] Fix more forgotten use of recent API change in `sonarqube-backend` plugin Signed-off-by: Neemys <36508659+Neemys@users.noreply.github.com> --- .../sonarqube-backend/src/service/router.test.ts | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/plugins/sonarqube-backend/src/service/router.test.ts b/plugins/sonarqube-backend/src/service/router.test.ts index 6b42e745be..426066d897 100644 --- a/plugins/sonarqube-backend/src/service/router.test.ts +++ b/plugins/sonarqube-backend/src/service/router.test.ts @@ -70,10 +70,10 @@ describe('createRouter', () => { }) .send(); expect(getFindingsMock).toBeCalledTimes(1); - expect(getFindingsMock).toBeCalledWith( - DUMMY_COMPONENT_KEY, - DUMMY_INSTANCE_KEY, - ); + expect(getFindingsMock).toBeCalledWith({ + componentKey: DUMMY_COMPONENT_KEY, + instanceName: DUMMY_INSTANCE_KEY, + }); expect(response.status).toEqual(200); expect(response.body).toEqual(measures); }); @@ -104,7 +104,10 @@ describe('createRouter', () => { .send(); expect(getFindingsMock).toBeCalledTimes(1); - expect(getFindingsMock).toBeCalledWith(DUMMY_COMPONENT_KEY, undefined); + expect(getFindingsMock).toBeCalledWith({ + componentKey: DUMMY_COMPONENT_KEY, + instanceName: undefined, + }); expect(response.status).toEqual(200); expect(response.body).toEqual(measures); }); From ead23df325a7759cefad70d2bc5884f635d1c6ba Mon Sep 17 00:00:00 2001 From: Neemys <36508659+Neemys@users.noreply.github.com> Date: Mon, 1 Aug 2022 17:49:15 +0200 Subject: [PATCH 30/30] Update api-report.md in `sonarqube-backend` plugin Signed-off-by: Neemys <36508659+Neemys@users.noreply.github.com> --- plugins/sonarqube-backend/api-report.md | 28 +++++++++++++++++-------- 1 file changed, 19 insertions(+), 9 deletions(-) diff --git a/plugins/sonarqube-backend/api-report.md b/plugins/sonarqube-backend/api-report.md index 8f0c672c6c..f4c48cadce 100644 --- a/plugins/sonarqube-backend/api-report.md +++ b/plugins/sonarqube-backend/api-report.md @@ -16,10 +16,13 @@ export class DefaultSonarqubeInfoProvider implements SonarqubeInfoProvider { getBaseUrl({ instanceName }?: { instanceName?: string }): { baseUrl: string; }; - getFindings( - componentKey: string, - instanceName?: string, - ): Promise; + getFindings({ + componentKey, + instanceName, + }: { + componentKey: string; + instanceName?: string; + }): Promise; } // @public @@ -32,7 +35,11 @@ export interface RouterOptions { export class SonarqubeConfig { constructor(instances: SonarqubeInstanceConfig[]); static fromConfig(config: Config): SonarqubeConfig; - getInstanceConfig(sonarqubeName?: string): SonarqubeInstanceConfig; + getInstanceConfig({ + sonarqubeName, + }?: { + sonarqubeName?: string; + }): SonarqubeInstanceConfig; // (undocumented) readonly instances: SonarqubeInstanceConfig[]; } @@ -48,10 +55,13 @@ export interface SonarqubeInfoProvider { getBaseUrl({ instanceName }?: { instanceName?: string }): { baseUrl: string; }; - getFindings( - componentKey: string, - instanceName?: string, - ): Promise; + getFindings({ + componentKey, + instanceName, + }: { + componentKey: string; + instanceName?: string; + }): Promise; } // @public