Add CodeScene plugin

[CodeScene](https://codescene.com/) is a multi-purpose tool bridging code, business and people. See hidden risks and social patterns in your code. Prioritize and reduce technical debt.

Signed-off-by: Julio Zynger <julio.zynger@soundcloud.com>
This commit is contained in:
Julio Zynger
2022-04-12 12:41:22 +02:00
parent 6de4927c75
commit 9bc6e9493f
31 changed files with 1615 additions and 0 deletions
+77
View File
@@ -0,0 +1,77 @@
/*
* 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 { createApiRef, DiscoveryApi } from '@backstage/core-plugin-api';
import { ResponseError } from '@backstage/errors';
import {
FetchProjectsResponse,
Project,
FetchAnalysesResponse,
Analysis,
} from './types';
export const codesceneApiRef = createApiRef<CodeSceneApi>({
id: 'plugin.codescene.service',
});
export interface CodeSceneApi {
fetchProjects(): Promise<FetchProjectsResponse>;
fetchProject(projectId: number): Promise<Project>;
fetchAnalyses(projectId: number): Promise<FetchAnalysesResponse>;
fetchLatestAnalysis(projectId: number): Promise<Analysis>;
}
type Options = {
discoveryApi: DiscoveryApi;
};
export class CodeSceneClient implements CodeSceneApi {
private readonly discoveryApi: DiscoveryApi;
constructor(options: Options) {
this.discoveryApi = options.discoveryApi;
}
async fetchProjects(): Promise<FetchProjectsResponse> {
return this.fetchFromApi('projects');
}
async fetchProject(projectId: number): Promise<Project> {
return this.fetchFromApi(`projects/${projectId}`);
}
async fetchAnalyses(projectId: number): Promise<FetchAnalysesResponse> {
return this.fetchFromApi(`projects/${projectId}/analyses`);
}
async fetchLatestAnalysis(projectId: number): Promise<Analysis> {
return this.fetchFromApi(`projects/${projectId}/analyses/latest`);
}
private async fetchFromApi(path: string): Promise<any> {
const apiUrl = await this.getApiUrl();
const res = await fetch(`${apiUrl}/${path}`);
if (!res.ok) {
throw await ResponseError.fromResponse(res);
}
return await res.json();
}
private async getApiUrl() {
const proxyUrl = await this.discoveryApi.getBaseUrl('proxy');
return `${proxyUrl}/codescene-api`;
}
}
+84
View File
@@ -0,0 +1,84 @@
/*
* 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.
*/
export interface Project {
id: number;
name: string;
description?: string;
project_owner?: string;
}
export interface Analysis {
id: number;
name: string;
project_id: number;
description?: string;
readable_analysis_time: string;
summary: Summary;
file_summary: FileSummary[];
high_level_metrics: HighLevelMetrics;
}
export interface Summary {
unique_issue_ids: number;
issues_filtered_as_outliers: number;
entities: number;
commits_with_issue_ids: number;
authors_count: number;
active_authors_count: number;
issues_with_cycle_time: number;
commits: number;
issue_ids_matched_to_issues: number;
issues_classed_as_defects: number;
issues_with_cost: number;
}
export interface FileSummary {
language: string;
number_of_files: number;
blank: number;
comment: number;
code: number;
}
export interface HighLevelMetrics {
current_score: number;
month_score: number;
year_score: number;
active_developers: number;
lines_of_code: number;
system_mastery: number;
code_health_weighted_average_last_month?: number;
code_health_month_worst_performer?: number;
code_health_weighted_average_current?: number;
hotspots_code_health_now_weighted_average?: number;
code_health_weighted_average_last_year?: number;
code_health_year_worst_performer?: number;
hotspots_code_health_month_weighted_average?: number;
hotspots_code_health_year_weighted_average?: number;
code_health_now_worst_performer?: number;
}
export interface FetchProjectsResponse {
page: number;
max_pages: number;
projects: Project[];
}
export interface FetchAnalysesResponse {
page: number;
max_pages: number;
analyses: Analysis[];
}
@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 24 21" version="1.1">
<g id="surface1">
<path d="M 21.898438 6.167969 C 20.5 2.242188 17.015625 0.00390625 12.003906 0.00390625 C 6.988281 0.00390625 3.5 2.242188 2.101562 6.167969 C 0.894531 6.589844 0 8.871094 0 11.707031 C 0 14.855469 1.101562 17.320312 2.511719 17.320312 C 2.542969 17.320312 2.574219 17.320312 2.605469 17.316406 C 3.089844 18.292969 3.722656 19.042969 4.511719 19.585938 C 6.425781 20.90625 9.085938 20.996094 11.886719 20.996094 L 12.113281 20.996094 C 12.148438 20.996094 12.183594 20.996094 12.21875 20.996094 C 14.988281 20.996094 17.601562 20.886719 19.488281 19.585938 C 20.277344 19.042969 20.914062 18.292969 21.394531 17.316406 C 21.425781 17.320312 21.457031 17.320312 21.488281 17.320312 C 22.898438 17.320312 24 14.855469 24 11.707031 C 24 8.871094 23.105469 6.589844 21.898438 6.167969 Z M 0.46875 11.707031 C 0.46875 9.285156 1.125 7.496094 1.886719 6.847656 C 1.578125 7.9375 1.417969 9.140625 1.417969 10.453125 C 1.417969 13.144531 1.730469 15.242188 2.382812 16.824219 C 1.4375 16.660156 0.46875 14.648438 0.46875 11.707031 Z M 21.132812 16.75 C 21.066406 16.902344 21 17.046875 20.929688 17.183594 C 20.488281 18.039062 19.921875 18.703125 19.222656 19.1875 C 17.453125 20.40625 15.03125 20.535156 12.226562 20.535156 C 12.1875 20.535156 12.148438 20.535156 12.113281 20.535156 L 11.886719 20.535156 C 9.03125 20.535156 6.566406 20.425781 4.777344 19.1875 C 4.078125 18.703125 3.515625 18.039062 3.070312 17.183594 C 3 17.046875 2.933594 16.902344 2.867188 16.75 C 2.207031 15.226562 1.886719 13.15625 1.886719 10.453125 C 1.886719 9.023438 2.085938 7.730469 2.46875 6.582031 C 2.519531 6.417969 2.582031 6.257812 2.640625 6.101562 C 4.035156 2.523438 7.320312 0.484375 12 0.484375 C 16.679688 0.484375 19.964844 2.523438 21.359375 6.101562 C 21.421875 6.257812 21.480469 6.417969 21.53125 6.582031 C 21.914062 7.730469 22.113281 9.023438 22.113281 10.453125 C 22.113281 13.15625 21.792969 15.222656 21.132812 16.75 Z M 21.617188 16.824219 C 22.269531 15.246094 22.582031 13.148438 22.582031 10.453125 C 22.582031 9.140625 22.421875 7.9375 22.113281 6.847656 C 22.875 7.5 23.53125 9.289062 23.53125 11.710938 C 23.53125 14.648438 22.5625 16.660156 21.617188 16.824219 Z M 21.617188 16.824219 "/>
<path d="M 12 3.476562 C 5.652344 3.476562 2.824219 6.046875 2.824219 11.816406 C 2.824219 17.425781 5.824219 20.152344 12 20.152344 C 18.175781 20.152344 21.175781 17.425781 21.175781 11.816406 C 21.175781 6.046875 18.347656 3.476562 12 3.476562 Z M 12 19.671875 C 6.058594 19.671875 3.292969 17.175781 3.292969 11.8125 C 3.292969 8.152344 4.28125 3.953125 12 3.953125 C 19.71875 3.953125 20.707031 8.152344 20.707031 11.8125 C 20.707031 17.175781 17.941406 19.671875 12 19.671875 Z M 12 19.671875 "/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 2.8 KiB

@@ -0,0 +1,147 @@
/*
* 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 { EmptyState, InfoCard } from '@backstage/core-components';
import { Button, Grid, makeStyles, Typography } from '@material-ui/core';
import React, { PropsWithChildren } from 'react';
import { Analysis } from '../../api/types';
import { Gauge } from '../Gauge/Gauge';
type CodeHealthKpi = {
name: string;
description: string;
value: number;
oldValue: number;
};
const CodeHealthKpiInfoCard = ({ children }: PropsWithChildren<{}>) => {
const linkInfo = {
title: 'What does this mean?',
link: 'https://codescene.com/blog/3-code-health-kpis/',
};
return (
<InfoCard
title="Code Health KPIs"
subheader="The Code Health metric identifies source code that is expensive and risky to maintain."
deepLink={linkInfo}
>
{children}
</InfoCard>
);
};
const useStyles = makeStyles({
root: {
height: '100%',
width: 100,
margin: '12.5px 0px 0px 50px',
},
});
export const CodeHealthKpisCard = ({
codesceneHost,
analysis,
}: {
codesceneHost: string;
analysis: Analysis;
}): JSX.Element => {
const classes = useStyles();
if (
!analysis.high_level_metrics.hotspots_code_health_now_weighted_average ||
!analysis.high_level_metrics.hotspots_code_health_month_weighted_average ||
!analysis.high_level_metrics.code_health_weighted_average_current ||
!analysis.high_level_metrics.code_health_weighted_average_last_month ||
!analysis.high_level_metrics.code_health_now_worst_performer ||
!analysis.high_level_metrics.code_health_month_worst_performer
) {
return (
<CodeHealthKpiInfoCard>
<EmptyState
missing="data"
title="Your project does not expose Code Health KPIs"
description="Enable the 'Full Code Health Scan' option and trigger an analysis to scan the health of all code in the project."
action={
<Button
color="primary"
href={`${codesceneHost}/projects/${analysis.project_id}/configuration#hotspots`}
variant="contained"
>
Configuration
</Button>
}
/>
</CodeHealthKpiInfoCard>
);
}
const hotspotCodeHealth: CodeHealthKpi = {
name: 'Hotspot Code Health',
description:
'A weighted average of the code health in your hotspots. Generally, this is the most critical metric since low code health in a hotspot will be expensive.',
value:
analysis.high_level_metrics.hotspots_code_health_now_weighted_average,
oldValue:
analysis.high_level_metrics.hotspots_code_health_month_weighted_average,
};
const averageCodeHealth: CodeHealthKpi = {
name: 'Average Code Health',
description:
'A weighted average of all the files in the codebase. This KPI indicates how deep any potential code health issues go.',
value: analysis.high_level_metrics.code_health_weighted_average_current,
oldValue:
analysis.high_level_metrics.code_health_weighted_average_last_month,
};
const worstPerformer: CodeHealthKpi = {
name: 'Worst Performer',
description:
'A single file code health score representing the lowest code health in any module across the codebase. Points out long-term risks.',
value: analysis.high_level_metrics.code_health_now_worst_performer,
oldValue: analysis.high_level_metrics.code_health_month_worst_performer,
};
const kpis = [hotspotCodeHealth, averageCodeHealth, worstPerformer];
const cards = kpis.map(kpi => {
const lastMonthText =
Math.abs(kpi.oldValue - kpi.value) < 0.1
? undefined
: `(from ${kpi.oldValue} last month)`;
return (
<div key={kpi.name}>
<Grid
container
spacing={2}
direction="row"
wrap="nowrap"
justifyContent="space-between"
alignItems="center"
>
<Grid item md={4}>
<div className={classes.root}>
<Gauge value={kpi.value} max={10} tooltipText={kpi.description} />
</div>
</Grid>
<Grid item md={8}>
<Typography variant="body1">{kpi.name}</Typography>
<Typography variant="h6">
<b>{Math.round(kpi.value * 10) / 10}</b> {lastMonthText}
</Typography>
</Grid>
</Grid>
</div>
);
});
return <CodeHealthKpiInfoCard>{cards}</CodeHealthKpiInfoCard>;
};
@@ -0,0 +1,69 @@
/*
* 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 React from 'react';
import { CodeScenePageComponent } from './CodeScenePageComponent';
import { ThemeProvider } from '@material-ui/core';
import { lightTheme } from '@backstage/theme';
import { rest } from 'msw';
import { setupServer } from 'msw/node';
import { rootRouteRef } from '../../routes';
import {
setupRequestMockHandlers,
renderInTestApp,
TestApiRegistry,
} from '@backstage/test-utils';
import { codesceneApiRef, CodeSceneClient } from '../../api/api';
import { DiscoveryApi } from '@backstage/core-plugin-api';
import { ApiProvider } from '@backstage/core-app-api';
describe('CodeScenePageComponent', () => {
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 discoveryApi: DiscoveryApi = {
async getBaseUrl(pluginId) {
return `http://base.com/${pluginId}`;
},
};
apis = TestApiRegistry.from([
codesceneApiRef,
new CodeSceneClient({ discoveryApi }),
]);
});
it('should render', async () => {
const rendered = await renderInTestApp(
<ApiProvider apis={apis}>
<ThemeProvider theme={lightTheme}>
<CodeScenePageComponent />
</ThemeProvider>
</ApiProvider>,
{
mountedRoutes: {
'/codescene': rootRouteRef,
},
},
);
expect(rendered.getByText('CodeScene')).toBeInTheDocument();
});
});
@@ -0,0 +1,43 @@
/*
* 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 React from 'react';
import { Grid } from '@material-ui/core';
import {
Header,
Page,
Content,
SupportButton,
} from '@backstage/core-components';
import { ProjectsComponent } from '../ProjectsComponent';
export const CodeScenePageComponent = () => (
<Page themeId="tool">
<Header
title="CodeScene"
subtitle="See hidden risks and social patterns in your code. Prioritize and reduce
technical debt."
>
<SupportButton />
</Header>
<Content>
<Grid container spacing={3} direction="column">
<Grid item>
<ProjectsComponent />
</Grid>
</Grid>
</Content>
</Page>
);
@@ -0,0 +1,16 @@
/*
* 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.
*/
export { CodeScenePageComponent } from './CodeScenePageComponent';
@@ -0,0 +1,109 @@
/*
* 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 React from 'react';
import { CodeSceneProjectDetailsPage } from './CodeSceneProjectDetailsPage';
import { ThemeProvider } from '@material-ui/core';
import { lightTheme } from '@backstage/theme';
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';
jest.mock('react-router-dom', () => {
return {
...(jest.requireActual('react-router-dom') as any),
useParams: () => ({
projectId: 123,
}),
};
});
describe('CodesceneProjectDetailsPage', () => {
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: 'test-project',
project_id: 123,
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(
<ApiProvider apis={apis}>
<ThemeProvider theme={lightTheme}>
<CodeSceneProjectDetailsPage />
</ThemeProvider>
</ApiProvider>,
);
expect(rendered.getByText('CodeScene: test-project')).toBeInTheDocument();
});
});
@@ -0,0 +1,158 @@
/*
* 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 {
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 { useParams } from 'react-router-dom';
import useAsync from 'react-use/lib/useAsync';
import { codesceneApiRef } from '../../api/api';
import { Analysis } from '../../api/types';
import { CodeHealthKpisCard } from '../CodeHealthKpisCard/CodeHealthKpisCard';
export const CodeSceneProjectDetailsPage = () => {
const params = useParams();
const projectId = Number(params.projectId);
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 ${params.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 (
<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={2}>
<Grid item xs sm md>
<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 style={{ minWidth: '460px' }}>
<InfoCard title="Analysis" deepLink={analysisLink}>
{analysisBody}
</InfoCard>
</Grid>
</Grid>
<Grid container spacing={2} direction="column">
<Grid item>{fileSummaryTable}</Grid>
</Grid>
</Content>
</Page>
);
};
@@ -0,0 +1,16 @@
/*
* 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.
*/
export { CodeSceneProjectDetailsPage } from './CodeSceneProjectDetailsPage';
@@ -0,0 +1,142 @@
/*
* 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 { BackstagePalette, BackstageTheme } from '@backstage/theme';
import { makeStyles, useTheme } from '@material-ui/core/styles';
import Tooltip from '@material-ui/core/Tooltip';
import { Circle } from 'rc-progress';
import React, { useEffect, useState } from 'react';
const useStyles = makeStyles<BackstageTheme>(
theme => ({
root: {
position: 'relative',
lineHeight: 0,
},
overlay: {
position: 'absolute',
top: '50%',
left: '60%',
transform: 'translate(-50%, -60%)',
fontSize: 30,
fontWeight: 'bold',
color: theme.palette.textContrast,
},
circle: {
width: '100%',
transform: 'translate(10%, 0)',
},
colorUnknown: {},
}),
{ name: 'CodeSceneGauge' },
);
/** @public */
export type GaugeProps = {
value: number;
max: number;
tooltipDelay?: number;
tooltipText: string;
};
/** @public */
export type GaugePropsGetColorOptions = {
palette: BackstagePalette;
value: number;
max: number;
};
/** @public */
export type GaugePropsGetColor = (args: GaugePropsGetColorOptions) => string;
export const getProgressColor: GaugePropsGetColor = ({
palette,
value,
max,
}) => {
if (isNaN(value)) {
return '#ddd';
}
if (value < max / 3) {
return palette.status.error;
} else if (value < max * (2 / 3)) {
return palette.status.warning;
}
return palette.status.ok;
};
/**
* Circular Progress Bar
*
* @public
*
*/
export function Gauge(props: GaugeProps) {
const classes = useStyles(props);
const { palette } = useTheme<BackstageTheme>();
const [hoverRef, setHoverRef] = useState<HTMLDivElement | null>(null);
const [open, setOpen] = useState(false);
const { value, max, tooltipDelay, tooltipText } = {
...props,
};
useEffect(() => {
const node = hoverRef;
const handleMouseOver = () => setOpen(true);
const handleMouseOut = () => setOpen(false);
if (node && tooltipText) {
node.addEventListener('mouseenter', handleMouseOver);
node.addEventListener('mouseleave', handleMouseOut);
return () => {
node.removeEventListener('mouseenter', handleMouseOver);
node.removeEventListener('mouseleave', handleMouseOut);
};
}
return () => {
setOpen(false);
};
}, [tooltipText, hoverRef]);
const asPercentage = (value * 100) / max;
return (
<div ref={setHoverRef} className={classes.root}>
<Tooltip
id="gauge-tooltip"
title={tooltipText}
placement="top"
leaveDelay={tooltipDelay}
onClose={() => setOpen(false)}
open={open}
>
<div>
<Circle
strokeLinecap="butt"
percent={asPercentage}
strokeWidth={12}
trailWidth={12}
strokeColor={getProgressColor({ palette, value, max })}
className={classes.circle}
/>
<div className={classes.overlay}>
{isNaN(value) ? 'N/A' : `${Math.round(value * 10) / 10}`}
</div>
</div>
</Tooltip>
</div>
);
}
@@ -0,0 +1,106 @@
/*
* 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 React from 'react';
import { ProjectsComponent } from './ProjectsComponent';
import { ThemeProvider } from '@material-ui/core';
import { lightTheme } from '@backstage/theme';
import { rest } from 'msw';
import { setupServer } from 'msw/node';
import { rootRouteRef } from '../../routes';
import {
setupRequestMockHandlers,
renderInTestApp,
TestApiRegistry,
} from '@backstage/test-utils';
import { CodeSceneApi, codesceneApiRef } from '../../api/api';
import { ApiProvider } from '@backstage/core-app-api';
import { FetchProjectsResponse, Analysis } from '../../api/types';
describe('ProjectsComponent', () => {
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 projectsResponse: FetchProjectsResponse = {
page: 1,
max_pages: 1,
projects: [
{
id: 123,
name: 'test-project',
},
],
};
const analysis: Analysis = {
id: 1,
name: 'test-project',
project_id: 123,
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,
{
fetchProjects: jest.fn(() => projectsResponse),
fetchLatestAnalysis: jest.fn(() => analysis),
} as unknown as CodeSceneApi,
]);
});
it('should render', async () => {
const rendered = await renderInTestApp(
<ApiProvider apis={apis}>
<ThemeProvider theme={lightTheme}>
<ProjectsComponent />
</ThemeProvider>
</ApiProvider>,
{
mountedRoutes: {
'/codescene': rootRouteRef,
},
},
);
expect(await rendered.findByText('Projects')).toBeInTheDocument();
expect(await rendered.findByText('test-project')).toBeInTheDocument();
});
});
@@ -0,0 +1,178 @@
/*
* 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 React from 'react';
import {
Content,
ContentHeader,
Progress,
ItemCardHeader,
} from '@backstage/core-components';
import { useRouteRef, useApi } from '@backstage/core-plugin-api';
import Alert from '@material-ui/lab/Alert';
import useAsync from 'react-use/lib/useAsync';
import { Link as RouterLink } from 'react-router-dom';
import { rootRouteRef } from '../../routes';
import { codesceneApiRef } from '../../api/api';
import { Project, Analysis } from '../../api/types';
import {
Grid,
Card,
CardActionArea,
Input,
makeStyles,
CardContent,
Chip,
CardActions,
} from '@material-ui/core';
const useStyles = makeStyles(() => ({
overflowXScroll: {
overflowX: 'scroll',
},
}));
function matchFilter(filter?: string): (entry: Project) => boolean {
const terms = filter
?.toLocaleLowerCase('en-US')
.split(/\s/)
.map(e => e.trim())
.filter(Boolean);
if (!terms?.length) {
return () => true;
}
return entry => {
const text = `${entry.name} ${entry.description || ''}`.toLocaleLowerCase(
'en-US',
);
return terms.every(term => text.includes(term));
};
}
function topLanguages(analysis: Analysis, coerceAtMost: number): string[] {
return analysis.file_summary
.sort((a, b) => b.code - a.code)
.map(fileSummary => fileSummary.language)
.slice(0, coerceAtMost);
}
type ProjectAndAnalysis = {
project: Project;
analysis?: Analysis;
};
export const ProjectsComponent = () => {
const routeRef = useRouteRef(rootRouteRef);
const codesceneApi = useApi(codesceneApiRef);
const classes = useStyles();
const [searchText, setSearchText] = React.useState('');
const {
value: projectsWithAnalyses,
loading,
error,
} = useAsync(async (): Promise<Record<number, ProjectAndAnalysis>> => {
const projects = (await codesceneApi.fetchProjects()).projects;
const promises = projects.map(project =>
codesceneApi.fetchLatestAnalysis(project.id),
);
// analyses associates project IDs with their latests analysis
const analyses: Record<number, Analysis> = await Promise.allSettled(
promises,
).then(results => {
return results
.filter(result => result.status === 'fulfilled')
.map(result => result as PromiseFulfilledResult<Analysis>)
.map(result => result.value)
.reduce(
(acc, analysis) => ({ ...acc, [analysis.project_id]: analysis }),
{},
);
});
return projects.reduce(
(acc, project) => ({
...acc,
[project.id]: {
project: project,
analysis: analyses[project.id],
},
}),
{},
);
}, []);
if (loading) {
return <Progress />;
} else if (error) {
return <Alert severity="error">{error.message}</Alert>;
} else if (
!projectsWithAnalyses ||
Object.keys(projectsWithAnalyses).length === 0
) {
return <Alert severity="error">No projects found!</Alert>;
}
const projects = Object.values(projectsWithAnalyses)
.map(p => p.project)
.sort((a, b) => a.name.localeCompare(b.name));
const cards = projects.filter(matchFilter(searchText)).map(project => {
const analysis = projectsWithAnalyses[project.id].analysis;
const subtitle = analysis
? `Last analysis: ${analysis.readable_analysis_time} · Score: ${analysis.high_level_metrics.current_score} · Active authors: ${analysis.high_level_metrics.active_developers}`
: undefined;
const chips = analysis
? topLanguages(analysis, 3).map(lang => (
<Chip label={lang} key={lang} size="small" />
))
: undefined;
return (
<Grid key={project.id} item xs={3}>
<Card>
<CardActionArea
style={{
height: '100%',
overflow: 'hidden',
width: '100%',
}}
component={RouterLink}
to={`${routeRef()}/${project.id}`}
>
<ItemCardHeader title={project.name} />
<CardContent>{subtitle}</CardContent>
<CardActions disableSpacing>{chips}</CardActions>
</CardActionArea>
</Card>
</Grid>
);
});
return (
<Content className={classes.overflowXScroll}>
<ContentHeader title="Projects">
<Input
id="projects-filter"
type="search"
placeholder="Filter"
onChange={e => setSearchText(e.target.value)}
/>
</ContentHeader>
<Grid container>{cards}</Grid>
</Content>
);
};
@@ -0,0 +1,16 @@
/*
* 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.
*/
export { ProjectsComponent } from './ProjectsComponent';
+28
View File
@@ -0,0 +1,28 @@
/*
* 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.
*/
export {
codescenePlugin,
CodescenePage,
CodesceneProjectDetailsPage,
} from './plugin';
import CodeSceneIconComponent from './assets/codescene.icon.svg';
import { IconComponent } from '@backstage/core-plugin-api';
/**
* @public
*/
export const CodeSceneIcon: IconComponent =
CodeSceneIconComponent as IconComponent;
+22
View File
@@ -0,0 +1,22 @@
/*
* 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 { codescenePlugin } from './plugin';
describe('codescene', () => {
it('should export plugin', () => {
expect(codescenePlugin).toBeDefined();
});
});
+69
View File
@@ -0,0 +1,69 @@
/*
* 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 {
createPlugin,
createRoutableExtension,
createApiFactory,
discoveryApiRef,
} from '@backstage/core-plugin-api';
import { codesceneApiRef, CodeSceneClient } from './api/api';
import { rootRouteRef, projectDetailsRouteRef } from './routes';
/**
* @public
*/
export const codescenePlugin = createPlugin({
id: 'codescene',
apis: [
createApiFactory({
api: codesceneApiRef,
deps: { discoveryApi: discoveryApiRef },
factory: ({ discoveryApi }) => new CodeSceneClient({ discoveryApi }),
}),
],
routes: {
root: rootRouteRef,
projectPage: projectDetailsRouteRef,
},
});
/**
* @public
*/
export const CodescenePage = codescenePlugin.provide(
createRoutableExtension({
name: 'CodescenePage',
component: () =>
import('./components/CodeScenePageComponent').then(
m => m.CodeScenePageComponent,
),
mountPoint: rootRouteRef,
}),
);
/**
* @public
*/
export const CodesceneProjectDetailsPage = codescenePlugin.provide(
createRoutableExtension({
name: 'CodesceneProjectDetailsPage',
component: () =>
import('./components/CodeSceneProjectDetailsPage').then(
m => m.CodeSceneProjectDetailsPage,
),
mountPoint: projectDetailsRouteRef,
}),
);
+25
View File
@@ -0,0 +1,25 @@
/*
* 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 { createRouteRef } from '@backstage/core-plugin-api';
export const rootRouteRef = createRouteRef({
id: 'codescene',
});
export const projectDetailsRouteRef = createRouteRef({
id: 'codescene-project-page',
params: ['projectId'],
});
+17
View File
@@ -0,0 +1,17 @@
/*
* 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 '@testing-library/jest-dom';
import 'cross-fetch/polyfill';