diff --git a/.github/workflows/lint-commit.yml b/.github/workflows/lint-commit.yml new file mode 100644 index 00000000000..210a08a771e --- /dev/null +++ b/.github/workflows/lint-commit.yml @@ -0,0 +1,28 @@ +on: + push: + branches: [main] + pull_request: + # By default, CI will trigger on opened/synchronize/reopened event types. + # https://docs.github.com/en/actions/writing-workflows/choosing-when-your-workflow-runs/events-that-trigger-workflows#pull_request + # Note: To re-run `lint-commits` after fixing the PR title, close-and-reopen the PR. + branches: [main, feature/*] + +# Cancel old jobs when a pull request is updated. +concurrency: + group: ${{ github.head_ref || github.run_id }} + cancel-in-progress: true + +jobs: + lint-commits: + # Note: To re-run `lint-commits` after fixing the PR title, close-and-reopen the PR. + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 1 + - uses: actions/setup-node@v4 + with: + node-version: '20' + - name: Check PR title + run: | + node "$GITHUB_WORKSPACE/.github/workflows/lintcommit.js" diff --git a/.github/workflows/lintcommit.js b/.github/workflows/lintcommit.js new file mode 100644 index 00000000000..299657f0a1c --- /dev/null +++ b/.github/workflows/lintcommit.js @@ -0,0 +1,213 @@ +// Copyright 2025 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +// Checks that a PR title conforms to our custom flavor of "conventional commits" +// (https://www.conventionalcommits.org/). +// +// To run self-tests, simply run this script: +// +// node lintcommit.js test +// +// TODO: "PR must describe Problem in a concise way, and Solution". +// TODO: this script intentionally avoids github APIs so that it is locally-debuggable, but if those +// are needed, use actions/github-script as described in: https://github.com/actions/github-script?tab=readme-ov-file#run-a-separate-file +// + +const fs = require('fs') +// This script intentionally avoids github APIs so that: +// 1. it is locally-debuggable +// 2. the CI job is fast ("npm install" is slow) +// But if we still want to use github API, we can keep it fast by using `actions/github-script` as +// described in: https://github.com/actions/github-script?tab=readme-ov-file#run-a-separate-file +// +// const core = require('@actions/core') +// const github = require('@actions/github') + +const types = new Set([ + 'build', + // Don't allow "chore" because it's over-used. + // Instead, add a new type if absolutely needed (if the existing ones can't possibly apply). + // 'chore', + 'ci', + 'config', + 'deps', + 'docs', + 'feat', + 'fix', + 'perf', + 'refactor', + 'revert', + 'style', + 'telemetry', + 'test', + 'types', +]) + +// TODO: Validate against this once we are satisfied with this list. +const scopes = new Set([ + 'amazonq', + 'core', + 'explorer', + 'lambda', + 'logs', + 'redshift', + 'q-chat', + 'q-featuredev', + 'q-inlinechat', + 'q-transform', + 'sam', + 's3', + 'telemetry', + 'toolkit', + 'ui', +]) +void scopes + +/** + * Checks that a pull request title, or commit message subject, follows the expected format: + * + * type(scope): message + * + * Returns undefined if `title` is valid, else an error message. + */ +function validateTitle(title) { + const parts = title.split(':') + const subject = parts.slice(1).join(':').trim() + + if (title.startsWith('Merge')) { + return undefined + } + + if (parts.length < 2) { + return 'missing colon (:) char' + } + + const typeScope = parts[0] + + const [type, scope] = typeScope.split(/\(([^)]+)\)$/) + + if (/\s+/.test(type)) { + return `type contains whitespace: "${type}"` + } else if (type === 'chore') { + return 'Do not use "chore" as a type. If the existing valid types are insufficent, add a new type to the `lintcommit.js` script.' + } else if (!types.has(type)) { + return `invalid type "${type}"` + } else if (!scope && typeScope.includes('(')) { + return `must be formatted like type(scope):` + } else if (!scope && ['feat', 'fix'].includes(type)) { + return `"${type}" type must include a scope (example: "${type}(amazonq)")` + } else if (scope && scope.length > 30) { + return 'invalid scope (must be <=30 chars)' + } else if (scope && /[^- a-z0-9]+/.test(scope)) { + return `invalid scope (must be lowercase, ascii only): "${scope}"` + } else if (subject.length === 0) { + return 'empty subject' + } else if (subject.length > 100) { + return 'invalid subject (must be <=100 chars)' + } + + return undefined +} + +function run() { + const eventData = JSON.parse(fs.readFileSync(process.env.GITHUB_EVENT_PATH, 'utf8')) + const pullRequest = eventData.pull_request + + // console.log(eventData) + + if (!pullRequest) { + console.info('No pull request found in the context') + return + } + + const title = pullRequest.title + + const failReason = validateTitle(title) + const msg = failReason + ? ` +Invalid pull request title: \`${title}\` + +* Problem: ${failReason} +* Expected format: \`type(scope): subject...\` + * type: one of (${Array.from(types).join(', ')}) + * scope: lowercase, <30 chars + * subject: must be <100 chars + * documentation: https://github.com/aws/aws-toolkit-vscode/blob/master/CONTRIBUTING.md#pull-request-title +* Hint: *close and re-open the PR* to re-trigger CI (after fixing the PR title). +` + : `Pull request title matches the [expected format](https://github.com/aws/aws-toolkit-vscode/blob/master/CONTRIBUTING.md#pull-request-title).` + + if (process.env.GITHUB_STEP_SUMMARY) { + fs.appendFileSync(process.env.GITHUB_STEP_SUMMARY, msg) + } + + if (failReason) { + console.error(msg) + process.exit(1) + } else { + console.info(msg) + } +} + +function _test() { + const tests = { + ' foo(scope): bar': 'type contains whitespace: " foo"', + 'build: update build process': undefined, + 'chore: update dependencies': + 'Do not use "chore" as a type. If the existing valid types are insufficent, add a new type to the `lintcommit.js` script.', + 'ci: configure CI/CD': undefined, + 'config: update configuration files': undefined, + 'deps: bump the aws-sdk group across 1 directory with 5 updates': undefined, + 'docs: update documentation': undefined, + 'feat(foo): add new feature': undefined, + 'feat(foo):': 'empty subject', + 'feat foo):': 'type contains whitespace: "feat foo)"', + 'feat(foo)): sujet': 'invalid type "feat(foo))"', + 'feat(foo: sujet': 'invalid type "feat(foo"', + 'feat(Q Foo Bar): bar': 'invalid scope (must be lowercase, ascii only): "Q Foo Bar"', + 'feat(scope):': 'empty subject', + 'feat(q foo bar): bar': undefined, + '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 ': + 'invalid subject (must be <=100 chars)', + 'feat: foo': '"feat" type must include a scope (example: "feat(amazonq)")', + 'fix: foo': '"fix" type must include a scope (example: "fix(amazonq)")', + 'fix(a-b-c): resolve issue': undefined, + 'foo (scope): bar': 'type contains whitespace: "foo "', + 'invalid title': 'missing colon (:) char', + 'perf: optimize performance': undefined, + 'refactor: improve code structure': undefined, + 'revert: feat: add new feature': undefined, + 'style: format code': undefined, + 'test: add new tests': undefined, + 'types: add type definitions': undefined, + 'Merge staging into feature/lambda-get-started': undefined, + } + + let passed = 0 + let failed = 0 + + for (const [title, expected] of Object.entries(tests)) { + const result = validateTitle(title) + if (result === expected) { + console.log(`✅ Test passed for "${title}"`) + passed++ + } else { + console.log(`❌ Test failed for "${title}" (expected "${expected}", got "${result}")`) + failed++ + } + } + + console.log(`\n${passed} tests passed, ${failed} tests failed`) +} + +function main() { + const mode = process.argv[2] + + if (mode === 'test') { + _test() + } else { + run() + } +} + +main()