All files / libs/tools/plugin/src/executors/pre-sonar executor.ts

100% Statements 194/194
100% Branches 12/12
100% Functions 6/6
100% Lines 194/194

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 1951x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 3x 3x 3x 3x 3x 3x 3x 3x 3x 3x 3x 3x 3x 3x 3x 3x 3x 3x 3x 3x 3x 3x 3x 3x 3x 3x 3x 3x 3x 3x 3x 3x 3x 3x 3x 3x 3x 3x 3x 3x 3x 3x 3x 3x 3x 3x 3x 3x 3x 3x 3x 1x 1x 3x 3x 3x 3x 3x 3x 3x 3x 3x 3x 3x 3x 3x 3x 3x 3x 3x 3x 3x 3x 3x 3x 3x 3x 3x 3x 3x 1x 1x 3x 3x 3x 3x 3x 3x 1x 1x 3x 3x 3x 3x 3x 3x 3x 3x 3x 3x 3x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 3x 3x 3x 3x 3x 3x 3x 3x 3x 3x 3x 3x 3x 3x 3x 3x 3x 3x 3x 3x 3x 3x 3x 3x 1x 1x  
import fs from 'node:fs';
 
import { getProjects, type ExecutorContext, type ProjectConfiguration } from '@nx/devkit';
import axios from 'axios';
import { difference } from 'lodash-es';
import { flushChanges, FsTree, printChanges } from 'nx/src/generators/tree';
 
import { assert } from '@amalia/ext/typescript';
 
import updateProjectConfiguration from '../../generators/helpers/project-configuration';
 
import { debug } from './debug';
import { getSonarEnvVars } from './env';
 
/**
 * For more information about the sonar configuration, please refer to the official documentation:
 * @link https://docs.sonarsource.com/sonarqube/latest/project-administration/analysis-scope/
 */
type ProjectConfigurationWithSonar = ProjectConfiguration & {
  targets: {
    sonar: {
      executor: '@koliveira15/nx-sonarqube:scan';
      options: {
        hostUrl: string;
 
        projectKey: string;
        projectName: string;
 
        qualityGate: boolean;
        branches: boolean;
        skipImplicitDeps: boolean;
 
        exclusions: string;
        testInclusions: string;
        extra: {
          'sonar.inclusions': string;
 
          'sonar.typescript.lcov.reportPaths': string;
 
          'sonar.path.temp': string;
          'sonar.working.directory': string;
        };
      };
    };
  };
};
 
export const DISABLED_SONAR_TAG = 'sonar:disabled';
 
const SONAR_TMP_ROOT = '/tmp/sonar-scanner/amalia-web';
const getTemporaryFolderName = (project: ProjectConfiguration): string => `${SONAR_TMP_ROOT}/tmp/${project.name}`;
const getScannerWorkFolderName = (project: ProjectConfiguration): string =>
  `${SONAR_TMP_ROOT}/scanner-work/${project.name}`;
 
const addSonarTarget = (project: ProjectConfiguration): ProjectConfigurationWithSonar => {
  assert(project.name, `Project name missing in ${project.root}`);
  const { SONAR_HOST_URL } = getSonarEnvVars();
 
  return {
    ...project,
    targets: {
      ...project.targets,
      sonar: {
        executor: '@koliveira15/nx-sonarqube:scan',
        options: {
          hostUrl: SONAR_HOST_URL,
 
          projectKey: project.name,
          projectName: project.name,
 
          // We can't analyze branches with the free version of SonarQube
          branches: false,
          // We don't want to fail the job if the quality gate fails
          qualityGate: false,
          // We don't want to analyze the implicit dependencies
          skipImplicitDeps: true,
 
          // Equivalent to "sonar.sources.exclusions" (exclude mocks, stories, mocks and fixtures from the analysis)
          exclusions:
            '**/__mocks__/**/*, **/__mockdata__/**/*, **/*.stories.tsx, **/*.stories.ts, **/*.stories.mdx, **/*.fixture.ts',
 
          // Equivalent to "sonar.test.inclusions"
          testInclusions: [
            `${project.root}/**/*.spec.ts`,
            `${project.root}/**/*.spec.tsx`,
            `${project.root}/**/*.test.ts`,
            `${project.root}/**/*.test.tsx`,
            `${project.root}/**/*.e2e-spec.ts`,
          ].join(', '),
          extra: {
            // Only include .ts and .tsx files in the analysis
            'sonar.inclusions': [`${project.root}/src/**/*.ts`, `${project.root}/src/**/*.tsx`].join(', '),
 
            // Specify where sonar can find the coverage report
            'sonar.typescript.lcov.reportPaths': `coverage/${project.root}/lcov.info`,
 
            // Sonar Scanner cannot run in parallel if it has the same working directories.
            // We need to specify a different tmp and working dir for each project.
            'sonar.path.temp': getTemporaryFolderName(project),
            'sonar.working.directory': getScannerWorkFolderName(project),
          },
        },
      },
    },
  };
};
 
const getProjectList = async () => {
  const { SONAR_HOST_URL, SONAR_TOKEN } = getSonarEnvVars();
 
  let page = 1;
  let shouldContinue = true;
 
  const output: string[] = [];
 
  while (shouldContinue) {
    const { data } = await axios.get<{ components: { name: string }[]; paging: { pageSize: number } }>(
      `${SONAR_HOST_URL}/api/components/search_projects?p=${page}`,
      {
        auth: {
          username: SONAR_TOKEN,
          password: '',
        },
      },
    );
 
    output.push(...data.components.map((c) => c.name));
 
    shouldContinue = data.components.length === data.paging.pageSize;
 
    page++;
  }
 
  return output;
};
 
const listProjectsToDelete = async (projectsToAnalyse: ProjectConfiguration[]) => {
  const projectsOnSonar = await getProjectList();
 
  const projectsInCodebase = projectsToAnalyse.map((p) => p.name);
 
  return difference(projectsOnSonar, projectsInCodebase);
};
 
const executor = async (_: unknown, { isVerbose = false, ...context }: ExecutorContext) => {
  debug.enabled ||= isVerbose;
  const tree = new FsTree(context.cwd, false);
  const allProjects = getProjects(tree);
  const projectsToAnalyze = Array.from(allProjects.values()).filter(
    (project) => !project.tags?.includes(DISABLED_SONAR_TAG),
  );
 
  const projectsToDelete = await listProjectsToDelete(projectsToAnalyze);
 
  // If there are projects to delete, we delete them using the bulk delete API.
  if (projectsToDelete.length) {
    debug(`Projects to delete: ${projectsToDelete.join(', ')}`);
 
    await axios.post(
      `${getSonarEnvVars().SONAR_HOST_URL}/api/projects/bulk_delete?projects=${projectsToDelete.join(',')}`,
      null,
      {
        headers: {
          Authorization: `Bearer ${getSonarEnvVars().SONAR_TOKEN}`,
        },
      },
    );
  }
 
  projectsToAnalyze.forEach((project) => {
    assert(project.name, `Project name missing in ${project.root}`);
 
    const projectConfiguration = addSonarTarget(project);
 
    debug(`Updating project ${project.name} with sonar configuration`, projectConfiguration.targets.sonar);
 
    updateProjectConfiguration(tree, project.root, projectConfiguration);
  });
 
  // Create the temporary directory for those projects.
  projectsToAnalyze.forEach((project) => {
    fs.mkdirSync(getTemporaryFolderName(project), { recursive: true });
    fs.mkdirSync(getScannerWorkFolderName(project), { recursive: true });
  });
 
  printChanges(tree.listChanges());
  flushChanges(context.cwd, tree.listChanges());
 
  return {
    success: true,
  };
};
 
export default executor;