common/output.ts (308 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.
*/
import {
Coverage,
QODANA_LICENSES_JSON,
QODANA_LICENSES_MD,
QODANA_OPEN_IN_IDE_NAME,
QODANA_REPORT_URL_NAME, VERSION,
} from "./qodana";
import * as fs from "fs";
import type {Log, Result} from 'sarif'
import {parseRules, Rule} from './utils'
export const COMMIT_USER = 'qodana-bot'
export const COMMIT_EMAIL = 'qodana-support@jetbrains.com'
export const QODANA_CHECK_NAME = 'Qodana'
const UNKNOWN_RULE_ID = 'Unknown'
const SUMMARY_TABLE_HEADER = '| Inspection name | Severity | Problems |'
const SUMMARY_TABLE_SEP = '| --- | --- | --- |'
const SUMMARY_MISC = `Contact us at [qodana-support@jetbrains.com](mailto:qodana-support@jetbrains.com)
- Or via our issue tracker: https://jb.gg/qodana-issue
- Or share your feedback: https://jb.gg/qodana-discussions`
const SUMMARY_PR_MODE = `💡 Qodana analysis was run in the pull request mode: only the changed files were checked`
export const FAILURE_LEVEL = 'failure'
export const WARNING_LEVEL = 'warning'
export const NOTICE_LEVEL = 'notice'
export interface ProblemDescriptor {
title: string | undefined
level: 'failure' | 'warning' | 'notice'
}
interface CloudData {
url?: string
}
interface OpenInIDEData {
cloud?: CloudData
}
export interface LicenseEntry {
name?: string
version?: string
license?: string
}
export interface LicenseInfo {
licenses: string,
packages: number
}
export interface Output {
title: string
summary: string
text: string
problemDescriptions: ProblemDescriptor[]
}
export function parseSarif(path: string, text: string): Output {
const sarif: Log = JSON.parse(
fs.readFileSync(path, {encoding: 'utf8'})
) as Log
const run = sarif.runs[0]
const rules = parseRules(run.tool)
let title = 'No new problems found by '
let problemDescriptions: ProblemDescriptor[] = []
if (run.results?.length) {
title = `${run.results.length} ${getProblemPlural(
run.results.length
)} found by `
problemDescriptions = run.results
.filter(
result =>
result.baselineState !== 'unchanged' &&
result.baselineState !== 'absent'
)
.map(result => parseResult(result, rules))
.filter((a): a is ProblemDescriptor => a !== null && a !== undefined)
}
const name = run.tool.driver.fullName || 'Qodana'
title += name
return {
title,
text: text,
summary: title,
problemDescriptions
}
}
export function parseResult(
result: Result,
rules: Map<string, Rule>
): ProblemDescriptor | null {
if (
!result.locations ||
result.locations.length === 0 ||
!result.locations[0].physicalLocation
) {
return null
}
return {
title: rules.get(result.ruleId!)?.shortDescription,
level: (() => {
switch (result.level) {
case 'error':
return FAILURE_LEVEL
case 'warning':
return WARNING_LEVEL
default:
return NOTICE_LEVEL
}
})()
}
}
function wrapToDiffBlock(message: string): string {
return `\`\`\`diff
${message}
\`\`\``
}
function makeConclusion(
conclusion: string,
failedByThreshold: boolean,
useDiffBlock: boolean,
): string {
if (useDiffBlock) {
return failedByThreshold ? `- ${conclusion}` : `+ ${conclusion}`
} else {
return failedByThreshold
? `<span style="background-color: #ffe6e6; color: red;">${conclusion}</span>`
: `<span style="background-color: #e6f4e6; color: green;">${conclusion}</span>`
}
}
export function getCoverageStats(c: Coverage, useDiffBlock: boolean): string {
if (c.totalLines === 0 && c.totalCoveredLines === 0) {
return ''
}
let stats = ''
if (c.totalLines !== 0) {
const conclusion = `${c.totalCoverage}% total lines covered`
stats += `${makeConclusion(conclusion, c.totalCoverage < c.totalCoverageThreshold, useDiffBlock)}
${c.totalLines} lines analyzed, ${c.totalCoveredLines} lines covered`
}
if (c.freshLines !== 0) {
const conclusion = `${c.freshCoverage}% fresh lines covered`
stats += `
${makeConclusion(conclusion, c.freshCoverage < c.freshCoverageThreshold, useDiffBlock)}
${c.freshLines} lines analyzed, ${c.freshCoveredLines} lines covered`
}
const coverageBlock = [
`@@ Code coverage @@`,
`${stats}`,
`# Calculated according to the filters of your coverage tool`
].join('\n')
return useDiffBlock ? wrapToDiffBlock(coverageBlock) : coverageBlock
}
export function getLicenseInfo(
resultsDir: string,
): LicenseInfo {
let licensesInfo = ''
let packages = 0
const licensesJson = `${resultsDir}/projectStructure/${QODANA_LICENSES_JSON}`
if (fs.existsSync(licensesJson)) {
const licenses = JSON.parse(
fs.readFileSync(licensesJson, {encoding: 'utf8'})
) as LicenseEntry[]
if (licenses.length > 0) {
packages = licenses.length
licensesInfo = fs.readFileSync(
`${resultsDir}/projectStructure/${QODANA_LICENSES_MD}`,
{encoding: 'utf8'}
)
}
}
return { licenses: licensesInfo, packages: packages }
}
export function getReportURL(resultsDir: string): string {
let reportUrlFile = `${resultsDir}/${QODANA_OPEN_IN_IDE_NAME}`
if (fs.existsSync(reportUrlFile)) {
const rawData = fs.readFileSync(reportUrlFile, {encoding: 'utf8'})
const data = JSON.parse(rawData) as OpenInIDEData
if (data?.cloud?.url) {
return data.cloud.url
}
} else {
reportUrlFile = `${resultsDir}/${QODANA_REPORT_URL_NAME}`
if (fs.existsSync(reportUrlFile)) {
return fs.readFileSync(reportUrlFile, {encoding: 'utf8'})
}
}
return ''
}
function wrapToToggleBlock(header: string, body: string): string {
return `<details>
<summary>${header}</summary>
${body}
</details>`
}
function getViewReportText(reportUrl: string, viewReportOptions: string): string {
if (reportUrl !== '') {
return `☁️ [View the detailed Qodana report](${reportUrl})`
}
return wrapToToggleBlock(
'View the detailed Qodana report',
viewReportOptions
)
}
/**
* Generates a table row for a given level.
* @param annotations The annotations to generate the table row from.
* @param level The level to generate the table row for.
*/
function getRowsByLevel(annotations: ProblemDescriptor[], level: string): string {
const problems = annotations.reduce(
(map: Map<string, number>, e) =>
map.set(
e.title ?? UNKNOWN_RULE_ID,
map.get(e.title ?? UNKNOWN_RULE_ID) !== undefined
? map.get(e.title ?? UNKNOWN_RULE_ID)! + 1
: 1
),
new Map()
)
return Array.from(problems.entries())
.sort((a, b) => b[1] - a[1])
.map(([title, count]) => `| \`${title}\` | ${level} | ${count} |`)
.join('\n')
}
/**
* Generates action summary string of annotations.
* @param toolName The name of the tool to generate the summary from.
* @param projectDir The path to the project.
* @param sourceDir The path to analyzed directory inside the project.
* @param problemsDescriptors The descriptions of problems to generate the summary from.
* @param coverageInfo The coverage is a Markdown text to generate the summary from.
* @param packages The number of dependencies in the analyzed project.
* @param licensesInfo The licenses a Markdown text to generate the summary from.
* @param reportUrl The URL to the Qodana report.
* @param prMode Whether the analysis was run in the pull request mode.
* @param dependencyCharsLimit Limit on how many characters can be included in comment
* @param reportViewOptionsHelp Instructions of how to configure a report viewing for tool
*/
export function getSummary(
toolName: string,
projectDir: string,
sourceDir: string,
problemsDescriptors: ProblemDescriptor[],
coverageInfo: string,
packages: number,
licensesInfo: string,
reportUrl: string,
prMode: boolean,
dependencyCharsLimit: number,
reportViewOptionsHelp: string
): string {
const contactBlock = wrapToToggleBlock('Contact Qodana team', SUMMARY_MISC)
let licensesBlock = ''
if (licensesInfo !== '' && licensesInfo.length < dependencyCharsLimit) {
licensesBlock = wrapToToggleBlock(
`Detected ${packages} ${getDepencencyPlural(packages)}`,
licensesInfo
)
}
let prModeBlock = ''
if (prMode) {
prModeBlock = SUMMARY_PR_MODE
}
if (reportUrl !== '') {
const firstToolName = toolName.split(' ')[0]
toolName = toolName.replace(
firstToolName,
`[${firstToolName}](${reportUrl})`
)
}
const analysisScope = (
projectDir === ''
? ''
: ['Analyzed project: `', projectDir, '/`\n'].join('')
).concat(
sourceDir === ''
? ''
: ['Analyzed directory: `', sourceDir, '/`\n'].join('')
)
if (problemsDescriptors.length === 0) {
return [
`# ${toolName}`,
analysisScope,
'**It seems all right 👌**',
'',
'No new problems were found according to the checks applied',
coverageInfo,
prModeBlock,
getViewReportText(reportUrl, reportViewOptionsHelp),
licensesBlock,
contactBlock
].join('\n')
}
return [
`# ${toolName}`,
analysisScope,
`**${problemsDescriptors.length} ${getProblemPlural(
problemsDescriptors.length
)}** were found`,
'',
SUMMARY_TABLE_HEADER,
SUMMARY_TABLE_SEP,
[
getRowsByLevel(
problemsDescriptors.filter(a => a.level === FAILURE_LEVEL),
'🔴 Failure'
),
getRowsByLevel(
problemsDescriptors.filter(a => a.level === WARNING_LEVEL),
'🔶 Warning'
),
getRowsByLevel(
problemsDescriptors.filter(a => a.level === NOTICE_LEVEL),
'◽️ Notice'
)
]
.filter(e => e !== '')
.join('\n'),
'',
coverageInfo,
prModeBlock,
getViewReportText(reportUrl, reportViewOptionsHelp),
licensesBlock,
contactBlock
].join('\n')
}
/**
* Generates a plural form of the word "problem" depending on the given count.
* @param count A number representing the count of problems
* @returns A formatted string with the correct plural form of "problem"
*/
export function getProblemPlural(count: number): string {
return `new problem${count !== 1 ? 's' : ''}`
}
/**
* Generates a plural form of the word "dependency" depending on the given count.
* @param count A number representing the count of dependencies
* @returns A formatted string with the correct plural form of "dependency"
*/
export function getDepencencyPlural(count: number): string {
return `dependenc${count !== 1 ? 'ies' : 'y'}`
}
export function getCommentTag(toolName: string, sourceDir: string): string {
// source dir needed in case of monorepo with projects analyzed by the same tool
return `<!-- JetBrains/qodana-action@v${VERSION} : ${toolName}, ${sourceDir} -->`
}