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, }), }),