paperclip/.github/scripts/check-pr-test-coverage.mjs

84 lines
3.0 KiB
JavaScript

#!/usr/bin/env node
/**
* check-pr-test-coverage.mjs
* Checks that a PR diff includes at least one test file. Respects conventional
* commit prefixes — skips check for docs/chore/build/ci/style/refactor PRs.
* Also detects mismatch: docs/chore PRs that contain real source code changes.
* Export: checkTestCoverage(files, prTitle) → { passed, failures }
*/
import { fileURLToPath } from 'node:url';
const TEST_PATTERNS = [
/\.test\.(ts|js|tsx|jsx|mjs|cjs)$/,
/\.spec\.(ts|js|tsx|jsx|mjs|cjs)$/,
/(?:^|\/)tests?\//,
/\/__tests__\//,
];
const SOURCE_CODE_PATTERN = /\.(ts|tsx|js|jsx|mjs|cjs)$/;
// Prefixes where test coverage is NOT required
const SKIP_TEST_PREFIXES = ['docs', 'chore', 'build', 'ci', 'style', 'refactor', 'revert'];
// Prefixes where source code changes are NOT expected (mismatch detection)
// Note: 'style' is excluded — formatting PRs legitimately touch source files
const NO_SOURCE_CODE_PREFIXES = ['docs', 'chore', 'build', 'ci'];
function parsePrefix(title) {
if (!title) return null;
const match = title.match(/^([a-z]+)(?:\([^)]*\))?:/);
return match ? match[1].toLowerCase() : null;
}
function isSourceFile(filename) {
if (!SOURCE_CODE_PATTERN.test(filename)) return false;
if (TEST_PATTERNS.some(p => p.test(filename))) return false;
return true;
}
export function checkTestCoverage(files, prTitle = '') {
const prefix = parsePrefix(prTitle);
// Mismatch detection: docs/chore/etc PR with real source code changes
if (prefix && NO_SOURCE_CODE_PREFIXES.includes(prefix)) {
const sourceChanges = files.filter(f => f.status !== 'removed' && isSourceFile(f.filename));
if (sourceChanges.length > 0) {
return {
passed: false,
failures: [
`PR is titled \`${prefix}:\` but includes source code changes ` +
`(${sourceChanges.slice(0, 3).map(f => f.filename).join(', ')}` +
`${sourceChanges.length > 3 ? ', ...' : ''}). ` +
`Please retitle as \`fix:\`, \`feat:\`, or \`refactor:\` so the right gates run, ` +
`or remove the source code changes if this is genuinely a \`${prefix}:\` PR.`,
],
};
}
}
// Skip test requirement for prefixes that don't change behavior
if (prefix && SKIP_TEST_PREFIXES.includes(prefix)) {
return { passed: true, failures: [] };
}
const hasTests = files.some(
f => f.status !== 'removed' && TEST_PATTERNS.some(p => p.test(f.filename))
);
return {
passed: hasTests,
failures: hasTests ? [] : [
'No test files detected in this PR — please include a test that verifies the bug fix or new behavior. ' +
'If this PR genuinely doesn\'t need a test (e.g. a refactor), please retitle with `refactor:` prefix.',
],
};
}
if (process.argv[1] === fileURLToPath(import.meta.url)) {
const files = JSON.parse(process.env.PR_FILES ?? '[]');
const title = process.env.PR_TITLE ?? '';
const result = checkTestCoverage(files, title);
console.log(JSON.stringify(result));
process.exit(result.passed ? 0 : 1);
}