Skip to content

Commit

Permalink
feat: support Conventional Commits via --type flag (#177)
Browse files Browse the repository at this point in the history
Co-authored-by: Hiroki Osame <hiroki.osame@gmail.com>
  • Loading branch information
ThijSlim and privatenumber committed May 3, 2023
1 parent f466f05 commit 0562761
Show file tree
Hide file tree
Showing 22 changed files with 723 additions and 11 deletions.
6 changes: 6 additions & 0 deletions src/cli.ts
Expand Up @@ -35,6 +35,11 @@ cli(
alias: 'a',
default: false,
},
type: {
type: String,
description: 'Type of commit message to generate',
alias: 't',
},
},

commands: [
Expand All @@ -56,6 +61,7 @@ cli(
argv.flags.generate,
argv.flags.exclude,
argv.flags.all,
argv.flags.type,
rawArgv,
);
}
Expand Down
3 changes: 3 additions & 0 deletions src/commands/aicommits.ts
Expand Up @@ -18,6 +18,7 @@ export default async (
generate: number | undefined,
excludeFiles: string[],
stageAll: boolean,
commitType: string | undefined,
rawArgv: string[],
) => (async () => {

Check warning on line 23 in src/commands/aicommits.ts

View workflow job for this annotation

GitHub Actions / Test (ubuntu-latest)

Async arrow function has a complexity of 12. Maximum allowed is 10

Check warning on line 23 in src/commands/aicommits.ts

View workflow job for this annotation

GitHub Actions / Test (windows-latest)

Async arrow function has a complexity of 12. Maximum allowed is 10
intro(bgCyan(black(' aicommits ')));
Expand Down Expand Up @@ -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();
Expand All @@ -58,6 +60,7 @@ export default async (
staged.diff,
config.generate,
config['max-length'],
config.type,
config.timeout,
config.proxy,
);
Expand Down
1 change: 1 addition & 0 deletions src/commands/prepare-commit-msg-hook.ts
Expand Up @@ -46,6 +46,7 @@ export default () => (async () => {
staged!.diff,
config.generate,
config['max-length'],
config.type,
config.timeout,
config.proxy,
);
Expand Down
15 changes: 14 additions & 1 deletion src/utils/config.ts
Expand Up @@ -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);

Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -99,7 +112,7 @@ type RawConfig = {
[key in ConfigKeys]?: string;
};

type ValidConfig = {
export type ValidConfig = {
[Key in ConfigKeys]: ReturnType<typeof configParsers[Key]>;
};

Expand Down
69 changes: 60 additions & 9 deletions src/utils/openai.ts
@@ -1,13 +1,14 @@
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
encoding_for_model,
} 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,
Expand Down Expand Up @@ -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 '<type>(<optional scope>): <commit message>';
}

return '<commit message>';
};

/**
* 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 = '';
Expand All @@ -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);
Expand All @@ -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,
Expand Down
1 change: 1 addition & 0 deletions tests/index.ts
Expand Up @@ -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'));
});
136 changes: 135 additions & 1 deletion tests/specs/cli/commits.ts
Expand Up @@ -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();
});
Expand Down Expand Up @@ -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({
Expand Down

0 comments on commit 0562761

Please sign in to comment.