feat: codescene plugin at entity level

Signed-off-by: blbose <lillian.bose@philips.com>
This commit is contained in:
blbose
2023-11-14 21:33:08 +05:30
committed by Scott Guymer
parent 72572b2fe1
commit e477ec4df2
13 changed files with 649 additions and 1 deletions
@@ -0,0 +1,135 @@
/*
* Copyright 2023 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 React from 'react';
import { CodeSceneEntityKPICard } from './CodeSceneEntityKPICard';
import { rest } from 'msw';
import { setupServer } from 'msw/node';
import {
setupRequestMockHandlers,
renderInTestApp,
TestApiRegistry,
} from '@backstage/test-utils';
import { CodeSceneApi, codesceneApiRef } from '../../api/api';
import { ApiProvider } from '@backstage/core-app-api';
import { Analysis } from '../../api/types';
import { ConfigReader } from '@backstage/config';
import { configApiRef } from '@backstage/core-plugin-api';
import { EntityProvider } from '@backstage/plugin-catalog-react';
const entity = {
metadata: {
namespace: 'default',
annotations: {
'codescene.io/project-id': '12345',
},
name: 'codescene-test-project',
title: 'codescene-test-project',
description: 'A demo codescene-test repo',
links: [
{
url: 'https://some-demo.com',
title: 'Demo',
icon: 'dashboard',
},
],
},
apiVersion: 'backstage.io/v1alpha1',
kind: 'Component',
spec: {
type: 'website',
owner: 'swcoe',
lifecycle: 'experimental',
},
relations: [
{
type: 'ownedBy',
targetRef: 'group:default/swcoe',
target: {
kind: 'group',
namespace: 'default',
name: 'swcoe',
},
},
],
};
describe('CodeSceneEntityKPICard', () => {
const server = setupServer();
// Enable same handlers for network requests
setupRequestMockHandlers(server);
let apis: TestApiRegistry;
// setup mock response
beforeEach(() => {
server.use(
rest.get('/*', (_, res, ctx) => res(ctx.status(200), ctx.json({}))),
);
const config = new ConfigReader({
codescene: {
baseUrl: 'www.fake-url.com',
},
});
const analysis: Analysis = {
id: 1,
name: 'codescene-test-project',
project_id: 12345,
readable_analysis_time: '2022-03-22',
summary: {
unique_issue_ids: 0,
issues_filtered_as_outliers: 0,
entities: 0,
commits_with_issue_ids: 0,
authors_count: 0,
active_authors_count: 0,
issues_with_cycle_time: 0,
commits: 0,
issue_ids_matched_to_issues: 0,
issues_classed_as_defects: 0,
issues_with_cost: 0,
},
file_summary: [],
high_level_metrics: {
current_score: 0,
month_score: 0,
year_score: 0,
active_developers: 0,
lines_of_code: 0,
system_mastery: 0,
},
};
apis = TestApiRegistry.from(
[
codesceneApiRef,
{
fetchLatestAnalysis: jest.fn(() => analysis),
} as unknown as CodeSceneApi,
],
[configApiRef, config],
);
});
it('should render', async () => {
const rendered = await renderInTestApp(
<EntityProvider entity={entity}>
<ApiProvider apis={apis}>
<CodeSceneEntityKPICard />
</ApiProvider>
</EntityProvider>,
);
expect(rendered.getByText(/Mar 22, 2022/i)).toBeInTheDocument();
});
});
@@ -0,0 +1,80 @@
/*
* Copyright 2023 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 {
Content,
EmptyState,
Progress,
ContentHeader,
} from '@backstage/core-components';
import { configApiRef, useApi } from '@backstage/core-plugin-api';
import Alert from '@material-ui/lab/Alert';
import React from 'react';
import { useEntity } from '@backstage/plugin-catalog-react';
import useAsync from 'react-use/lib/useAsync';
import { codesceneApiRef } from '../../api/api';
import { Analysis } from '../../api/types';
import { CodeHealthKpisCard } from '../CodeHealthKpisCard/CodeHealthKpisCard';
import {
getProjectAnnotation,
CODESCENE_PROJECT_ANNOTATION,
isCodeSceneAvailable,
} from '../../utils/commonUtil';
import { DateTime } from 'luxon';
import { MissingAnnotationEmptyState } from '@backstage/plugin-catalog-react';
export const CodeSceneEntityKPICard = () => {
const { entity } = useEntity();
const { projectId } = getProjectAnnotation(entity);
const codesceneApi = useApi(codesceneApiRef);
const config = useApi(configApiRef);
const codesceneHost = config.getString('codescene.baseUrl');
const {
value: analysis,
loading,
error,
} = useAsync(async (): Promise<Analysis> => {
return await codesceneApi.fetchLatestAnalysis(projectId);
}, []);
if (loading) {
return <Progress />;
} else if (error) {
return <Alert severity="error">{error.message}</Alert>;
} else if (!analysis) {
return (
<EmptyState
missing="content"
title="CodeScene analysis"
description={`No analysis found for project with id ${projectId}`}
/>
);
}
return isCodeSceneAvailable(entity) ? (
<Content>
<ContentHeader title="CodeScene Dashboard">
Last analyzed on{' '}
{DateTime.fromISO(analysis.readable_analysis_time).toLocaleString(
DateTime.DATETIME_MED,
)}
</ContentHeader>
<CodeHealthKpisCard codesceneHost={codesceneHost} analysis={analysis} />
</Content>
) : (
<MissingAnnotationEmptyState annotation={CODESCENE_PROJECT_ANNOTATION} />
);
};
@@ -0,0 +1,16 @@
/*
* Copyright 2023 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.
*/
export { CodeSceneEntityKPICard } from './CodeSceneEntityKPICard';
@@ -0,0 +1,135 @@
/*
* Copyright 2023 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 React from 'react';
import { CodeSceneEntityPage } from './CodeSceneEntityPage';
import { rest } from 'msw';
import { setupServer } from 'msw/node';
import {
setupRequestMockHandlers,
renderInTestApp,
TestApiRegistry,
} from '@backstage/test-utils';
import { CodeSceneApi, codesceneApiRef } from '../../api/api';
import { ApiProvider } from '@backstage/core-app-api';
import { Analysis } from '../../api/types';
import { ConfigReader } from '@backstage/config';
import { configApiRef } from '@backstage/core-plugin-api';
import { EntityProvider } from '@backstage/plugin-catalog-react';
const entity = {
metadata: {
namespace: 'default',
annotations: {
'codescene.io/project-id': '12345',
},
name: 'codescene-test-project',
title: 'codescene-test-project',
description: 'A demo codescene-test repo',
links: [
{
url: 'https://some-demo.com',
title: 'Demo',
icon: 'dashboard',
},
],
},
apiVersion: 'backstage.io/v1alpha1',
kind: 'Component',
spec: {
type: 'website',
owner: 'swcoe',
lifecycle: 'experimental',
},
relations: [
{
type: 'ownedBy',
targetRef: 'group:default/swcoe',
target: {
kind: 'group',
namespace: 'default',
name: 'swcoe',
},
},
],
};
describe('CodeSceneEntityPage', () => {
const server = setupServer();
// Enable sane handlers for network requests
setupRequestMockHandlers(server);
let apis: TestApiRegistry;
// setup mock response
beforeEach(() => {
server.use(
rest.get('/*', (_, res, ctx) => res(ctx.status(200), ctx.json({}))),
);
const config = new ConfigReader({
codescene: {
baseUrl: 'www.fake-url.com',
},
});
const analysis: Analysis = {
id: 1,
name: 'codescene-test-project',
project_id: 12345,
readable_analysis_time: '2022-03-22',
summary: {
unique_issue_ids: 0,
issues_filtered_as_outliers: 0,
entities: 0,
commits_with_issue_ids: 0,
authors_count: 0,
active_authors_count: 0,
issues_with_cycle_time: 0,
commits: 0,
issue_ids_matched_to_issues: 0,
issues_classed_as_defects: 0,
issues_with_cost: 0,
},
file_summary: [],
high_level_metrics: {
current_score: 0,
month_score: 0,
year_score: 0,
active_developers: 0,
lines_of_code: 0,
system_mastery: 0,
},
};
apis = TestApiRegistry.from(
[
codesceneApiRef,
{
fetchLatestAnalysis: jest.fn(() => analysis),
} as unknown as CodeSceneApi,
],
[configApiRef, config],
);
});
it('should render', async () => {
const rendered = await renderInTestApp(
<EntityProvider entity={entity}>
<ApiProvider apis={apis}>
<CodeSceneEntityPage />
</ApiProvider>
</EntityProvider>,
);
expect(rendered.getByText(/codescene-test-project/i)).toBeInTheDocument();
});
});
@@ -0,0 +1,167 @@
/*
* Copyright 2023 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 {
Content,
EmptyState,
GaugeCard,
Header,
InfoCard,
Page,
Progress,
SupportButton,
Table,
TableColumn,
} from '@backstage/core-components';
import { configApiRef, useApi } from '@backstage/core-plugin-api';
import { Grid, Typography } from '@material-ui/core';
import Alert from '@material-ui/lab/Alert';
import React from 'react';
import { useEntity } from '@backstage/plugin-catalog-react';
import useAsync from 'react-use/lib/useAsync';
import { codesceneApiRef } from '../../api/api';
import { Analysis } from '../../api/types';
import { CodeHealthKpisCard } from '../CodeHealthKpisCard/CodeHealthKpisCard';
import { MissingAnnotationEmptyState } from '@backstage/plugin-catalog-react';
import {
getProjectAnnotation,
CODESCENE_PROJECT_ANNOTATION,
isCodeSceneAvailable,
} from '../../utils/commonUtil';
export const CodeSceneEntityPage = () => {
const { entity } = useEntity();
const { projectId } = getProjectAnnotation(entity);
const codesceneApi = useApi(codesceneApiRef);
const config = useApi(configApiRef);
const {
value: analysis,
loading,
error,
} = useAsync(async (): Promise<Analysis> => {
return await codesceneApi.fetchLatestAnalysis(projectId);
}, []);
if (loading) {
return <Progress />;
} else if (error) {
return <Alert severity="error">{error.message}</Alert>;
} else if (!analysis) {
return (
<EmptyState
missing="content"
title="CodeScene analysis"
description={`No analysis found for project with id ${projectId}`}
/>
);
}
const columns: TableColumn[] = [
{
title: 'Language',
field: 'language',
highlight: true,
},
{
title: 'Files',
field: 'number_of_files',
type: 'numeric',
},
{
title: 'Code',
field: 'code',
type: 'numeric',
},
{
title: 'Comment',
field: 'comment',
type: 'numeric',
},
];
const fileSummaryTable = (
<Table
options={{ paging: false, padding: 'dense' }}
data={analysis.file_summary.sort((a, b) => b.code - a.code)}
columns={columns}
title="File Summary"
/>
);
const codesceneHost = config.getString('codescene.baseUrl');
const analysisPath = `${codesceneHost}/${projectId}/analyses/${analysis.id}`;
const analysisLink = { title: 'Check the analysis', link: analysisPath };
const analysisBody = (
<>
<Typography variant="body1">
<b>Active authors</b>: {analysis.summary.active_authors_count}
</Typography>
<Typography variant="body1">
<b>Total authors</b>: {analysis.summary.authors_count}
</Typography>
<Typography variant="body1">
<b>Commits</b>: {analysis.summary.commits}
</Typography>
</>
);
return isCodeSceneAvailable(entity) ? (
<Page themeId="tool">
<Header
title={`CodeScene: ${analysis.name}`}
subtitle={`Last analysis: ${analysis.readable_analysis_time}`}
>
<SupportButton>
See hidden risks and social patterns in your code. Prioritize and
reduce technical debt.
</SupportButton>
</Header>
<Content>
<Grid container spacing={3}>
<Grid item md={4} xs={12}>
<CodeHealthKpisCard
codesceneHost={codesceneHost}
analysis={analysis}
/>
</Grid>
<Grid item>
<GaugeCard
title="System Mastery"
progress={analysis.high_level_metrics.system_mastery / 100}
/>
</Grid>
<Grid item>
<GaugeCard
title="Current Score"
progress={analysis.high_level_metrics.current_score / 10}
/>
</Grid>
<Grid item>
<InfoCard title="Analysis" deepLink={analysisLink}>
{analysisBody}
</InfoCard>
</Grid>
</Grid>
<Grid container spacing={2} direction="column">
<Grid item>{fileSummaryTable}</Grid>
</Grid>
</Content>
</Page>
) : (
<MissingAnnotationEmptyState annotation={CODESCENE_PROJECT_ANNOTATION} />
);
};
@@ -0,0 +1,16 @@
/*
* Copyright 2023 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.
*/
export { CodeSceneEntityPage } from './CodeSceneEntityPage';
+7 -1
View File
@@ -1,5 +1,5 @@
/*
* Copyright 2022 The Backstage Authors
* Copyright 2023 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.
@@ -17,5 +17,11 @@ export {
codescenePlugin,
CodeScenePage,
CodeSceneProjectDetailsPage,
CodeSceneEntityPage,
CodeSceneEntityKPICard,
} from './plugin';
export { CodeSceneIcon } from './CodeSceneIcon';
export {
isCodeSceneAvailable,
CODESCENE_PROJECT_ANNOTATION,
} from './utils/commonUtil';
+22
View File
@@ -67,3 +67,25 @@ export const CodeSceneProjectDetailsPage = codescenePlugin.provide(
mountPoint: projectDetailsRouteRef,
}),
);
export const CodeSceneEntityKPICard = codescenePlugin.provide(
createRoutableExtension({
name: 'CodeSceneEntityKPICard',
component: () =>
import('./components/CodeSceneEntityKPICard').then(
m => m.CodeSceneEntityKPICard,
),
mountPoint: rootRouteRef,
}),
);
export const CodeSceneEntityPage = codescenePlugin.provide(
createRoutableExtension({
name: 'CodeSceneEntityPage',
component: () =>
import('./components/CodeSceneEntityPage').then(
m => m.CodeSceneEntityPage,
),
mountPoint: rootRouteRef,
}),
);
+29
View File
@@ -0,0 +1,29 @@
/*
* Copyright 2023 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 { Entity } from '@backstage/catalog-model';
export const CODESCENE_PROJECT_ANNOTATION = 'codescene.io/project-id';
export const getProjectAnnotation = (entity: Entity) => {
const annotation =
entity?.metadata.annotations?.[CODESCENE_PROJECT_ANNOTATION];
const projectId = annotation?.split('/')[0];
return { projectId: Number(projectId) };
};
export const isCodeSceneAvailable = (entity: Entity) =>
Boolean(getProjectAnnotation(entity).projectId);