diff --git a/.changeset/pretty-readers-grow.md b/.changeset/pretty-readers-grow.md new file mode 100644 index 0000000000..f3193eb878 --- /dev/null +++ b/.changeset/pretty-readers-grow.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-codescene': minor +--- + +Add CodeScene plugin diff --git a/.github/styles/vocab.txt b/.github/styles/vocab.txt index d2257f1b6f..72864d2c9f 100644 --- a/.github/styles/vocab.txt +++ b/.github/styles/vocab.txt @@ -50,6 +50,8 @@ codeblocks Codecov codehilite Codehilite +codescene +CodeScene codemod codemods codeowners diff --git a/microsite/data/plugins/codescene.yaml b/microsite/data/plugins/codescene.yaml new file mode 100644 index 0000000000..4a7c224cf1 --- /dev/null +++ b/microsite/data/plugins/codescene.yaml @@ -0,0 +1,9 @@ +--- +title: CodeScene +author: CodeScene +authorUrl: https://codescene.com/ +category: Quality +description: CodeScene is a multi-purpose tool bridging code, business and people. See hidden risks and social patterns in your code. Prioritize and reduce technical debt. +documentation: https://github.com/backstage/backstage/tree/master/plugins/codescene +iconUrl: img/codescene_logo.svg +npmPackageName: '@backstage/plugin-codescene' diff --git a/microsite/static/img/codescene_logo.svg b/microsite/static/img/codescene_logo.svg new file mode 100644 index 0000000000..9e433b2c7b --- /dev/null +++ b/microsite/static/img/codescene_logo.svg @@ -0,0 +1,50 @@ + + + + + + + + + + + + + + + + + + diff --git a/plugins/codescene/.eslintrc.js b/plugins/codescene/.eslintrc.js new file mode 100644 index 0000000000..13573efa9c --- /dev/null +++ b/plugins/codescene/.eslintrc.js @@ -0,0 +1,3 @@ +module.exports = { + extends: [require.resolve('@backstage/cli/config/eslint')], +}; diff --git a/plugins/codescene/README.md b/plugins/codescene/README.md new file mode 100644 index 0000000000..88c03848af --- /dev/null +++ b/plugins/codescene/README.md @@ -0,0 +1,62 @@ +# codescene + +[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. + +![codescene-logo](./src/assets/codescene.icon.svg) + +The CodeScene Backstage Plugin exposes a page component that will list the existing projects and their analysis data on your CodeScene instance. + +![screenshot](./docs/codescene-plugin-screenshot.png) + +## Setup + +1. Install the plugin by running: + +```bash +# From your Backstage root directory +yarn add --cwd packages/app @backstage/plugin-codescene +``` + +2. Add the routes and pages to your `App.tsx`: + +```tsx +import { + CodescenePage, + CodesceneProjectDetailsPage, +} from '@backstage/plugin-codescene'; + +... + +} /> +} +/> +``` + +3. Add to the sidebar item routing to the new page: + +```tsx +// In packages/app/src/components/Root/Root.tsx +import { CodeSceneIcon } from '@backstage/plugin-codescene'; + +{ + /* other sidebar items... */ +} +; +``` + +4. Setup the `app-config.yaml` `codescene` proxy and configuration blocks: + +```yaml +proxy: + '/codescene-api': + target: '/api/v1' + allowedMethods: ['GET'] + allowedHeaders: ['Authorization'] + headers: + Authorization: Basic ${CODESCENE_AUTH_CREDENTIALS} +--- +codescene: + baseUrl: +``` diff --git a/plugins/codescene/api-report.md b/plugins/codescene/api-report.md new file mode 100644 index 0000000000..1cf41bd99a --- /dev/null +++ b/plugins/codescene/api-report.md @@ -0,0 +1,33 @@ +## API Report File for "@backstage/plugin-codescene" + +> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). + +```ts +/// + +import { BackstagePlugin } from '@backstage/core-plugin-api'; +import { IconComponent } from '@backstage/core-plugin-api'; +import { RouteRef } from '@backstage/core-plugin-api'; + +// @public (undocumented) +export const CodeSceneIcon: IconComponent; + +// @public (undocumented) +export const CodescenePage: () => JSX.Element; + +// @public (undocumented) +export const codescenePlugin: BackstagePlugin< + { + root: RouteRef; + projectPage: RouteRef<{ + projectId: string; + }>; + }, + {} +>; + +// @public (undocumented) +export const CodesceneProjectDetailsPage: () => JSX.Element; + +// (No @packageDocumentation comment for this package) +``` diff --git a/plugins/codescene/config.d.ts b/plugins/codescene/config.d.ts new file mode 100644 index 0000000000..711b7fd91e --- /dev/null +++ b/plugins/codescene/config.d.ts @@ -0,0 +1,27 @@ +/* + * 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 Config { + /** + * Configuration options for the CodeScene plugin + */ + codescene?: { + /** + * The base url of the CodeScene installation. + * @visibility frontend + */ + baseUrl: string; + }; +} diff --git a/plugins/codescene/dev/index.tsx b/plugins/codescene/dev/index.tsx new file mode 100644 index 0000000000..6821e26615 --- /dev/null +++ b/plugins/codescene/dev/index.tsx @@ -0,0 +1,36 @@ +/* + * 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 { createDevApp } from '@backstage/dev-utils'; +import { + codescenePlugin, + CodescenePage, + CodesceneProjectDetailsPage, +} from '../src/plugin'; + +createDevApp() + .registerPlugin(codescenePlugin) + .addPage({ + element: , + title: 'Root Page', + path: '/codescene', + }) + .addPage({ + element: , + title: 'Details Page', + path: '/codescene/:projectId', + }) + .render(); diff --git a/plugins/codescene/docs/codescene-plugin-screenshot.png b/plugins/codescene/docs/codescene-plugin-screenshot.png new file mode 100644 index 0000000000..deff2e8287 Binary files /dev/null and b/plugins/codescene/docs/codescene-plugin-screenshot.png differ diff --git a/plugins/codescene/package.json b/plugins/codescene/package.json new file mode 100644 index 0000000000..cad0fc8b36 --- /dev/null +++ b/plugins/codescene/package.json @@ -0,0 +1,58 @@ +{ + "name": "@backstage/plugin-codescene", + "version": "0.0.0", + "main": "src/index.ts", + "types": "src/index.ts", + "license": "Apache-2.0", + "publishConfig": { + "access": "public", + "main": "dist/index.esm.js", + "types": "dist/index.d.ts" + }, + "backstage": { + "role": "frontend-plugin" + }, + "scripts": { + "start": "backstage-cli package start", + "build": "backstage-cli package build", + "lint": "backstage-cli package lint", + "test": "backstage-cli package test", + "clean": "backstage-cli package clean", + "prepack": "backstage-cli package prepack", + "postpack": "backstage-cli package postpack" + }, + "dependencies": { + "@backstage/config": "^1.0.0", + "@backstage/core-components": "^0.9.3-next.1", + "@backstage/core-plugin-api": "^1.0.0", + "@backstage/errors": "^1.0.0", + "@backstage/theme": "^0.2.15", + "@material-ui/core": "^4.9.10", + "@material-ui/icons": "^4.9.1", + "@material-ui/lab": "^4.0.0-alpha.57", + "rc-progress": "3.2.4", + "react-router-dom": "6.0.0-beta.0", + "react-use": "^17.2.4" + }, + "peerDependencies": { + "react": "^16.13.1 || ^17.0.0" + }, + "devDependencies": { + "@backstage/cli": "^0.17.0-next.2", + "@backstage/core-app-api": "^1.0.1-next.0", + "@backstage/dev-utils": "^1.0.1-next.0", + "@backstage/test-utils": "^1.0.1-next.1", + "@testing-library/jest-dom": "^5.10.1", + "@testing-library/react": "^12.1.3", + "@testing-library/user-event": "^13.1.8", + "@types/jest": "*", + "@types/node": "*", + "msw": "^0.35.0", + "cross-fetch": "^3.1.5" + }, + "files": [ + "dist", + "config.d.ts" + ], + "configSchema": "config.d.ts" +} diff --git a/plugins/codescene/src/api/api.ts b/plugins/codescene/src/api/api.ts new file mode 100644 index 0000000000..db730733bd --- /dev/null +++ b/plugins/codescene/src/api/api.ts @@ -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({ + id: 'plugin.codescene.service', +}); + +export interface CodeSceneApi { + fetchProjects(): Promise; + fetchProject(projectId: number): Promise; + fetchAnalyses(projectId: number): Promise; + fetchLatestAnalysis(projectId: number): Promise; +} + +type Options = { + discoveryApi: DiscoveryApi; +}; + +export class CodeSceneClient implements CodeSceneApi { + private readonly discoveryApi: DiscoveryApi; + + constructor(options: Options) { + this.discoveryApi = options.discoveryApi; + } + + async fetchProjects(): Promise { + return this.fetchFromApi('projects'); + } + + async fetchProject(projectId: number): Promise { + return this.fetchFromApi(`projects/${projectId}`); + } + + async fetchAnalyses(projectId: number): Promise { + return this.fetchFromApi(`projects/${projectId}/analyses`); + } + + async fetchLatestAnalysis(projectId: number): Promise { + return this.fetchFromApi(`projects/${projectId}/analyses/latest`); + } + + private async fetchFromApi(path: string): Promise { + 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`; + } +} diff --git a/plugins/codescene/src/api/types.ts b/plugins/codescene/src/api/types.ts new file mode 100644 index 0000000000..20ebc6b7a3 --- /dev/null +++ b/plugins/codescene/src/api/types.ts @@ -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[]; +} diff --git a/plugins/codescene/src/assets/codescene.icon.svg b/plugins/codescene/src/assets/codescene.icon.svg new file mode 100644 index 0000000000..e8102a4116 --- /dev/null +++ b/plugins/codescene/src/assets/codescene.icon.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/plugins/codescene/src/components/CodeHealthKpisCard/CodeHealthKpisCard.tsx b/plugins/codescene/src/components/CodeHealthKpisCard/CodeHealthKpisCard.tsx new file mode 100644 index 0000000000..f7dda5c56c --- /dev/null +++ b/plugins/codescene/src/components/CodeHealthKpisCard/CodeHealthKpisCard.tsx @@ -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 ( + + {children} + + ); +}; + +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 ( + + + Configuration + + } + /> + + ); + } + + 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 ( +
+ + +
+ +
+
+ + {kpi.name} + + {Math.round(kpi.value * 10) / 10} {lastMonthText} + + +
+
+ ); + }); + + return {cards}; +}; diff --git a/plugins/codescene/src/components/CodeScenePageComponent/CodeScenePageComponent.test.tsx b/plugins/codescene/src/components/CodeScenePageComponent/CodeScenePageComponent.test.tsx new file mode 100644 index 0000000000..d8ffca841d --- /dev/null +++ b/plugins/codescene/src/components/CodeScenePageComponent/CodeScenePageComponent.test.tsx @@ -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( + + + + + , + { + mountedRoutes: { + '/codescene': rootRouteRef, + }, + }, + ); + expect(rendered.getByText('CodeScene')).toBeInTheDocument(); + }); +}); diff --git a/plugins/codescene/src/components/CodeScenePageComponent/CodeScenePageComponent.tsx b/plugins/codescene/src/components/CodeScenePageComponent/CodeScenePageComponent.tsx new file mode 100644 index 0000000000..118a68f39e --- /dev/null +++ b/plugins/codescene/src/components/CodeScenePageComponent/CodeScenePageComponent.tsx @@ -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 = () => ( + +
+ +
+ + + + + + + +
+); diff --git a/plugins/codescene/src/components/CodeScenePageComponent/index.ts b/plugins/codescene/src/components/CodeScenePageComponent/index.ts new file mode 100644 index 0000000000..934ee52b67 --- /dev/null +++ b/plugins/codescene/src/components/CodeScenePageComponent/index.ts @@ -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'; diff --git a/plugins/codescene/src/components/CodeSceneProjectDetailsPage/CodeSceneProjectDetailsPage.test.tsx b/plugins/codescene/src/components/CodeSceneProjectDetailsPage/CodeSceneProjectDetailsPage.test.tsx new file mode 100644 index 0000000000..5f5a304007 --- /dev/null +++ b/plugins/codescene/src/components/CodeSceneProjectDetailsPage/CodeSceneProjectDetailsPage.test.tsx @@ -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( + + + + + , + ); + expect(rendered.getByText('CodeScene: test-project')).toBeInTheDocument(); + }); +}); diff --git a/plugins/codescene/src/components/CodeSceneProjectDetailsPage/CodeSceneProjectDetailsPage.tsx b/plugins/codescene/src/components/CodeSceneProjectDetailsPage/CodeSceneProjectDetailsPage.tsx new file mode 100644 index 0000000000..1197edb47e --- /dev/null +++ b/plugins/codescene/src/components/CodeSceneProjectDetailsPage/CodeSceneProjectDetailsPage.tsx @@ -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 => { + return await codesceneApi.fetchLatestAnalysis(projectId); + }, []); + + if (loading) { + return ; + } else if (error) { + return {error.message}; + } else if (!analysis) { + return ( + + ); + } + + 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 = ( + 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 = ( + <> + + Active authors: {analysis.summary.active_authors_count} + + + Total authors: {analysis.summary.authors_count} + + + Commits: {analysis.summary.commits} + + + ); + + return ( + +
+ + See hidden risks and social patterns in your code. Prioritize and + reduce technical debt. + +
+ + + + + + + + + + + + + + {analysisBody} + + + + + {fileSummaryTable} + + +
+ ); +}; diff --git a/plugins/codescene/src/components/CodeSceneProjectDetailsPage/index.ts b/plugins/codescene/src/components/CodeSceneProjectDetailsPage/index.ts new file mode 100644 index 0000000000..50578df508 --- /dev/null +++ b/plugins/codescene/src/components/CodeSceneProjectDetailsPage/index.ts @@ -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'; diff --git a/plugins/codescene/src/components/Gauge/Gauge.tsx b/plugins/codescene/src/components/Gauge/Gauge.tsx new file mode 100644 index 0000000000..2ea63126aa --- /dev/null +++ b/plugins/codescene/src/components/Gauge/Gauge.tsx @@ -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( + 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(); + const [hoverRef, setHoverRef] = useState(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 ( +
+ setOpen(false)} + open={open} + > +
+ +
+ {isNaN(value) ? 'N/A' : `${Math.round(value * 10) / 10}`} +
+
+
+
+ ); +} diff --git a/plugins/codescene/src/components/ProjectsComponent/ProjectsComponent.test.tsx b/plugins/codescene/src/components/ProjectsComponent/ProjectsComponent.test.tsx new file mode 100644 index 0000000000..4c9ee9ed42 --- /dev/null +++ b/plugins/codescene/src/components/ProjectsComponent/ProjectsComponent.test.tsx @@ -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( + + + + + , + { + mountedRoutes: { + '/codescene': rootRouteRef, + }, + }, + ); + expect(await rendered.findByText('Projects')).toBeInTheDocument(); + expect(await rendered.findByText('test-project')).toBeInTheDocument(); + }); +}); diff --git a/plugins/codescene/src/components/ProjectsComponent/ProjectsComponent.tsx b/plugins/codescene/src/components/ProjectsComponent/ProjectsComponent.tsx new file mode 100644 index 0000000000..212ab6e746 --- /dev/null +++ b/plugins/codescene/src/components/ProjectsComponent/ProjectsComponent.tsx @@ -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> => { + 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 = await Promise.allSettled( + promises, + ).then(results => { + return results + .filter(result => result.status === 'fulfilled') + .map(result => result as PromiseFulfilledResult) + .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 ; + } else if (error) { + return {error.message}; + } else if ( + !projectsWithAnalyses || + Object.keys(projectsWithAnalyses).length === 0 + ) { + return No projects found!; + } + + 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 => ( + + )) + : undefined; + return ( + + + + + {subtitle} + {chips} + + + + ); + }); + + return ( + + + setSearchText(e.target.value)} + /> + + {cards} + + ); +}; diff --git a/plugins/codescene/src/components/ProjectsComponent/index.ts b/plugins/codescene/src/components/ProjectsComponent/index.ts new file mode 100644 index 0000000000..f89c0de697 --- /dev/null +++ b/plugins/codescene/src/components/ProjectsComponent/index.ts @@ -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'; diff --git a/plugins/codescene/src/index.ts b/plugins/codescene/src/index.ts new file mode 100644 index 0000000000..fa4406fb8f --- /dev/null +++ b/plugins/codescene/src/index.ts @@ -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; diff --git a/plugins/codescene/src/plugin.test.ts b/plugins/codescene/src/plugin.test.ts new file mode 100644 index 0000000000..1ffdb95b06 --- /dev/null +++ b/plugins/codescene/src/plugin.test.ts @@ -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(); + }); +}); diff --git a/plugins/codescene/src/plugin.ts b/plugins/codescene/src/plugin.ts new file mode 100644 index 0000000000..eb885589a2 --- /dev/null +++ b/plugins/codescene/src/plugin.ts @@ -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, + }), +); diff --git a/plugins/codescene/src/routes.ts b/plugins/codescene/src/routes.ts new file mode 100644 index 0000000000..2fd9a5ee38 --- /dev/null +++ b/plugins/codescene/src/routes.ts @@ -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'], +}); diff --git a/plugins/codescene/src/setupTests.ts b/plugins/codescene/src/setupTests.ts new file mode 100644 index 0000000000..9bb3e72355 --- /dev/null +++ b/plugins/codescene/src/setupTests.ts @@ -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'; diff --git a/scripts/api-extractor.ts b/scripts/api-extractor.ts index 79f1f1eeb4..6dc5585958 100644 --- a/scripts/api-extractor.ts +++ b/scripts/api-extractor.ts @@ -243,6 +243,7 @@ const NO_WARNING_PACKAGES = [ 'plugins/catalog-common', 'plugins/catalog-graph', 'plugins/catalog-react', + 'plugins/codescene', 'plugins/graphiql', 'plugins/org', 'plugins/periskop',