diff --git a/docs/features/software-catalog/well-known-annotations.md b/docs/features/software-catalog/well-known-annotations.md
index 40a4ca3ea1..47b1d5885d 100644
--- a/docs/features/software-catalog/well-known-annotations.md
+++ b/docs/features/software-catalog/well-known-annotations.md
@@ -173,6 +173,22 @@ The value of these annotations are the corresponding attributes that were found
when ingestion the entity from LDAP. Not all of them may be present, depending
on what attributes that the server presented at ingestion time.
+### sonarqube.org/project-key
+
+```yaml
+# Example:
+metadata:
+ annotations:
+ sonarqube.org/project-key: pump-station
+```
+
+The value of this annotation is the project key of a
+[SonarQube](https://sonarqube.org) or [SonarCloud](https://sonarcloud.io)
+project within your organization.
+
+Specifying this annotation may enable SonarQube related features in Backstage
+for that entity.
+
## Deprecated Annotations
The following annotations are deprecated, and only listed here to aid in
diff --git a/microsite/data/plugins/sonarqube.yaml b/microsite/data/plugins/sonarqube.yaml
new file mode 100644
index 0000000000..a2982a4a58
--- /dev/null
+++ b/microsite/data/plugins/sonarqube.yaml
@@ -0,0 +1,9 @@
+---
+title: SonarQube
+author: SDA SE
+authorUrl: https://sda.se/
+category: Quality
+description: Components to display code quality metrics from SonarCloud and SonarQube.
+documentation: https://github.com/spotify/backstage/blob/master/plugins/sonarqube/README.md
+iconUrl: img/sonarqube-icon.svg
+npmPackageName: '@backstage/plugin-sonarqube'
diff --git a/microsite/static/img/sonarqube-icon.svg b/microsite/static/img/sonarqube-icon.svg
new file mode 100644
index 0000000000..d3de342b38
--- /dev/null
+++ b/microsite/static/img/sonarqube-icon.svg
@@ -0,0 +1,41 @@
+
+
+
diff --git a/plugins/sonarqube/.eslintrc.js b/plugins/sonarqube/.eslintrc.js
new file mode 100644
index 0000000000..13573efa9c
--- /dev/null
+++ b/plugins/sonarqube/.eslintrc.js
@@ -0,0 +1,3 @@
+module.exports = {
+ extends: [require.resolve('@backstage/cli/config/eslint')],
+};
diff --git a/plugins/sonarqube/README.md b/plugins/sonarqube/README.md
new file mode 100644
index 0000000000..3d3c1f725b
--- /dev/null
+++ b/plugins/sonarqube/README.md
@@ -0,0 +1,95 @@
+# SonarQube Plugin
+
+The SonarQube Plugin displays code statistics from [SonarCloud](https://sonarcloud.io) or [SonarQube](https://sonarqube.com).
+
+
+
+## Getting Started
+
+1. Install the SonarQube Plugin:
+
+```bash
+yarn add @backstage/plugin-sonarqube
+```
+
+2. Add plugin to the app:
+
+```js
+// packages/app/src/plugins.ts
+
+export { plugin as SonarQube } from '@backstage/plugin-sonarqube';
+```
+
+3. Add the `SonarQubeCard` to the EntityPage:
+
+```jsx
+// packages/app/src/components/catalog/EntityPage.tsx
+
+import { SonarQubeCard } from '@backstage/plugin-sonarqube';
+
+const OverviewContent = ({ entity }: { entity: Entity }) => (
+
+ // ...
+
+
+
+ // ...
+
+);
+```
+
+4. Add the proxy config:
+
+**SonarCloud**
+
+```yaml
+// app-config.yaml
+
+proxy:
+ '/sonarqube':
+ target: https://sonarcloud.io/api
+ allowedMethods: ['GET']
+ headers:
+ Authorization:
+ # Content: 'Basic base64(":")' <-- note the trailing ':'
+ # Example: Basic bXktYXBpLWtleTo=
+ $env: SONARQUBE_AUTH_HEADER
+```
+
+**SonarQube**
+
+```yaml
+// app-config.yaml
+
+proxy:
+ '/sonarqube':
+ target: https://your.sonarqube.instance.com/api
+ allowedMethods: ['GET']
+ headers:
+ Authorization:
+ # Content: 'Basic base64(":")' <-- note the trailing ':'
+ # Example: Basic bXktYXBpLWtleTo=
+ $env: SONARQUBE_AUTH_HEADER
+
+sonarQube:
+ baseUrl: https://your.sonarqube.instance.com
+```
+
+5. Get and provide `SONARQUBE_AUTH_HEADER` as env variable (https://sonarcloud.io/account/security or https://docs.sonarqube.org/latest/user-guide/user-token/)
+
+6. Add the `sonarqube.org/project-key` annotation to your component-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:
+ sonarqube.org/project-key: YOUR_PROJECT_KEY
+spec:
+ type: library
+ owner: Spotify
+ lifecycle: experimental
+```
diff --git a/plugins/sonarqube/dev/index.tsx b/plugins/sonarqube/dev/index.tsx
new file mode 100644
index 0000000000..812a5585d4
--- /dev/null
+++ b/plugins/sonarqube/dev/index.tsx
@@ -0,0 +1,20 @@
+/*
+ * Copyright 2020 Spotify AB
+ *
+ * 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 { createDevApp } from '@backstage/dev-utils';
+import { plugin } from '../src/plugin';
+
+createDevApp().registerPlugin(plugin).render();
diff --git a/plugins/sonarqube/docs/sonar-card.png b/plugins/sonarqube/docs/sonar-card.png
new file mode 100644
index 0000000000..cbab02edd6
Binary files /dev/null and b/plugins/sonarqube/docs/sonar-card.png differ
diff --git a/plugins/sonarqube/package.json b/plugins/sonarqube/package.json
new file mode 100644
index 0000000000..fc14805c06
--- /dev/null
+++ b/plugins/sonarqube/package.json
@@ -0,0 +1,52 @@
+{
+ "name": "@backstage/plugin-sonarqube",
+ "version": "0.1.1-alpha.26",
+ "main": "src/index.ts",
+ "types": "src/index.ts",
+ "license": "Apache-2.0",
+ "private": false,
+ "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.1.1-alpha.26",
+ "@backstage/core": "^0.1.1-alpha.26",
+ "@backstage/theme": "^0.1.1-alpha.26",
+ "@material-ui/core": "^4.11.0",
+ "@material-ui/icons": "^4.9.1",
+ "@material-ui/lab": "4.0.0-alpha.45",
+ "@material-ui/styles": "^4.10.0",
+ "cross-fetch": "^3.0.6",
+ "rc-progress": "^3.0.0",
+ "react": "^16.13.1",
+ "react-dom": "^16.13.1",
+ "react-use": "^15.3.3"
+ },
+ "devDependencies": {
+ "@backstage/cli": "^0.1.1-alpha.26",
+ "@backstage/dev-utils": "^0.1.1-alpha.26",
+ "@backstage/test-utils": "^0.1.1-alpha.26",
+ "@testing-library/jest-dom": "^5.10.1",
+ "@testing-library/react": "^10.4.1",
+ "@testing-library/user-event": "^12.0.7",
+ "@types/jest": "^26.0.7",
+ "@types/node": "^12.0.0",
+ "cross-fetch": "^3.0.6",
+ "msw": "^0.21.2"
+ },
+ "files": [
+ "dist"
+ ]
+}
diff --git a/plugins/sonarqube/src/api/index.test.ts b/plugins/sonarqube/src/api/index.test.ts
new file mode 100644
index 0000000000..272d021493
--- /dev/null
+++ b/plugins/sonarqube/src/api/index.test.ts
@@ -0,0 +1,161 @@
+/*
+ * Copyright 2020 Spotify AB
+ *
+ * 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 { UrlPatternDiscovery } from '@backstage/core';
+import { msw } from '@backstage/test-utils';
+import { rest } from 'msw';
+import { setupServer } from 'msw/node';
+import { FindingSummary, SonarQubeApi } from './index';
+import { ComponentWrapper, MeasuresWrapper } from './types';
+
+const server = setupServer();
+
+describe('SonarQubeApi', () => {
+ msw.setupDefaultHandlers(server);
+
+ const mockBaseUrl = 'http://backstage:9191/api/proxy';
+ const discoveryApi = UrlPatternDiscovery.compile(mockBaseUrl);
+
+ const setupHandlers = () => {
+ server.use(
+ rest.get(`${mockBaseUrl}/sonarqube/components/show`, (req, res, ctx) => {
+ expect(req.url.searchParams.toString()).toBe('component=our-service');
+ return res(
+ ctx.json({
+ component: {
+ analysisDate: '2020-01-01T00:00:00Z',
+ },
+ } as ComponentWrapper),
+ );
+ }),
+ );
+
+ server.use(
+ rest.get(`${mockBaseUrl}/sonarqube/measures/search`, (req, res, ctx) => {
+ expect(req.url.searchParams.toString()).toBe(
+ 'projectKeys=our-service&metricKeys=alert_status%2Cbugs%2Creliability_rating%2Cvulnerabilities%2Csecurity_rating%2Ccode_smells%2Csqale_rating%2Ccoverage%2Cduplicated_lines_density',
+ );
+ return res(
+ ctx.json({
+ measures: [
+ {
+ metric: 'alert_status',
+ value: 'OK',
+ component: 'our-service',
+ },
+ {
+ metric: 'alert_status',
+ value: 'ERROR',
+ component: 'other-service',
+ },
+ {
+ metric: 'bugs',
+ value: '2',
+ component: 'our-service',
+ },
+ {
+ metric: 'reliability_rating',
+ value: '3.0',
+ component: 'our-service',
+ },
+ {
+ metric: 'vulnerabilities',
+ value: '4',
+ component: 'our-service',
+ },
+ {
+ metric: 'security_rating',
+ value: '1.0',
+ component: 'our-service',
+ },
+ {
+ metric: 'code_smells',
+ value: '100',
+ component: 'our-service',
+ },
+ {
+ metric: 'sqale_rating',
+ value: '2.0',
+ component: 'our-service',
+ },
+ {
+ metric: 'coverage',
+ value: '55.5',
+ component: 'our-service',
+ },
+ {
+ metric: 'duplicated_lines_density',
+ value: '1.0',
+ component: 'our-service',
+ },
+ ],
+ } as MeasuresWrapper),
+ );
+ }),
+ );
+ };
+
+ it('should report finding summary', async () => {
+ setupHandlers();
+
+ const client = new SonarQubeApi({ discoveryApi });
+
+ const summary = await client.getFindingSummary('our-service');
+
+ expect(summary).toEqual({
+ lastAnalysis: '2020-01-01T00:00:00Z',
+ metrics: {
+ alert_status: 'OK',
+ bugs: '2',
+ reliability_rating: '3.0',
+ vulnerabilities: '4',
+ security_rating: '1.0',
+ code_smells: '100',
+ sqale_rating: '2.0',
+ coverage: '55.5',
+ duplicated_lines_density: '1.0',
+ },
+ projectUrl: 'https://sonarcloud.io/dashboard?id=our-service',
+ } as FindingSummary);
+ });
+
+ it('should report finding summary (custom baseUrl)', async () => {
+ setupHandlers();
+
+ const client = new SonarQubeApi({
+ discoveryApi,
+ baseUrl: 'http://a.instance.local',
+ });
+
+ const summary = await client.getFindingSummary('our-service');
+
+ expect(summary).toEqual({
+ lastAnalysis: '2020-01-01T00:00:00Z',
+ metrics: {
+ alert_status: 'OK',
+ bugs: '2',
+ reliability_rating: '3.0',
+ vulnerabilities: '4',
+ security_rating: '1.0',
+ code_smells: '100',
+ sqale_rating: '2.0',
+ coverage: '55.5',
+ duplicated_lines_density: '1.0',
+ },
+ projectUrl: 'http://a.instance.local/dashboard?id=our-service',
+ } as FindingSummary);
+ });
+});
diff --git a/plugins/sonarqube/src/api/index.ts b/plugins/sonarqube/src/api/index.ts
new file mode 100644
index 0000000000..01f6f74fe3
--- /dev/null
+++ b/plugins/sonarqube/src/api/index.ts
@@ -0,0 +1,110 @@
+/*
+ * Copyright 2020 Spotify AB
+ *
+ * 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';
+import fetch from 'cross-fetch';
+import { ComponentWrapper, MeasuresWrapper, MetricKey } from './types';
+
+/**
+ * Define a type to make sure that all metrics are used
+ */
+type Metrics = {
+ [key in MetricKey]: string | undefined;
+};
+
+export interface FindingSummary {
+ lastAnalysis: string;
+ metrics: Metrics;
+ projectUrl: string;
+}
+
+export const sonarQubeApiRef = createApiRef({
+ id: 'plugin.sonarqube.service',
+ description: 'Used by the SonarQube plugin to make requests',
+});
+
+export class SonarQubeApi {
+ discoveryApi: DiscoveryApi;
+ baseUrl: string;
+
+ constructor({
+ discoveryApi,
+ baseUrl = 'https://sonarcloud.io/',
+ }: {
+ discoveryApi: DiscoveryApi;
+ baseUrl?: string;
+ }) {
+ this.discoveryApi = discoveryApi;
+ this.baseUrl = baseUrl.endsWith('/') ? baseUrl : `${baseUrl}/`;
+ }
+
+ private async callApi(path: string): Promise {
+ const apiUrl = `${await this.discoveryApi.getBaseUrl('proxy')}/sonarqube`;
+ const response = await fetch(`${apiUrl}/${path}`);
+ if (response.status === 200) {
+ return (await response.json()) as T;
+ }
+ return undefined;
+ }
+
+ async getFindingSummary(
+ componentKey?: string,
+ ): Promise {
+ if (!componentKey) {
+ return undefined;
+ }
+
+ const component = await this.callApi(
+ `components/show?component=${componentKey}`,
+ );
+ if (!component) {
+ return undefined;
+ }
+
+ const metrics: Metrics = {
+ alert_status: undefined,
+ bugs: undefined,
+ reliability_rating: undefined,
+ vulnerabilities: undefined,
+ security_rating: undefined,
+ code_smells: undefined,
+ sqale_rating: undefined,
+ coverage: undefined,
+ duplicated_lines_density: undefined,
+ };
+
+ const measures = await this.callApi(
+ `measures/search?projectKeys=${componentKey}&metricKeys=${Object.keys(
+ metrics,
+ ).join(',')}`,
+ );
+ if (!measures) {
+ return undefined;
+ }
+
+ measures.measures
+ .filter(m => m.component === componentKey)
+ .forEach(m => {
+ metrics[m.metric] = m.value;
+ });
+
+ return {
+ lastAnalysis: component.component.analysisDate,
+ metrics,
+ projectUrl: `${this.baseUrl}dashboard?id=${componentKey}`,
+ };
+ }
+}
diff --git a/plugins/sonarqube/src/api/types.ts b/plugins/sonarqube/src/api/types.ts
new file mode 100644
index 0000000000..17539070cb
--- /dev/null
+++ b/plugins/sonarqube/src/api/types.ts
@@ -0,0 +1,55 @@
+/*
+ * Copyright 2020 Spotify AB
+ *
+ * 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 ComponentWrapper {
+ component: Component;
+}
+
+export interface Component {
+ analysisDate: string;
+}
+
+export interface MeasuresWrapper {
+ measures: Measure[];
+}
+
+export type MetricKey =
+ // alert status
+ | 'alert_status'
+
+ // bugs and rating (-> reliability)
+ | 'bugs'
+ | 'reliability_rating'
+
+ // vulnerabilities and rating (-> security)
+ | 'vulnerabilities'
+ | 'security_rating'
+
+ // code smells and rating (-> maintainability)
+ | 'code_smells'
+ | 'sqale_rating'
+
+ // coverage
+ | 'coverage'
+
+ // duplicated lines
+ | 'duplicated_lines_density';
+
+export interface Measure {
+ metric: MetricKey;
+ value: string;
+ component: string;
+}
diff --git a/plugins/sonarqube/src/components/SonarQubeCard/Percentage.tsx b/plugins/sonarqube/src/components/SonarQubeCard/Percentage.tsx
new file mode 100644
index 0000000000..9d1fc415ff
--- /dev/null
+++ b/plugins/sonarqube/src/components/SonarQubeCard/Percentage.tsx
@@ -0,0 +1,45 @@
+/*
+ * Copyright 2020 Spotify AB
+ *
+ * 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 { BackstageTheme } from '@backstage/theme';
+import { makeStyles } from '@material-ui/core/styles';
+import { useTheme } from '@material-ui/styles';
+import { Circle } from 'rc-progress';
+import React from 'react';
+
+const useStyles = makeStyles((theme: BackstageTheme) => ({
+ root: {
+ height: theme.spacing(3),
+ width: theme.spacing(3),
+ },
+}));
+
+export const Percentage = ({ value }: { value?: string }) => {
+ const classes = useStyles();
+ const theme = useTheme();
+
+ return (
+
+ );
+};
diff --git a/plugins/sonarqube/src/components/SonarQubeCard/Rating.tsx b/plugins/sonarqube/src/components/SonarQubeCard/Rating.tsx
new file mode 100644
index 0000000000..df90e2b821
--- /dev/null
+++ b/plugins/sonarqube/src/components/SonarQubeCard/Rating.tsx
@@ -0,0 +1,112 @@
+/*
+ * Copyright 2020 Spotify AB
+ *
+ * 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 { BackstageTheme } from '@backstage/theme';
+import { Avatar } from '@material-ui/core';
+import { lighten, makeStyles } from '@material-ui/core/styles';
+import { CSSProperties } from '@material-ui/styles';
+import React, { useMemo } from 'react';
+
+const useStyles = makeStyles((theme: BackstageTheme) => {
+ const commonCardRating: CSSProperties = {
+ height: theme.spacing(3),
+ width: theme.spacing(3),
+ color: theme.palette.common.white,
+ };
+
+ return {
+ ratingDefault: {
+ ...commonCardRating,
+ background: theme.palette.status.aborted,
+ },
+ ratingA: {
+ ...commonCardRating,
+ background: theme.palette.status.ok,
+ },
+ ratingB: {
+ ...commonCardRating,
+ background: lighten(theme.palette.status.ok, 0.5),
+ },
+ ratingC: {
+ ...commonCardRating,
+ background: theme.palette.status.pending,
+ },
+ ratingD: {
+ ...commonCardRating,
+ background: theme.palette.status.warning,
+ },
+ ratingE: {
+ ...commonCardRating,
+ background: theme.palette.error.main,
+ },
+ };
+});
+
+export const Rating = ({
+ rating,
+ hideValue,
+}: {
+ rating?: string;
+ hideValue?: boolean;
+}) => {
+ const classes = useStyles();
+
+ const ratingProp = useMemo(() => {
+ switch (rating) {
+ case '1.0':
+ return {
+ name: 'A',
+ className: classes.ratingA,
+ };
+
+ case '2.0':
+ return {
+ name: 'B',
+ className: classes.ratingB,
+ };
+
+ case '3.0':
+ return {
+ name: 'C',
+ className: classes.ratingC,
+ };
+
+ case '4.0':
+ return {
+ name: 'D',
+ className: classes.ratingD,
+ };
+
+ case '5.0':
+ return {
+ name: 'E',
+ className: classes.ratingE,
+ };
+
+ default:
+ return {
+ name: '',
+ className: classes.ratingDefault,
+ };
+ }
+ }, [classes, rating]);
+
+ return (
+
+ {!hideValue && ratingProp.name}
+
+ );
+};
diff --git a/plugins/sonarqube/src/components/SonarQubeCard/RatingCard.tsx b/plugins/sonarqube/src/components/SonarQubeCard/RatingCard.tsx
new file mode 100644
index 0000000000..cd678b7366
--- /dev/null
+++ b/plugins/sonarqube/src/components/SonarQubeCard/RatingCard.tsx
@@ -0,0 +1,78 @@
+/*
+ * Copyright 2020 Spotify AB
+ *
+ * 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 { Grid, Typography } from '@material-ui/core';
+import { makeStyles } from '@material-ui/core/styles';
+import React, { ReactNode } from 'react';
+
+const useStyles = makeStyles(theme => {
+ return {
+ root: {
+ margin: theme.spacing(1, 0),
+ },
+ upper: {
+ display: 'flex',
+ justifyContent: 'center',
+ alignItems: 'center',
+ },
+ cardTitle: {
+ textAlign: 'center',
+ },
+ wrapIcon: {
+ display: 'inline-flex',
+ verticalAlign: 'baseline',
+ },
+ left: {
+ display: 'flex',
+ },
+ right: {
+ display: 'flex',
+ marginLeft: theme.spacing(0.5),
+ },
+ };
+});
+
+export const RatingCard = ({
+ leftSlot,
+ rightSlot,
+ title,
+ titleIcon,
+}: {
+ leftSlot: ReactNode;
+ rightSlot: ReactNode;
+ title: string;
+ titleIcon?: ReactNode;
+}) => {
+ const classes = useStyles();
+
+ return (
+
+
+
+ {leftSlot}
+
+
+ {rightSlot}
+
+
+
+
+ {titleIcon} {title}
+
+
+
+ );
+};
diff --git a/plugins/sonarqube/src/components/SonarQubeCard/SonarQubeCard.tsx b/plugins/sonarqube/src/components/SonarQubeCard/SonarQubeCard.tsx
new file mode 100644
index 0000000000..8e6cc817a1
--- /dev/null
+++ b/plugins/sonarqube/src/components/SonarQubeCard/SonarQubeCard.tsx
@@ -0,0 +1,230 @@
+/*
+ * Copyright 2020 Spotify AB
+ *
+ * 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';
+import {
+ EmptyState,
+ InfoCard,
+ MissingAnnotationEmptyState,
+ Progress,
+ useApi,
+} from '@backstage/core';
+import { Chip, Grid } from '@material-ui/core';
+import { makeStyles } from '@material-ui/core/styles';
+import BugReport from '@material-ui/icons/BugReport';
+import LockOpen from '@material-ui/icons/LockOpen';
+import SentimentVeryDissatisfied from '@material-ui/icons/SentimentVeryDissatisfied';
+import React, { useMemo } from 'react';
+import { useAsync } from 'react-use';
+import { sonarQubeApiRef } from '../../api';
+import {
+ SONARQUBE_PROJECT_KEY_ANNOTATION,
+ useProjectKey,
+} from '../useProjectKey';
+import { Percentage } from './Percentage';
+import { Rating } from './Rating';
+import { RatingCard } from './RatingCard';
+import { Value } from './Value';
+
+const useStyles = makeStyles(theme => ({
+ badgeLabel: {
+ color: theme.palette.common.white,
+ },
+ badgeError: {
+ margin: 0,
+ backgroundColor: theme.palette.error.main,
+ },
+ badgeSuccess: {
+ margin: 0,
+ backgroundColor: theme.palette.success.main,
+ },
+ header: {
+ padding: theme.spacing(2, 2, 2, 2.5),
+ },
+ action: {
+ margin: 0,
+ },
+ lastAnalyzed: {
+ color: theme.palette.text.secondary,
+ },
+ disabled: {
+ backgroundColor: theme.palette.background.default,
+ },
+}));
+
+interface DuplicationRating {
+ greaterThan: number;
+ rating: '1.0' | '2.0' | '3.0' | '4.0' | '5.0';
+}
+
+const defaultDuplicationRatings: DuplicationRating[] = [
+ { greaterThan: 0, rating: '1.0' },
+ { greaterThan: 3, rating: '2.0' },
+ { greaterThan: 5, rating: '3.0' },
+ { greaterThan: 10, rating: '4.0' },
+ { greaterThan: 20, rating: '5.0' },
+];
+
+export const SonarQubeCard = ({
+ entity,
+ variant = 'gridItem',
+ duplicationRatings = defaultDuplicationRatings,
+}: {
+ entity: Entity;
+ variant?: string;
+ duplicationRatings?: DuplicationRating[];
+}) => {
+ const sonarQubeApi = useApi(sonarQubeApiRef);
+
+ const projectTitle = useProjectKey(entity);
+
+ const { value, loading } = useAsync(
+ async () => sonarQubeApi.getFindingSummary(projectTitle),
+ [sonarQubeApi, projectTitle],
+ );
+
+ const deepLink =
+ !loading && value
+ ? {
+ title: 'View more',
+ link: value.projectUrl,
+ }
+ : undefined;
+
+ const gatePassed = value && value.metrics.alert_status === 'OK';
+
+ const classes = useStyles();
+
+ const qualityBadge = !loading && value && (
+
+ );
+
+ const duplicationRating = useMemo(() => {
+ if (loading || !value || !value.metrics.duplicated_lines_density) {
+ return '';
+ }
+
+ let rating = '';
+
+ for (const r of duplicationRatings) {
+ if (+value.metrics.duplicated_lines_density >= r.greaterThan) {
+ rating = r.rating;
+ }
+ }
+
+ return rating;
+ }, [loading, value, duplicationRatings]);
+
+ return (
+
+ {loading && }
+
+ {!loading && !projectTitle && (
+
+ )}
+
+ {!loading && projectTitle && !value && (
+
+ )}
+
+ {!loading && value && (
+ <>
+
+
+ }
+ title="Bugs"
+ leftSlot={}
+ rightSlot={}
+ />
+ }
+ title="Vulnerabilities"
+ leftSlot={}
+ rightSlot={}
+ />
+ }
+ title="Code Smells"
+ leftSlot={}
+ rightSlot={}
+ />
+
+ }
+ rightSlot={}
+ />
+ }
+ rightSlot={
+
+ }
+ />
+
+
+ Last analyzed on{' '}
+ {new Date(value.lastAnalysis).toLocaleString('en-US', {
+ timeZone: 'UTC',
+ day: 'numeric',
+ month: 'short',
+ year: 'numeric',
+ hour: '2-digit',
+ minute: '2-digit',
+ hour12: false,
+ })}
+
+
+ >
+ )}
+
+ );
+};
diff --git a/plugins/sonarqube/src/components/SonarQubeCard/Value.tsx b/plugins/sonarqube/src/components/SonarQubeCard/Value.tsx
new file mode 100644
index 0000000000..e7a5492e68
--- /dev/null
+++ b/plugins/sonarqube/src/components/SonarQubeCard/Value.tsx
@@ -0,0 +1,34 @@
+/*
+ * Copyright 2020 Spotify AB
+ *
+ * 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 { BackstageTheme } from '@backstage/theme';
+import { makeStyles } from '@material-ui/core/styles';
+import React from 'react';
+
+const useStyles = makeStyles((theme: BackstageTheme) => {
+ return {
+ value: {
+ fontSize: '1.5rem',
+ fontWeight: theme.typography.fontWeightMedium,
+ },
+ };
+});
+
+export const Value = ({ value }: { value?: string }) => {
+ const classes = useStyles();
+
+ return {value};
+};
diff --git a/plugins/sonarqube/src/components/SonarQubeCard/index.ts b/plugins/sonarqube/src/components/SonarQubeCard/index.ts
new file mode 100644
index 0000000000..e349f1e1f9
--- /dev/null
+++ b/plugins/sonarqube/src/components/SonarQubeCard/index.ts
@@ -0,0 +1,17 @@
+/*
+ * Copyright 2020 Spotify AB
+ *
+ * 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 { SonarQubeCard } from './SonarQubeCard';
diff --git a/plugins/sonarqube/src/components/index.ts b/plugins/sonarqube/src/components/index.ts
new file mode 100644
index 0000000000..8f0786bb8e
--- /dev/null
+++ b/plugins/sonarqube/src/components/index.ts
@@ -0,0 +1,17 @@
+/*
+ * Copyright 2020 Spotify AB
+ *
+ * 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 './SonarQubeCard';
diff --git a/plugins/sonarqube/src/components/useProjectKey.ts b/plugins/sonarqube/src/components/useProjectKey.ts
new file mode 100644
index 0000000000..dcff79d972
--- /dev/null
+++ b/plugins/sonarqube/src/components/useProjectKey.ts
@@ -0,0 +1,23 @@
+/*
+ * Copyright 2020 Spotify AB
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import { Entity } from '@backstage/catalog-model';
+
+export const SONARQUBE_PROJECT_KEY_ANNOTATION = 'sonarqube.org/project-key';
+
+export const useProjectKey = (entity: Entity) => {
+ return entity?.metadata.annotations?.[SONARQUBE_PROJECT_KEY_ANNOTATION] ?? '';
+};
diff --git a/plugins/sonarqube/src/index.ts b/plugins/sonarqube/src/index.ts
new file mode 100644
index 0000000000..f09aeb1038
--- /dev/null
+++ b/plugins/sonarqube/src/index.ts
@@ -0,0 +1,18 @@
+/*
+ * Copyright 2020 Spotify AB
+ *
+ * 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 './components';
+export { plugin } from './plugin';
diff --git a/plugins/sonarqube/src/plugin.test.ts b/plugins/sonarqube/src/plugin.test.ts
new file mode 100644
index 0000000000..32730b64c3
--- /dev/null
+++ b/plugins/sonarqube/src/plugin.test.ts
@@ -0,0 +1,23 @@
+/*
+ * Copyright 2020 Spotify AB
+ *
+ * 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 { plugin } from './plugin';
+
+describe('sonarqube', () => {
+ it('should export plugin', () => {
+ expect(plugin).toBeDefined();
+ });
+});
diff --git a/plugins/sonarqube/src/plugin.ts b/plugins/sonarqube/src/plugin.ts
new file mode 100644
index 0000000000..154d4ee3b8
--- /dev/null
+++ b/plugins/sonarqube/src/plugin.ts
@@ -0,0 +1,38 @@
+/*
+ * Copyright 2020 Spotify AB
+ *
+ * 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 {
+ configApiRef,
+ createApiFactory,
+ createPlugin,
+ discoveryApiRef,
+} from '@backstage/core';
+import { SonarQubeApi, sonarQubeApiRef } from './api';
+
+export const plugin = createPlugin({
+ id: 'sonarqube',
+ apis: [
+ createApiFactory({
+ api: sonarQubeApiRef,
+ deps: { configApi: configApiRef, discoveryApi: discoveryApiRef },
+ factory: ({ configApi, discoveryApi }) =>
+ new SonarQubeApi({
+ discoveryApi,
+ baseUrl: configApi.getOptionalString('sonarQube.baseUrl'),
+ }),
+ }),
+ ],
+});
diff --git a/plugins/sonarqube/src/setupTests.ts b/plugins/sonarqube/src/setupTests.ts
new file mode 100644
index 0000000000..825bcd4115
--- /dev/null
+++ b/plugins/sonarqube/src/setupTests.ts
@@ -0,0 +1,17 @@
+/*
+ * Copyright 2020 Spotify AB
+ *
+ * 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';