Skip to content

Code PushUp integration guide for Nx monorepos

Katka Pilátová edited this page Feb 8, 2024 · 22 revisions

This is a guide for how to integrate Code PushUp CLI and ESLint plugin in an Nx monorepo, and how to automatically upload reports to portal's staging environment.

Warning

Only Nx 17 is supported. If your repo uses an older version, you'll need to update first - run npx nx migrate latest --interactive (confirm latest versions of TypeScript and Angular), followed by npx nx migrate --run-migrations (more info in Nx docs).

ESLint config

Code PushUp provides several recommended ESLint presets in the @code-pushup/eslint-config NPM package. It's a quick way of setting up a strict ESLint configuration, which can report a large amount of potential problems or code style suggestions in any codebase. The intention isn't to fix all the issues right away, but rather to start tracking them with Code PushUp.

Tip

The configuration and setup will differ dependending on your tech stack. One example is given below, but refer to the official @code-pushup/eslint-config docs for what other configs are available and how to set them up.

Note that you can either extend a config for your entire monorepo in the root .eslintrc.json, or only extend it in a specific project's .eslintrc.json instead. This may be useful when your monorepo is more diverse, e.g. only front-end projects would extend @code-pushup/eslint-config/angular, but a back-end project would extend @code-pushup/eslint-config/node instead, while @code-pushup/eslint-config/typescript would be extended globally.

Example for Nx monorepo using Angular, Jest and Cypress
  1. Install peer dependencies as required (for more info, see each config's setup docs):

    npm i -D eslint-plugin-{cypress,deprecation,functional@latest,jest,import,promise,rxjs,sonarjs,unicorn@48} eslint-import-resolver-typescript
  2. Install Code PushUp's ESLint config package:

    npm i -D @code-pushup/eslint-config
  3. In .eslintrc.json, extend configs:

    {
      "root": true,
      "ignorePatterns": ["**/*"],
      "plugins": ["@nx"],
      "overrides": [
        {
          "files": ["*.ts", "*.tsx", "*.js", "*.jsx"],
          "rules": {
            "@nx/enforce-module-boundaries": [
              "error",
              {
                "enforceBuildableLibDependency": true,
                "allow": [],
                "depConstraints": [
                  {
                    "sourceTag": "*",
                    "onlyDependOnLibsWithTags": ["*"]
                  }
                ]
              }
            ]
          }
        },
        {
          "files": ["*.ts", "*.tsx", ".html"], // <-- .html needed for Angular config, should also be included in project.json's lintFilePatterns
          "extends": [
            "plugin:@nx/typescript",
            // extend configs for TS files
            "@code-pushup/eslint-config/angular",
            "@code-pushup/eslint-config/jest",
            "@code-pushup/eslint-config/cypress"
          ],
          "settings": {
            // configure TS path aliases
            "import/resolver": {
              "typescript": {
                "project": "tsconfig.base.json"
              }
            }
          },
          "rules": {
            // ... customize as needed ...
            "@angular-eslint/component-selector": [
              "warn",
              {
                "type": "element",
                "style": "kebab-case",
                "prefix": ["cp"] // <-- replace with your own prefix
              }
            ],
            "@angular-eslint/directive-selector": [
              "warn",
              {
                "type": "attribute",
                "style": "camelCase",
                "prefix": "cp" // <-- replace with your own prefix
              }
            ],
            "@angular-eslint/pipe-prefix": [
              "warn",
              {
                "prefixes": ["cp"] // <-- replace with your own prefix
              }
            ],
            // if you wish to enforce control flow syntax:
            "@angular-eslint/template/prefer-control-flow": "warn"
          }
        },
        {
          "files": ["*.js", "*.jsx"],
          "extends": ["plugin:@nx/javascript", "@code-pushup"], // add default config for JS files
          "rules": {}
        }
      ]
    }
  4. Set parserOptions.project to correct tsconfig location in each Nx project's .eslintrc.json (more info in Nx docs). E.g.:

    {
      "extends": ["../../.eslintrc.json"],
      "ignorePatterns": ["!**/*"],
      "overrides": [
        {
          "files": ["*.ts", "*.tsx"],
          "parserOptions": {
            "project": ["libs/utils/tsconfig.*?.json"]
          }
        }
      ]
    }
  5. Test with npx nx run-many -t lint or npx nx lint <project> to see what errors and warnings are reported. You can customize or even disable rules using the rules section in .eslintrc.json, if you need to tweak the configuration to better match your team's preferences, or even report an issue in code-pushup/eslint-config repo.

Nx lint in CI

At this point, you probably have a lot of problems being reported. If nx lint is a required check in CI, some way to temporarily disable the failing rules is needed. While the CI should pass, we still want those problems to be reported to Code PushUp.

This can be achieved by renaming each project's .eslintrc.json to code-pushup.eslintrc.json, and creating a new .eslintrc.json per project which extends ./code-pushup.eslintrc.json with additional overrides which turn off failing rules. You can copy-paste and run the eslint-to-code-pushup.mjs script (also pasted below) to automate this for you. The result should look like packages/core/.eslintrc.json from the Code PushUp CLI repo, for example.

eslint-to-code-pushup.mjs
import {
  createProjectGraphAsync,
  readProjectsConfigurationFromProjectGraph,
} from '@nx/devkit';
import { ESLint } from 'eslint';
import minimatch from 'minimatch';
import fs from 'node:fs/promises';
import path from 'node:path';

// replace these patterns as needed
const TEST_FILE_PATTERNS = [
  '*.spec.ts',
  '*.test.ts',
  '**/test/**/*',
  '**/mock/**/*',
  '**/mocks/**/*',
  '*.cy.ts',
  '*.stories.ts',
];

const graph = await createProjectGraphAsync({ exitOnError: true });
const projects = Object.values(
  readProjectsConfigurationFromProjectGraph(graph).projects,
)
  .filter(project => 'lint' in (project.targets ?? {}))
  .sort((a, b) => a.root.localeCompare(b.root));

for (let i = 0; i < projects.length; i++) {
  const project = projects[i];

  /** @type {import('@nx/eslint/src/executors/lint/schema').Schema} */
  const options = project.targets.lint.options;

  const eslintrc = options.eslintConfig ?? `${project.root}/.eslintrc.json`;
  const patterns = options.lintFilePatterns ?? project.root;

  console.info(
    `Processing Nx ${project.projectType ?? 'project'} "${project.name}" (${
      i + 1
    }/${projects.length}) ...`,
  );

  const eslint = new ESLint({
    overrideConfigFile: eslintrc,
    useEslintrc: false,
    errorOnUnmatchedPattern: false,
    resolvePluginsRelativeTo: options.resolvePluginsRelativeTo ?? undefined,
    ignorePath: options.ignorePath ?? undefined,
    rulePaths: options.rulesdir ?? [],
  });

  const results = await eslint.lintFiles(patterns);

  /** @type {Set<string>} */
  const failingRules = new Set();
  /** @type {Set<string>} */
  const failingRulesTestsOnly = new Set();
  /** @type {Map<string, number>} */
  const errorCounts = new Map();
  /** @type {Map<string, number>} */
  const warningCounts = new Map();

  for (const result of results) {
    const isTestFile = TEST_FILE_PATTERNS.some(pattern =>
      minimatch(result.filePath, pattern),
    );
    for (const { ruleId, severity } of result.messages) {
      if (isTestFile) {
        if (!failingRules.has(ruleId)) {
          failingRulesTestsOnly.add(ruleId);
        }
      } else {
        failingRules.add(ruleId);
        failingRulesTestsOnly.delete(ruleId);
      }
      if (severity === 1) {
        warningCounts.set(ruleId, (warningCounts.get(ruleId) ?? 0) + 1);
      } else {
        errorCounts.set(ruleId, (errorCounts.get(ruleId) ?? 0) + 1);
      }
    }
  }

  /** @param {string} ruleId */
  const formatCounts = ruleId =>
    [
      { kind: 'error', count: errorCounts.get(ruleId) },
      { kind: 'warning', count: warningCounts.get(ruleId) },
    ]
      .filter(({ count }) => count > 0)
      .map(({ kind, count }) =>
        count === 1 ? `1 ${kind}` : `${count} ${kind}s`,
      )
      .join(', ');

  if (failingRules.size > 0) {
    console.info(`• ${failingRules.size} rules need to be disabled`);
    failingRules.forEach(ruleId => {
      console.info(`  - ${ruleId} (${formatCounts(ruleId)})`);
    });
  }
  if (failingRulesTestsOnly.size > 0) {
    console.info(
      `• ${failingRulesTestsOnly.size} rules need to be disabled only for test files`,
    );
    failingRulesTestsOnly.forEach(ruleId => {
      console.info(`  - ${ruleId} (${formatCounts(ruleId)})`);
    });
  }

  if (failingRules.size === 0 && failingRulesTestsOnly.size === 0) {
    console.info('• no rules need to be disabled, nothing to do here\n');
    continue;
  }

  const cpEslintrc =
    'code-pushup.' + path.basename(eslintrc).replace(/^\./, '');

  /** @param {Set<string>} rules */
  const formatRules = (rules, indentLevel = 2) =>
    Array.from(rules.values())
      .sort((a, b) => {
        if (a.includes('/') !== b.includes('/')) {
          return a.includes('/') ? 1 : -1;
        }
        return a.localeCompare(b);
      })
      .map(
        (ruleId, i, arr) =>
          '  '.repeat(indentLevel) +
          `"${ruleId}": "off"${
            i === arr.length - 1 ? '' : ','
          } // ${formatCounts(ruleId)}`,
      )
      .join('\n')
      .replace(/,$/, '');

  /** @type {import('eslint').Linter.Config} */
  const config = `{
  "extends": ["./${cpEslintrc}"],
  // temporarily disable failing rules so \`nx lint\` passes
  // number of errors/warnings per rule recorded at ${new Date().toString()}
  "rules": {
${formatRules(failingRules)}
  }
  ${
    !failingRulesTestsOnly.size
      ? ''
      : `,
  "overrides": [
    {
      "files": ${JSON.stringify(TEST_FILE_PATTERNS)},
      "rules": {
${formatRules(failingRulesTestsOnly, 4)}
      }
    }
  ]`
  }
}`;

  const content = /\.c?[jt]s$/.test(eslintrc)
    ? `module.exports = ${config}`
    : config;

  const cpEslintrcPath = path.join(project.root, cpEslintrc);
  await fs.copyFile(eslintrc, cpEslintrcPath);
  console.info(`• copied ${eslintrc} to ${cpEslintrcPath}`);

  await fs.writeFile(eslintrc, content);
  console.info(
    `• replaced ${eslintrc} to extend ${cpEslintrc} and disable failing rules\n`,
  );
}

process.exit(0);

Verify that nx lint now passes for all your projects.

Code PushUp CLI and ESLint plugin

Now that we have our ESLint configs ready, we can install @code-pushup/cli and @code-pushup/eslint-plugin and configure them to collect Code PushUp reports.

  1. Install NPM packages:

    npm i -D @code-pushup/cli @code-pushup/eslint-plugin
  2. Create a code-pushup.config.ts file:

    import eslintPlugin, {
      eslintConfigFromNxProjects,
    } from '@code-pushup/eslint-plugin';
    import type { CoreConfig } from '@code-pushup/models';
    
    const config: CoreConfig = {
      plugins: [await eslintPlugin(await eslintConfigFromNxProjects())],
      categories: [
        {
          slug: 'bug-prevention',
          title: 'Bug prevention',
          refs: [
            { type: 'group', plugin: 'eslint', slug: 'problems', weight: 1 },
          ],
        },
        {
          slug: 'code-style',
          title: 'Code style',
          refs: [
            { type: 'group', plugin: 'eslint', slug: 'suggestions', weight: 1 },
          ],
        },
      ],
    };
    
    export default config;
  3. Add .code-pushup directory to your .gitignore file.

  4. Collect a first report (be patient, it may take a while):

    npx code-pushup collect

    Report summary will be printed to terminal, and two files should be created:

    • .code-pushup/report.json (for upload),
    • .code-pushup/report.md (full human-readable report).
Alternatives to linting all Nx projects at once The configuration above will run ESLint on every project in the monorepo in one go. If you prefer to be more granular, then you have two other options:
  1. Use eslintConfigFromNxProject and specify the name of the project you wish to target.

    import eslintPlugin, {
      eslintConfigFromNxProject,
    } from '@code-pushup/eslint-plugin';
    
    const config: CoreConfig = {
      // ...
      plugins: [await eslintPlugin(await eslintConfigFromNxProject('website'))],
    };

    This will lint only this project and projects it depends on.

    If you wish to target multiple projects as entry points (e.g. 2 different applications), create a code-pushup.config.ts for each of these projects.

  2. Create a code-pushup.config.ts for each project you want to lint, e.g.:

    const config: CoreConfig = {
      // ...
      persist: {
        // ...
        outputDir: 'dist/apps/website',
      },
      plugins: [
        await eslintPlugin({
          // path to project's .eslintrc.json
          eslintrc: 'apps/website/.eslintrc.json',
          // same as lintFilePatterns in project.json
          patterns: ['apps/website/**/*.ts', 'apps/website/**/*.html'],
        }),
      ],
    };

    This will produce individual reports for each project, each Nx project will be completely separated in the portal.

If you decide to use multiple code-pushup.config.ts files, then you can still share common configuration by importing a root code-pushup.preset.ts (similar to Jest). And you can create a custom code-pushup target using nx:run-commands executor in order to use with nx affected and nx run-many.

Upload to portal

In order to upload the report to the portal, you'll need to have access to an organization in the staging environment.

Creating an API key

  1. Sign in with your work email address to Code PushUp portal in staging. You should then see organizations where your email (or domain) has been whitelisted, along with their projects.
  2. Select any project in your organization and go to Settings (menu in top-right corner):
    portal screenshot - menu with Settings
  3. Click Create API key, give it a name, expiration and choose read/write permissions.
    portal screenshot - new API key dialog
  4. Copy the generated API key to your clipboard and store it securely for later use.

Creating a Code PushUp project (optional)

Note

If your repository already has some project in the Code PushUp portal staging environment, then you may skip this section.

If your repository is on GitHub, then the Code PushUp (staging) GitHub App must be installed there, so that the portal has permissions to query for branches and commits.

If the project doesn't yet exist in the staging database, it needs to be added to the organization and linked to the correct repository. This can be done in the portal repository with npm run add-organization and subsequently npm run add-project (requires MONGODB_URI environment variable with connection string for staging DB).

Upload configuration

In code-pushup.config.ts, add configuration for the upload command:

// optional, if you want to use .env file:
// import 'dotenv/config';

const config: CoreConfig = {
  // ... plugins, categories, etc. ...
  upload: {
    // portal API for staging environment
    server: 'https://api.staging.code-pushup.dev/graphql',
    // API key you created earlier, but use environment variable to keep it secret
    apiKey: process.env.CP_API_KEY!,
    // replace with the slug of your organization
    organization: 'code-pushup',
    // replace with the slug of your project
    project: 'cli',
  },
};

Then set the CP_API_KEY environment variable to the value you created earlier and run the upload command (will look for .code-pushup/report.json file):

CP_API_KEY=... npx code-pushup upload

Warning

The latest commit has to be pushed to remote, otherwise the upload will fail because it can't verify the commit exists.

CI automation

Add the API key you created earlier to your CI's secrets manager (e.g. as a GitHub Actions secret).

Run npx code-pushup autorun as a shell script in a CI job that's triggered on push. For GitHub Actions, it should look something like this:

name: Code PushUp

on: push

jobs:
  code_pushup:
    runs-on: ubuntu-latest
    name: Code PushUp
    steps:
      # prerequisites
      - name: Checkout repository
        uses: actions/checkout@v3
      - name: Set up Node.js
        uses: actions/setup-node@v3
        with:
          node-version: 18
          cache: npm
      - name: Install dependencies
        run: npm ci
      # Code PushUp command
      - name: Collect and upload Code PushUp report
        run: npx code-pushup autorun
        env:
          # provide secret as environment variable
          CP_API_KEY: ${{ secrets.CP_API_KEY }}
          # add other options as needed
          NODE_OPTIONS: --max-old-space-size=8192
      # optional extra step if you want to preserve report.json, report.md
      - name: Save report files as workflow artifact
        uses: actions/upload-artifact@v3
        with:
          name: code-pushup-report
          path: .code-pushup/