| 
 | 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