Skip to content

Commit 19931e5

Browse files
authored
ci: steal commit message linter from VSC toolkit (#5351)
Attempt to enforce more consistency on commit messages
1 parent 39eacec commit 19931e5

File tree

2 files changed

+241
-0
lines changed

2 files changed

+241
-0
lines changed

.github/workflows/lint-commit.yml

+28
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
on:
2+
push:
3+
branches: [main]
4+
pull_request:
5+
# By default, CI will trigger on opened/synchronize/reopened event types.
6+
# https://docs.github.com/en/actions/writing-workflows/choosing-when-your-workflow-runs/events-that-trigger-workflows#pull_request
7+
# Note: To re-run `lint-commits` after fixing the PR title, close-and-reopen the PR.
8+
branches: [main, feature/*]
9+
10+
# Cancel old jobs when a pull request is updated.
11+
concurrency:
12+
group: ${{ github.head_ref || github.run_id }}
13+
cancel-in-progress: true
14+
15+
jobs:
16+
lint-commits:
17+
# Note: To re-run `lint-commits` after fixing the PR title, close-and-reopen the PR.
18+
runs-on: ubuntu-latest
19+
steps:
20+
- uses: actions/checkout@v4
21+
with:
22+
fetch-depth: 1
23+
- uses: actions/setup-node@v4
24+
with:
25+
node-version: '20'
26+
- name: Check PR title
27+
run: |
28+
node "$GITHUB_WORKSPACE/.github/workflows/lintcommit.js"

.github/workflows/lintcommit.js

+213
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,213 @@
1+
// Copyright 2025 Amazon.com, Inc. or its affiliates. All Rights Reserved.
2+
// SPDX-License-Identifier: Apache-2.0
3+
4+
// Checks that a PR title conforms to our custom flavor of "conventional commits"
5+
// (https://www.conventionalcommits.org/).
6+
//
7+
// To run self-tests, simply run this script:
8+
//
9+
// node lintcommit.js test
10+
//
11+
// TODO: "PR must describe Problem in a concise way, and Solution".
12+
// TODO: this script intentionally avoids github APIs so that it is locally-debuggable, but if those
13+
// are needed, use actions/github-script as described in: https://github.com/actions/github-script?tab=readme-ov-file#run-a-separate-file
14+
//
15+
16+
const fs = require('fs')
17+
// This script intentionally avoids github APIs so that:
18+
// 1. it is locally-debuggable
19+
// 2. the CI job is fast ("npm install" is slow)
20+
// But if we still want to use github API, we can keep it fast by using `actions/github-script` as
21+
// described in: https://github.com/actions/github-script?tab=readme-ov-file#run-a-separate-file
22+
//
23+
// const core = require('@actions/core')
24+
// const github = require('@actions/github')
25+
26+
const types = new Set([
27+
'build',
28+
// Don't allow "chore" because it's over-used.
29+
// Instead, add a new type if absolutely needed (if the existing ones can't possibly apply).
30+
// 'chore',
31+
'ci',
32+
'config',
33+
'deps',
34+
'docs',
35+
'feat',
36+
'fix',
37+
'perf',
38+
'refactor',
39+
'revert',
40+
'style',
41+
'telemetry',
42+
'test',
43+
'types',
44+
])
45+
46+
// TODO: Validate against this once we are satisfied with this list.
47+
const scopes = new Set([
48+
'amazonq',
49+
'core',
50+
'explorer',
51+
'lambda',
52+
'logs',
53+
'redshift',
54+
'q-chat',
55+
'q-featuredev',
56+
'q-inlinechat',
57+
'q-transform',
58+
'sam',
59+
's3',
60+
'telemetry',
61+
'toolkit',
62+
'ui',
63+
])
64+
void scopes
65+
66+
/**
67+
* Checks that a pull request title, or commit message subject, follows the expected format:
68+
*
69+
* type(scope): message
70+
*
71+
* Returns undefined if `title` is valid, else an error message.
72+
*/
73+
function validateTitle(title) {
74+
const parts = title.split(':')
75+
const subject = parts.slice(1).join(':').trim()
76+
77+
if (title.startsWith('Merge')) {
78+
return undefined
79+
}
80+
81+
if (parts.length < 2) {
82+
return 'missing colon (:) char'
83+
}
84+
85+
const typeScope = parts[0]
86+
87+
const [type, scope] = typeScope.split(/\(([^)]+)\)$/)
88+
89+
if (/\s+/.test(type)) {
90+
return `type contains whitespace: "${type}"`
91+
} else if (type === 'chore') {
92+
return 'Do not use "chore" as a type. If the existing valid types are insufficent, add a new type to the `lintcommit.js` script.'
93+
} else if (!types.has(type)) {
94+
return `invalid type "${type}"`
95+
} else if (!scope && typeScope.includes('(')) {
96+
return `must be formatted like type(scope):`
97+
} else if (!scope && ['feat', 'fix'].includes(type)) {
98+
return `"${type}" type must include a scope (example: "${type}(amazonq)")`
99+
} else if (scope && scope.length > 30) {
100+
return 'invalid scope (must be <=30 chars)'
101+
} else if (scope && /[^- a-z0-9]+/.test(scope)) {
102+
return `invalid scope (must be lowercase, ascii only): "${scope}"`
103+
} else if (subject.length === 0) {
104+
return 'empty subject'
105+
} else if (subject.length > 100) {
106+
return 'invalid subject (must be <=100 chars)'
107+
}
108+
109+
return undefined
110+
}
111+
112+
function run() {
113+
const eventData = JSON.parse(fs.readFileSync(process.env.GITHUB_EVENT_PATH, 'utf8'))
114+
const pullRequest = eventData.pull_request
115+
116+
// console.log(eventData)
117+
118+
if (!pullRequest) {
119+
console.info('No pull request found in the context')
120+
return
121+
}
122+
123+
const title = pullRequest.title
124+
125+
const failReason = validateTitle(title)
126+
const msg = failReason
127+
? `
128+
Invalid pull request title: \`${title}\`
129+
130+
* Problem: ${failReason}
131+
* Expected format: \`type(scope): subject...\`
132+
* type: one of (${Array.from(types).join(', ')})
133+
* scope: lowercase, <30 chars
134+
* subject: must be <100 chars
135+
* documentation: https://github.com/aws/aws-toolkit-vscode/blob/master/CONTRIBUTING.md#pull-request-title
136+
* Hint: *close and re-open the PR* to re-trigger CI (after fixing the PR title).
137+
`
138+
: `Pull request title matches the [expected format](https://github.com/aws/aws-toolkit-vscode/blob/master/CONTRIBUTING.md#pull-request-title).`
139+
140+
if (process.env.GITHUB_STEP_SUMMARY) {
141+
fs.appendFileSync(process.env.GITHUB_STEP_SUMMARY, msg)
142+
}
143+
144+
if (failReason) {
145+
console.error(msg)
146+
process.exit(1)
147+
} else {
148+
console.info(msg)
149+
}
150+
}
151+
152+
function _test() {
153+
const tests = {
154+
' foo(scope): bar': 'type contains whitespace: " foo"',
155+
'build: update build process': undefined,
156+
'chore: update dependencies':
157+
'Do not use "chore" as a type. If the existing valid types are insufficent, add a new type to the `lintcommit.js` script.',
158+
'ci: configure CI/CD': undefined,
159+
'config: update configuration files': undefined,
160+
'deps: bump the aws-sdk group across 1 directory with 5 updates': undefined,
161+
'docs: update documentation': undefined,
162+
'feat(foo): add new feature': undefined,
163+
'feat(foo):': 'empty subject',
164+
'feat foo):': 'type contains whitespace: "feat foo)"',
165+
'feat(foo)): sujet': 'invalid type "feat(foo))"',
166+
'feat(foo: sujet': 'invalid type "feat(foo"',
167+
'feat(Q Foo Bar): bar': 'invalid scope (must be lowercase, ascii only): "Q Foo Bar"',
168+
'feat(scope):': 'empty subject',
169+
'feat(q foo bar): bar': undefined,
170+
'feat(foo): x x x x x x x x x x x x x x x x x x x x x x x x x x x x x x x x x x x x x x x x x x x x x x x x x x x ':
171+
'invalid subject (must be <=100 chars)',
172+
'feat: foo': '"feat" type must include a scope (example: "feat(amazonq)")',
173+
'fix: foo': '"fix" type must include a scope (example: "fix(amazonq)")',
174+
'fix(a-b-c): resolve issue': undefined,
175+
'foo (scope): bar': 'type contains whitespace: "foo "',
176+
'invalid title': 'missing colon (:) char',
177+
'perf: optimize performance': undefined,
178+
'refactor: improve code structure': undefined,
179+
'revert: feat: add new feature': undefined,
180+
'style: format code': undefined,
181+
'test: add new tests': undefined,
182+
'types: add type definitions': undefined,
183+
'Merge staging into feature/lambda-get-started': undefined,
184+
}
185+
186+
let passed = 0
187+
let failed = 0
188+
189+
for (const [title, expected] of Object.entries(tests)) {
190+
const result = validateTitle(title)
191+
if (result === expected) {
192+
console.log(`✅ Test passed for "${title}"`)
193+
passed++
194+
} else {
195+
console.log(`❌ Test failed for "${title}" (expected "${expected}", got "${result}")`)
196+
failed++
197+
}
198+
}
199+
200+
console.log(`\n${passed} tests passed, ${failed} tests failed`)
201+
}
202+
203+
function main() {
204+
const mode = process.argv[2]
205+
206+
if (mode === 'test') {
207+
_test()
208+
} else {
209+
run()
210+
}
211+
}
212+
213+
main()

0 commit comments

Comments
 (0)