Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: validate commit message for 1 commit PRs #87

Merged
merged 3 commits into from
Feb 15, 2021
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
5 changes: 5 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,11 @@ The action works without configuration, however you can provide options for cust
# validation of the PR title and the pull request checks remain pending.
# Note that a second check will be reported if this is enabled.
wip: true
# When using "Squash and merge" on a PR with only one commit, GitHub
# will suggest using that commit message instead of the PR title for the
# merge commit, and it's easy to commit this by mistake. Enable this option
# to also validate the commit message for one commit PRs.
validateSingleCommit: true
Copy link
Owner

@amannn amannn Feb 15, 2021

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thank you for including good docs and testing the feature!

```

## Event triggers
Expand Down
30 changes: 27 additions & 3 deletions src/index.js
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
const core = require('@actions/core');
const github = require('@actions/github');
const parseConfig = require('./parseConfig');
const validatePrTitle = require('./validatePrTitle');
const validateCommitMessage = require('./validateCommitMessage');

module.exports = async function run() {
try {
Expand All @@ -12,7 +12,8 @@ module.exports = async function run() {
requireScope,
wip,
subjectPattern,
subjectPatternError
subjectPatternError,
validateSingleCommit
} = parseConfig();

const contextPullRequest = github.context.payload.pull_request;
Expand Down Expand Up @@ -41,13 +42,36 @@ module.exports = async function run() {
let validationError;
if (!isWip) {
try {
await validatePrTitle(pullRequest.title, {
await validateCommitMessage(pullRequest.title, {
types,
scopes,
requireScope,
subjectPattern,
subjectPatternError
});

if (validateSingleCommit) {
const {data: commits} = await client.pulls.listCommits({
owner,
repo,
pull_number: contextPullRequest.number,
per_page: 2
});

if (commits.length === 1) {
await validateCommitMessage(
commits[0].commit.message,
{
types,
scopes,
requireScope,
subjectPattern,
subjectPatternError
},
'single commit message'
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: I'd move the messageType inside the second options argument. I'm using this object as a workaround for named arguments as I think it helps a bit with clarity, making it clear at the call site what the argument represents.

But the comment below is closely related to this as well.

);
}
}
} catch (error) {
validationError = error;
}
Expand Down
10 changes: 9 additions & 1 deletion src/parseConfig.js
Original file line number Diff line number Diff line change
Expand Up @@ -33,12 +33,20 @@ module.exports = function parseConfig() {
wip = ConfigParser.parseBoolean(process.env.INPUT_WIP);
}

let validateSingleCommit;
if (process.env.INPUT_VALIDATESINGLECOMMIT) {
validateSingleCommit = ConfigParser.parseBoolean(
process.env.INPUT_VALIDATESINGLECOMMIT
);
}

return {
types,
scopes,
requireScope,
wip,
subjectPattern,
subjectPatternError
subjectPatternError,
validateSingleCommit
};
};
25 changes: 13 additions & 12 deletions src/validatePrTitle.js → src/validateCommitMessage.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,14 +5,15 @@ const formatMessage = require('./formatMessage');

const defaultTypes = Object.keys(conventionalCommitTypes.types);

module.exports = async function validatePrTitle(
prTitle,
{types, scopes, requireScope, subjectPattern, subjectPatternError} = {}
module.exports = async function validateCommitMessage(
commitMessage,
{types, scopes, requireScope, subjectPattern, subjectPatternError} = {},
messageType = 'pull request title'
) {
if (!types) types = defaultTypes;

const {parserOpts} = await conventionalCommitsConfig();
const result = parser(prTitle, parserOpts);
const result = parser(commitMessage, parserOpts);

function printAvailableTypes() {
return `Available types:\n${types
Expand All @@ -34,25 +35,25 @@ module.exports = async function validatePrTitle(

if (!result.type) {
throw new Error(
`No release type found in pull request title "${prTitle}". Add a prefix to indicate what kind of release this pull request corresponds to (see https://www.conventionalcommits.org/).\n\n${printAvailableTypes()}`
`No release type found in ${messageType} "${commitMessage}". Add a prefix to indicate what kind of release this pull request corresponds to (see https://www.conventionalcommits.org/).\n\n${printAvailableTypes()}`
);
}

if (!result.subject) {
throw new Error(`No subject found in pull request title "${prTitle}".`);
throw new Error(`No subject found in ${messageType} "${commitMessage}".`);
}

if (!types.includes(result.type)) {
throw new Error(
`Unknown release type "${
result.type
}" found in pull request title "${prTitle}". \n\n${printAvailableTypes()}`
}" found in ${messageType} "${commitMessage}". \n\n${printAvailableTypes()}`
);
}

if (requireScope && !result.scope) {
throw new Error(
`No scope found in pull request title "${prTitle}". Use one of the available scopes: ${scopes.join(
`No scope found in ${messageType} "${commitMessage}". Use one of the available scopes: ${scopes.join(
', '
)}.`
);
Expand All @@ -68,7 +69,7 @@ module.exports = async function validatePrTitle(
unknownScopes.length > 1 ? 'scopes' : 'scope'
} "${unknownScopes.join(
','
)}" found in pull request title "${prTitle}". Use one of the available scopes: ${scopes.join(
)}" found in ${messageType} "${commitMessage}". Use one of the available scopes: ${scopes.join(
', '
)}.`
);
Expand All @@ -78,7 +79,7 @@ module.exports = async function validatePrTitle(
if (subjectPatternError) {
message = formatMessage(subjectPatternError, {
subject: result.subject,
title: prTitle
title: commitMessage
});
}

Expand All @@ -90,14 +91,14 @@ module.exports = async function validatePrTitle(

if (!match) {
throwSubjectPatternError(
`The subject "${result.subject}" found in pull request title "${prTitle}" doesn't match the configured pattern "${subjectPattern}".`
`The subject "${result.subject}" found in ${messageType} "${commitMessage}" doesn't match the configured pattern "${subjectPattern}".`
);
}

const matchedPart = match[0];
if (matchedPart.length !== result.subject.length) {
throwSubjectPatternError(
`The subject "${result.subject}" found in pull request title "${prTitle}" isn't an exact match for the configured pattern "${subjectPattern}". Please provide a subject that matches the whole pattern exactly.`
`The subject "${result.subject}" found in ${messageType} "${commitMessage}" isn't an exact match for the configured pattern "${subjectPattern}". Please provide a subject that matches the whole pattern exactly.`
);
}
}
Expand Down
58 changes: 34 additions & 24 deletions src/validatePrTitle.test.js → src/validateCommitMessage.test.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
const validatePrTitle = require('./validatePrTitle');
const validateCommitMessage = require('./validateCommitMessage');

it('allows valid PR titles that use the default types', async () => {
const inputs = [
Expand All @@ -10,84 +10,86 @@ it('allows valid PR titles that use the default types', async () => {
];

for (let index = 0; index < inputs.length; index++) {
await validatePrTitle(inputs[index]);
await validateCommitMessage(inputs[index]);
}
});

it('throws for PR titles without a type', async () => {
await expect(validatePrTitle('Fix bug')).rejects.toThrow(
await expect(validateCommitMessage('Fix bug')).rejects.toThrow(
'No release type found in pull request title "Fix bug".'
);
});

it('throws for PR titles with only a type', async () => {
await expect(validatePrTitle('fix:')).rejects.toThrow(
await expect(validateCommitMessage('fix:')).rejects.toThrow(
'No release type found in pull request title "fix:".'
);
});

it('throws for PR titles without a subject', async () => {
await expect(validatePrTitle('fix: ')).rejects.toThrow(
await expect(validateCommitMessage('fix: ')).rejects.toThrow(
'No subject found in pull request title "fix: ".'
);
});

it('throws for PR titles with an unknown type', async () => {
await expect(validatePrTitle('foo: Bar')).rejects.toThrow(
await expect(validateCommitMessage('foo: Bar')).rejects.toThrow(
'Unknown release type "foo" found in pull request title "foo: Bar".'
);
});

describe('defined scopes', () => {
it('allows a missing scope by default', async () => {
await validatePrTitle('fix: Bar');
await validateCommitMessage('fix: Bar');
});

it('allows all scopes by default', async () => {
await validatePrTitle('fix(core): Bar');
await validateCommitMessage('fix(core): Bar');
});

it('allows a missing scope when custom scopes are defined', async () => {
await validatePrTitle('fix: Bar', {scopes: ['foo']});
await validateCommitMessage('fix: Bar', {scopes: ['foo']});
});

it('allows a matching scope', async () => {
await validatePrTitle('fix(core): Bar', {scopes: ['core']});
await validateCommitMessage('fix(core): Bar', {scopes: ['core']});
});

it('allows multiple matching scopes', async () => {
await validatePrTitle('fix(core,e2e): Bar', {
await validateCommitMessage('fix(core,e2e): Bar', {
scopes: ['core', 'e2e', 'web']
});
});

it('throws when an unknown scope is detected within multiple scopes', async () => {
await expect(
validatePrTitle('fix(core,e2e,foo,bar): Bar', {scopes: ['foo', 'core']})
validateCommitMessage('fix(core,e2e,foo,bar): Bar', {
scopes: ['foo', 'core']
})
).rejects.toThrow(
'Unknown scopes "e2e,bar" found in pull request title "fix(core,e2e,foo,bar): Bar". Use one of the available scopes: foo, core.'
);
});

it('throws when an unknown scope is detected', async () => {
await expect(
validatePrTitle('fix(core): Bar', {scopes: ['foo']})
validateCommitMessage('fix(core): Bar', {scopes: ['foo']})
).rejects.toThrow(
'Unknown scope "core" found in pull request title "fix(core): Bar". Use one of the available scopes: foo.'
);
});

describe('require scope', () => {
it('passes when a scope is provided', async () => {
await validatePrTitle('fix(core): Bar', {
await validateCommitMessage('fix(core): Bar', {
scopes: ['core'],
requireScope: true
});
});

it('throws when a scope is missing', async () => {
await expect(
validatePrTitle('fix: Bar', {
validateCommitMessage('fix: Bar', {
scopes: ['foo', 'bar'],
requireScope: true
})
Expand All @@ -104,13 +106,13 @@ describe('custom types', () => {
const types = ['foo', 'bar', 'baz'];

for (let index = 0; index < inputs.length; index++) {
await validatePrTitle(inputs[index], {types});
await validateCommitMessage(inputs[index], {types});
}
});

it('throws for PR titles with an unknown type', async () => {
await expect(
validatePrTitle('fix: Foobar', {types: ['foo', 'bar']})
validateCommitMessage('fix: Foobar', {types: ['foo', 'bar']})
).rejects.toThrow(
'Unknown release type "fix" found in pull request title "fix: Foobar".'
);
Expand All @@ -119,11 +121,11 @@ describe('custom types', () => {

describe('description validation', () => {
it('does not validate the description by default', async () => {
await validatePrTitle('fix: sK!"§4123');
await validateCommitMessage('fix: sK!"§4123');
});

it('can pass the validation when `subjectPatternError` is configured', async () => {
await validatePrTitle('fix: foobar', {
await validateCommitMessage('fix: foobar', {
subjectPattern: '^(?![A-Z]).+$',
subjectPatternError:
'The subject found in the pull request title cannot start with an uppercase character.'
Expand All @@ -134,7 +136,7 @@ describe('description validation', () => {
const customError =
'The subject found in the pull request title cannot start with an uppercase character.';
await expect(
validatePrTitle('fix: Foobar', {
validateCommitMessage('fix: Foobar', {
subjectPattern: '^(?![A-Z]).+$',
subjectPatternError: customError
})
Expand All @@ -143,7 +145,7 @@ describe('description validation', () => {

it('interpolates variables into `subjectPatternError`', async () => {
await expect(
validatePrTitle('fix: Foobar', {
validateCommitMessage('fix: Foobar', {
subjectPattern: '^(?![A-Z]).+$',
subjectPatternError:
'The subject "{subject}" found in the pull request title "{title}" cannot start with an uppercase character.'
Expand All @@ -155,7 +157,7 @@ describe('description validation', () => {

it('throws for invalid subjects', async () => {
await expect(
validatePrTitle('fix: Foobar', {
validateCommitMessage('fix: Foobar', {
subjectPattern: '^(?![A-Z]).+$'
})
).rejects.toThrow(
Expand All @@ -165,7 +167,7 @@ describe('description validation', () => {

it('throws for only partial matches', async () => {
await expect(
validatePrTitle('fix: Foobar', {
validateCommitMessage('fix: Foobar', {
subjectPattern: 'Foo'
})
).rejects.toThrow(
Expand All @@ -174,8 +176,16 @@ describe('description validation', () => {
});

it('accepts valid subjects', async () => {
await validatePrTitle('fix: foobar', {
await validateCommitMessage('fix: foobar', {
subjectPattern: '^(?![A-Z]).+$'
});
});
});

it('throws an appropriate error message for single commit message errors', async () => {
await expect(
validateCommitMessage('Fix bug', undefined, 'single commit message')
).rejects.toThrow(
'No release type found in single commit message "Fix bug".'
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Currently the full error message looks like this:

No release type found in single commit message "Validate commit message for 1 commit PRs (and test option)". Add a prefix to indicate what kind of release this pull request corresponds to (see https://www.conventionalcommits.org/).

I'm not sure this helps the PR author to address the issue since we say "Add a prefix".

Maybe we should clarify that we're working around an issue of Github here, possibly referencing https://github.community/t/how-to-change-the-default-squash-merge-commit-message/1155 and telling the user to add another commit to avoid the issue? That's also what semantic-pull-requests does.

Since the error message becomes a bit different here, maybe it makes sense to pass in an enum value to validateCommitMessage that can be identified and used to alter the message as necessary.

E.g.

validateMessage('foo', {type: 'title'})
validateMessage('foo', {type: 'commit'})

I've also renamed to function again here, since it can be either a commit message or a PR title.

What do you think?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't want to complicate the logic in validateMessage too much - how about I revert the changes I made there, and use a try/catch in index.js to replace any single commit error with a catch-all message for the problem?

Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That's a great idea!

);
});