common/qodana.ts (263 lines of code) (raw):

/* * Copyright 2021-2025 JetBrains s.r.o. * * 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 * * https://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. */ // noinspection JSUnusedGlobalSymbols import {checksum, version} from './cli.json' import {createHash} from 'crypto' import * as fs from 'fs' import path from 'path' import JSZip from 'jszip' import {promisify} from 'util' const readdir = promisify(fs.readdir) const stat = promisify(fs.stat) const mkdir = promisify(fs.mkdir) export const SUPPORTED_PLATFORMS = ['windows', 'linux', 'darwin'] export const SUPPORTED_ARCHS = ['x86_64', 'arm64'] export const FAIL_THRESHOLD_OUTPUT = 'The number of problems exceeds the failThreshold' export const QODANA_SARIF_NAME = 'qodana.sarif.json' export const QODANA_SHORT_SARIF_NAME = 'qodana-short.sarif.json' export const QODANA_REPORT_URL_NAME = 'qodana.cloud' export const QODANA_OPEN_IN_IDE_NAME = 'open-in-ide.json' export const QODANA_LICENSES_MD = 'thirdPartySoftwareList.md' export const QODANA_LICENSES_JSON = 'third-party-libraries.json' export const EXECUTABLE = 'qodana' export const VERSION = version export const COVERAGE_THRESHOLD = 50 export function getQodanaSha256(arch: string, platform: string): string { switch (`${platform}_${arch}`) { case 'windows_x86_64': return checksum['windows_x86_64'] case 'windows_arm64': return checksum['windows_arm64'] case 'linux_x86_64': return checksum['linux_x86_64'] case 'linux_arm64': return checksum['linux_arm64'] case 'darwin_x86_64': return checksum['darwin_x86_64'] case 'darwin_arm64': return checksum['darwin_arm64'] default: throw new Error(`Qodana CLI does not exist for ${platform}_${arch}`) } } /** * Returns the architecture name suitable for the published Qodana CLI archive name. */ export function getProcessArchName(): string { return process.arch === 'x64' ? 'x86_64' : 'arm64' } /** * Returns the platform name suitable for the published Qodana CLI archive name. */ export function getProcessPlatformName(): string { // noinspection JSDeprecatedSymbols return process.platform === 'win32' ? 'windows' : process.platform } /** * Gets Qodana CLI download URL from the GitHub Releases API. */ export function getQodanaUrl( arch: string, platform: string, nightly = false ): string { if (!SUPPORTED_PLATFORMS.includes(platform)) { throw new Error(`Unsupported platform: ${platform}`) } if (!SUPPORTED_ARCHS.includes(arch)) { throw new Error(`Unsupported architecture: ${arch}`) } const archive = platform === 'windows' ? 'zip' : 'tar.gz' const cli_version = nightly ? 'nightly' : `v${version}` return `https://github.com/JetBrains/qodana-cli/releases/download/${cli_version}/qodana_${platform}_${arch}.${archive}` } export enum QodanaExitCode { Success = 0, FailThreshold = 255 } /** * Check if Qodana Docker image execution is successful. * The codes are documented here: https://www.jetbrains.com/help/qodana/qodana-sarif-output.html#Invocations * @param exitCode */ export function isExecutionSuccessful(exitCode: number): boolean { return Object.values(QodanaExitCode).includes(exitCode) } /** * Finds the wanted argument value in the given args if there is one. * @param argShort the short argument name. * @param argLong the long argument name. * @param args command arguments. * @returns the arg value to use. */ export function extractArg( argShort: string, argLong: string, args: string[] ): string { let arg = '' for (let i = 0; i < args.length; i++) { if (args[i] === argShort || args[i] === argLong) { arg = args[i + 1] break } } return arg } export function isNativeMode(args: string[]): boolean { if (args.includes('--ide') || args.includes('--within-docker=false')) { return true } let index = args.findIndex(arg => arg =='--within-docker') if (index == -1) return false let nextIndex = index + 1 return args.length > nextIndex && args[nextIndex] == 'false'; } /** * Builds the `qodana pull` command arguments. * @returns The `qodana scan` command arguments. * @param args additional CLI arguments. */ export function getQodanaPullArgs(args: string[]): string[] { const pullArgs = ['pull'] const linter = extractArg('-l', '--linter', args) if (linter) { pullArgs.push('-l', linter) } const image = extractArg('--image', '--image', args) if (image) { pullArgs.push('--image', image) } const project = extractArg('-i', '--project-dir', args) if (project) { pullArgs.push('-i', project) } const config = extractArg('--config', '--config', args) if (config) { pullArgs.push('--config', config) } return pullArgs } /** * Builds the `qodana scan` command arguments. * @param args additional CLI arguments. * @param resultsDir the directory to store the results. * @param cacheDir the directory to store the cache. * @returns The `qodana scan` command arguments. */ export function getQodanaScanArgs( args: string[], resultsDir: string, cacheDir: string ): string[] { const cliArgs: string[] = [ 'scan', '--cache-dir', cacheDir, '--results-dir', resultsDir ] if (!isNativeMode(args)) { cliArgs.push('--skip-pull') } if (args) { cliArgs.push(...args) } return cliArgs } export const NONE = 'none' export const BRANCH = 'branch' export const PULL_REQUEST = 'pull-request' const PUSH_FIXES_TYPES = [NONE, BRANCH, PULL_REQUEST] export type PushFixesType = (typeof PUSH_FIXES_TYPES)[number] /** * The context of the current run – described in action.yaml. */ export interface Inputs { args: string[] resultsDir: string cacheDir: string primaryCacheKey: string additionalCacheKey: string cacheDefaultBranchOnly: boolean uploadResult: boolean uploadSarif: boolean artifactName: string useCaches: boolean useAnnotations: boolean prMode: boolean postComment: boolean githubToken: string pushFixes: PushFixesType commitMessage: string useNightly: boolean workingDirectory: string } /** * The test code coverage information. */ export interface Coverage { totalCoverage: number totalLines: number totalCoveredLines: number freshCoverage: number freshLines: number freshCoveredLines: number totalCoverageThreshold: number freshCoverageThreshold: number } /** * Read the coverage information from the SARIF file. * @param sarifPath the path to the SARIF file. */ export function getCoverageFromSarif(sarifPath: string): Coverage { if (fs.existsSync(sarifPath)) { const sarifContents = JSON.parse( fs.readFileSync(sarifPath, {encoding: 'utf8'}) ) if (sarifContents.runs[0].properties['coverage']) { return { totalCoverage: sarifContents.runs[0].properties['coverage']['totalCoverage'] || 0, totalLines: sarifContents.runs[0].properties['coverage']['totalLines'] || 0, totalCoveredLines: sarifContents.runs[0].properties['coverage']['totalCoveredLines'] || 0, freshCoverage: sarifContents.runs[0].properties['coverage']['freshCoverage'] || 0, freshLines: sarifContents.runs[0].properties['coverage']['freshLines'] || 0, freshCoveredLines: sarifContents.runs[0].properties['coverage']['freshCoveredLines'] || 0, totalCoverageThreshold: sarifContents.runs[0].properties['qodanaFailureConditions']?.[ 'testCoverageThresholds' ]?.['totalCoverage'] || COVERAGE_THRESHOLD, freshCoverageThreshold: sarifContents.runs[0].properties['qodanaFailureConditions']?.[ 'testCoverageThresholds' ]?.['freshCoverage'] || COVERAGE_THRESHOLD } } else { return { totalCoverage: 0, totalLines: 0, totalCoveredLines: 0, freshCoverage: 0, freshLines: 0, freshCoveredLines: 0, totalCoverageThreshold: COVERAGE_THRESHOLD, freshCoverageThreshold: COVERAGE_THRESHOLD } } } throw new Error(`SARIF file not found: ${sarifPath}`) } /** * Get the SHA256 checksum of the given file. * @param file absolute path to the file. */ export function sha256sum(file: string): string { const hash = createHash('sha256') hash.update(fs.readFileSync(file)) return hash.digest('hex') } /** * Returns the message when Qodana binary is corrupted. * @param expected expected sha256 checksum * @param actual actual sha256 checksum */ export function getQodanaSha256MismatchMessage( expected: string, actual: string ): string { return `Downloaded Qodana CLI binary is corrupted. Expected SHA-256 checksum: ${expected}, actual checksum: ${actual}` } /** * Validates the given branch name. * @param branchName the branch name to sanitize. */ export function validateBranchName(branchName: string): string { const validBranchNameRegex = /^[a-zA-Z0-9/\-_.]+$/ if (!validBranchNameRegex.test(branchName)) { throw new Error( `Invalid branch name: not allowed characters are used: ${branchName}` ) } return branchName } async function getFilePathsRecursively(dir: string): Promise<string[]> { const list = await readdir(dir) const statPromises = list.map(async file => { const fullPath = path.resolve(dir, file) const statPromise = await stat(fullPath) if (statPromise && statPromise.isDirectory()) { return getFilePathsRecursively(fullPath) } return [fullPath] }) return (await Promise.all(statPromises)).reduce( (acc, val) => acc.concat(val), [] ) } async function createZipFromFolder(dir: string): Promise<JSZip> { const absRoot = path.resolve(dir) const filePaths = await getFilePathsRecursively(dir) const zip = new JSZip() for (const filePath of filePaths) { const relative = filePath.replace(absRoot, '') zip.file(relative, fs.createReadStream(filePath), { unixPermissions: '777' }) } return zip } /** * Compresses the given folder into a ZIP archive. * @param srcDir the source directory to compress. * @param destFile the destination ZIP file. */ export async function compressFolder( srcDir: string, destFile: string ): Promise<void> { await mkdir(path.dirname(destFile), {recursive: true}) const zip = await createZipFromFolder(srcDir) await new Promise<void>((resolve, reject) => { zip .generateNodeStream({streamFiles: true, compression: 'DEFLATE'}) .pipe(fs.createWriteStream(destFile)) .on('error', err => reject(err)) .on('finish', () => resolve()) }) }