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 ?? [], + }; + } +}