diff --git a/.changeset/poor-icons-flow.md b/.changeset/poor-icons-flow.md new file mode 100644 index 0000000000..f3cc22c339 --- /dev/null +++ b/.changeset/poor-icons-flow.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-code-climate': minor +--- + +Added Code Climate plugin diff --git a/plugins/code-climate/.eslintrc.js b/plugins/code-climate/.eslintrc.js new file mode 100644 index 0000000000..13573efa9c --- /dev/null +++ b/plugins/code-climate/.eslintrc.js @@ -0,0 +1,3 @@ +module.exports = { + extends: [require.resolve('@backstage/cli/config/eslint')], +}; diff --git a/plugins/code-climate/README.md b/plugins/code-climate/README.md new file mode 100644 index 0000000000..a67e70b11a --- /dev/null +++ b/plugins/code-climate/README.md @@ -0,0 +1,85 @@ +# Code Climate Plugin + +The Code Climate Plugin displays a few stats from the quality section from [Code Climate](https://codeclimate.com). + +![Code Climate Card](./docs/code-climate-card.png) + +## Getting Started + +1. Install the Code Climate Plugin: + +```bash +# From your Backstage root directory +cd packages/app +yarn add @backstage/plugin-code-climate +``` + +2. Add the `EntityCodeClimateCard` to the EntityPage: + +```jsx +// packages/app/src/components/catalog/EntityPage.tsx + +import { EntityCodeClimateCard } from '@backstage/plugin-code-climate'; + +const overviewContent = ( + + // ... + + + + // ... + +); +``` + +3. Add the proxy config: + +```yaml +# app-config.yaml + +proxy: + '/codeclimate/api': + target: https://api.codeclimate.com/v1 + headers: + Authorization: Token token=${CODECLIMATE_TOKEN} +``` + +4. Create a new API access token (https://codeclimate.com/profile/tokens) and provide `CODECLIMATE_TOKEN` as an env variable. + +5. Add the `codeclimate.com/repo-id` annotation to your `catalog-info.yaml` file: + +```yaml +apiVersion: backstage.io/v1alpha1 +kind: Component +metadata: + name: backstage + description: | + Backstage is an open-source developer portal that puts the developer experience first. + annotations: + codeclimate.com/repo-id: YOUR_REPO_ID +spec: + type: library + owner: CNCF + lifecycle: experimental +``` + +### Demo Mode + +The plugin provides a MockAPI that always returns dummy data instead of talking to the Code Climate backend. +You can add it by overriding the `codeClimateApiRef`: + +```ts +// packages/app/src/apis.ts + +import { createApiFactory } from '@backstage/core-plugin-api'; +import { + MockCodeClimateApi, + codeClimateApiRef, +} from '@backstage/plugin-code-climate'; + +export const apis = [ + // ... + + createApiFactory(codeClimateApiRef, new MockCodeClimateApi()), +]; +``` diff --git a/plugins/code-climate/api-report.md b/plugins/code-climate/api-report.md new file mode 100644 index 0000000000..11366384d3 --- /dev/null +++ b/plugins/code-climate/api-report.md @@ -0,0 +1,87 @@ +## API Report File for "@backstage/plugin-code-climate" + +> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). + +```ts +/// + +import { ApiRef } from '@backstage/core-plugin-api'; +import { BackstagePlugin } from '@backstage/core-plugin-api'; +import { DiscoveryApi } from '@backstage/core-plugin-api'; +import { RouteRef } from '@backstage/core-plugin-api'; + +// Warning: (ae-missing-release-tag) "CodeClimateApi" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public (undocumented) +export interface CodeClimateApi { + // (undocumented) + fetchData(repoID: string): Promise; +} + +// Warning: (ae-missing-release-tag) "codeClimateApiRef" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public (undocumented) +export const codeClimateApiRef: ApiRef; + +// Warning: (ae-missing-release-tag) "CodeClimateData" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public (undocumented) +export type CodeClimateData = { + repoID: string; + maintainability: { + letter: string; + value: string; + }; + testCoverage: { + letter: string; + value: string; + }; + numberOfCodeSmells: number; + numberOfDuplication: number; + numberOfOtherIssues: number; +}; + +// Warning: (ae-missing-release-tag) "codeClimatePlugin" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public (undocumented) +export const codeClimatePlugin: BackstagePlugin< + { + root: RouteRef; + }, + {} +>; + +// Warning: (ae-missing-release-tag) "EntityCodeClimateCard" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public (undocumented) +export const EntityCodeClimateCard: () => JSX.Element; + +// Warning: (ae-missing-release-tag) "MockCodeClimateApi" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public (undocumented) +export class MockCodeClimateApi implements CodeClimateApi { + // (undocumented) + fetchData(): Promise; +} + +// Warning: (ae-missing-release-tag) "mockData" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public (undocumented) +export const mockData: CodeClimateData; + +// Warning: (ae-missing-release-tag) "ProductionCodeClimateApi" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public (undocumented) +export class ProductionCodeClimateApi implements CodeClimateApi { + constructor(discoveryApi: DiscoveryApi); + // (undocumented) + fetchAllData(options: { + apiUrl: string; + repoID: string; + snapshotID: string; + testReportID: string; + }): Promise; + // (undocumented) + fetchData(repoID: string): Promise; +} +``` diff --git a/plugins/code-climate/dev/index.tsx b/plugins/code-climate/dev/index.tsx new file mode 100644 index 0000000000..d5fc4c28e3 --- /dev/null +++ b/plugins/code-climate/dev/index.tsx @@ -0,0 +1,93 @@ +/* + * Copyright 2020 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'; +// eslint-disable-next-line import/no-extraneous-dependencies +import { createDevApp, EntityGridItem } from '@backstage/dev-utils'; +import { Grid } from '@material-ui/core'; +import React from 'react'; +import { + EntityCodeClimateCard, + MockCodeClimateApi, + CodeClimateApi, + codeClimateApiRef, +} from '../src'; +import { CODECLIMATE_REPO_ID_ANNOTATION } from '../src/plugin'; +import { Content, Header, Page } from '@backstage/core-components'; + +const entity = (name?: string) => + ({ + apiVersion: 'backstage.io/v1alpha1', + kind: 'Component', + metadata: { + annotations: { + [CODECLIMATE_REPO_ID_ANNOTATION]: name, + }, + name: name, + }, + } as Entity); + +createDevApp() + .registerApi({ + api: codeClimateApiRef, + deps: {}, + factory: () => + ({ + fetchData: async (repoID: string) => { + switch (repoID) { + case 'error': + throw new Error('Error!'); + + case 'never': + return new Promise(() => {}); + + case 'no-values': + return undefined; + + case 'with-values': + return new MockCodeClimateApi().fetchData(); + + default: + return []; + } + }, + } as CodeClimateApi), + }) + .addPage({ + title: 'Cards', + element: ( + +
+ + + + + + + + + + + + + + + + + + ), + }) + .render(); diff --git a/plugins/code-climate/docs/code-climate-card.png b/plugins/code-climate/docs/code-climate-card.png new file mode 100644 index 0000000000..b94e23eebe Binary files /dev/null and b/plugins/code-climate/docs/code-climate-card.png differ diff --git a/plugins/code-climate/package.json b/plugins/code-climate/package.json new file mode 100644 index 0000000000..ef1c83c522 --- /dev/null +++ b/plugins/code-climate/package.json @@ -0,0 +1,56 @@ +{ + "name": "@backstage/plugin-code-climate", + "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" + }, + "scripts": { + "build": "backstage-cli plugin:build", + "start": "backstage-cli plugin:serve", + "lint": "backstage-cli lint", + "test": "backstage-cli test", + "diff": "backstage-cli plugin:diff", + "prepack": "backstage-cli prepack", + "postpack": "backstage-cli postpack", + "clean": "backstage-cli clean" + }, + "dependencies": { + "@backstage/catalog-model": "^0.10.0", + "@backstage/core-components": "^0.8.9", + "@backstage/core-plugin-api": "^0.6.1", + "@backstage/plugin-catalog-react": "^0.6.15", + "@backstage/theme": "^0.2.15", + "@material-ui/core": "^4.12.2", + "@material-ui/icons": "^4.9.1", + "@material-ui/lab": "4.0.0-alpha.57", + "humanize-duration": "^3.27.1", + "luxon": "^2.0.2", + "react-router": "6.0.0-beta.0", + "react-use": "^17.2.4" + }, + "peerDependencies": { + "react": "^16.13.1 || ^17.0.0" + }, + "devDependencies": { + "@backstage/cli": "^0.14.0", + "@backstage/dev-utils": "^0.2.22", + "@backstage/test-utils": "^0.2.5", + "@testing-library/jest-dom": "^5.10.1", + "@testing-library/react": "^11.2.5", + "@testing-library/user-event": "^13.1.8", + "@types/humanize-duration": "^3.27.1", + "@types/jest": "^26.0.7", + "@types/luxon": "^2.0.9", + "@types/node": "^14.14.32", + "cross-fetch": "^3.1.5", + "msw": "^0.35.0" + }, + "files": [ + "dist" + ] +} diff --git a/plugins/code-climate/src/api/code-climate-api.ts b/plugins/code-climate/src/api/code-climate-api.ts new file mode 100644 index 0000000000..fb4f41ebb5 --- /dev/null +++ b/plugins/code-climate/src/api/code-climate-api.ts @@ -0,0 +1,26 @@ +/* + * Copyright 2020 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 { CodeClimateData } from './code-climate-data'; +import { createApiRef } from '@backstage/core-plugin-api'; + +export const codeClimateApiRef = createApiRef({ + id: 'plugin.code-climate.service', +}); + +export interface CodeClimateApi { + fetchData(repoID: string): Promise; +} diff --git a/plugins/code-climate/src/api/code-climate-data.ts b/plugins/code-climate/src/api/code-climate-data.ts new file mode 100644 index 0000000000..9271905157 --- /dev/null +++ b/plugins/code-climate/src/api/code-climate-data.ts @@ -0,0 +1,184 @@ +/* + * Copyright 2020 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 type CodeClimateRepoData = { + data: { + id: string; + type: string; + attributes: { + analysis_version: number; + badge_token: string; + branch: string; + created_at: string; + delegated_config_repo_id: string; + diff_coverage_enforced: boolean; + diff_coverage_threshold: number; + enable_notifications: boolean; + github_slug: string; + human_name: string; + last_activity_at: string; + test_reporter_id: string | null; + total_coverage_enforced: boolean; + vcs_database_id: string; + vcs_host: string; + score: string | null; + }; + relationships: { + latest_default_branch_snapshot: { + data: { id: string; type: string }; + }; + latest_default_branch_test_report: { + data: { id: string; type: string }; + }; + account: { + data: { id: string; type: string }; + }; + }; + links: { + self: string; + services: string; + web_coverage: string; + web_issues: string; + maintainability_badge: string; + test_coverage_badge: string; + }; + meta: { permissions: { admin: boolean } }; + }; +}; + +export type CodeClimateMaintainabilityData = { + data: { + id: string; + type: string; + attributes: { + commit_sha: string; + committed_at: string; + created_at: string; + lines_of_code: number; + ratings: [ + { + path: string; + letter: string; + measure: { + value: number; + unit: string; + meta: { + remediation_time: { value: number; unit: string }; + implementation_time: { + value: number; + unit: string; + }; + }; + }; + pillar: string; + }, + ]; + gpa: string | null; + worker_version: number; + }; + meta: { + issues_count: number; + measures: { + remediation: { value: number; unit: string }; + technical_debt_ratio: { + value: number; + unit: string; + meta: { + remediation_time: { value: number; unit: string }; + implementation_time: { + value: number; + unit: string; + }; + }; + }; + }; + }; + }; +}; + +export type CodeClimateTestCoverageData = { + data: { + id: string; + type: string; + attributes: { + branch: string; + commit_sha: string; + committed_at: string; + covered_percent: number; + lines_of_code: number; + rating: { + path: string; + letter: string; + measure: { value: number; unit: string }; + pillar: string; + }; + received_at: string; + state: string; + }; + }; +}; + +export type CodeClimateIssuesData = { + data: [ + { + id: string; + type: string; + attributes: { + categories: string[]; + check_name: string; + constant_name: string; + content: { body: string }; + description: string; + engine_name: string; + fingerprint: string; + location: { + path: string; + end_line: number; + start_line: number; + }; + other_locations: string[]; + remediation_points: number; + severity: string; + }; + meta: { permissions: { manageable: boolean } }; + }, + ]; + links: { + self: string; + next: string; + last: string; + }; + meta: { current_page: number; total_pages: number; total_count: number }; +}; + +export type CodeClimateData = { + repoID: string; + maintainability: { + letter: string; + value: string; + }; + testCoverage: { + letter: string; + value: string; + }; + numberOfCodeSmells: number; + numberOfDuplication: number; + numberOfOtherIssues: number; +}; + +export type CodeClimateApiError = { + detail: string; +}; diff --git a/plugins/code-climate/src/api/index.ts b/plugins/code-climate/src/api/index.ts new file mode 100644 index 0000000000..60fc079698 --- /dev/null +++ b/plugins/code-climate/src/api/index.ts @@ -0,0 +1,21 @@ +/* + * Copyright 2020 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 * from './mock'; +export type { CodeClimateApi } from './code-climate-api'; +export { codeClimateApiRef } from './code-climate-api'; +export type { CodeClimateData } from './code-climate-data'; +export { ProductionCodeClimateApi } from './production-api'; diff --git a/plugins/code-climate/src/api/mock/code-climate-maintainability-mock.json b/plugins/code-climate/src/api/mock/code-climate-maintainability-mock.json new file mode 100644 index 0000000000..46a7905e4b --- /dev/null +++ b/plugins/code-climate/src/api/mock/code-climate-maintainability-mock.json @@ -0,0 +1,49 @@ +{ + "data": { + "id": "6b8cc37a64b741dd9d516119", + "type": "snapshots", + "attributes": { + "commit_sha": "d670460b4b4aece5915caf5c68d12f560a9fe3e4", + "committed_at": "2022-01-14T15:17:29.311Z", + "created_at": "2022-01-31T10:07:40.415Z", + "lines_of_code": 8854, + "ratings": [ + { + "path": "/", + "letter": "B", + "measure": { + "value": 5.264879308188034, + "unit": "percent", + "meta": { + "remediation_time": { "value": 7186.52, "unit": "minute" }, + "implementation_time": { + "value": 136499.2353922225, + "unit": "minute" + } + } + }, + "pillar": "Maintainability" + } + ], + "gpa": null, + "worker_version": 69440 + }, + "meta": { + "issues_count": 127, + "measures": { + "remediation": { "value": 7186.52, "unit": "minute" }, + "technical_debt_ratio": { + "value": 5.264879308188034, + "unit": "percent", + "meta": { + "remediation_time": { "value": 7186.52, "unit": "minute" }, + "implementation_time": { + "value": 136499.2353922225, + "unit": "minute" + } + } + } + } + } + } +} diff --git a/plugins/code-climate/src/api/mock/code-climate-test-coverage-mock.json b/plugins/code-climate/src/api/mock/code-climate-test-coverage-mock.json new file mode 100644 index 0000000000..203dd81776 --- /dev/null +++ b/plugins/code-climate/src/api/mock/code-climate-test-coverage-mock.json @@ -0,0 +1,21 @@ +{ + "data": { + "id": "6b8cc37a64b741dd9d516119", + "type": "test_reports", + "attributes": { + "branch": "master", + "commit_sha": "d670460b4b4aece5915caf5c68d12f560a9fe3e4", + "committed_at": "2022-01-14T15:17:26.000Z", + "covered_percent": 88.41059602649007, + "lines_of_code": 8854, + "rating": { + "path": "/", + "letter": "B", + "measure": { "value": 88.41059602649007, "unit": "percent" }, + "pillar": "Test Coverage" + }, + "received_at": "2022-01-14T15:25:38.352Z", + "state": "done" + } + } +} diff --git a/plugins/code-climate/src/api/mock/index.ts b/plugins/code-climate/src/api/mock/index.ts new file mode 100644 index 0000000000..56ba9c7974 --- /dev/null +++ b/plugins/code-climate/src/api/mock/index.ts @@ -0,0 +1,18 @@ +/* + * Copyright 2020 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 { mockData } from './mock-api'; +export { MockCodeClimateApi } from './mock-api'; diff --git a/plugins/code-climate/src/api/mock/mock-api.ts b/plugins/code-climate/src/api/mock/mock-api.ts new file mode 100644 index 0000000000..74e4d83027 --- /dev/null +++ b/plugins/code-climate/src/api/mock/mock-api.ts @@ -0,0 +1,56 @@ +/* + * Copyright 2020 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 { CodeClimateData } from '../code-climate-data'; +import { CodeClimateApi } from '../code-climate-api'; +import maintainabilityMock from './code-climate-maintainability-mock.json'; +import testCoverageMock from './code-climate-test-coverage-mock.json'; +import { Duration } from 'luxon'; +import humanizeDuration from 'humanize-duration'; + +const maintainabilityData = maintainabilityMock.data.attributes.ratings[0]; +const testCoverageData = testCoverageMock.data.attributes.rating; + +const maintainabilityValue: any = {}; +maintainabilityValue[ + maintainabilityData.measure.meta.implementation_time.unit +] = maintainabilityData.measure.meta.implementation_time.value.toFixed(); + +export const mockData: CodeClimateData = { + repoID: '6b8cc37a64b741dd9d516119', + maintainability: { + letter: maintainabilityData.letter, + value: humanizeDuration( + Duration.fromObject(maintainabilityValue).toMillis(), + { largest: 1 }, + ), + }, + testCoverage: { + letter: testCoverageData.letter, + value: testCoverageData.measure.value.toFixed(), + }, + numberOfCodeSmells: 97, + numberOfDuplication: 49, + numberOfOtherIssues: 26, +}; + +export class MockCodeClimateApi implements CodeClimateApi { + fetchData(): Promise { + return new Promise(resolve => { + setTimeout(() => resolve(mockData), 800); + }); + } +} diff --git a/plugins/code-climate/src/api/production-api.ts b/plugins/code-climate/src/api/production-api.ts new file mode 100644 index 0000000000..665d7e9f63 --- /dev/null +++ b/plugins/code-climate/src/api/production-api.ts @@ -0,0 +1,160 @@ +/* + * Copyright 2020 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 { + CodeClimateData, + CodeClimateRepoData, + CodeClimateMaintainabilityData, + CodeClimateTestCoverageData, + CodeClimateIssuesData, +} from './code-climate-data'; +import { CodeClimateApi } from './code-climate-api'; +import { DiscoveryApi } from '@backstage/core-plugin-api'; +import { Duration } from 'luxon'; +import humanizeDuration from 'humanize-duration'; + +const pageQuery = encodeURIComponent('page[size]'); +const statusFilter = encodeURIComponent('filter[status][$in][]'); +const categoriesFilter = encodeURIComponent('filter[categories][$in][]'); +const basicIssuesOptions = `${pageQuery}=1&${statusFilter}=open&${statusFilter}=confirmed`; +const codeSmellsQuery = `${basicIssuesOptions}&${categoriesFilter}=Complexity`; +const duplicationQuery = `${basicIssuesOptions}&${categoriesFilter}=Duplication`; +const otherIssuesQuery = `${basicIssuesOptions}&${categoriesFilter}=Bug%20Risk`; + +export class ProductionCodeClimateApi implements CodeClimateApi { + constructor(private readonly discoveryApi: DiscoveryApi) {} + + async fetchAllData(options: { + apiUrl: string; + repoID: string; + snapshotID: string; + testReportID: string; + }): Promise { + const { apiUrl, repoID, snapshotID, testReportID } = options; + + const [ + maintainabilityResponse, + testCoverageResponse, + codeSmellsResponse, + duplicationResponse, + otherIssuesResponse, + ] = await Promise.all([ + await fetch(`${apiUrl}/repos/${repoID}/snapshots/${snapshotID}`), + await fetch(`${apiUrl}/repos/${repoID}/test_reports/${testReportID}`), + await fetch( + `${apiUrl}/repos/${repoID}/snapshots/${snapshotID}/issues?${codeSmellsQuery}`, + ), + await fetch( + `${apiUrl}/repos/${repoID}/snapshots/${snapshotID}/issues?${duplicationQuery}`, + ), + await fetch( + `${apiUrl}/repos/${repoID}/snapshots/${snapshotID}/issues?${otherIssuesQuery}`, + ), + ]); + + if ( + !maintainabilityResponse.ok || + !testCoverageResponse.ok || + !codeSmellsResponse.ok || + !duplicationResponse.ok || + !otherIssuesResponse.ok + ) { + throw new Error('Failed fetching Code Climate info'); + } + + const maintainabilityData = ( + (await maintainabilityResponse.json()) as CodeClimateMaintainabilityData + ).data.attributes.ratings[0]; + const testCoverageData = ( + (await testCoverageResponse.json()) as CodeClimateTestCoverageData + ).data.attributes.rating; + const codeSmellsData = ( + (await codeSmellsResponse.json()) as CodeClimateIssuesData + ).meta.total_count; + const duplicationData = ( + (await duplicationResponse.json()) as CodeClimateIssuesData + ).meta.total_count; + const otherIssuesData = ( + (await otherIssuesResponse.json()) as CodeClimateIssuesData + ).meta.total_count; + + return [ + maintainabilityData, + testCoverageData, + codeSmellsData, + duplicationData, + otherIssuesData, + ]; + } + + async fetchData(repoID: string): Promise { + if (!repoID) { + throw new Error('No Repo id found'); + } + + const apiUrl = `${await this.discoveryApi.getBaseUrl( + 'proxy', + )}/codeclimate/api`; + + const repoResponse = await fetch(`${apiUrl}/repos/${repoID}`); + + if (!repoResponse.ok) { + throw new Error('Failed fetching Code Climate info'); + } + + const repoData = ((await repoResponse.json()) as CodeClimateRepoData).data; + const snapshotID = + repoData.relationships.latest_default_branch_snapshot.data.id; + const testReportID = + repoData.relationships.latest_default_branch_test_report.data.id; + + const [ + maintainabilityData, + testCoverageData, + codeSmellsData, + duplicationData, + otherIssuesData, + ] = await this.fetchAllData({ + apiUrl, + repoID, + snapshotID, + testReportID, + }); + + const maintainabilityValue: any = {}; + maintainabilityValue[ + maintainabilityData.measure.meta.implementation_time.unit + ] = maintainabilityData.measure.meta.implementation_time.value.toFixed(); + + return { + repoID, + maintainability: { + letter: maintainabilityData.letter, + value: humanizeDuration( + Duration.fromObject(maintainabilityValue).toMillis(), + { largest: 1 }, + ), + }, + testCoverage: { + letter: testCoverageData.letter, + value: testCoverageData.measure.value.toFixed(), + }, + numberOfCodeSmells: codeSmellsData, + numberOfDuplication: duplicationData, + numberOfOtherIssues: otherIssuesData, + }; + } +} diff --git a/plugins/code-climate/src/components/CodeClimateCard/CodeClimateCard.tsx b/plugins/code-climate/src/components/CodeClimateCard/CodeClimateCard.tsx new file mode 100644 index 0000000000..82af326690 --- /dev/null +++ b/plugins/code-climate/src/components/CodeClimateCard/CodeClimateCard.tsx @@ -0,0 +1,25 @@ +/* + * Copyright 2020 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 { InfoCard } from '@backstage/core-components'; +import { CodeClimateCardContents } from '../CodeClimateCardContents'; + +export const CodeClimateCard = () => ( + + + +); diff --git a/plugins/code-climate/src/components/CodeClimateCard/index.ts b/plugins/code-climate/src/components/CodeClimateCard/index.ts new file mode 100644 index 0000000000..51eaaf71af --- /dev/null +++ b/plugins/code-climate/src/components/CodeClimateCard/index.ts @@ -0,0 +1,17 @@ +/* + * Copyright 2020 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 { CodeClimateCard } from './CodeClimateCard'; diff --git a/plugins/code-climate/src/components/CodeClimateCardContents/CodeClimateCardContents.tsx b/plugins/code-climate/src/components/CodeClimateCardContents/CodeClimateCardContents.tsx new file mode 100644 index 0000000000..c9dc1894fc --- /dev/null +++ b/plugins/code-climate/src/components/CodeClimateCardContents/CodeClimateCardContents.tsx @@ -0,0 +1,64 @@ +/* + * Copyright 2020 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 useAsync from 'react-use/lib/useAsync'; +import { codeClimateApiRef } from '../../api'; +import { CodeClimateTable } from '../CodeClimateTable'; +import { CODECLIMATE_REPO_ID_ANNOTATION } from '../../plugin'; +import { useEntity } from '@backstage/plugin-catalog-react'; +import { + EmptyState, + ErrorPanel, + MissingAnnotationEmptyState, + Progress, +} from '@backstage/core-components'; +import { useApi } from '@backstage/core-plugin-api'; + +export const CodeClimateCardContents = () => { + const { entity } = useEntity(); + const codeClimateApi = useApi(codeClimateApiRef); + + const repoID = + entity?.metadata.annotations?.[CODECLIMATE_REPO_ID_ANNOTATION] ?? ''; + + const { loading, value, error } = useAsync( + () => codeClimateApi.fetchData(repoID), + [codeClimateApi, repoID], + ); + + if (loading) { + return ; + } else if (!repoID) { + return ( + + ); + } else if (error) { + return ; + } else if (!value) { + return ( + + ); + } + + return ; +}; diff --git a/plugins/code-climate/src/components/CodeClimateCardContents/index.ts b/plugins/code-climate/src/components/CodeClimateCardContents/index.ts new file mode 100644 index 0000000000..29f59be2a3 --- /dev/null +++ b/plugins/code-climate/src/components/CodeClimateCardContents/index.ts @@ -0,0 +1,17 @@ +/* + * Copyright 2020 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 { CodeClimateCardContents } from './CodeClimateCardContents'; diff --git a/plugins/code-climate/src/components/CodeClimateTable/CodeClimateTable.test.tsx b/plugins/code-climate/src/components/CodeClimateTable/CodeClimateTable.test.tsx new file mode 100644 index 0000000000..a2ced6f5a2 --- /dev/null +++ b/plugins/code-climate/src/components/CodeClimateTable/CodeClimateTable.test.tsx @@ -0,0 +1,37 @@ +/* + * Copyright 2020 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 { render } from '@testing-library/react'; +import { CodeClimateTable } from './CodeClimateTable'; +import { mockData } from '../../api/mock'; +import { ThemeProvider } from '@material-ui/core'; +import { lightTheme } from '@backstage/theme'; + +describe('CodeClimateTable', () => { + it('should render values in a table', async () => { + const table = await render( + + + , + ); + expect(await table.findByText('3 months')).toBeInTheDocument(); + expect(await table.findByText('88%')).toBeInTheDocument(); + expect(await table.findByText('97')).toBeInTheDocument(); + expect(await table.findByText('49')).toBeInTheDocument(); + expect(await table.findByText('26')).toBeInTheDocument(); + }); +}); diff --git a/plugins/code-climate/src/components/CodeClimateTable/CodeClimateTable.tsx b/plugins/code-climate/src/components/CodeClimateTable/CodeClimateTable.tsx new file mode 100644 index 0000000000..e8e204ff9a --- /dev/null +++ b/plugins/code-climate/src/components/CodeClimateTable/CodeClimateTable.tsx @@ -0,0 +1,212 @@ +/* + * Copyright 2020 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 { CodeClimateData } from '../../api'; +import { Link } from '@backstage/core-components'; +import { Box, makeStyles, Typography } from '@material-ui/core'; +import { BackstageTheme } from '@backstage/theme'; + +const letterStyle = { + color: 'white', + border: 0, + borderRadius: '3px', + fontSize: '40px', + padding: '5px 20px', +}; + +const fontSize = { + fontSize: '25px', +}; + +const letterColor = (letter: string) => { + if (letter === 'A') { + return '#45d298'; + } else if (letter === 'B') { + return '#a5d86e'; + } else if (letter === 'C') { + return '#f1ce0c'; + } else if (letter === 'D') { + return '#f29141'; + } else if (letter === 'F') { + return '#df5869'; + } + + return '#45d298'; +}; + +const useStyles = makeStyles< + BackstageTheme, + { + maintainabilityLetter: string; + testCoverageLetter: string; + } +>({ + spaceAround: { + display: 'flex', + justifyContent: 'space-around', + }, + spaceBetween: { + display: 'flex', + justifyContent: 'space-between', + alignItems: 'center', + }, + maintainabilityLetterColor: { + ...letterStyle, + backgroundColor: props => letterColor(props.maintainabilityLetter), + }, + testCoverageLetterColor: { + ...letterStyle, + backgroundColor: props => letterColor(props.testCoverageLetter), + }, + fontSize: { + ...fontSize, + }, + letterDetails: { + ...fontSize, + paddingLeft: '10px', + }, + paddingSides20: { + padding: '0px 20px', + }, +}); + +export const CodeClimateTable = ({ + codeClimateData, +}: { + codeClimateData: CodeClimateData; +}) => { + const { + repoID, + maintainability: { + letter: maintainabilityLetter, + value: maintainabilityValue, + }, + testCoverage: { letter: testCoverageLetter, value: testCoverageValue }, + numberOfCodeSmells, + numberOfDuplication, + numberOfOtherIssues, + } = codeClimateData; + + const classes = useStyles({ maintainabilityLetter, testCoverageLetter }); + + if (!codeClimateData) { + return null; + } + + return ( + <> +
+
+ + Maintainability + +
+ + {maintainabilityLetter} + + + + {maintainabilityValue} + + +
+
+
+ + Test Coverage + +
+ + {testCoverageLetter} + + + + {testCoverageValue}% + + +
+
+
+ +
+ + Code Smells: + + + + {numberOfCodeSmells} + + +
+ + + Duplication: + + + + {numberOfDuplication} + + + +
+ + Other Issues: + + + + {numberOfOtherIssues} + + +
+
+ + ); +}; diff --git a/plugins/code-climate/src/components/CodeClimateTable/index.ts b/plugins/code-climate/src/components/CodeClimateTable/index.ts new file mode 100644 index 0000000000..bdf79bb94b --- /dev/null +++ b/plugins/code-climate/src/components/CodeClimateTable/index.ts @@ -0,0 +1,17 @@ +/* + * Copyright 2020 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 { CodeClimateTable } from './CodeClimateTable'; diff --git a/plugins/code-climate/src/index.ts b/plugins/code-climate/src/index.ts new file mode 100644 index 0000000000..b6ef174b8f --- /dev/null +++ b/plugins/code-climate/src/index.ts @@ -0,0 +1,24 @@ +/* + * Copyright 2020 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. + */ + +/** + * A Backstage plugin that integrates towards Code Climate + * + * @packageDocumentation + */ + +export * from './api'; +export { codeClimatePlugin, EntityCodeClimateCard } from './plugin'; diff --git a/plugins/code-climate/src/plugin.test.ts b/plugins/code-climate/src/plugin.test.ts new file mode 100644 index 0000000000..ea791f2c87 --- /dev/null +++ b/plugins/code-climate/src/plugin.test.ts @@ -0,0 +1,23 @@ +/* + * Copyright 2020 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 { codeClimatePlugin } from './plugin'; + +describe('codeclimate', () => { + it('should export plugin', () => { + expect(codeClimatePlugin).toBeDefined(); + }); +}); diff --git a/plugins/code-climate/src/plugin.ts b/plugins/code-climate/src/plugin.ts new file mode 100644 index 0000000000..dcf86f1e19 --- /dev/null +++ b/plugins/code-climate/src/plugin.ts @@ -0,0 +1,58 @@ +/* + * Copyright 2020 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 { ProductionCodeClimateApi, codeClimateApiRef } from './api'; +import { + createApiFactory, + createPlugin, + createRouteRef, + discoveryApiRef, + identityApiRef, + createComponentExtension, +} from '@backstage/core-plugin-api'; + +export const CODECLIMATE_REPO_ID_ANNOTATION = 'codeclimate.com/repo-id'; + +export const rootRouteRef = createRouteRef({ + id: 'code-climate', +}); + +export const codeClimatePlugin = createPlugin({ + id: 'code-climate', + apis: [ + createApiFactory({ + api: codeClimateApiRef, + deps: { + discoveryApi: discoveryApiRef, + identityApi: identityApiRef, + }, + factory: ({ discoveryApi }) => new ProductionCodeClimateApi(discoveryApi), + }), + ], + routes: { + root: rootRouteRef, + }, +}); + +export const EntityCodeClimateCard = codeClimatePlugin.provide( + createComponentExtension({ + name: 'EntityCodeClimateCard', + component: { + lazy: () => + import('./components/CodeClimateCard').then(m => m.CodeClimateCard), + }, + }), +); diff --git a/plugins/code-climate/src/setupTests.ts b/plugins/code-climate/src/setupTests.ts new file mode 100644 index 0000000000..43d9e09713 --- /dev/null +++ b/plugins/code-climate/src/setupTests.ts @@ -0,0 +1,20 @@ +/* + * Copyright 2020 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. + */ + +/* eslint-disable import/no-extraneous-dependencies */ + +import '@testing-library/jest-dom'; +import 'cross-fetch/polyfill'; diff --git a/yarn.lock b/yarn.lock index bbd75035ce..a3adc4d7bf 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5785,6 +5785,11 @@ resolved "https://registry.npmjs.org/@types/humanize-duration/-/humanize-duration-3.25.1.tgz#b6140d5fc00ff3917b3f521784abef4bc0387ccc" integrity sha512-WZU/4bb+lvzyDmZzjJtp++9mfKy6B3lH6gGISgkcz6SU8hMILKRM0vi08TxIsb0dQB4Gzo68MWLmctu6xqUi9g== +"@types/humanize-duration@^3.27.1": + version "3.27.1" + resolved "https://registry.npmjs.org/@types/humanize-duration/-/humanize-duration-3.27.1.tgz#f14740d1f585a0a8e3f46359b62fda8b0eaa31e7" + integrity sha512-K3e+NZlpCKd6Bd/EIdqjFJRFHbrq5TzPPLwREk5Iv/YoIjQrs6ljdAUCo+Lb2xFlGNOjGSE0dqsVD19cZL137w== + "@types/inquirer@^7.3.3": version "7.3.3" resolved "https://registry.npmjs.org/@types/inquirer/-/inquirer-7.3.3.tgz#92e6676efb67fa6925c69a2ee638f67a822952ac" @@ -5954,6 +5959,11 @@ resolved "https://registry.npmjs.org/@types/luxon/-/luxon-2.0.9.tgz#782a0edfa6d699191292c13168bd496cd66b87c6" integrity sha512-ZuzIc7aN+i2ZDMWIiSmMdubR9EMMSTdEzF6R+FckP4p6xdnOYKqknTo/k+xXQvciSXlNGIwA4OPU5X7JIFzYdA== +"@types/luxon@^2.0.9": + version "2.0.9" + resolved "https://registry.npmjs.org/@types/luxon/-/luxon-2.0.9.tgz#782a0edfa6d699191292c13168bd496cd66b87c6" + integrity sha512-ZuzIc7aN+i2ZDMWIiSmMdubR9EMMSTdEzF6R+FckP4p6xdnOYKqknTo/k+xXQvciSXlNGIwA4OPU5X7JIFzYdA== + "@types/mdast@^3.0.0", "@types/mdast@^3.0.3": version "3.0.3" resolved "https://registry.npmjs.org/@types/mdast/-/mdast-3.0.3.tgz#2d7d671b1cd1ea3deb306ea75036c2a0407d2deb" @@ -13948,7 +13958,7 @@ human-signals@^2.1.0: resolved "https://registry.npmjs.org/human-signals/-/human-signals-2.1.0.tgz#dc91fcba42e4d06e4abaed33b3e7a3c02f514ea0" integrity sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw== -humanize-duration@^3.25.1, humanize-duration@^3.26.0, humanize-duration@^3.27.0: +humanize-duration@^3.25.1, humanize-duration@^3.26.0, humanize-duration@^3.27.0, humanize-duration@^3.27.1: version "3.27.1" resolved "https://registry.npmjs.org/humanize-duration/-/humanize-duration-3.27.1.tgz#2cd4ea4b03bd92184aee6d90d77a8f3d7628df69" integrity sha512-jCVkMl+EaM80rrMrAPl96SGG4NRac53UyI1o/yAzebDntEY6K6/Fj2HOjdPg8omTqIe5Y0wPBai2q5xXrIbarA==