diff --git a/plugins/sonarqube-issues/.eslintignore b/plugins/sonarqube-issues/.eslintignore new file mode 100644 index 0000000..b947077 --- /dev/null +++ b/plugins/sonarqube-issues/.eslintignore @@ -0,0 +1,2 @@ +node_modules/ +dist/ diff --git a/plugins/sonarqube-issues/.eslintrc.js b/plugins/sonarqube-issues/.eslintrc.js new file mode 100644 index 0000000..fc0ef33 --- /dev/null +++ b/plugins/sonarqube-issues/.eslintrc.js @@ -0,0 +1,29 @@ +module.exports = { + env: { + browser: true, + es2021: true, + }, + extends: [ + "plugin:react/jsx-runtime", + "plugin:react-hooks/recommended", + "standard-with-typescript", + "prettier", + ], + overrides: [], + parserOptions: { + ecmaVersion: "latest", + project: "tsconfig.json", + sourceType: "module", + tsconfigRootDir: __dirname, + }, + plugins: ["react"], + rules: { + // conflicts with no-extra-boolean-cast + "@typescript-eslint/strict-boolean-expressions": "off", + }, + settings: { + react: { + version: "detect", + }, + }, +}; diff --git a/plugins/sonarqube-issues/.gitignore b/plugins/sonarqube-issues/.gitignore new file mode 100644 index 0000000..4c577a9 --- /dev/null +++ b/plugins/sonarqube-issues/.gitignore @@ -0,0 +1,12 @@ +# OSX +*.DS_Store + +# IDEs +.idea +*.iml +.vscode + +# This project +node_modules/ +dist/ +yarn-error.log diff --git a/plugins/sonarqube-issues/.prettierignore b/plugins/sonarqube-issues/.prettierignore new file mode 100644 index 0000000..b947077 --- /dev/null +++ b/plugins/sonarqube-issues/.prettierignore @@ -0,0 +1,2 @@ +node_modules/ +dist/ diff --git a/plugins/sonarqube-issues/README.md b/plugins/sonarqube-issues/README.md new file mode 100644 index 0000000..e962cab --- /dev/null +++ b/plugins/sonarqube-issues/README.md @@ -0,0 +1,133 @@ +# SonarQube Issues Cortex Plugin + +View SonarQube Issues associated with your services!! + +
+ +The SonarQube Issues plugin shows open SonarQube issues associated with the SonarQube project specified in the entity's `cortex.yaml`. If the `cortex.yaml` has a [SonarQube Project key](https://docs.cortex.io/docs/reference/integrations/sonarqube#entity-descriptor) defined in its `x-cortex-static-analysis` configuration, it will query for issues pertaining to that project. For example: + +```yaml +openapi: 3.0.1 +info: + title: Funrepo + description: it is a fun repo + x-cortex-git: + github: + alias: cortex + repository: martindstone-org/funrepo + x-cortex-tag: funrepo + x-cortex-type: service + x-cortex-static-analysis: + sonarqube: + project: martindstone-org_funrepo +``` + +## Setup + +This plugin requires a proxy to SonarQube. To set up: + +- Create a token in SonarQube by clicking on your profile > My Account > Security +- In Cortex, define a secret whose value is your new token. Name it `sonarqube_plugin`. +- Create a proxy: + + - Navigate to Plugins, then click on the Proxies tab, then click on Create Proxy + - Give the proxy a name, like SonarQube Proxy, then click on Add URL. + - For the URL Prefix, type in the API base URL of your SonarQube instance. The default for cloud is `https://sonarcloud.io`. **If you are self-hosting SonarQube, you will have to put in your own base URL instead.** + - Click on Add Header and add a header whose name is `Authorization` and whose value is `Bearer {{{secrets.sonarqube_plugin}}}` (include the curly braces!) + +- Once you are done, the proxy should look like the below: + +
+ +### Self-Hosted setup + +The plugin uses `https://sonarcloud.io` as its default API base URL. If you are self-hosting Sonarqube, then you will have a different URL. To configure the plugin to use that URL, you can create a Sonarqube plugin configuration entity in Cortex with your own API base URL. + +- Consider creating a new entity type, so that any existing scorecards are not affected by ths configuration entity. In this example, we have created a new entity type called `plugin-configuration` +- Create a new entity with the tag `sonarqube-plugin-config` +- Set `x-cortex-definition.sonarqube-url` to the value of your ServiceNow Instance URL. For example, if my Sonarqube API base URL was `https://sonarqube.martindstone.com`, my `sonarqube-plugin-config` entity would look like this: + +```yaml +openapi: 3.0.1 +info: + title: Sonarqube Plugin Config + description: "" + x-cortex-tag: sonarqube-plugin-config + x-cortex-type: plugin-configuration + x-cortex-definition: + sonarqube-url: https://sonarqube.martindstone.com +``` + +Now, you can build and add the plugin. + +- Build the plugin: + - Make sure you have npm or yarn. + - In your terminal, in the `sonarqube-issues` directory, type `yarn` or `npm install` to install the dependencies; then type `npm run build` or `yarn build` to build the plugin. +- The compiled plugin will be created in `dist/ui.html`. +- In Plugins > All, click **Register Plugin**. +- Give the plugin a name, like SonarQube Issues. This is the name users will see in the plugin listing. +- Under **Associated Proxy**, choose the proxy you just created. +- Under **Plugin Context**, click on Add another context; choose Selection type: Include, and Entity types: service. +- This plugin does not work in the Global context. Turn off the switch labeled **Include in global context**. +- In The **Plugin code** section, upload the `dist/ui.html` file you just built. +- Click on **Save plugin**. + +### Create a plugin configuration entity (self-hosted only) + +This plugin will connect to SonarQube's cloud instance out of the box, so if you are using SonarQube in the cloud, you should skip this step. If you are self-hosting SonarQube and need to direct the plugin to a different REST API endpoint, create a plugin configuration entity with your SonarQube REST API base URL as follows: + +- Consider creating a new entity type, so that any existing scorecards are not affected by this configuration entity. In this example, we have created a new entity type called `plugin-configuration` +- Create a new entity with the tag `sonarqube-plugin-config` +- Set `x-cortex-definition.sonarqube-url` to the value of your SonarQube API base URL. For example, if my SonarQube API base URL was `https://sonarqube.martindstone.com`, my `sonarqube-plugin-config` entity would look like this: + +``` +openapi: 3.0.1 +info: + title: SonarQube Plugin Config + description: "" + x-cortex-tag: sonarqube-plugin-config + x-cortex-type: plugin-configuration + x-cortex-definition: + sonarqube-url: https://sonarqube.martindstone.com +``` + +Now, when you navigate to a Cortex service that has a SonarQube project associated with it, you should be able to click on Plugins > SonarQube Issues and see the SonarQube Issues associated with the project that is linked to the service. + +## Troubleshooting + +### Getting a message that "No SonarQube details were found for this entity" + +If you get the following message: + +
+ +This means that the plugin did not find a SonarQube project defined as described [here](https://docs.cortex.io/docs/reference/integrations/sonarqube#entity-descriptor). + +# Setting up your dev environment + +SonarQube Issues Cortex Plugin is a [Cortex](https://www.cortex.io/) plugin. To see how to run the plugin inside of Cortex, see [our docs](https://docs.cortex.io/docs/plugins). + +### Prerequisites + +Developing and building this plugin requires either [yarn](https://classic.yarnpkg.com/lang/en/docs/install/) or [npm](https://docs.npmjs.com/downloading-and-installing-node-js-and-npm). + +## Getting started + +1. Run `yarn` or `npm install` to download all dependencies +2. Run `yarn build` or `npm run build` to compile the plugin code into `./dist/ui.html` +3. Upload `ui.html` into Cortex on a create or edit plugin page +4. Add or update the code and repeat steps 2-3 as necessary + +### Notable scripts + +The following commands come pre-configured in this repository. You can see all available commands in the `scripts` section of [package.json](./package.json). They can be run with npm via `npm run {script_name}` or with yarn via `yarn {script_name}`, depending on your package manager preference. For instance, the `build` command can be run with `npm run build` or `yarn build`. + +- `build` - compiles the plugin. The compiled code root is `./src/index.tsx` (or as defined by [webpack.config.js](webpack.config.js)) and the output is generated into `dist/ui.html`. +- `test` - runs all tests defined in the repository using [jest](https://jestjs.io/) +- `lint` - runs lint and format checking on the repository using [prettier](https://prettier.io/) and [eslint](https://eslint.org/) +- `lintfix` - runs eslint in fix mode to fix any linting errors that can be fixed automatically +- `formatfix` - runs Prettier in fix mode to fix any formatting errors that can be fixed automatically + +### Available React components + +See available UI components via our [Storybook](https://cortexapps.github.io/plugin-core/). diff --git a/plugins/sonarqube-issues/__mocks__/fileMock.js b/plugins/sonarqube-issues/__mocks__/fileMock.js new file mode 100644 index 0000000..0a445d0 --- /dev/null +++ b/plugins/sonarqube-issues/__mocks__/fileMock.js @@ -0,0 +1 @@ +module.exports = "test-file-stub"; diff --git a/plugins/sonarqube-issues/__mocks__/styleMock.js b/plugins/sonarqube-issues/__mocks__/styleMock.js new file mode 100644 index 0000000..f053ebf --- /dev/null +++ b/plugins/sonarqube-issues/__mocks__/styleMock.js @@ -0,0 +1 @@ +module.exports = {}; diff --git a/plugins/sonarqube-issues/babel.config.js b/plugins/sonarqube-issues/babel.config.js new file mode 100644 index 0000000..1442fdf --- /dev/null +++ b/plugins/sonarqube-issues/babel.config.js @@ -0,0 +1,8 @@ +module.exports = { + plugins: ["@babel/plugin-syntax-jsx"], + presets: [ + ["@babel/preset-env", { targets: { node: "current" } }], + "@babel/preset-typescript", + ["@babel/preset-react", { runtime: "automatic" }], + ], +}; diff --git a/plugins/sonarqube-issues/img/sonarqube-no-project.png b/plugins/sonarqube-issues/img/sonarqube-no-project.png new file mode 100644 index 0000000..1c69e17 Binary files /dev/null and b/plugins/sonarqube-issues/img/sonarqube-no-project.png differ diff --git a/plugins/sonarqube-issues/img/sonarqube-plugin.png b/plugins/sonarqube-issues/img/sonarqube-plugin.png new file mode 100644 index 0000000..67fa08f Binary files /dev/null and b/plugins/sonarqube-issues/img/sonarqube-plugin.png differ diff --git a/plugins/sonarqube-issues/img/sonarqube-proxy.png b/plugins/sonarqube-issues/img/sonarqube-proxy.png new file mode 100644 index 0000000..fbfc3ab Binary files /dev/null and b/plugins/sonarqube-issues/img/sonarqube-proxy.png differ diff --git a/plugins/sonarqube-issues/img/sonarqube-token.png b/plugins/sonarqube-issues/img/sonarqube-token.png new file mode 100644 index 0000000..44805f6 Binary files /dev/null and b/plugins/sonarqube-issues/img/sonarqube-token.png differ diff --git a/plugins/sonarqube-issues/jest.config.js b/plugins/sonarqube-issues/jest.config.js new file mode 100644 index 0000000..e7cc6d7 --- /dev/null +++ b/plugins/sonarqube-issues/jest.config.js @@ -0,0 +1,18 @@ +module.exports = { + moduleNameMapper: { + // map static asset imports to a stub file under the assumption they are not important to our tests + "\\.(jpg|jpeg|png|gif|eot|otf|webp|svg|ttf|woff|woff2|mp4|webm|wav|mp3|m4a|aac|oga)$": + "/__mocks__/fileMock.js", + // map style asset imports to a stub file under the assumption they are not important to our tests + "\\.(css|less)$": "/__mocks__/styleMock.js", + "@cortexapps/plugin-core/components": + "/../../node_modules/@cortexapps/plugin-core/dist/components.cjs.js", + "@cortexapps/plugin-core": + "/../../node_modules/@cortexapps/plugin-core/dist/index.cjs.js", + }, + setupFilesAfterEnv: ["/setupTests.ts"], + testEnvironment: "jsdom", + transform: { + "^.+\\.tsx?$": "babel-jest", + }, +}; diff --git a/plugins/sonarqube-issues/package.json b/plugins/sonarqube-issues/package.json new file mode 100644 index 0000000..84f2386 --- /dev/null +++ b/plugins/sonarqube-issues/package.json @@ -0,0 +1,66 @@ +{ + "name": "SonarQube-Issues-Cortex-Plugin", + "version": "0.1.0", + "license": "MIT", + "dependencies": { + "@chakra-ui/react": "2", + "@cortexapps/plugin-core": "^2.0.0", + "@emotion/react": "^11.13.3", + "@emotion/styled": "^11.13.0", + "framer-motion": "^11.11.17", + "react": "^18.2.0", + "react-dom": "^18.2.0" + }, + "devDependencies": { + "@babel/core": "^7.21.3", + "@babel/plugin-syntax-jsx": "^7.18.6", + "@babel/preset-env": "^7.20.2", + "@babel/preset-react": "^7.18.6", + "@babel/preset-typescript": "^7.21.0", + "@popperjs/core": "^2.11.8", + "@testing-library/jest-dom": "^5.16.5", + "@testing-library/react": "^14.0.0", + "@types/react": "^18.0.28", + "@types/react-dom": "^18.0.11", + "@typescript-eslint/eslint-plugin": "^5.0.0", + "@typescript-eslint/parser": "^5.55.0", + "babel-jest": "^29.5.0", + "css-loader": "^6.7.3", + "eslint": "^8.0.1", + "eslint-config-prettier": "^8.7.0", + "eslint-config-standard-with-typescript": "^34.0.0", + "eslint-plugin-import": "^2.25.2", + "eslint-plugin-n": "^15.6.1", + "eslint-plugin-promise": "^6.0.0", + "eslint-plugin-react": "^7.32.2", + "eslint-plugin-react-hooks": "^4.6.0", + "html-webpack-plugin": "^5.5.0", + "jest": "^29.5.0", + "jest-environment-jsdom": "^29.5.0", + "jest-fetch-mock": "^3.0.3", + "npm-run-all": "^4.1.5", + "prettier": "^2.8.4", + "prop-types": "^15.8.1", + "react-dev-utils": "^12.0.1", + "style-loader": "^3.3.1", + "terser-webpack-plugin": "^5.3.7", + "ts-loader": "^9.4.2", + "typescript": "^4.9.5", + "url-loader": "^4.1.1", + "webpack": "^5.76.1", + "webpack-cli": "^5.0.1", + "webpack-dev-server": "^4.15.0" + }, + "scripts": { + "build": "webpack --mode=production", + "clean": "rm -r ./dist", + "dev": "webpack serve --mode=development", + "fix": "run-p formatfix lintfix", + "formatfix": "yarn prettier . --write", + "formatcheck": "yarn prettier . --check", + "lint": "run-p formatcheck lintcheck", + "lintcheck": "yarn eslint src", + "lintfix": "yarn lintcheck --fix", + "test": "jest" + } +} diff --git a/plugins/sonarqube-issues/setupTests.ts b/plugins/sonarqube-issues/setupTests.ts new file mode 100644 index 0000000..fa0a10d --- /dev/null +++ b/plugins/sonarqube-issues/setupTests.ts @@ -0,0 +1,60 @@ +import "@testing-library/jest-dom/extend-expect"; +import fetchMock from "jest-fetch-mock"; + +fetchMock.enableMocks(); + +const mockContext = { + apiBaseUrl: "https://api.getcortexapp.com", + entity: { + definition: null, + description: null, + groups: null, + name: "Inventory planner", + ownership: { + emails: [ + { + description: null, + email: "nikhil@cortex.io", + inheritance: null, + id: 1, + }, + ], + }, + tag: "inventory-planner", + type: "service", + }, + location: "ENTITY", + user: { + email: "ganesh@cortex.io", + name: "Ganesh Datta", + role: "ADMIN", + }, +}; + +jest.mock("@cortexapps/plugin-core/components", () => { + const originalModule = jest.requireActual( + "@cortexapps/plugin-core/components" + ); + return { + ...originalModule, + usePluginContext: () => { + return mockContext; + }, + PluginProvider: ({ children }) => { + return children; + }, + }; +}); + +jest.mock("@cortexapps/plugin-core", () => { + const originalModule = jest.requireActual("@cortexapps/plugin-core"); + return { + ...originalModule, + CortexApi: { + ...originalModule.CortexApi, + getContext: () => { + return mockContext; + }, + }, + }; +}); diff --git a/plugins/sonarqube-issues/src/api/Cortex.ts b/plugins/sonarqube-issues/src/api/Cortex.ts new file mode 100644 index 0000000..01a0d12 --- /dev/null +++ b/plugins/sonarqube-issues/src/api/Cortex.ts @@ -0,0 +1,8 @@ +export const getEntityYaml = async ( + baseUrl: string, + entityTag: string +): Promise> => { + const res = await fetch(`${baseUrl}/catalog/${entityTag}/openapi`); + + return await res.json(); +}; diff --git a/plugins/sonarqube-issues/src/baseStyles.css b/plugins/sonarqube-issues/src/baseStyles.css new file mode 100644 index 0000000..e9c5e5f --- /dev/null +++ b/plugins/sonarqube-issues/src/baseStyles.css @@ -0,0 +1,3 @@ +body { + font: 14px sans-serif; +} diff --git a/plugins/sonarqube-issues/src/components/App.test.tsx b/plugins/sonarqube-issues/src/components/App.test.tsx new file mode 100644 index 0000000..09a0ad9 --- /dev/null +++ b/plugins/sonarqube-issues/src/components/App.test.tsx @@ -0,0 +1,15 @@ +import { render } from "@testing-library/react"; +import App from "./App"; +import { waitForLoading } from "../../../testUtils/testUtils"; + +fetchMock.mockResponse(JSON.stringify({})); +describe("App", () => { + it("verifies that the plugin works", async () => { + render(); + + expect(fetch).toHaveBeenCalledWith( + "https://api.getcortexapp.com/catalog/inventory-planner/openapi" + ); + await waitForLoading(); + }); +}); diff --git a/plugins/sonarqube-issues/src/components/App.tsx b/plugins/sonarqube-issues/src/components/App.tsx new file mode 100644 index 0000000..7638ea1 --- /dev/null +++ b/plugins/sonarqube-issues/src/components/App.tsx @@ -0,0 +1,84 @@ +import type React from "react"; +import { Box, PluginProvider } from "@cortexapps/plugin-core/components"; +import { ChakraProvider } from "@chakra-ui/react"; +import "../baseStyles.css"; +import ErrorBoundary from "./ErrorBoundary"; +import CortexEntity from "./EntityInfo"; + +const App: React.FC = () => { + return ( + + + + + + +
+ +
+
+
+
+ ); +}; + +export default App; diff --git a/plugins/sonarqube-issues/src/components/EntityInfo.test.tsx b/plugins/sonarqube-issues/src/components/EntityInfo.test.tsx new file mode 100644 index 0000000..7c256eb --- /dev/null +++ b/plugins/sonarqube-issues/src/components/EntityInfo.test.tsx @@ -0,0 +1,24 @@ +import { render, screen, waitFor } from "@testing-library/react"; +import EntityInfo from "./EntityInfo"; + +describe("EntityInfo", () => { + it("shows message when no SonarQube info found", async () => { + fetchMock.mockIf( + /^https:\/\/api\.getcortexapp\.com\/catalog\/.*/, + async (_req: Request) => { + return await Promise.resolve( + JSON.stringify({ + info: {}, + }) + ); + } + ); + render(); + + await waitFor(() => { + expect( + screen.queryByText("No SonarQube details were found for this service") + ).toBeInTheDocument(); + }); + }); +}); diff --git a/plugins/sonarqube-issues/src/components/EntityInfo.tsx b/plugins/sonarqube-issues/src/components/EntityInfo.tsx new file mode 100644 index 0000000..8db73e1 --- /dev/null +++ b/plugins/sonarqube-issues/src/components/EntityInfo.tsx @@ -0,0 +1,56 @@ +import { isEmpty, isNil } from "lodash"; +import { useCallback, useEffect, useState } from "react"; +import type React from "react"; +import { getEntityYaml } from "../api/Cortex"; +import { + Box, + Stack, + Text, + usePluginContext, +} from "@cortexapps/plugin-core/components"; +import SonarqubeIssues from "./SonarqubeIssues"; + +const CortexEntity: React.FC = () => { + const context = usePluginContext(); + const [entityYaml, setEntityYaml] = useState< + Record | undefined + >(); + + const fetchEntityYaml = useCallback(async () => { + const entityTag = context.entity?.tag; + + if (!isNil(entityTag)) { + const yaml = await getEntityYaml(context.apiBaseUrl, entityTag); + setEntityYaml(yaml); + } + }, [context.apiBaseUrl, context.entity?.tag]); + + useEffect(() => { + void fetchEntityYaml(); + }, [fetchEntityYaml]); + + return ( +
+ {!isEmpty(entityYaml) && ( + + + {isEmpty( + entityYaml?.info?.["x-cortex-static-analysis"]?.sonarqube + ) ? ( + + + No SonarQube details were found for this{" "} + {context.entity?.type ?? "entity"} + + + ) : ( + + )} + + + )} +
+ ); +}; + +export default CortexEntity; diff --git a/plugins/sonarqube-issues/src/components/ErrorBoundary.tsx b/plugins/sonarqube-issues/src/components/ErrorBoundary.tsx new file mode 100644 index 0000000..f862bd3 --- /dev/null +++ b/plugins/sonarqube-issues/src/components/ErrorBoundary.tsx @@ -0,0 +1,39 @@ +import React from "react"; + +interface ErrorBoundaryProps extends React.PropsWithChildren {} + +interface ErrorBoundaryState { + hasError: boolean; +} + +class ErrorBoundary extends React.Component< + ErrorBoundaryProps, + ErrorBoundaryState +> { + public state: ErrorBoundaryState = { + hasError: false, + }; + + public static getDerivedStateFromError(_: Error): ErrorBoundaryState { + // Update state so the next render will show the fallback UI. + return { hasError: true }; + } + + public componentDidCatch(error: Error, errorInfo: React.ErrorInfo): void { + console.error("Uncaught error:", error, errorInfo); + } + + public render(): React.ReactNode { + if (this.state.hasError) { + return ( +

+ Oops! There was a runtime error. See the console for more details. +

+ ); + } + + return this.props.children; + } +} + +export default ErrorBoundary; diff --git a/plugins/sonarqube-issues/src/components/SonarQubeCommentModal.tsx b/plugins/sonarqube-issues/src/components/SonarQubeCommentModal.tsx new file mode 100644 index 0000000..b85b34a --- /dev/null +++ b/plugins/sonarqube-issues/src/components/SonarQubeCommentModal.tsx @@ -0,0 +1,51 @@ +import type React from "react"; +import { useState } from "react"; +import { Modal, Button, Input, Text } from "@cortexapps/plugin-core/components"; + +interface SonarQubeCommentModalProps { + isOpen: boolean; + toggleModal: () => void; + issue: any; + onCommentSubmit: (commentText: string) => Promise; +} + +const SonarQubeCommentModal: React.FC = ({ + isOpen, + toggleModal, + issue, + onCommentSubmit, +}) => { + const [isCommenting, setIsCommenting] = useState(false); + const [commentText, setCommentText] = useState(""); + + const handleSubmit = (): void => { + setIsCommenting(true); + void onCommentSubmit(commentText).then(() => { + setCommentText(""); + setIsCommenting(false); + toggleModal(); + }); + }; + + return ( + + + Commenting on Issue {issue.message || issue.key} + +
+ ) => { + setCommentText(event.target.value); + }} + /> +
+ +
+ ); +}; + +export default SonarQubeCommentModal; diff --git a/plugins/sonarqube-issues/src/components/SonarqubeIssues.test.tsx b/plugins/sonarqube-issues/src/components/SonarqubeIssues.test.tsx new file mode 100644 index 0000000..c163364 --- /dev/null +++ b/plugins/sonarqube-issues/src/components/SonarqubeIssues.test.tsx @@ -0,0 +1,210 @@ +import { render, waitFor } from "@testing-library/react"; +import SonarqubeIssues from "./SonarqubeIssues"; + +const issuesResp = { + total: 3, + p: 1, + ps: 100, + paging: { + pageIndex: 1, + pageSize: 100, + total: 3, + }, + effortTotal: 12, + debtTotal: 12, + issues: [ + { + key: "AZGA0FU2K5CbPgAYf4Pr", + rule: "typescript:S1854", + severity: "MAJOR", + component: "martindstone-org_funrepo:index.tsx", + project: "martindstone-org_funrepo", + line: 3, + hash: "ad1e379169a24735d922555c92f04126", + textRange: { + startLine: 3, + endLine: 3, + startOffset: 10, + endOffset: 22, + }, + flows: [], + status: "OPEN", + message: 'Remove this useless assignment to variable "secretApiKey".', + effort: "1min", + debt: "1min", + assignee: "martindstone@github", + author: "martindstone@me.com", + tags: ["cwe", "unused"], + creationDate: "2024-08-23T21:54:55+0200", + updateDate: "2024-08-26T21:21:44+0200", + type: "CODE_SMELL", + organization: "martindstone-org", + cleanCodeAttribute: "LOGICAL", + cleanCodeAttributeCategory: "INTENTIONAL", + impacts: [ + { + softwareQuality: "MAINTAINABILITY", + severity: "MEDIUM", + }, + ], + issueStatus: "OPEN", + }, + { + key: "AZGA0FU2K5CbPgAYf4Ps", + rule: "typescript:S1854", + severity: "MAJOR", + component: "martindstone-org_funrepo:index.tsx", + project: "martindstone-org_funrepo", + line: 6, + hash: "c50e075544f8218bf672958c96e2b394", + textRange: { + startLine: 6, + endLine: 6, + startOffset: 10, + endOffset: 24, + }, + flows: [], + status: "OPEN", + message: 'Remove this useless assignment to variable "unusedVariable".', + effort: "1min", + debt: "1min", + assignee: "martindstone@github", + author: "martindstone@me.com", + tags: ["cwe", "unused"], + creationDate: "2024-08-23T21:54:55+0200", + updateDate: "2024-08-26T17:11:19+0200", + type: "CODE_SMELL", + organization: "martindstone-org", + cleanCodeAttribute: "LOGICAL", + cleanCodeAttributeCategory: "INTENTIONAL", + impacts: [ + { + softwareQuality: "MAINTAINABILITY", + severity: "MEDIUM", + }, + ], + issueStatus: "OPEN", + }, + { + key: "AZGA0FU2K5CbPgAYf4Pt", + rule: "typescript:S1862", + severity: "MAJOR", + component: "martindstone-org_funrepo:index.tsx", + project: "martindstone-org_funrepo", + line: 21, + hash: "1421bf36954c21103f4a1934e5b63fa4", + textRange: { + startLine: 21, + endLine: 21, + startOffset: 15, + endOffset: 26, + }, + flows: [ + { + locations: [ + { + component: "martindstone-org_funrepo:index.tsx", + textRange: { + startLine: 19, + endLine: 19, + startOffset: 8, + endOffset: 19, + }, + msg: "Covering", + }, + ], + }, + ], + status: "OPEN", + message: "This condition is covered by the one on line 19", + effort: "10min", + debt: "10min", + assignee: "martindstone@github", + author: "martindstone@me.com", + tags: ["pitfall", "unused"], + creationDate: "2024-08-23T21:54:55+0200", + updateDate: "2024-08-24T03:12:29+0200", + type: "BUG", + organization: "martindstone-org", + cleanCodeAttribute: "LOGICAL", + cleanCodeAttributeCategory: "INTENTIONAL", + impacts: [ + { + softwareQuality: "RELIABILITY", + severity: "MEDIUM", + }, + ], + issueStatus: "OPEN", + }, + ], + components: [ + { + organization: "martindstone-org", + key: "martindstone-org_funrepo:index.tsx", + uuid: "AZGA0FJxK5CbPgAYf4Pq", + enabled: true, + qualifier: "FIL", + name: "index.tsx", + longName: "index.tsx", + path: "index.tsx", + }, + ], + organizations: [ + { + key: "martindstone-org", + name: "martindstone-org", + }, + ], + facets: [], +}; + +const serviceYaml = { + info: { + "x-cortex-static-analysis": { + sonarqube: { + project: "martindstone-org_funrepo", + }, + }, + }, +}; + +describe("Issues", () => { + it("has Issues", async () => { + fetchMock.mockIf( + /^https:\/\/sonarcloud\.io\/api\/issues/, + async (_req: Request) => { + return await Promise.resolve(JSON.stringify(issuesResp)); + } + ); + + const { queryByText, queryAllByText } = render( + + ); + await waitFor(() => { + expect(queryByText("Loading")).not.toBeInTheDocument(); + const majorElements = queryAllByText("major"); + expect(majorElements.length).toEqual(3); + const commentElements = queryAllByText("Comment"); + expect(commentElements.length).toBeGreaterThan(0); + }); + }); + + it("has no Issues", async () => { + fetchMock.mockIf( + /^https:\/\/sonarcloud\.io\/api\/issues/, + async (_req: Request) => { + return await Promise.resolve(JSON.stringify({ issues: [] })); + } + ); + const { queryByText } = render( + + ); + + await waitFor(() => { + expect(queryByText("Loading")).not.toBeInTheDocument(); + expect( + queryByText(/We could not find any Sonarqube issues/) + ).toBeInTheDocument(); + }); + }); +}); diff --git a/plugins/sonarqube-issues/src/components/SonarqubeIssues.tsx b/plugins/sonarqube-issues/src/components/SonarqubeIssues.tsx new file mode 100644 index 0000000..9799233 --- /dev/null +++ b/plugins/sonarqube-issues/src/components/SonarqubeIssues.tsx @@ -0,0 +1,229 @@ +import type React from "react"; +import { useMemo, useState, useCallback } from "react"; +import { + SimpleTable, + Box, + Text, + Loader, + usePluginContext, + Button, +} from "@cortexapps/plugin-core/components"; + +import { useToast } from "@chakra-ui/react"; + +import { useSonarQubeConfig, useSonarQubeIssues } from "../hooks"; +import SonarQubeCommentModal from "./SonarQubeCommentModal"; + +import "../baseStyles.css"; + +const getErrorMessageFromResponse = async ( + response: Response +): Promise => { + if (response.headers.get("Content-Type")?.includes("application/json")) { + try { + const json = await response.json(); + const msg: string = json.message; + return msg; + } catch (e) {} + } + return response.statusText || response.status.toString(); +}; + +interface SonarqubeIssuesProps { + entityYaml: Record; +} + +const SonarqubeIssues: React.FC = ({ entityYaml }) => { + const toast = useToast(); + const context = usePluginContext(); + + const [issueForComment, setIssueForComment] = useState(null); + const [isModalOpen, setIsModalOpen] = useState(false); + + const project = useMemo((): string => { + return entityYaml?.info?.["x-cortex-static-analysis"]?.sonarqube?.project; + }, [entityYaml?.info]); + + const { baseUrl, isLoading: isConfigLoading } = useSonarQubeConfig(); + const { + issues, + hasIssues, + isLoading: isIssuesLoading, + } = useSonarQubeIssues(baseUrl, project); + + const isLoading = isConfigLoading || isIssuesLoading; + + const openCommentModal = (issue: string): void => { + setIssueForComment(issue); + setIsModalOpen(true); + }; + + const sendComment = useCallback( + async (commentText: string) => { + const url = `${baseUrl}/api/issues/add_comment`; + + const params = new URLSearchParams(); + params.append("issue", issueForComment as string); + params.append("text", commentText); + + try { + const response = await fetch(url, { + method: "POST", + headers: { + "Content-Type": "application/x-www-form-urlencoded", + }, + body: params.toString(), + }); + + if (response.ok) { + toast({ + title: "Comment added successfully", + status: "success", + duration: 5000, + isClosable: true, + }); + } else { + const msg: string = await getErrorMessageFromResponse(response); + throw new Error(msg); + } + } catch (err) { + const msg: string = err.message || err.toString(); + toast({ + title: `Failed to add comment: ${msg}`, + status: "error", + duration: 5000, + isClosable: true, + }); + } + }, + [issueForComment, baseUrl, toast] + ); + + const issuesByKey = useMemo(() => { + if (issues && issues instanceof Array && issues.length > 0) { + return issues.reduce((acc: Record, issue: any) => { + const key = issue.key; + acc[key] = issue; + return acc; + }, {}); + } + return {}; + }, [issues]); + + const config = { + columns: [ + { + Cell: (createdAt: string) => { + const createdAtDate = new Date(createdAt); + if (!createdAtDate || isNaN(createdAtDate.getTime())) { + return ( + + {createdAt} + + ); + } + return ( + + + {createdAtDate ? createdAtDate.toLocaleString() : createdAt} + + + ); + }, + accessor: "creationDate", + id: "creationDate", + title: "Created At", + width: "20%", + }, + { + Cell: (severity: string) => ( + + {severity.toLocaleLowerCase()} + + ), + accessor: "severity", + id: "severity", + title: "Severity", + width: "10%", + }, + { + Cell: (author: string) => ( + + {author} + + ), + accessor: "author", + id: "author", + title: "Author", + width: "15%", + }, + { + Cell: (issue: any) => { + const key: string = issue.key || ""; + const project: string = issue.project || ""; + const message = issue.message || ""; + if (!key || !project || !message) { + return Issue not found; + } + const url = `${baseUrl}/project/issues?open=${key}&id=${project}`; + return ( + + + + {message} + + + + ); + }, + id: "message", + title: "Message", + width: "45%", + }, + { + Cell: (key: string) => ( + + ), + accessor: "key", + id: "comment", + title: "Comment", + width: "10%", + }, + ], + }; + + if (isLoading) { + return ; + } + + return hasIssues ? ( + <> + {issueForComment && issuesByKey[issueForComment] && ( + { + setIsModalOpen((prev) => !prev); + }} + issue={issuesByKey[issueForComment]} + onCommentSubmit={sendComment} + /> + )} + + + ) : ( + + + We could not find any Sonarqube issues associated with this{" "} + {context?.entity?.type ?? "entity"}. + + + ); +}; + +export default SonarqubeIssues; diff --git a/plugins/sonarqube-issues/src/hooks.tsx b/plugins/sonarqube-issues/src/hooks.tsx new file mode 100644 index 0000000..d52bedf --- /dev/null +++ b/plugins/sonarqube-issues/src/hooks.tsx @@ -0,0 +1,111 @@ +import { useState, useEffect } from "react"; +import { usePluginContext } from "@cortexapps/plugin-core/components"; +import { useToast } from "@chakra-ui/react"; + +const getErrorMessageFromResponse = async ( + response: Response +): Promise => { + if (response.headers.get("Content-Type")?.includes("application/json")) { + try { + const json = await response.json(); + return json.message || response.statusText || response.status.toString(); + } catch (e) { + return response.statusText || response.status.toString(); + } + } + return response.statusText || response.status.toString(); +}; + +export interface UseSonarQubeConfigReturn { + baseUrl: string; + isLoading: boolean; +} + +export interface UseSonarQubeIssuesReturn { + issues: any[]; + hasIssues: boolean; + isLoading: boolean; +} + +export const useSonarQubeConfig = (): UseSonarQubeConfigReturn => { + const context = usePluginContext(); + const [baseUrl, setBaseUrl] = useState(""); + const [isLoading, setIsLoading] = useState(true); + + useEffect(() => { + if (!context?.apiBaseUrl) { + setIsLoading(false); + return; + } + + const fetchPluginConfig = async (): Promise => { + setIsLoading(true); + let newBaseUrl = "https://sonarcloud.io"; + try { + const response = await fetch( + `${context.apiBaseUrl}/catalog/sonarqube-plugin-config/openapi` + ); + const data = await response.json(); + const baseUrlFromEntity = + data.info["x-cortex-definition"]["sonarqube-url"]; + if (baseUrlFromEntity) { + newBaseUrl = baseUrlFromEntity; + } + } catch (e) { + } finally { + setIsLoading(false); + } + setBaseUrl(newBaseUrl); + }; + void fetchPluginConfig(); + }, [context?.apiBaseUrl]); + return { baseUrl, isLoading }; +}; + +export const useSonarQubeIssues = ( + baseUrl: string, + project: string +): UseSonarQubeIssuesReturn => { + const [issues, setIssues] = useState([]); + const [hasIssues, setHasIssues] = useState(false); + const [isLoading, setIsLoading] = useState(true); + const toast = useToast(); + + useEffect(() => { + if (!project || !baseUrl) { + setIsLoading(false); + return; + } + + const fetchIssues = async (): Promise => { + setIsLoading(true); + try { + const issueUrl = `${baseUrl}/api/issues/search?componentKeys=${project}&resolved=false&s=CREATION_DATE&asc=false`; + const response = await fetch(issueUrl); + if (!response.ok) { + throw new Error(await getErrorMessageFromResponse(response)); + } + const data = await response.json(); + + if (data.issues instanceof Array && data.issues.length > 0) { + setIssues(data.issues); + setHasIssues(true); + } + } catch (err) { + const msg: string = err.message || err.toString(); + toast({ + title: `Failed to fetch issues: ${msg}`, + status: "error", + duration: null, + isClosable: true, + }); + } finally { + setIsLoading(false); + } + }; + + void fetchIssues(); + }, [baseUrl, project, toast]); + + return { issues, hasIssues, isLoading }; +}; diff --git a/plugins/sonarqube-issues/src/index.html b/plugins/sonarqube-issues/src/index.html new file mode 100644 index 0000000..dd8e7eb --- /dev/null +++ b/plugins/sonarqube-issues/src/index.html @@ -0,0 +1,10 @@ + + + + +
+ diff --git a/plugins/sonarqube-issues/src/index.tsx b/plugins/sonarqube-issues/src/index.tsx new file mode 100644 index 0000000..2a10097 --- /dev/null +++ b/plugins/sonarqube-issues/src/index.tsx @@ -0,0 +1,9 @@ +import { createRoot } from "react-dom/client"; +import App from "./components/App"; + +document.addEventListener("DOMContentLoaded", function () { + const container = document.getElementById("cortex-plugin-root"); + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + const root = createRoot(container!); + root.render(); +}); diff --git a/plugins/sonarqube-issues/src/typings.d.ts b/plugins/sonarqube-issues/src/typings.d.ts new file mode 100644 index 0000000..1a3dd3c --- /dev/null +++ b/plugins/sonarqube-issues/src/typings.d.ts @@ -0,0 +1,4 @@ +declare module "*.svg" { + const content: any; + export default content; +} diff --git a/plugins/sonarqube-issues/tsconfig.json b/plugins/sonarqube-issues/tsconfig.json new file mode 100644 index 0000000..2ebb4a8 --- /dev/null +++ b/plugins/sonarqube-issues/tsconfig.json @@ -0,0 +1,16 @@ +{ + "compilerOptions": { + "allowSyntheticDefaultImports": true, + "experimentalDecorators": true, + "jsx": "react-jsx", + "moduleResolution": "node", + "noImplicitAny": false, + "noUnusedLocals": true, + "noUnusedParameters": true, + "outDir": "dist", + "removeComments": true, + "strictNullChecks": true, + "target": "es6", + "typeRoots": ["../../node_modules/@types", "./node_modules/@types"] + } +} diff --git a/plugins/sonarqube-issues/webpack.config.js b/plugins/sonarqube-issues/webpack.config.js new file mode 100644 index 0000000..851c75f --- /dev/null +++ b/plugins/sonarqube-issues/webpack.config.js @@ -0,0 +1,69 @@ +const HtmlWebpackPlugin = require("html-webpack-plugin"); +const InlineChunkHtmlPlugin = require("react-dev-utils/InlineChunkHtmlPlugin"); +const TerserPlugin = require("terser-webpack-plugin"); +const path = require("path"); + +module.exports = (env, argv) => ({ + mode: argv.mode === "production" ? "production" : "development", + + entry: { + ui: "./src/index.tsx", // The entry point for your UI plugin + }, + + module: { + rules: [ + // Converts TypeScript code to JavaScript + { test: /\.tsx?$/, use: "ts-loader", exclude: /node_modules/ }, + + // Enables including CSS by doing "import './file.css'" in your TypeScript code + { test: /\.css$/, use: ["style-loader", { loader: "css-loader" }] }, + + // Allows you to use "<%= require('./file.svg') %>" in your HTML code to get a data URI + { test: /\.(png|jpg|gif|webp|svg)$/, loader: "url-loader" }, + ], + }, + + // minify the code + optimization: { + minimize: true, + minimizer: [ + new TerserPlugin({ + terserOptions: { + output: { + // make sure emojis don't get mangled 🙂 + ascii_only: true, + }, + }, + }), + ], + usedExports: true, + }, + + // Webpack tries these extensions for you if you omit the extension, like "import './file'" + resolve: { extensions: [".tsx", ".ts", ".jsx", ".js"] }, + + output: { + filename: "[name].js", + path: path.resolve(__dirname, "dist"), // Compile into a folder named "dist" + publicPath: "", + }, + + // Tells Webpack to generate "ui.html" and to inline "ui.ts" into it + plugins: [ + new HtmlWebpackPlugin({ + template: "./src/index.html", + filename: "ui.html", + chunks: ["ui"], + cache: false, + }), + new InlineChunkHtmlPlugin(HtmlWebpackPlugin, [/ui/]), + ], + + devServer: { + compress: true, + port: 9000, + static: { + directory: path.join(__dirname, "dist"), + }, + }, +}); diff --git a/yarn.lock b/yarn.lock index a72abc0..1ba3a62 100644 --- a/yarn.lock +++ b/yarn.lock @@ -10824,4 +10824,4 @@ zod@^3.21.4: zwitch@^2.0.0: version "2.0.4" resolved "https://registry.npmjs.org/zwitch/-/zwitch-2.0.4.tgz" - integrity sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A== + integrity sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A== \ No newline at end of file