Merge pull request #11925 from soprasteria/neemys/implement-multiple-sonarqube-instances
Add ability to configure multiple sonarqube instances
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -38,5 +38,11 @@ export const sonarQubeApiRef = createApiRef<SonarQubeApi>({
|
||||
});
|
||||
|
||||
export type SonarQubeApi = {
|
||||
getFindingSummary(componentKey?: string): Promise<FindingSummary | undefined>;
|
||||
getFindingSummary({
|
||||
componentKey,
|
||||
projectInstance,
|
||||
}: {
|
||||
componentKey?: string;
|
||||
projectInstance?: 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 { ComponentWrapper, MeasuresWrapper } from './types';
|
||||
import { InstanceUrlWrapper, FindingsWrapper } from './types';
|
||||
import { UrlPatternDiscovery } from '@backstage/core-app-api';
|
||||
import { IdentityApi } from '@backstage/core-plugin-api';
|
||||
|
||||
@@ -40,133 +40,77 @@ 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&instanceKey=',
|
||||
);
|
||||
|
||||
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),
|
||||
);
|
||||
}),
|
||||
);
|
||||
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),
|
||||
);
|
||||
}),
|
||||
);
|
||||
@@ -180,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',
|
||||
@@ -210,59 +156,63 @@ 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');
|
||||
|
||||
expect(summary).toEqual(
|
||||
expect.objectContaining({
|
||||
lastAnalysis: '2020-01-01T00: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',
|
||||
},
|
||||
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 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({
|
||||
componentKey: 'our:service',
|
||||
projectInstance: 'custom',
|
||||
});
|
||||
|
||||
const summary = await client.getFindingSummary('our:service');
|
||||
|
||||
expect(summary).toEqual(
|
||||
expect.objectContaining({
|
||||
lastAnalysis: '2020-01-01T00:00:00Z',
|
||||
lastAnalysis: '2020-01-03T00:00:00Z',
|
||||
metrics: {
|
||||
alert_status: 'OK',
|
||||
bugs: '2',
|
||||
alert_status: 'ERROR',
|
||||
bugs: '45',
|
||||
reliability_rating: '5.0',
|
||||
},
|
||||
projectUrl: 'http://a.instance.local/dashboard?id=our%3Aservice',
|
||||
}) as FindingSummary,
|
||||
@@ -278,25 +228,27 @@ describe('SonarQubeClient', () => {
|
||||
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&instanceKey=',
|
||||
);
|
||||
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),
|
||||
);
|
||||
}),
|
||||
);
|
||||
|
||||
const client = new SonarQubeClient({
|
||||
discoveryApi,
|
||||
baseUrl: 'http://a.instance.local',
|
||||
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');
|
||||
});
|
||||
@@ -304,25 +256,27 @@ 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&instanceKey=',
|
||||
);
|
||||
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),
|
||||
);
|
||||
}),
|
||||
);
|
||||
|
||||
const client = new SonarQubeClient({
|
||||
discoveryApi,
|
||||
baseUrl: 'http://a.instance.local',
|
||||
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');
|
||||
});
|
||||
|
||||
@@ -16,26 +16,22 @@
|
||||
|
||||
import fetch from 'cross-fetch';
|
||||
import { FindingSummary, Metrics, SonarQubeApi } from './SonarQubeApi';
|
||||
import { ComponentWrapper, MeasuresWrapper } 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>(
|
||||
@@ -44,7 +40,7 @@ export class SonarQubeClient implements SonarQubeApi {
|
||||
): Promise<T | undefined> {
|
||||
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,40 +56,18 @@ export class SonarQubeClient implements SonarQubeApi {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
private async getSupportedMetrics(): Promise<string[]> {
|
||||
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<FindingSummary | undefined> {
|
||||
async getFindingSummary({
|
||||
componentKey,
|
||||
projectInstance,
|
||||
}: {
|
||||
componentKey?: string;
|
||||
projectInstance?: string;
|
||||
} = {}): Promise<FindingSummary | undefined> {
|
||||
if (!componentKey) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const component = await this.callApi<ComponentWrapper>('components/show', {
|
||||
component: componentKey,
|
||||
});
|
||||
if (!component) {
|
||||
return undefined;
|
||||
}
|
||||
const instanceKey = projectInstance || '';
|
||||
|
||||
const metrics: Metrics = {
|
||||
alert_status: undefined,
|
||||
@@ -109,44 +83,49 @@ 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 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 measures = await this.callApi<MeasuresWrapper>('measures/search', {
|
||||
projectKeys: componentKey,
|
||||
metricKeys: metricKeys.join(','),
|
||||
const findings = await this.callApi<FindingsWrapper>('findings', {
|
||||
componentKey,
|
||||
instanceKey,
|
||||
});
|
||||
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,
|
||||
)}`,
|
||||
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,15 +14,12 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
export interface ComponentWrapper {
|
||||
component: Component;
|
||||
export interface InstanceUrlWrapper {
|
||||
instanceUrl: string;
|
||||
}
|
||||
|
||||
export interface Component {
|
||||
export interface FindingsWrapper {
|
||||
analysisDate: string;
|
||||
}
|
||||
|
||||
export interface MeasuresWrapper {
|
||||
measures: Measure[];
|
||||
}
|
||||
|
||||
@@ -55,7 +52,6 @@ export type MetricKey =
|
||||
export interface Measure {
|
||||
metric: MetricKey;
|
||||
value: string;
|
||||
component: string;
|
||||
}
|
||||
|
||||
export type SonarUrlProcessorFunc = (identifier: string) => string;
|
||||
|
||||
@@ -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,14 @@ 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({
|
||||
componentKey: projectTitle,
|
||||
projectInstance: projectInstance,
|
||||
}),
|
||||
[sonarQubeApi, projectTitle],
|
||||
);
|
||||
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -17,10 +17,38 @@
|
||||
import { Entity } from '@backstage/catalog-model';
|
||||
|
||||
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 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.
|
||||
*/
|
||||
export const useProjectInfo = (
|
||||
entity: Entity,
|
||||
): {
|
||||
projectInstance: string | undefined;
|
||||
projectKey: string | undefined;
|
||||
} => {
|
||||
let projectInstance = undefined;
|
||||
let projectKey = undefined;
|
||||
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