UNPKG

eslint-formatter-gitlab

Version:

Show ESLint results directly in the GitLab code quality results

196 lines (175 loc) 6.14 kB
/** * @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