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>
This commit is contained in:
Neemys
2022-06-17 17:43:40 +02:00
parent 619b515172
commit f9c310a439
9 changed files with 139 additions and 42 deletions
+4 -1
View File
@@ -38,5 +38,8 @@ export const sonarQubeApiRef = createApiRef<SonarQubeApi>({
});
export type SonarQubeApi = {
getFindingSummary(componentKey?: string): Promise<FindingSummary | undefined>;
getFindingSummary(
projectInstance?: string,
componentKey?: string,
): Promise<FindingSummary | undefined>;
};
@@ -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');
+25 -12
View File
@@ -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<T>(
@@ -62,11 +58,14 @@ export class SonarQubeClient implements SonarQubeApi {
async getFindingSummary(
componentKey?: string,
projectInstance?: string,
): Promise<FindingSummary | undefined> {
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<InstanceUrlWrapper>(
'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<FindingsWrapper>('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,
)}`,
};
+4
View File
@@ -14,6 +14,10 @@
* limitations under the License.
*/
export interface InstanceUrlWrapper {
instanceUrl: string;
}
export interface FindingsWrapper {
analysisDate: string;
measures: Measure[];
@@ -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],
);
@@ -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 };
};
+1 -4
View File
@@ -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,
}),
}),