formatting

This commit is contained in:
Hassan El Mghari
2024-01-26 09:53:16 -08:00
parent 578572b1d9
commit f33bfeed60
14 changed files with 836 additions and 3251 deletions

View File

@@ -6,6 +6,7 @@ end_of_line = lf
charset = utf-8 charset = utf-8
trim_trailing_whitespace = true trim_trailing_whitespace = true
insert_final_newline = true insert_final_newline = true
quote_type = single
[*.yml] [*.yml]
indent_style = space indent_style = space

View File

@@ -19,66 +19,36 @@
"aic": "./dist/cli.mjs" "aic": "./dist/cli.mjs"
}, },
"scripts": { "scripts": {
"prepare": "simple-git-hooks",
"build": "pkgroll --minify", "build": "pkgroll --minify",
"lint": "eslint --cache .", "lint": "",
"type-check": "tsc", "type-check": "tsc",
"test": "tsx tests", "test": "tsx tests",
"prepack": "pnpm build && clean-pkg-json" "prepack": "pnpm build && clean-pkg-json"
}, },
"simple-git-hooks": {
"pre-commit": "pnpm lint-staged"
},
"lint-staged": {
"*.ts": "eslint --cache"
},
"dependencies": { "dependencies": {
"@dqbd/tiktoken": "^1.0.2" "@dqbd/tiktoken": "^1.0.2"
}, },
"devDependencies": { "devDependencies": {
"@clack/prompts": "^0.6.1", "@clack/prompts": "^0.7.0",
"@pvtnbr/eslint-config": "^0.33.0",
"@types/ini": "^1.3.31", "@types/ini": "^1.3.31",
"@types/inquirer": "^9.0.3", "@types/inquirer": "^9.0.3",
"@types/node": "^18.14.2", "@types/node": "^18.14.2",
"clean-pkg-json": "^1.2.0", "clean-pkg-json": "^1.2.0",
"cleye": "^1.3.2", "cleye": "^1.3.2",
"eslint": "^8.35.0",
"execa": "^7.0.0", "execa": "^7.0.0",
"fs-fixture": "^1.2.0", "fs-fixture": "^1.2.0",
"https-proxy-agent": "^5.0.1", "https-proxy-agent": "^5.0.1",
"ini": "^3.0.1", "ini": "^3.0.1",
"kolorist": "^1.7.0", "kolorist": "^1.7.0",
"lint-staged": "^13.1.2",
"manten": "^0.7.0", "manten": "^0.7.0",
"openai": "^3.2.1", "openai": "^3.2.1",
"pkgroll": "^1.9.0", "pkgroll": "^1.9.0",
"simple-git-hooks": "^2.8.1",
"tsx": "^3.12.3", "tsx": "^3.12.3",
"typescript": "^4.9.5" "typescript": "^4.9.5"
}, },
"eslintConfig": {
"extends": "@pvtnbr",
"rules": {
"unicorn/no-process-exit": "off"
},
"overrides": [
{
"files": "./src/commands/prepare-commit-msg-hook.ts",
"rules": {
"unicorn/prevent-abbreviations": "off"
}
}
]
},
"release": { "release": {
"branches": [ "branches": [
"main" "main"
] ]
},
"pnpm": {
"patchedDependencies": {
"@clack/prompts@0.6.1": "patches/@clack__prompts@0.6.1.patch"
}
} }
} }

3262
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -21,7 +21,8 @@ cli(
flags: { flags: {
generate: { generate: {
type: Number, type: Number,
description: 'Number of messages to generate (Warning: generating multiple costs more) (default: 1)', description:
'Number of messages to generate (Warning: generating multiple costs more) (default: 1)',
alias: 'g', alias: 'g',
}, },
exclude: { exclude: {
@@ -31,7 +32,8 @@ cli(
}, },
all: { all: {
type: Boolean, type: Boolean,
description: 'Automatically stage changes in tracked files for the commit', description:
'Automatically stage changes in tracked files for the commit',
alias: 'a', alias: 'a',
default: false, default: false,
}, },
@@ -42,16 +44,13 @@ cli(
}, },
}, },
commands: [ commands: [configCommand, hookCommand],
configCommand,
hookCommand,
],
help: { help: {
description, description,
}, },
ignoreArgv: type => type === 'unknown-flag' || type === 'argument', ignoreArgv: (type) => type === 'unknown-flag' || type === 'argument',
}, },
(argv) => { (argv) => {
if (isCalledFromGitHook) { if (isCalledFromGitHook) {
@@ -62,9 +61,9 @@ cli(
argv.flags.exclude, argv.flags.exclude,
argv.flags.all, argv.flags.all,
argv.flags.type, argv.flags.type,
rawArgv, rawArgv
); );
} }
}, },
rawArgv, rawArgv
); );

View File

@@ -1,9 +1,12 @@
import { execa } from 'execa'; import { execa } from 'execa';
import { black, dim, green, red, bgCyan } from 'kolorist';
import { import {
black, dim, green, red, bgCyan, intro,
} from 'kolorist'; outro,
import { spinner,
intro, outro, spinner, select, confirm, isCancel, select,
confirm,
isCancel,
} from '@clack/prompts'; } from '@clack/prompts';
import { import {
assertGitRepo, assertGitRepo,
@@ -19,8 +22,9 @@ export default async (
excludeFiles: string[], excludeFiles: string[],
stageAll: boolean, stageAll: boolean,
commitType: string | undefined, commitType: string | undefined,
rawArgv: string[], rawArgv: string[]
) => (async () => { ) =>
(async () => {
intro(bgCyan(black(' aicommits '))); intro(bgCyan(black(' aicommits ')));
await assertGitRepo(); await assertGitRepo();
@@ -36,16 +40,22 @@ export default async (
if (!staged) { if (!staged) {
detectingFiles.stop('Detecting staged files'); detectingFiles.stop('Detecting staged files');
throw new KnownError('No staged changes found. Stage your changes manually, or automatically stage all changes with the `--all` flag.'); throw new KnownError(
'No staged changes found. Stage your changes manually, or automatically stage all changes with the `--all` flag.'
);
} }
detectingFiles.stop(`${getDetectedMessage(staged.files)}:\n${staged.files.map(file => ` ${file}`).join('\n') detectingFiles.stop(
}`); `${getDetectedMessage(staged.files)}:\n${staged.files
.map((file) => ` ${file}`)
.join('\n')}`
);
const { env } = process; const { env } = process;
const config = await getConfig({ const config = await getConfig({
OPENAI_KEY: env.OPENAI_KEY || env.OPENAI_API_KEY, OPENAI_KEY: env.OPENAI_KEY || env.OPENAI_API_KEY,
proxy: env.https_proxy || env.HTTPS_PROXY || env.http_proxy || env.HTTP_PROXY, proxy:
env.https_proxy || env.HTTPS_PROXY || env.http_proxy || env.HTTP_PROXY,
generate: generate?.toString(), generate: generate?.toString(),
type: commitType?.toString(), type: commitType?.toString(),
}); });
@@ -63,7 +73,7 @@ export default async (
config['max-length'], config['max-length'],
config.type, config.type,
config.timeout, config.timeout,
config.proxy, config.proxy
); );
} finally { } finally {
s.stop('Changes analyzed'); s.stop('Changes analyzed');
@@ -87,7 +97,7 @@ export default async (
} else { } else {
const selected = await select({ const selected = await select({
message: `Pick a commit message to use: ${dim('(Ctrl+c to exit)')}`, message: `Pick a commit message to use: ${dim('(Ctrl+c to exit)')}`,
options: messages.map(value => ({ label: value, value })), options: messages.map((value) => ({ label: value, value })),
}); });
if (isCancel(selected)) { if (isCancel(selected)) {

View File

@@ -3,11 +3,13 @@ import { red } from 'kolorist';
import { hasOwn, getConfig, setConfigs } from '../utils/config.js'; import { hasOwn, getConfig, setConfigs } from '../utils/config.js';
import { KnownError, handleCliError } from '../utils/error.js'; import { KnownError, handleCliError } from '../utils/error.js';
export default command({ export default command(
{
name: 'config', name: 'config',
parameters: ['<mode>', '<key=value...>'], parameters: ['<mode>', '<key=value...>'],
}, (argv) => { },
(argv) => {
(async () => { (async () => {
const { mode, keyValue: keyValues } = argv._; const { mode, keyValue: keyValues } = argv._;
@@ -23,7 +25,7 @@ export default command({
if (mode === 'set') { if (mode === 'set') {
await setConfigs( await setConfigs(
keyValues.map(keyValue => keyValue.split('=') as [string, string]), keyValues.map((keyValue) => keyValue.split('=') as [string, string])
); );
return; return;
} }
@@ -34,4 +36,5 @@ export default command({
handleCliError(error); handleCliError(error);
process.exit(1); process.exit(1);
}); });
}); }
);

View File

@@ -12,11 +12,9 @@ const symlinkPath = `.git/hooks/${hookName}`;
const hookPath = fileURLToPath(new URL('cli.mjs', import.meta.url)); const hookPath = fileURLToPath(new URL('cli.mjs', import.meta.url));
export const isCalledFromGitHook = ( export const isCalledFromGitHook = process.argv[1]
process.argv[1]
.replace(/\\/g, '/') // Replace Windows back slashes with forward slashes .replace(/\\/g, '/') // Replace Windows back slashes with forward slashes
.endsWith(`/${symlinkPath}`) .endsWith(`/${symlinkPath}`);
);
const isWindows = process.platform === 'win32'; const isWindows = process.platform === 'win32';
const windowsHook = ` const windowsHook = `
@@ -24,10 +22,12 @@ const windowsHook = `
import(${JSON.stringify(pathToFileURL(hookPath))}) import(${JSON.stringify(pathToFileURL(hookPath))})
`.trim(); `.trim();
export default command({ export default command(
{
name: 'hook', name: 'hook',
parameters: ['<install/uninstall>'], parameters: ['<install/uninstall>'],
}, (argv) => { },
(argv) => {
(async () => { (async () => {
const gitRepoPath = await assertGitRepo(); const gitRepoPath = await assertGitRepo();
const { installUninstall: mode } = argv._; const { installUninstall: mode } = argv._;
@@ -38,21 +38,22 @@ export default command({
if (hookExists) { if (hookExists) {
// If the symlink is broken, it will throw an error // If the symlink is broken, it will throw an error
// eslint-disable-next-line @typescript-eslint/no-empty-function // eslint-disable-next-line @typescript-eslint/no-empty-function
const realpath = await fs.realpath(absoltueSymlinkPath).catch(() => {}); const realpath = await fs
.realpath(absoltueSymlinkPath)
.catch(() => {});
if (realpath === hookPath) { if (realpath === hookPath) {
console.warn('The hook is already installed'); console.warn('The hook is already installed');
return; return;
} }
throw new KnownError(`A different ${hookName} hook seems to be installed. Please remove it before installing aicommits.`); throw new KnownError(
`A different ${hookName} hook seems to be installed. Please remove it before installing aicommits.`
);
} }
await fs.mkdir(path.dirname(absoltueSymlinkPath), { recursive: true }); await fs.mkdir(path.dirname(absoltueSymlinkPath), { recursive: true });
if (isWindows) { if (isWindows) {
await fs.writeFile( await fs.writeFile(absoltueSymlinkPath, windowsHook);
absoltueSymlinkPath,
windowsHook,
);
} else { } else {
await fs.symlink(hookPath, absoltueSymlinkPath, 'file'); await fs.symlink(hookPath, absoltueSymlinkPath, 'file');
await fs.chmod(absoltueSymlinkPath, 0o755); await fs.chmod(absoltueSymlinkPath, 0o755);
@@ -92,4 +93,5 @@ export default command({
handleCliError(error); handleCliError(error);
process.exit(1); process.exit(1);
}); });
}); }
);

View File

@@ -1,10 +1,6 @@
import fs from 'fs/promises'; import fs from 'fs/promises';
import { import { intro, outro, spinner } from '@clack/prompts';
intro, outro, spinner, import { black, green, red, bgCyan } from 'kolorist';
} from '@clack/prompts';
import {
black, green, red, bgCyan,
} from 'kolorist';
import { getStagedDiff } from '../utils/git.js'; import { getStagedDiff } from '../utils/git.js';
import { getConfig } from '../utils/config.js'; import { getConfig } from '../utils/config.js';
import { generateCommitMessage } from '../utils/openai.js'; import { generateCommitMessage } from '../utils/openai.js';
@@ -12,9 +8,12 @@ import { KnownError, handleCliError } from '../utils/error.js';
const [messageFilePath, commitSource] = process.argv.slice(2); const [messageFilePath, commitSource] = process.argv.slice(2);
export default () => (async () => { export default () =>
(async () => {
if (!messageFilePath) { if (!messageFilePath) {
throw new KnownError('Commit message file path is missing. This file should be called from the "prepare-commit-msg" git hook'); throw new KnownError(
'Commit message file path is missing. This file should be called from the "prepare-commit-msg" git hook'
);
} }
// If a commit message is passed in, ignore // If a commit message is passed in, ignore
@@ -32,7 +31,8 @@ export default () => (async () => {
const { env } = process; const { env } = process;
const config = await getConfig({ const config = await getConfig({
proxy: env.https_proxy || env.HTTPS_PROXY || env.http_proxy || env.HTTP_PROXY, proxy:
env.https_proxy || env.HTTPS_PROXY || env.http_proxy || env.HTTP_PROXY,
}); });
const s = spinner(); const s = spinner();
@@ -48,7 +48,7 @@ export default () => (async () => {
config['max-length'], config['max-length'],
config.type, config.type,
config.timeout, config.timeout,
config.proxy, config.proxy
); );
} finally { } finally {
s.stop('Changes analyzed'); s.stop('Changes analyzed');
@@ -67,14 +67,19 @@ export default () => (async () => {
let instructions = ''; let instructions = '';
if (supportsComments) { if (supportsComments) {
instructions = `# 🤖 AI generated commit${hasMultipleMessages ? 's' : ''}\n`; instructions = `# 🤖 AI generated commit${
hasMultipleMessages ? 's' : ''
}\n`;
} }
if (hasMultipleMessages) { if (hasMultipleMessages) {
if (supportsComments) { if (supportsComments) {
instructions += '# Select one of the following messages by uncommeting:\n'; instructions +=
'# Select one of the following messages by uncommeting:\n';
} }
instructions += `\n${messages.map(message => `# ${message}`).join('\n')}`; instructions += `\n${messages
.map((message) => `# ${message}`)
.join('\n')}`;
} else { } else {
if (supportsComments) { if (supportsComments) {
instructions += '# Edit the message below and commit:\n'; instructions += '# Edit the message below and commit:\n';
@@ -82,10 +87,7 @@ export default () => (async () => {
instructions += `\n${messages[0]}\n`; instructions += `\n${messages[0]}\n`;
} }
await fs.appendFile( await fs.appendFile(messageFilePath, instructions);
messageFilePath,
instructions,
);
outro(`${green('✔')} Saved commit message!`); outro(`${green('✔')} Saved commit message!`);
})().catch((error) => { })().catch((error) => {
outro(`${red('✖')} ${error.message}`); outro(`${red('✖')} ${error.message}`);

View File

@@ -8,16 +8,13 @@ import { KnownError } from './error.js';
const commitTypes = ['', 'conventional'] as const; const commitTypes = ['', 'conventional'] as const;
export type CommitType = typeof commitTypes[number]; export type CommitType = (typeof commitTypes)[number];
const { hasOwnProperty } = Object.prototype; const { hasOwnProperty } = Object.prototype;
export const hasOwn = (object: unknown, key: PropertyKey) => hasOwnProperty.call(object, key); export const hasOwn = (object: unknown, key: PropertyKey) =>
hasOwnProperty.call(object, key);
const parseAssert = ( const parseAssert = (name: string, condition: any, message: string) => {
name: string,
condition: any,
message: string,
) => {
if (!condition) { if (!condition) {
throw new KnownError(`Invalid config property ${name}: ${message}`); throw new KnownError(`Invalid config property ${name}: ${message}`);
} }
@@ -26,7 +23,9 @@ const parseAssert = (
const configParsers = { const configParsers = {
OPENAI_KEY(key?: string) { OPENAI_KEY(key?: string) {
if (!key) { if (!key) {
throw new KnownError('Please set your OpenAI API key via `aicommits config set OPENAI_KEY=<your token>`'); throw new KnownError(
'Please set your OpenAI API key via `aicommits config set OPENAI_KEY=<your token>`'
);
} }
parseAssert('OPENAI_KEY', key.startsWith('sk-'), 'Must start with "sk-"'); parseAssert('OPENAI_KEY', key.startsWith('sk-'), 'Must start with "sk-"');
// Key can range from 43~51 characters. There's no spec to assert this. // Key can range from 43~51 characters. There's no spec to assert this.
@@ -39,7 +38,11 @@ const configParsers = {
} }
parseAssert('locale', locale, 'Cannot be empty'); parseAssert('locale', locale, 'Cannot be empty');
parseAssert('locale', /^[a-z-]+$/i.test(locale), 'Must be a valid locale (letters and dashes/underscores). You can consult the list of codes in: https://wikipedia.org/wiki/List_of_ISO_639-1_codes'); parseAssert(
'locale',
/^[a-z-]+$/i.test(locale),
'Must be a valid locale (letters and dashes/underscores). You can consult the list of codes in: https://wikipedia.org/wiki/List_of_ISO_639-1_codes'
);
return locale; return locale;
}, },
generate(count?: string) { generate(count?: string) {
@@ -60,7 +63,11 @@ const configParsers = {
return ''; return '';
} }
parseAssert('type', commitTypes.includes(type as CommitType), 'Invalid commit type'); parseAssert(
'type',
commitTypes.includes(type as CommitType),
'Invalid commit type'
);
return type as CommitType; return type as CommitType;
}, },
@@ -100,7 +107,11 @@ const configParsers = {
parseAssert('max-length', /^\d+$/.test(maxLength), 'Must be an integer'); parseAssert('max-length', /^\d+$/.test(maxLength), 'Must be an integer');
const parsed = Number(maxLength); const parsed = Number(maxLength);
parseAssert('max-length', parsed >= 20, 'Must be greater than 20 characters'); parseAssert(
'max-length',
parsed >= 20,
'Must be greater than 20 characters'
);
return parsed; return parsed;
}, },
@@ -113,7 +124,7 @@ type RawConfig = {
}; };
export type ValidConfig = { export type ValidConfig = {
[Key in ConfigKeys]: ReturnType<typeof configParsers[Key]>; [Key in ConfigKeys]: ReturnType<(typeof configParsers)[Key]>;
}; };
const configPath = path.join(os.homedir(), '.aicommits'); const configPath = path.join(os.homedir(), '.aicommits');
@@ -130,7 +141,7 @@ const readConfigFile = async (): Promise<RawConfig> => {
export const getConfig = async ( export const getConfig = async (
cliConfig?: RawConfig, cliConfig?: RawConfig,
suppressErrors?: boolean, suppressErrors?: boolean
): Promise<ValidConfig> => { ): Promise<ValidConfig> => {
const config = await readConfigFile(); const config = await readConfigFile();
const parsedConfig: Record<string, unknown> = {}; const parsedConfig: Record<string, unknown> = {};
@@ -151,9 +162,7 @@ export const getConfig = async (
return parsedConfig as ValidConfig; return parsedConfig as ValidConfig;
}; };
export const setConfigs = async ( export const setConfigs = async (keyValues: [key: string, value: string][]) => {
keyValues: [key: string, value: string][],
) => {
const config = await readConfigFile(); const config = await readConfigFile();
for (const [key, value] of keyValues) { for (const [key, value] of keyValues) {

View File

@@ -6,15 +6,16 @@ export class KnownError extends Error {}
const indent = ' '; const indent = ' ';
export const handleCliError = (error: any) => { export const handleCliError = (error: any) => {
if ( if (error instanceof Error && !(error instanceof KnownError)) {
error instanceof Error
&& !(error instanceof KnownError)
) {
if (error.stack) { if (error.stack) {
console.error(dim(error.stack.split('\n').slice(1).join('\n'))); console.error(dim(error.stack.split('\n').slice(1).join('\n')));
} }
console.error(`\n${indent}${dim(`aicommits v${version}`)}`); console.error(`\n${indent}${dim(`aicommits v${version}`)}`);
console.error(`\n${indent}Please open a Bug report with the information above:`); console.error(
console.error(`${indent}https://github.com/Nutlope/aicommits/issues/new/choose`); `\n${indent}Please open a Bug report with the information above:`
);
console.error(
`${indent}https://github.com/Nutlope/aicommits/issues/new/choose`
);
} }
}; };

View File

@@ -1,4 +1,8 @@
import fs from 'fs/promises'; import fs from 'fs/promises';
// lstat is used because this is also used to check if a symlink file exists // lstat is used because this is also used to check if a symlink file exists
export const fileExists = (filePath: string) => fs.lstat(filePath).then(() => true, () => false); export const fileExists = (filePath: string) =>
fs.lstat(filePath).then(
() => true,
() => false
);

View File

@@ -2,7 +2,11 @@ import { execa } from 'execa';
import { KnownError } from './error.js'; import { KnownError } from './error.js';
export const assertGitRepo = async () => { export const assertGitRepo = async () => {
const { stdout, failed } = await execa('git', ['rev-parse', '--show-toplevel'], { reject: false }); const { stdout, failed } = await execa(
'git',
['rev-parse', '--show-toplevel'],
{ reject: false }
);
if (failed) { if (failed) {
throw new KnownError('The current directory must be a Git repository!'); throw new KnownError('The current directory must be a Git repository!');
@@ -23,36 +27,22 @@ const filesToExclude = [
export const getStagedDiff = async (excludeFiles?: string[]) => { export const getStagedDiff = async (excludeFiles?: string[]) => {
const diffCached = ['diff', '--cached', '--diff-algorithm=minimal']; const diffCached = ['diff', '--cached', '--diff-algorithm=minimal'];
const { stdout: files } = await execa( const { stdout: files } = await execa('git', [
'git',
[
...diffCached, ...diffCached,
'--name-only', '--name-only',
...filesToExclude, ...filesToExclude,
...( ...(excludeFiles ? excludeFiles.map(excludeFromDiff) : []),
excludeFiles ]);
? excludeFiles.map(excludeFromDiff)
: []
),
],
);
if (!files) { if (!files) {
return; return;
} }
const { stdout: diff } = await execa( const { stdout: diff } = await execa('git', [
'git',
[
...diffCached, ...diffCached,
...filesToExclude, ...filesToExclude,
...( ...(excludeFiles ? excludeFiles.map(excludeFromDiff) : []),
excludeFiles ]);
? excludeFiles.map(excludeFromDiff)
: []
),
],
);
return { return {
files: files.split('\n'), files: files.split('\n'),
@@ -60,4 +50,7 @@ export const getStagedDiff = async (excludeFiles?: string[]) => {
}; };
}; };
export const getDetectedMessage = (files: string[]) => `Detected ${files.length.toLocaleString()} staged file${files.length > 1 ? 's' : ''}`; export const getDetectedMessage = (files: string[]) =>
`Detected ${files.length.toLocaleString()} staged file${
files.length > 1 ? 's' : ''
}`;

View File

@@ -1,6 +1,9 @@
import https from 'https'; import https from 'https';
import type { ClientRequest, IncomingMessage } from 'http'; import type { ClientRequest, IncomingMessage } from 'http';
import type { CreateChatCompletionRequest, CreateChatCompletionResponse } from 'openai'; import type {
CreateChatCompletionRequest,
CreateChatCompletionResponse,
} from 'openai';
import { import {
type TiktokenModel, type TiktokenModel,
// encoding_for_model, // encoding_for_model,
@@ -16,8 +19,9 @@ const httpsPost = async (
headers: Record<string, string>, headers: Record<string, string>,
json: unknown, json: unknown,
timeout: number, timeout: number,
proxy?: string, proxy?: string
) => new Promise<{ ) =>
new Promise<{
request: ClientRequest; request: ClientRequest;
response: IncomingMessage; response: IncomingMessage;
data: string; data: string;
@@ -35,15 +39,11 @@ const httpsPost = async (
'Content-Length': Buffer.byteLength(postContent), 'Content-Length': Buffer.byteLength(postContent),
}, },
timeout, timeout,
agent: ( agent: proxy ? createHttpsProxyAgent(proxy) : undefined,
proxy
? createHttpsProxyAgent(proxy)
: undefined
),
}, },
(response) => { (response) => {
const body: Buffer[] = []; const body: Buffer[] = [];
response.on('data', chunk => body.push(chunk)); response.on('data', (chunk) => body.push(chunk));
response.on('end', () => { response.on('end', () => {
resolve({ resolve({
request, request,
@@ -51,12 +51,16 @@ const httpsPost = async (
data: Buffer.concat(body).toString(), data: Buffer.concat(body).toString(),
}); });
}); });
}, }
); );
request.on('error', reject); request.on('error', reject);
request.on('timeout', () => { request.on('timeout', () => {
request.destroy(); request.destroy();
reject(new KnownError(`Time out error: request took over ${timeout}ms. Try increasing the \`timeout\` config, or checking the OpenAI API status https://status.openai.com`)); reject(
new KnownError(
`Time out error: request took over ${timeout}ms. Try increasing the \`timeout\` config, or checking the OpenAI API status https://status.openai.com`
)
);
}); });
request.write(postContent); request.write(postContent);
@@ -67,7 +71,7 @@ const createChatCompletion = async (
apiKey: string, apiKey: string,
json: CreateChatCompletionRequest, json: CreateChatCompletionRequest,
timeout: number, timeout: number,
proxy?: string, proxy?: string
) => { ) => {
const { response, data } = await httpsPost( const { response, data } = await httpsPost(
'api.openai.com', 'api.openai.com',
@@ -77,13 +81,13 @@ const createChatCompletion = async (
}, },
json, json,
timeout, timeout,
proxy, proxy
); );
if ( if (
!response.statusCode !response.statusCode ||
|| response.statusCode < 200 response.statusCode < 200 ||
|| response.statusCode > 299 response.statusCode > 299
) { ) {
let errorMessage = `OpenAI API Error: ${response.statusCode} - ${response.statusMessage}`; let errorMessage = `OpenAI API Error: ${response.statusCode} - ${response.statusMessage}`;
@@ -101,7 +105,11 @@ const createChatCompletion = async (
return JSON.parse(data) as CreateChatCompletionResponse; return JSON.parse(data) as CreateChatCompletionResponse;
}; };
const sanitizeMessage = (message: string) => message.trim().replace(/[\n\r]/g, '').replace(/(\w)\.$/, '$1'); const sanitizeMessage = (message: string) =>
message
.trim()
.replace(/[\n\r]/g, '')
.replace(/(\w)\.$/, '$1');
const deduplicateMessages = (array: string[]) => Array.from(new Set(array)); const deduplicateMessages = (array: string[]) => Array.from(new Set(array));
@@ -131,7 +139,7 @@ export const generateCommitMessage = async (
maxLength: number, maxLength: number,
type: CommitType, type: CommitType,
timeout: number, timeout: number,
proxy?: string, proxy?: string
) => { ) => {
try { try {
const completion = await createChatCompletion( const completion = await createChatCompletion(
@@ -157,18 +165,20 @@ export const generateCommitMessage = async (
n: completions, n: completions,
}, },
timeout, timeout,
proxy, proxy
); );
return deduplicateMessages( return deduplicateMessages(
completion.choices completion.choices
.filter(choice => choice.message?.content) .filter((choice) => choice.message?.content)
.map(choice => sanitizeMessage(choice.message!.content)), .map((choice) => sanitizeMessage(choice.message!.content))
); );
} catch (error) { } catch (error) {
const errorAsAny = error as any; const errorAsAny = error as any;
if (errorAsAny.code === 'ENOTFOUND') { if (errorAsAny.code === 'ENOTFOUND') {
throw new KnownError(`Error connecting to ${errorAsAny.hostname} (${errorAsAny.syscall}). Are you connected to the internet?`); throw new KnownError(
`Error connecting to ${errorAsAny.hostname} (${errorAsAny.syscall}). Are you connected to the internet?`
);
} }
throw errorAsAny; throw errorAsAny;

View File

@@ -4,7 +4,8 @@ const commitTypeFormats: Record<CommitType, string> = {
'': '<commit message>', '': '<commit message>',
conventional: '<type>(<optional scope>): <commit message>', conventional: '<type>(<optional scope>): <commit message>',
}; };
const specifyCommitFormat = (type: CommitType) => `The output response must be in format:\n${commitTypeFormats[type]}`; const specifyCommitFormat = (type: CommitType) =>
`The output response must be in format:\n${commitTypeFormats[type]}`;
const commitTypes: Record<CommitType, string> = { const commitTypes: Record<CommitType, string> = {
'': '', '': '',
@@ -17,10 +18,11 @@ const commitTypes: Record<CommitType, string> = {
* Conventional Changelog: * Conventional Changelog:
* https://github.com/conventional-changelog/conventional-changelog/blob/d0e5d5926c8addba74bc962553dd8bcfba90e228/packages/conventional-changelog-conventionalcommits/writer-opts.js#L182-L193 * https://github.com/conventional-changelog/conventional-changelog/blob/d0e5d5926c8addba74bc962553dd8bcfba90e228/packages/conventional-changelog-conventionalcommits/writer-opts.js#L182-L193
*/ */
conventional: `Choose a type from the type-to-description JSON below that best describes the git diff:\n${ conventional: `Choose a type from the type-to-description JSON below that best describes the git diff:\n${JSON.stringify(
JSON.stringify({ {
docs: 'Documentation only changes', docs: 'Documentation only changes',
style: 'Changes that do not affect the meaning of the code (white-space, formatting, missing semi-colons, etc)', 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', refactor: 'A code change that neither fixes a bug nor adds a feature',
perf: 'A code change that improves performance', perf: 'A code change that improves performance',
test: 'Adding missing tests or correcting existing tests', test: 'Adding missing tests or correcting existing tests',
@@ -30,19 +32,24 @@ const commitTypes: Record<CommitType, string> = {
revert: 'Reverts a previous commit', revert: 'Reverts a previous commit',
feat: 'A new feature', feat: 'A new feature',
fix: 'A bug fix', fix: 'A bug fix',
}, null, 2) },
}`, null,
2
)}`,
}; };
export const generatePrompt = ( export const generatePrompt = (
locale: string, locale: string,
maxLength: number, maxLength: number,
type: CommitType, type: CommitType
) => [ ) =>
[
'Generate a concise git commit message written in present tense for the following code diff with the given specifications below:', 'Generate a concise git commit message written in present tense for the following code diff with the given specifications below:',
`Message language: ${locale}`, `Message language: ${locale}`,
`Commit message must be a maximum of ${maxLength} characters.`, `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.', 'Exclude anything unnecessary such as translation. Your entire response will be passed directly into git commit.',
commitTypes[type], commitTypes[type],
specifyCommitFormat(type), specifyCommitFormat(type),
].filter(Boolean).join('\n'); ]
.filter(Boolean)
.join('\n');