eslint-formatter-gitlab
Version:
Show ESLint results directly in the GitLab code quality results
196 lines (175 loc) • 6.14 kB
JavaScript
/**
* @import { InspectColorForeground } from 'node:util'
* @import { ESLint } from 'eslint'
*/
import { mkdir, readFile, writeFile } from 'node:fs/promises'
import { dirname, join, relative, resolve } from 'node:path'
import { styleText } from 'node:util'
import { toCodeClimate } from 'eslint-formatter-codeclimate'
import yaml from 'yaml'
/** @type {yaml.CollectionTag} */
const reference = {
tag: '!reference',
collection: 'seq',
default: false,
resolve() {
// We only allow the syntax. We don’t actually resolve the reference.
}
}
/**
* @param {string} projectDir
* The GitLab project directory.
* @param {string | undefined} jobName
* The GitLab CI job name.
* @returns {Promise<string>}
* The output path of the code quality artifact.
*/
async function getOutputPath(projectDir, jobName) {
const configPath = join(projectDir, process.env.CI_CONFIG_PATH ?? '.gitlab-ci.yml')
// GitlabCI allows a custom configuration path which can be a URL or a path relative to another
// project. In these cases CI_CONFIG_PATH is empty and we'll have to require the user provide
// ESLINT_CODE_QUALITY_REPORT.
let configContents
try {
configContents = await readFile(configPath, 'utf8')
} catch (cause) {
throw new Error(
'Could not resolve .gitlab-ci.yml to automatically detect report artifact path.' +
' Please manually provide a path via the ESLINT_CODE_QUALITY_REPORT variable.',
{ cause }
)
}
// A GitLab CI configuration file may start with a header document.
// https://docs.gitlab.com/ci/yaml/#header-keywords
const docs = yaml.parseAllDocuments(configContents, {
version: '1.1',
customTags: [reference]
})
const path = [jobName, 'artifacts', 'reports', 'codequality']
/** @type {unknown} */
let location
for (const doc of docs) {
location = doc.getIn(path)
if (location != null) {
break
}
}
if (typeof location !== 'string' || !location) {
throw new TypeError(
`Expected ${path.join('.')} to be one exact path, got: ${JSON.stringify(location)}`
)
}
return resolve(projectDir, location)
}
/**
* Make a text singular or plural based on the count.
*
* @param {number} count
* The count of the data.
* @param {string} text
* The text to make singular or plural.
* @returns {string}
* The formatted text.
*/
function plural(count, text) {
return `${count} ${text}${count === 1 ? '' : 's'}`
}
/**
* @param {ESLint.LintResult[]} results
* The ESLint report results.
* @param {string} projectDir
* The GitLab project directory.
* @param {(color: InspectColorForeground, text: string) => string} color
* A function to color text or not.
* @returns {string}
* The ESLint messages converted to a format suitable as output in GitLab CI job logs.
*/
function gitlabConsoleFormatter(results, projectDir, color) {
// Severity labels manually padded to have equal lengths and end with spaces
const labelFatal = `${color('magenta', 'fatal')} `
const labelError = `${color('red', 'error')} `
const labelWarn = `${color('yellow', 'warn')} `
const lines = ['']
/** @type {string | undefined} */
let gitLabBaseURL
const projectUrl = process.env.CI_PROJECT_URL
const commitSha = process.env.CI_COMMIT_SHORT_SHA
if (projectUrl && commitSha) {
gitLabBaseURL = `${projectUrl}/-/blob/${commitSha}/`
}
let fatal = 0
let errors = 0
let warnings = 0
let maxRuleIdLength = 0
let maxMsgLength = 0
for (const result of results) {
fatal += result.fatalErrorCount
errors += result.errorCount - result.fatalErrorCount
warnings += result.warningCount
for (const message of result.messages) {
if (message.ruleId) {
maxRuleIdLength = Math.max(maxRuleIdLength, message.ruleId.length)
}
maxMsgLength = Math.max(maxMsgLength, message.message.length)
}
}
for (const result of results) {
const { filePath, messages } = result
const repoFilePath = relative(projectDir, filePath)
for (const message of messages) {
let line = message.fatal ? labelFatal : message.severity === 1 ? labelWarn : labelError
line += String(message.ruleId || '').padEnd(maxRuleIdLength + 2)
line += message.message.padEnd(maxMsgLength + 2)
if (gitLabBaseURL) {
// Create link to referenced file in GitLab
let anchor = `#L${message.line}`
if (message.endLine != null && message.endLine !== message.line) {
anchor += `-${message.endLine}`
}
line += color('blue', `${gitLabBaseURL}${repoFilePath}${anchor}`)
} else {
line += `${filePath}:${message.line}:${message.column}`
}
lines.push(line)
}
}
const total = warnings + errors + fatal
if (total > 0) {
const details = `(${fatal} fatal, ${plural(errors, 'error')}, ${plural(warnings, 'warning')})`
lines.push('', `${color('red', '✖')} ${plural(total, 'problem')} ${details}`)
} else {
lines.push(`${color('green', '✔')} No problems found`)
}
lines.push('')
return lines.join('\n')
}
/**
* @param {ESLint.LintResult[]} results
* The ESLint report results.
* @param {ESLint.LintResultData} data
* The ESLint report result data.
* @returns {Promise<string>}
* The ESLint output to print to the console.
*/
async function eslintFormatterGitLab(results, data) {
let outputPath = process.env.ESLINT_CODE_QUALITY_REPORT
const projectDir = process.env.CI_PROJECT_DIR ?? data.cwd
const jobName = process.env.CI_JOB_NAME
if (jobName || outputPath) {
const issues = toCodeClimate(results, data.rulesMeta, projectDir)
outputPath ||= await getOutputPath(projectDir, jobName)
const dir = dirname(outputPath)
await mkdir(dir, { recursive: true })
await writeFile(outputPath, `${JSON.stringify(issues, null, 2)}\n`)
}
return gitlabConsoleFormatter(
results,
projectDir,
data.color
? (color, text) => styleText(color, text, { validateStream: false })
: data.color === false
? (color, text) => text
: styleText
)
}
export default eslintFormatterGitLab