Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions dist/main.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -284526,6 +284526,22 @@ async function run(ghCtx = new contextExports.Context(), commitFetcherFactory =
const linter = new Linter(commitsToLint, result.filepath, helpUrl, workingDirectory);
const result1 = await linter.lint();
await result1.format(new DefaultFormatter());
// Surface the per-commit reason in the log (and as annotations),
// not just the aggregate count emitted by setFailed below. The
// detailed table is already written to the job summary, but that
// lives on a separate tab; emitting one line per failing commit
// here puts the reason where the log is actually read. GitHub
// renders at most ~10 annotations of each level per step, but the
// remaining lines still appear in the log output.
for (const item of result1.items) {
const head = item.input.split('\n')[0].trim();
for (const e of item.errors) {
coreExports.error(`${item.hash} ${head} — ${e.message}`);
}
for (const w of item.warnings) {
coreExports.warning(`${item.hash} ${head} — ${w.message}`);
}
}
if (result1.hasErrors) {
if (failOnErrs) {
setFailed(`Found ${result1.errorCommitsCount} commit message${result1.errorCommitsCount === 1 ? '' : 's'} with errors`);
Expand Down
2 changes: 1 addition & 1 deletion dist/main.cjs.map

Large diffs are not rendered by default.

9 changes: 8 additions & 1 deletion jest.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -21,8 +21,15 @@ export default {
'json',
'node',
],
testPathIgnorePatterns: ['/node_modules/', '/frontend/', '/dist/'],
testPathIgnorePatterns: [
'/node_modules/',
'/frontend/',
'/dist/',
'/packages/',
],
resetModules: false,
globalSetup: './test/setup.ts',
globalTeardown: './test/teardown.ts',
collectCoverage: true,
coverageDirectory: './.out',
collectCoverageFrom: ['src/**/*.{ts,tsx,js,jsx}', '!src/**/*.d.ts'],
Expand Down
7 changes: 0 additions & 7 deletions knip.config.js

This file was deleted.

8 changes: 8 additions & 0 deletions knip.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
export default {
entry: ['src/main.ts', 'src/load.patch.ts'],
ignore: ['knip.config.ts'],
ignoreDependencies: [
/^@semantic-release\//,
/^@commitlint\/config-conventional$/,
],
};
4 changes: 2 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,9 @@
"scripts": {
"prepack": "tsc",
"build": "rollup --config=rollup.config.mjs",
"test": "NODE_OPTIONS='--experimental-vm-modules' jest --verbose --config=jest.config.mjs --runInBand",
"test": "jest --verbose --config=jest.config.mjs",
"test:watch": "npm run test -- --watch",
"test:debug": "jest --verbose --config=jest.config.mjs --runInBand --detectOpenHandles",
"test:debug": "jest --verbose --config=jest.config.mjs --detectOpenHandles",
"format": "prettier --write .",
"format:check": "prettier --check .",
"lint": "npx eslint .",
Expand Down
19 changes: 19 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
import {
debug,
endGroup,
error,
getInput,
info,
notice,
Expand Down Expand Up @@ -277,6 +278,24 @@ export async function run(
const result1 = await linter.lint();

await result1.format(new DefaultFormatter());

// Surface the per-commit reason in the log (and as annotations),
// not just the aggregate count emitted by setFailed below. The
// detailed table is already written to the job summary, but that
// lives on a separate tab; emitting one line per failing commit
// here puts the reason where the log is actually read. GitHub
// renders at most ~10 annotations of each level per step, but the
// remaining lines still appear in the log output.
for (const item of result1.items) {
const head = item.input.split('\n')[0].trim();
for (const e of item.errors) {
error(`${item.hash} ${head} — ${e.message}`);
}
for (const w of item.warnings) {
warning(`${item.hash} ${head} — ${w.message}`);
}
}

if (result1.hasErrors) {
if (failOnErrs) {
setFailed(
Expand Down
65 changes: 65 additions & 0 deletions test/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -372,4 +372,69 @@ describe('Commitlint Action Integration Tests', () => {
expect(allOutput).toContain("event 'push'");
})();
});

test('emits one error line per failing commit with the rule reason', () => {
return withTempDir(async ({ tmp }) => {
writeFileSync(
join(tmp, '.commitlintrc.json'),
JSON.stringify({
extends: ['@commitlint/config-conventional'],
rules: { 'body-max-line-length': [2, 'always', 200] },
}),
);

const longLine = 'x'.repeat(201);
const stdoutChunks: string[] = [];
const stdoutSpy = jest
.spyOn(process.stdout, 'write')
.mockImplementation(((chunk: string | Uint8Array): boolean => {
stdoutChunks.push(
typeof chunk === 'string' ? chunk : Buffer.from(chunk).toString(),
);
return true;
}) as typeof process.stdout.write);

try {
await expect(
runAction(
{
'github-token': 'fake-token',
'working-directory': tmp,
'fail-on-errors': 'true',
},
{
GITHUB_WORKSPACE: tmp,
GITHUB_EVENT_NAME: 'push',
GITHUB_REPOSITORY: 'test-owner/test-repo',
},
{},
(): ICommitFetcher | null => ({
fetchCommits: async (): Promise<CommitToLint[]> => [
{
hash: 'aaa1111',
message: `fix(deps): bump dep\n\n${longLine}`,
},
{ hash: 'bbb2222', message: `fix: another\n\n${longLine}` },
],
}),
),
).rejects.toThrow('Found 2 commit messages with errors');
} finally {
stdoutSpy.mockRestore();
}

const allOutput = stdoutChunks.join('');
// One annotation per failing commit, each carrying the rule reason and
// the commit hash — not just the aggregate setFailed count.
const errorLines = allOutput
.split('\n')
.filter((line) => line.startsWith('::error::'));
expect(errorLines).toHaveLength(2);
expect(allOutput).toContain('::error::aaa1111 fix(deps): bump dep');
expect(allOutput).toContain('::error::bbb2222 fix: another');
expect(allOutput).toContain(
"body's lines must not be longer than 200 characters",
);
})();
});
});
67 changes: 67 additions & 0 deletions test/linter/linter.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -239,4 +239,71 @@ describe('Linter', () => {
expect(result.hasOnlyWarnings).toBe(false);
}),
);

// Regression guard: a header scope such as `fix(deps): ...` is parsed as a
// scope and must not change the linting outcome. A scoped header with a
// clean body passes, while failures come from body content (e.g. an
// over-long body line) regardless of whether a scope is present. This
// pins down the behaviour so the parenthesised scope is never mistaken
// for the cause of an unrelated body-max-line-length failure.
describe('header scope handling', () => {
const scopeConfig = {
rules: {
'type-enum': [RuleConfigSeverity.Error, 'always', ['feat', 'fix']],
'body-max-line-length': [RuleConfigSeverity.Error, 'always', 200],
},
};
const longLine = 'x'.repeat(201);
const shortBody = 'a clean, well-wrapped body line';

it(
'passes a scoped header with a compliant body',
withTempDir(async ({ tmp: projectDir }) => {
const configPath = createCommitlintrcJson(
projectDir,
scopeConfig,
'.commitlintrc.json',
);
const linter = new Linter(
[{ hash: 'sc01', message: `fix(deps): bump a dep\n\n${shortBody}` }],
configPath,
'',
projectDir,
);
const result = await linter.lint();

expect(result.items[0].valid).toBe(true);
expect(result.hasErrors).toBe(false);
}),
);

it(
'fails on an over-long body line whether or not a scope is present',
withTempDir(async ({ tmp: projectDir }) => {
const configPath = createCommitlintrcJson(
projectDir,
scopeConfig,
'.commitlintrc.json',
);
const linter = new Linter(
[
{ hash: 'sc02', message: `fix(deps): bump a dep\n\n${longLine}` },
{ hash: 'sc03', message: `fix: bump a dep\n\n${longLine}` },
],
configPath,
'',
projectDir,
);
const result = await linter.lint();

const errorNames = result.items.map((item) =>
item.errors.map((error) => error.name),
);
expect(errorNames).toEqual([
['body-max-line-length'],
['body-max-line-length'],
]);
}),
);
});
});
4 changes: 4 additions & 0 deletions test/setup.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
// noinspection JSUnusedGlobalSymbols
export default async function setup(): Promise<void> {
// No external services are required for the test suite.
}
4 changes: 4 additions & 0 deletions test/teardown.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
// noinspection JSUnusedGlobalSymbols
export default async function teardown(): Promise<void> {
// No external services to tear down.
}
3 changes: 2 additions & 1 deletion tsconfig.jest.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,8 @@
"allowJs": true,
"sourceMap": true,
"declaration": false,
"noEmit": true
"noEmit": true,
"rootDir": "."
},
"include": ["**/*"],
"exclude": ["node_modules", "**/*.spec.ts", "dist"]
Expand Down
Loading