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:
@@ -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
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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,
|
||||
)}`,
|
||||
};
|
||||
|
||||
@@ -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 };
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
}),
|
||||
|
||||
Reference in New Issue
Block a user