Skip to content

Commit 564c188

Browse files
committed
🐛 fix(ci): relax commit message check for Copilot and GitHub web edits
- add requireEmoji option (default true) to validateCommitMessage so PR title stays strict while commit messages accept emoji-less conventional-commit format (e.g. "docs(qa): ...") - auto-skip GitHub web UI patterns (Update/Create/Delete/Rename) in relaxed mode - update workflow to pass requireEmoji: false for commit-messages job - add 18 new tests for relaxed mode and isGitHubWebEdit (53 total)
1 parent a0f04c1 commit 564c188

3 files changed

Lines changed: 134 additions & 36 deletions

File tree

.github/scripts/commit-message-check.js

Lines changed: 28 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -49,26 +49,17 @@ function findTypeForEmoji(emoji) {
4949
return null;
5050
}
5151

52-
/**
53-
* Return true when the subject line is an auto-generated merge commit
54-
* that should be exempt from conventional-commit rules.
55-
*/
5652
function isAutoGenerated(subject) {
5753
return /^Merge /.test(subject);
5854
}
5955

56+
function isGitHubWebEdit(subject) {
57+
return /^(Update|Create|Delete|Rename) .+/.test(subject);
58+
}
59+
6060
// ── Core validation ──────────────────────────────────────────────────
6161

62-
/**
63-
* Validate a commit message (or PR title) against the project's
64-
* conventional-commit spec with emoji prefixes.
65-
*
66-
* Only the first line (subject) is validated.
67-
*
68-
* @param {string} message Full commit message or PR title
69-
* @returns {{ valid: boolean, errors: string[], skipped: boolean }}
70-
*/
71-
function validateCommitMessage(message) {
62+
function validateCommitMessage(message, {requireEmoji = true} = {}) {
7263
const errors = [];
7364

7465
if (!message || typeof message !== 'string') {
@@ -81,72 +72,74 @@ function validateCommitMessage(message) {
8172
return {valid: false, errors: ['Empty subject line'], skipped: false};
8273
}
8374

84-
// Auto-generated merge commits are exempt
8575
if (isAutoGenerated(subject)) {
8676
return {valid: true, errors: [], skipped: true};
8777
}
8878

89-
// ── Length ────────────────────────────────────────────────────
79+
if (!requireEmoji && isGitHubWebEdit(subject)) {
80+
return {valid: true, errors: [], skipped: true};
81+
}
82+
9083
if (subject.length > MAX_SUBJECT_LENGTH) {
9184
errors.push(
9285
`Subject exceeds ${MAX_SUBJECT_LENGTH} characters (${subject.length})`,
9386
);
9487
}
9588

96-
// ── Split emoji from rest ────────────────────────────────────
9789
const firstSpace = subject.indexOf(' ');
9890
if (firstSpace === -1) {
9991
errors.push(
100-
'Subject must start with an emoji followed by a space',
92+
requireEmoji
93+
? 'Subject must start with an emoji followed by a space'
94+
: 'Subject must contain a space',
10195
);
10296
return {valid: false, errors, skipped: false};
10397
}
10498

105-
const emoji = subject.slice(0, firstSpace);
99+
const firstToken = subject.slice(0, firstSpace);
106100
const rest = subject.slice(firstSpace + 1);
107101

108-
// ── Emoji lookup ─────────────────────────────────────────────
109-
const emojiType = findTypeForEmoji(emoji);
110-
if (!emojiType) {
102+
const emojiType = findTypeForEmoji(firstToken);
103+
const hasEmoji = emojiType !== null;
104+
105+
if (requireEmoji && !hasEmoji) {
111106
const validList = Object.entries(EMOJI_TYPE_MAP)
112107
.map(([e, t]) => `${e} ${t}`)
113108
.join(', ');
114-
errors.push(`Unknown emoji "${emoji}". Valid: ${validList}`);
109+
errors.push(`Unknown emoji "${firstToken}". Valid: ${validList}`);
115110
}
116111

117-
// ── Parse: <type>[(<scope>)][!]: <description> ───────────────
118-
const restMatch = rest.match(/^(\w+)(?:\(([^)]+)\))?(!)?: (.+)$/);
119-
if (!restMatch) {
112+
const textToParse = hasEmoji ? rest : subject;
113+
const formatMatch = textToParse.match(/^(\w+)(?:\(([^)]+)\))?(!)?: (.+)$/);
114+
if (!formatMatch) {
120115
errors.push(
121-
'Format after emoji must be: <type>[(<scope>)][!]: <description>',
116+
hasEmoji
117+
? 'Format after emoji must be: <type>[(<scope>)][!]: <description>'
118+
: 'Format must be: <type>[(<scope>)][!]: <description>',
122119
);
123120
return {valid: false, errors, skipped: false};
124121
}
125122

126-
const [, type, , , description] = restMatch;
123+
const [, type, , , description] = formatMatch;
127124

128-
// ── Type enum ────────────────────────────────────────────────
129125
if (!VALID_TYPES.includes(type)) {
130126
errors.push(
131127
`Invalid type "${type}". Valid: ${VALID_TYPES.join(', ')}`,
132128
);
133129
}
134130

135-
// ── Emoji ↔ type consistency ─────────────────────────────────
136-
if (emojiType && VALID_TYPES.includes(type) && emojiType !== type) {
131+
if (hasEmoji && emojiType && VALID_TYPES.includes(type) && emojiType !== type) {
137132
const correctEmoji = TYPE_EMOJI_MAP[type] || '?';
138133
errors.push(
139-
`Emoji ${emoji} is for "${emojiType}", not "${type}". ` +
134+
`Emoji ${firstToken} is for "${emojiType}", not "${type}". ` +
140135
`Use ${correctEmoji} for "${type}"`,
141136
);
142137
}
143138

144-
// ── No capitalization ────────────────────────────────────────
145139
if (description && /^[A-Z]/.test(description)) {
146140
errors.push('Description must start with a lowercase letter');
147141
}
148142

149-
// ── No trailing period ───────────────────────────────────────
150143
if (description && description.endsWith('.')) {
151144
errors.push('Description must not end with a period');
152145
}
@@ -162,5 +155,6 @@ module.exports = {
162155
validateCommitMessage,
163156
findTypeForEmoji,
164157
isAutoGenerated,
158+
isGitHubWebEdit,
165159
stripVS16,
166160
};

.github/scripts/commit-message-check.test.js

Lines changed: 105 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ const {
1010
validateCommitMessage,
1111
findTypeForEmoji,
1212
isAutoGenerated,
13+
isGitHubWebEdit,
1314
stripVS16,
1415
} = require('./commit-message-check.js');
1516

@@ -178,7 +179,7 @@ describe('validateCommitMessage – invalid', () => {
178179
it('no space after emoji', () => {
179180
const r = validateCommitMessage('\u2728feat: add login');
180181
assert.equal(r.valid, false);
181-
assert.ok(r.errors.some(e => e.includes('Format after emoji')));
182+
assert.ok(r.errors.some(e => e.includes('Format')));
182183
});
183184

184185
it('unknown emoji', () => {
@@ -237,3 +238,106 @@ describe('validateCommitMessage – invalid', () => {
237238
assert.ok(r.errors.length >= 3, `expected >= 3 errors, got ${r.errors.length}: ${r.errors.join('; ')}`);
238239
});
239240
});
241+
242+
// ── isGitHubWebEdit ───────────────────────────────────────────
243+
244+
describe('isGitHubWebEdit', () => {
245+
it('detects Update pattern', () => {
246+
assert.equal(isGitHubWebEdit('Update pr-readiness-check.yml'), true);
247+
});
248+
249+
it('detects Create pattern', () => {
250+
assert.equal(isGitHubWebEdit('Create new-file.md'), true);
251+
});
252+
253+
it('detects Delete pattern', () => {
254+
assert.equal(isGitHubWebEdit('Delete old-file.js'), true);
255+
});
256+
257+
it('detects Rename pattern', () => {
258+
assert.equal(isGitHubWebEdit('Rename foo.js to bar.js'), true);
259+
});
260+
261+
it('rejects normal commit', () => {
262+
assert.equal(isGitHubWebEdit('fix: update login flow'), false);
263+
});
264+
265+
it('rejects partial match at wrong position', () => {
266+
assert.equal(isGitHubWebEdit('Please Update the file'), false);
267+
});
268+
});
269+
270+
// ── validateCommitMessage – relaxed mode (requireEmoji: false) ─
271+
272+
describe('validateCommitMessage – relaxed mode', () => {
273+
const relaxed = {requireEmoji: false};
274+
275+
it('accepts emoji-less conventional commit', () => {
276+
const r = validateCommitMessage('docs(qa): add full phpdoc for shared ICU detector', relaxed);
277+
assert.equal(r.valid, true);
278+
assert.equal(r.skipped, false);
279+
});
280+
281+
it('accepts emoji-less commit with scope and !', () => {
282+
const r = validateCommitMessage('feat(api)!: remove legacy endpoint', relaxed);
283+
assert.equal(r.valid, true);
284+
});
285+
286+
it('accepts emoji-less commit without scope', () => {
287+
const r = validateCommitMessage('refactor: share ICU source detection logic across LQA flows', relaxed);
288+
assert.equal(r.valid, true);
289+
});
290+
291+
it('still accepts emoji commits in relaxed mode', () => {
292+
const r = validateCommitMessage('\u2728 feat: add user login', relaxed);
293+
assert.equal(r.valid, true);
294+
});
295+
296+
it('still validates emoji-type consistency in relaxed mode', () => {
297+
const r = validateCommitMessage('\u{1F41B} feat: wrong emoji', relaxed);
298+
assert.equal(r.valid, false);
299+
assert.ok(r.errors.some(e => e.includes('not "feat"')));
300+
});
301+
302+
it('skips GitHub web edit in relaxed mode', () => {
303+
const r = validateCommitMessage('Update .github/workflows/pr-readiness-check.yml', relaxed);
304+
assert.equal(r.valid, true);
305+
assert.equal(r.skipped, true);
306+
});
307+
308+
it('does NOT skip GitHub web edit in strict mode', () => {
309+
const r = validateCommitMessage('Update .github/workflows/pr-readiness-check.yml');
310+
assert.equal(r.valid, false);
311+
});
312+
313+
it('still rejects invalid type in relaxed mode', () => {
314+
const r = validateCommitMessage('feature: add login', relaxed);
315+
assert.equal(r.valid, false);
316+
assert.ok(r.errors.some(e => e.includes('Invalid type')));
317+
});
318+
319+
it('still rejects capitalized description in relaxed mode', () => {
320+
const r = validateCommitMessage('fix: Update broken thing', relaxed);
321+
assert.equal(r.valid, false);
322+
assert.ok(r.errors.some(e => e.includes('lowercase')));
323+
});
324+
325+
it('still rejects trailing period in relaxed mode', () => {
326+
const r = validateCommitMessage('fix: update broken thing.', relaxed);
327+
assert.equal(r.valid, false);
328+
assert.ok(r.errors.some(e => e.includes('period')));
329+
});
330+
331+
it('still rejects over-length in relaxed mode', () => {
332+
const desc = 'a'.repeat(96);
333+
const r = validateCommitMessage(`fix: ${desc}`, relaxed);
334+
assert.equal(r.valid, false);
335+
assert.ok(r.errors.some(e => e.includes('exceeds')));
336+
});
337+
338+
it('skips merge commits in relaxed mode', () => {
339+
const r = validateCommitMessage('Merge branch \'main\' into feature', relaxed);
340+
assert.equal(r.valid, true);
341+
assert.equal(r.skipped, true);
342+
});
343+
});

.github/workflows/commit-message-check.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -76,7 +76,7 @@ jobs:
7676
const failures = [];
7777
for (const c of commits) {
7878
const msg = c.commit.message;
79-
const result = validateCommitMessage(msg);
79+
const result = validateCommitMessage(msg, {requireEmoji: false});
8080
if (result.skipped) continue;
8181
if (!result.valid) {
8282
const sha = c.sha.slice(0, 7);

0 commit comments

Comments
 (0)