From 0562761dc2cdb758cfabb1369df979334e3e617e Mon Sep 17 00:00:00 2001 From: Thijs Limmen Date: Wed, 3 May 2023 15:17:09 +0200 Subject: [PATCH] feat: support Conventional Commits via `--type` flag (#177) Co-authored-by: Hiroki Osame --- src/cli.ts | 6 + src/commands/aicommits.ts | 3 + src/commands/prepare-commit-msg-hook.ts | 1 + src/utils/config.ts | 15 +- src/utils/openai.ts | 69 +++++++- tests/index.ts | 1 + tests/specs/cli/commits.ts | 136 ++++++++++++++- tests/specs/openai/conventional-commits.ts | 161 ++++++++++++++++++ tests/specs/openai/diff-fixtures/README.md | 11 ++ tests/specs/openai/diff-fixtures/chore.txt | 18 ++ .../openai/diff-fixtures/code-refactoring.txt | 34 ++++ .../specs/openai/diff-fixtures/code-style.txt | 28 +++ .../diff-fixtures/continous-integration.txt | 30 ++++ .../diff-fixtures/deprecate-feature.txt | 27 +++ .../diff-fixtures/documentation-changes.txt | 30 ++++ .../fix-nullpointer-exception.txt | 14 ++ .../github-action-build-pipeline.txt | 21 +++ .../openai/diff-fixtures/new-feature.txt | 47 +++++ .../diff-fixtures/performance-improvement.txt | 26 +++ .../openai/diff-fixtures/remove-feature.txt | 27 +++ .../testing-react-application.txt | 22 +++ tests/specs/openai/index.ts | 7 + 22 files changed, 723 insertions(+), 11 deletions(-) create mode 100644 tests/specs/openai/conventional-commits.ts create mode 100644 tests/specs/openai/diff-fixtures/README.md create mode 100644 tests/specs/openai/diff-fixtures/chore.txt create mode 100644 tests/specs/openai/diff-fixtures/code-refactoring.txt create mode 100644 tests/specs/openai/diff-fixtures/code-style.txt create mode 100644 tests/specs/openai/diff-fixtures/continous-integration.txt create mode 100644 tests/specs/openai/diff-fixtures/deprecate-feature.txt create mode 100644 tests/specs/openai/diff-fixtures/documentation-changes.txt create mode 100644 tests/specs/openai/diff-fixtures/fix-nullpointer-exception.txt create mode 100644 tests/specs/openai/diff-fixtures/github-action-build-pipeline.txt create mode 100644 tests/specs/openai/diff-fixtures/new-feature.txt create mode 100644 tests/specs/openai/diff-fixtures/performance-improvement.txt create mode 100644 tests/specs/openai/diff-fixtures/remove-feature.txt create mode 100644 tests/specs/openai/diff-fixtures/testing-react-application.txt create mode 100644 tests/specs/openai/index.ts diff --git a/src/cli.ts b/src/cli.ts index 01b30c60..129bac3d 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -35,6 +35,11 @@ cli( alias: 'a', default: false, }, + type: { + type: String, + description: 'Type of commit message to generate', + alias: 't', + }, }, commands: [ @@ -56,6 +61,7 @@ cli( argv.flags.generate, argv.flags.exclude, argv.flags.all, + argv.flags.type, rawArgv, ); } diff --git a/src/commands/aicommits.ts b/src/commands/aicommits.ts index ab937caa..91b70920 100644 --- a/src/commands/aicommits.ts +++ b/src/commands/aicommits.ts @@ -18,6 +18,7 @@ export default async ( generate: number | undefined, excludeFiles: string[], stageAll: boolean, + commitType: string | undefined, rawArgv: string[], ) => (async () => { intro(bgCyan(black(' aicommits '))); @@ -45,6 +46,7 @@ export default async ( OPENAI_KEY: env.OPENAI_KEY || env.OPENAI_API_KEY, proxy: env.https_proxy || env.HTTPS_PROXY || env.http_proxy || env.HTTP_PROXY, generate: generate?.toString(), + type: commitType?.toString(), }); const s = spinner(); @@ -58,6 +60,7 @@ export default async ( staged.diff, config.generate, config['max-length'], + config.type, config.timeout, config.proxy, ); diff --git a/src/commands/prepare-commit-msg-hook.ts b/src/commands/prepare-commit-msg-hook.ts index dcf9b5b7..b8ec3e25 100644 --- a/src/commands/prepare-commit-msg-hook.ts +++ b/src/commands/prepare-commit-msg-hook.ts @@ -46,6 +46,7 @@ export default () => (async () => { staged!.diff, config.generate, config['max-length'], + config.type, config.timeout, config.proxy, ); diff --git a/src/utils/config.ts b/src/utils/config.ts index f09c8229..47429e5d 100644 --- a/src/utils/config.ts +++ b/src/utils/config.ts @@ -6,6 +6,10 @@ import type { TiktokenModel } from '@dqbd/tiktoken'; import { fileExists } from './fs.js'; import { KnownError } from './error.js'; +const commitTypes = ['', 'conventional'] as const; + +export type CommitType = typeof commitTypes[number]; + const { hasOwnProperty } = Object.prototype; export const hasOwn = (object: unknown, key: PropertyKey) => hasOwnProperty.call(object, key); @@ -51,6 +55,15 @@ const configParsers = { return parsed; }, + type(type?: string) { + if (!type) { + return ''; + } + + parseAssert('type', commitTypes.includes(type as CommitType), 'Invalid commit type'); + + return type as CommitType; + }, proxy(url?: string) { if (!url || url.length === 0) { return undefined; @@ -99,7 +112,7 @@ type RawConfig = { [key in ConfigKeys]?: string; }; -type ValidConfig = { +export type ValidConfig = { [Key in ConfigKeys]: ReturnType; }; diff --git a/src/utils/openai.ts b/src/utils/openai.ts index 9317b993..93ded56d 100644 --- a/src/utils/openai.ts +++ b/src/utils/openai.ts @@ -1,6 +1,6 @@ import https from 'https'; import type { ClientRequest, IncomingMessage } from 'http'; -import type { CreateChatCompletionRequest, CreateChatCompletionResponse } from 'openai'; +import type { ChatCompletionRequestMessage, CreateChatCompletionRequest, CreateChatCompletionResponse } from 'openai'; import { TiktokenModel, // eslint-disable-next-line camelcase @@ -8,6 +8,7 @@ import { } from '@dqbd/tiktoken'; import createHttpsProxyAgent from 'https-proxy-agent'; import { KnownError } from './error.js'; +import type { CommitType } from './config.js'; const httpsPost = async ( hostname: string, @@ -104,16 +105,51 @@ const sanitizeMessage = (message: string) => message.trim().replace(/[\n\r]/g, ' const deduplicateMessages = (array: string[]) => Array.from(new Set(array)); -const getPrompt = ( +const getBasePrompt = ( locale: string, - diff: string, maxLength: number, ) => `${[ 'Generate a concise git commit message written in present tense for the following code diff with the given specifications below:', `Message language: ${locale}`, `Commit message must be a maximum of ${maxLength} characters.`, 'Exclude anything unnecessary such as translation. Your entire response will be passed directly into git commit.', -].join('\n')}\n\n${diff}`; +].join('\n')}`; + +const getCommitMessageFormatOutputExample = (type: CommitType) => `The output response must be in format:\n${getCommitMessageFormat(type)}`; + +const getCommitMessageFormat = (type: CommitType) => { + if (type === 'conventional') { + return '(): '; + } + + return ''; +}; + +/** + * References: + * Commitlint: + * https://github.com/conventional-changelog/commitlint/blob/18fbed7ea86ac0ec9d5449b4979b762ec4305a92/%40commitlint/config-conventional/index.js#L40-L100 + * + * Conventional Changelog: + * https://github.com/conventional-changelog/conventional-changelog/blob/d0e5d5926c8addba74bc962553dd8bcfba90e228/packages/conventional-changelog-conventionalcommits/writer-opts.js#L182-L193 + */ +const getExtraContextForConventionalCommits = () => ( + `Choose a type from the type-to-description JSON below that best describes the git diff:\n${ + JSON.stringify({ + docs: 'Documentation only changes', + style: 'Changes that do not affect the meaning of the code (white-space, formatting, missing semi-colons, etc)', + refactor: 'A code change that neither fixes a bug nor adds a feature', + perf: 'A code change that improves performance', + test: 'Adding missing tests or correcting existing tests', + build: 'Changes that affect the build system or external dependencies', + ci: 'Changes to our CI configuration files and scripts', + chore: "Other changes that don't modify src or test files", + revert: 'Reverts a previous commit', + feat: 'A new feature', + fix: 'A bug fix', + }, null, 2) + }` +); const generateStringFromLength = (length: number) => { let result = ''; @@ -139,10 +175,28 @@ export const generateCommitMessage = async ( diff: string, completions: number, maxLength: number, + type: CommitType, timeout: number, proxy?: string, ) => { - const prompt = getPrompt(locale, diff, maxLength); + const prompt = getBasePrompt(locale, maxLength); + + const conventionalCommitsExtraContext = type === 'conventional' + ? getExtraContextForConventionalCommits() + : ''; + + const commitMessageFormatOutputExample = getCommitMessageFormatOutputExample(type); + + const messages: ChatCompletionRequestMessage[] = [ + { + role: 'system', + content: `${prompt}\n${conventionalCommitsExtraContext}\n${commitMessageFormatOutputExample}`, + }, + { + role: 'user', + content: diff, + }, + ]; // Padded by 5 for more room for the completion. const stringFromLength = generateStringFromLength(maxLength + 5); @@ -155,10 +209,7 @@ export const generateCommitMessage = async ( apiKey, { model, - messages: [{ - role: 'user', - content: prompt, - }], + messages, temperature: 0.7, top_p: 1, frequency_penalty: 0, diff --git a/tests/index.ts b/tests/index.ts index 71c30867..e9ba0a1c 100644 --- a/tests/index.ts +++ b/tests/index.ts @@ -2,6 +2,7 @@ import { describe } from 'manten'; describe('aicommits', ({ runTestSuite }) => { runTestSuite(import('./specs/cli/index.js')); + runTestSuite(import('./specs/openai/index.js')); runTestSuite(import('./specs/config.js')); runTestSuite(import('./specs/git-hook.js')); }); diff --git a/tests/specs/cli/commits.ts b/tests/specs/cli/commits.ts index 917a1eb7..497acf63 100644 --- a/tests/specs/cli/commits.ts +++ b/tests/specs/cli/commits.ts @@ -86,7 +86,7 @@ export default testSuite(({ describe }) => { commitMessage, length: commitMessage.length, }); - expect(commitMessage.length <= 20).toBe(true); + expect(commitMessage.length).toBeLessThanOrEqual(20); await fixture.rm(); }); @@ -208,6 +208,140 @@ export default testSuite(({ describe }) => { await fixture.rm(); }); + describe('commit types', ({ test }) => { + test('Should not use conventional commits by default', async () => { + const conventionalCommitPattern = /(build|chore|ci|docs|feat|fix|perf|refactor|revert|style|test):\s/; + const { fixture, aicommits } = await createFixture({ + ...files, + }); + const git = await createGit(fixture.path); + + await git('add', ['data.json']); + + const committing = aicommits(); + + committing.stdout!.on('data', (buffer: Buffer) => { + const stdout = buffer.toString(); + if (stdout.match('└')) { + committing.stdin!.write('y'); + committing.stdin!.end(); + } + }); + + await committing; + + const statusAfter = await git('status', ['--porcelain', '--untracked-files=no']); + expect(statusAfter.stdout).toBe(''); + + const { stdout: commitMessage } = await git('log', ['--oneline']); + console.log('Committed with:', commitMessage); + expect(commitMessage).not.toMatch(conventionalCommitPattern); + + await fixture.rm(); + }); + + test('Conventional commits', async () => { + const conventionalCommitPattern = /(build|chore|ci|docs|feat|fix|perf|refactor|revert|style|test):\s/; + const { fixture, aicommits } = await createFixture({ + ...files, + '.aicommits': `${files['.aicommits']}\ntype=conventional`, + }); + const git = await createGit(fixture.path); + + await git('add', ['data.json']); + + const committing = aicommits(); + + committing.stdout!.on('data', (buffer: Buffer) => { + const stdout = buffer.toString(); + if (stdout.match('└')) { + committing.stdin!.write('y'); + committing.stdin!.end(); + } + }); + + await committing; + + const statusAfter = await git('status', ['--porcelain', '--untracked-files=no']); + expect(statusAfter.stdout).toBe(''); + + const { stdout: commitMessage } = await git('log', ['--oneline']); + console.log('Committed with:', commitMessage); + expect(commitMessage).toMatch(conventionalCommitPattern); + + await fixture.rm(); + }); + + test('Accepts --type flag, overriding config', async () => { + const conventionalCommitPattern = /(build|chore|ci|docs|feat|fix|perf|refactor|revert|style|test):\s/; + const { fixture, aicommits } = await createFixture({ + ...files, + '.aicommits': `${files['.aicommits']}\ntype=other`, + }); + const git = await createGit(fixture.path); + + await git('add', ['data.json']); + + // Generate flag should override generate config + const committing = aicommits([ + '--type', 'conventional', + ]); + + committing.stdout!.on('data', (buffer: Buffer) => { + const stdout = buffer.toString(); + if (stdout.match('└')) { + committing.stdin!.write('y'); + committing.stdin!.end(); + } + }); + + await committing; + + const statusAfter = await git('status', ['--porcelain', '--untracked-files=no']); + expect(statusAfter.stdout).toBe(''); + + const { stdout: commitMessage } = await git('log', ['--oneline']); + console.log('Committed with:', commitMessage); + expect(commitMessage).toMatch(conventionalCommitPattern); + + await fixture.rm(); + }); + + test('Accepts empty --type flag', async () => { + const conventionalCommitPattern = /(build|chore|ci|docs|feat|fix|perf|refactor|revert|style|test):\s/; + const { fixture, aicommits } = await createFixture({ + ...files, + '.aicommits': `${files['.aicommits']}\ntype=conventional`, + }); + const git = await createGit(fixture.path); + + await git('add', ['data.json']); + + const committing = aicommits([ + '--type', '', + ]); + + committing.stdout!.on('data', (buffer: Buffer) => { + const stdout = buffer.toString(); + if (stdout.match('└')) { + committing.stdin!.write('y'); + committing.stdin!.end(); + } + }); + + await committing; + + const statusAfter = await git('status', ['--porcelain', '--untracked-files=no']); + expect(statusAfter.stdout).toBe(''); + + const { stdout: commitMessage } = await git('log', ['--oneline']); + console.log('Committed with:', commitMessage); + expect(commitMessage).not.toMatch(conventionalCommitPattern); + + await fixture.rm(); + }); + }); + describe('proxy', ({ test }) => { test('Fails on invalid proxy', async () => { const { fixture, aicommits } = await createFixture({ diff --git a/tests/specs/openai/conventional-commits.ts b/tests/specs/openai/conventional-commits.ts new file mode 100644 index 00000000..1fd537ee --- /dev/null +++ b/tests/specs/openai/conventional-commits.ts @@ -0,0 +1,161 @@ +import { readFile } from 'fs/promises'; +import path from 'path'; +import { fileURLToPath } from 'url'; +import { expect, testSuite } from 'manten'; +import { + generateCommitMessage, +} from '../../../src/utils/openai.js'; +import type { ValidConfig } from '../../../src/utils/config.js'; + +const { OPENAI_KEY } = process.env; + +export default testSuite(({ describe }) => { + if (!OPENAI_KEY) { + console.warn('⚠️ process.env.OPENAI_KEY is necessary to run these tests. Skipping...'); + return; + } + + describe('Conventional Commits', async ({ test }) => { + await test('Should not translate conventional commit type to Japanase when locale config is set to japanese', async () => { + const japaneseConventionalCommitPattern = /(build|chore|ci|docs|feat|fix|perf|refactor|revert|style|test)(\(.*\))?: [\u3000-\u303F\u3040-\u309F\u30A0-\u30FF\uFF00-\uFF9F\u4E00-\u9FAF\u3400-\u4DBF]/; + + const gitDiff = await readDiffFromFile('new-feature.txt'); + + const commitMessage = await runGenerateCommitMessage(gitDiff, { + locale: 'ja', + }); + + expect(commitMessage).toMatch(japaneseConventionalCommitPattern); + console.log('Generated message:', commitMessage); + }); + + await test('Should use "feat:" conventional commit when change relate to adding a new feature', async () => { + const gitDiff = await readDiffFromFile('new-feature.txt'); + + const commitMessage = await runGenerateCommitMessage(gitDiff); + + // should match "feat:" or "feat():" + expect(commitMessage).toMatch(/(feat(\(.*\))?):/); + console.log('Generated message:', commitMessage); + }); + + await test('Should use "refactor:" conventional commit when change relate to code refactoring', async () => { + const gitDiff = await readDiffFromFile('code-refactoring.txt'); + + const commitMessage = await runGenerateCommitMessage(gitDiff); + + // should match "refactor:" or "refactor():" + expect(commitMessage).toMatch(/(refactor(\(.*\))?):/); + console.log('Generated message:', commitMessage); + }); + + await test('Should use "test:" conventional commit when change relate to testing a React application', async () => { + const gitDiff = await readDiffFromFile('testing-react-application.txt'); + + const commitMessage = await runGenerateCommitMessage(gitDiff); + + // should match "test:" or "test():" + expect(commitMessage).toMatch(/(test(\(.*\))?):/); + console.log('Generated message:', commitMessage); + }); + + await test('Should use "build:" conventional commit when change relate to github action build pipeline', async () => { + const gitDiff = await readDiffFromFile( + 'github-action-build-pipeline.txt', + ); + + const commitMessage = await runGenerateCommitMessage(gitDiff); + + // should match "build:" or "build():" + expect(commitMessage).toMatch(/((build|ci)(\(.*\))?):/); + console.log('Generated message:', commitMessage); + }); + + await test('Should use "(ci|build):" conventional commit when change relate to continious integration', async () => { + const gitDiff = await readDiffFromFile('continous-integration.txt'); + + const commitMessage = await runGenerateCommitMessage(gitDiff); + + // should match "ci:" or "ci(): + // It also sometimes generates build and feat + expect(commitMessage).toMatch(/((ci|build|feat)(\(.*\))?):/); + console.log('Generated message:', commitMessage); + }); + + await test('Should use "docs:" conventional commit when change relate to documentation changes', async () => { + const gitDiff = await readDiffFromFile('documentation-changes.txt'); + const commitMessage = await runGenerateCommitMessage(gitDiff); + + // should match "docs:" or "docs():" + expect(commitMessage).toMatch(/(docs(\(.*\))?):/); + console.log('Generated message:', commitMessage); + }); + + await test('Should use "fix:" conventional commit when change relate to fixing code', async () => { + const gitDiff = await readDiffFromFile('fix-nullpointer-exception.txt'); + const commitMessage = await runGenerateCommitMessage(gitDiff); + + // should match "fix:" or "fix():" + // Sometimes it generates refactor + expect(commitMessage).toMatch(/((fix|refactor)(\(.*\))?):/); + console.log('Generated message:', commitMessage); + }); + + await test('Should use "style:" conventional commit when change relate to code style improvements', async () => { + const gitDiff = await readDiffFromFile('code-style.txt'); + const commitMessage = await runGenerateCommitMessage(gitDiff); + + // should match "style:" or "style(