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())
})
}