formatting
This commit is contained in:
@@ -6,6 +6,7 @@ end_of_line = lf
|
||||
charset = utf-8
|
||||
trim_trailing_whitespace = true
|
||||
insert_final_newline = true
|
||||
quote_type = single
|
||||
|
||||
[*.yml]
|
||||
indent_style = space
|
||||
|
||||
34
package.json
34
package.json
@@ -19,66 +19,36 @@
|
||||
"aic": "./dist/cli.mjs"
|
||||
},
|
||||
"scripts": {
|
||||
"prepare": "simple-git-hooks",
|
||||
"build": "pkgroll --minify",
|
||||
"lint": "eslint --cache .",
|
||||
"lint": "",
|
||||
"type-check": "tsc",
|
||||
"test": "tsx tests",
|
||||
"prepack": "pnpm build && clean-pkg-json"
|
||||
},
|
||||
"simple-git-hooks": {
|
||||
"pre-commit": "pnpm lint-staged"
|
||||
},
|
||||
"lint-staged": {
|
||||
"*.ts": "eslint --cache"
|
||||
},
|
||||
"dependencies": {
|
||||
"@dqbd/tiktoken": "^1.0.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@clack/prompts": "^0.6.1",
|
||||
"@pvtnbr/eslint-config": "^0.33.0",
|
||||
"@clack/prompts": "^0.7.0",
|
||||
"@types/ini": "^1.3.31",
|
||||
"@types/inquirer": "^9.0.3",
|
||||
"@types/node": "^18.14.2",
|
||||
"clean-pkg-json": "^1.2.0",
|
||||
"cleye": "^1.3.2",
|
||||
"eslint": "^8.35.0",
|
||||
"execa": "^7.0.0",
|
||||
"fs-fixture": "^1.2.0",
|
||||
"https-proxy-agent": "^5.0.1",
|
||||
"ini": "^3.0.1",
|
||||
"kolorist": "^1.7.0",
|
||||
"lint-staged": "^13.1.2",
|
||||
"manten": "^0.7.0",
|
||||
"openai": "^3.2.1",
|
||||
"pkgroll": "^1.9.0",
|
||||
"simple-git-hooks": "^2.8.1",
|
||||
"tsx": "^3.12.3",
|
||||
"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": {
|
||||
"branches": [
|
||||
"main"
|
||||
]
|
||||
},
|
||||
"pnpm": {
|
||||
"patchedDependencies": {
|
||||
"@clack/prompts@0.6.1": "patches/@clack__prompts@0.6.1.patch"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
3262
pnpm-lock.yaml
generated
3262
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
17
src/cli.ts
17
src/cli.ts
@@ -21,7 +21,8 @@ cli(
|
||||
flags: {
|
||||
generate: {
|
||||
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',
|
||||
},
|
||||
exclude: {
|
||||
@@ -31,7 +32,8 @@ cli(
|
||||
},
|
||||
all: {
|
||||
type: Boolean,
|
||||
description: 'Automatically stage changes in tracked files for the commit',
|
||||
description:
|
||||
'Automatically stage changes in tracked files for the commit',
|
||||
alias: 'a',
|
||||
default: false,
|
||||
},
|
||||
@@ -42,16 +44,13 @@ cli(
|
||||
},
|
||||
},
|
||||
|
||||
commands: [
|
||||
configCommand,
|
||||
hookCommand,
|
||||
],
|
||||
commands: [configCommand, hookCommand],
|
||||
|
||||
help: {
|
||||
description,
|
||||
},
|
||||
|
||||
ignoreArgv: type => type === 'unknown-flag' || type === 'argument',
|
||||
ignoreArgv: (type) => type === 'unknown-flag' || type === 'argument',
|
||||
},
|
||||
(argv) => {
|
||||
if (isCalledFromGitHook) {
|
||||
@@ -62,9 +61,9 @@ cli(
|
||||
argv.flags.exclude,
|
||||
argv.flags.all,
|
||||
argv.flags.type,
|
||||
rawArgv,
|
||||
rawArgv
|
||||
);
|
||||
}
|
||||
},
|
||||
rawArgv,
|
||||
rawArgv
|
||||
);
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
import { execa } from 'execa';
|
||||
import { black, dim, green, red, bgCyan } from 'kolorist';
|
||||
import {
|
||||
black, dim, green, red, bgCyan,
|
||||
} from 'kolorist';
|
||||
import {
|
||||
intro, outro, spinner, select, confirm, isCancel,
|
||||
intro,
|
||||
outro,
|
||||
spinner,
|
||||
select,
|
||||
confirm,
|
||||
isCancel,
|
||||
} from '@clack/prompts';
|
||||
import {
|
||||
assertGitRepo,
|
||||
@@ -19,8 +22,9 @@ export default async (
|
||||
excludeFiles: string[],
|
||||
stageAll: boolean,
|
||||
commitType: string | undefined,
|
||||
rawArgv: string[],
|
||||
) => (async () => {
|
||||
rawArgv: string[]
|
||||
) =>
|
||||
(async () => {
|
||||
intro(bgCyan(black(' aicommits ')));
|
||||
await assertGitRepo();
|
||||
|
||||
@@ -36,16 +40,22 @@ export default async (
|
||||
|
||||
if (!staged) {
|
||||
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 config = await getConfig({
|
||||
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(),
|
||||
type: commitType?.toString(),
|
||||
});
|
||||
@@ -63,7 +73,7 @@ export default async (
|
||||
config['max-length'],
|
||||
config.type,
|
||||
config.timeout,
|
||||
config.proxy,
|
||||
config.proxy
|
||||
);
|
||||
} finally {
|
||||
s.stop('Changes analyzed');
|
||||
@@ -87,7 +97,7 @@ export default async (
|
||||
} else {
|
||||
const selected = await select({
|
||||
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)) {
|
||||
|
||||
@@ -3,11 +3,13 @@ import { red } from 'kolorist';
|
||||
import { hasOwn, getConfig, setConfigs } from '../utils/config.js';
|
||||
import { KnownError, handleCliError } from '../utils/error.js';
|
||||
|
||||
export default command({
|
||||
export default command(
|
||||
{
|
||||
name: 'config',
|
||||
|
||||
parameters: ['<mode>', '<key=value...>'],
|
||||
}, (argv) => {
|
||||
},
|
||||
(argv) => {
|
||||
(async () => {
|
||||
const { mode, keyValue: keyValues } = argv._;
|
||||
|
||||
@@ -23,7 +25,7 @@ export default command({
|
||||
|
||||
if (mode === 'set') {
|
||||
await setConfigs(
|
||||
keyValues.map(keyValue => keyValue.split('=') as [string, string]),
|
||||
keyValues.map((keyValue) => keyValue.split('=') as [string, string])
|
||||
);
|
||||
return;
|
||||
}
|
||||
@@ -34,4 +36,5 @@ export default command({
|
||||
handleCliError(error);
|
||||
process.exit(1);
|
||||
});
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
@@ -12,11 +12,9 @@ const symlinkPath = `.git/hooks/${hookName}`;
|
||||
|
||||
const hookPath = fileURLToPath(new URL('cli.mjs', import.meta.url));
|
||||
|
||||
export const isCalledFromGitHook = (
|
||||
process.argv[1]
|
||||
export const isCalledFromGitHook = process.argv[1]
|
||||
.replace(/\\/g, '/') // Replace Windows back slashes with forward slashes
|
||||
.endsWith(`/${symlinkPath}`)
|
||||
);
|
||||
.endsWith(`/${symlinkPath}`);
|
||||
|
||||
const isWindows = process.platform === 'win32';
|
||||
const windowsHook = `
|
||||
@@ -24,10 +22,12 @@ const windowsHook = `
|
||||
import(${JSON.stringify(pathToFileURL(hookPath))})
|
||||
`.trim();
|
||||
|
||||
export default command({
|
||||
export default command(
|
||||
{
|
||||
name: 'hook',
|
||||
parameters: ['<install/uninstall>'],
|
||||
}, (argv) => {
|
||||
},
|
||||
(argv) => {
|
||||
(async () => {
|
||||
const gitRepoPath = await assertGitRepo();
|
||||
const { installUninstall: mode } = argv._;
|
||||
@@ -38,21 +38,22 @@ export default command({
|
||||
if (hookExists) {
|
||||
// If the symlink is broken, it will throw an error
|
||||
// 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) {
|
||||
console.warn('The hook is already installed');
|
||||
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 });
|
||||
|
||||
if (isWindows) {
|
||||
await fs.writeFile(
|
||||
absoltueSymlinkPath,
|
||||
windowsHook,
|
||||
);
|
||||
await fs.writeFile(absoltueSymlinkPath, windowsHook);
|
||||
} else {
|
||||
await fs.symlink(hookPath, absoltueSymlinkPath, 'file');
|
||||
await fs.chmod(absoltueSymlinkPath, 0o755);
|
||||
@@ -92,4 +93,5 @@ export default command({
|
||||
handleCliError(error);
|
||||
process.exit(1);
|
||||
});
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
@@ -1,10 +1,6 @@
|
||||
import fs from 'fs/promises';
|
||||
import {
|
||||
intro, outro, spinner,
|
||||
} from '@clack/prompts';
|
||||
import {
|
||||
black, green, red, bgCyan,
|
||||
} from 'kolorist';
|
||||
import { intro, outro, spinner } from '@clack/prompts';
|
||||
import { black, green, red, bgCyan } from 'kolorist';
|
||||
import { getStagedDiff } from '../utils/git.js';
|
||||
import { getConfig } from '../utils/config.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);
|
||||
|
||||
export default () => (async () => {
|
||||
export default () =>
|
||||
(async () => {
|
||||
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
|
||||
@@ -32,7 +31,8 @@ export default () => (async () => {
|
||||
|
||||
const { env } = process;
|
||||
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();
|
||||
@@ -48,7 +48,7 @@ export default () => (async () => {
|
||||
config['max-length'],
|
||||
config.type,
|
||||
config.timeout,
|
||||
config.proxy,
|
||||
config.proxy
|
||||
);
|
||||
} finally {
|
||||
s.stop('Changes analyzed');
|
||||
@@ -67,14 +67,19 @@ export default () => (async () => {
|
||||
let instructions = '';
|
||||
|
||||
if (supportsComments) {
|
||||
instructions = `# 🤖 AI generated commit${hasMultipleMessages ? 's' : ''}\n`;
|
||||
instructions = `# 🤖 AI generated commit${
|
||||
hasMultipleMessages ? 's' : ''
|
||||
}\n`;
|
||||
}
|
||||
|
||||
if (hasMultipleMessages) {
|
||||
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 {
|
||||
if (supportsComments) {
|
||||
instructions += '# Edit the message below and commit:\n';
|
||||
@@ -82,10 +87,7 @@ export default () => (async () => {
|
||||
instructions += `\n${messages[0]}\n`;
|
||||
}
|
||||
|
||||
await fs.appendFile(
|
||||
messageFilePath,
|
||||
instructions,
|
||||
);
|
||||
await fs.appendFile(messageFilePath, instructions);
|
||||
outro(`${green('✔')} Saved commit message!`);
|
||||
})().catch((error) => {
|
||||
outro(`${red('✖')} ${error.message}`);
|
||||
|
||||
@@ -8,16 +8,13 @@ import { KnownError } from './error.js';
|
||||
|
||||
const commitTypes = ['', 'conventional'] as const;
|
||||
|
||||
export type CommitType = typeof commitTypes[number];
|
||||
export type CommitType = (typeof commitTypes)[number];
|
||||
|
||||
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 = (
|
||||
name: string,
|
||||
condition: any,
|
||||
message: string,
|
||||
) => {
|
||||
const parseAssert = (name: string, condition: any, message: string) => {
|
||||
if (!condition) {
|
||||
throw new KnownError(`Invalid config property ${name}: ${message}`);
|
||||
}
|
||||
@@ -26,7 +23,9 @@ const parseAssert = (
|
||||
const configParsers = {
|
||||
OPENAI_KEY(key?: string) {
|
||||
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-"');
|
||||
// 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', /^[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;
|
||||
},
|
||||
generate(count?: string) {
|
||||
@@ -60,7 +63,11 @@ const configParsers = {
|
||||
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;
|
||||
},
|
||||
@@ -100,7 +107,11 @@ const configParsers = {
|
||||
parseAssert('max-length', /^\d+$/.test(maxLength), 'Must be an integer');
|
||||
|
||||
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;
|
||||
},
|
||||
@@ -113,7 +124,7 @@ type RawConfig = {
|
||||
};
|
||||
|
||||
export type ValidConfig = {
|
||||
[Key in ConfigKeys]: ReturnType<typeof configParsers[Key]>;
|
||||
[Key in ConfigKeys]: ReturnType<(typeof configParsers)[Key]>;
|
||||
};
|
||||
|
||||
const configPath = path.join(os.homedir(), '.aicommits');
|
||||
@@ -130,7 +141,7 @@ const readConfigFile = async (): Promise<RawConfig> => {
|
||||
|
||||
export const getConfig = async (
|
||||
cliConfig?: RawConfig,
|
||||
suppressErrors?: boolean,
|
||||
suppressErrors?: boolean
|
||||
): Promise<ValidConfig> => {
|
||||
const config = await readConfigFile();
|
||||
const parsedConfig: Record<string, unknown> = {};
|
||||
@@ -151,9 +162,7 @@ export const getConfig = async (
|
||||
return parsedConfig as ValidConfig;
|
||||
};
|
||||
|
||||
export const setConfigs = async (
|
||||
keyValues: [key: string, value: string][],
|
||||
) => {
|
||||
export const setConfigs = async (keyValues: [key: string, value: string][]) => {
|
||||
const config = await readConfigFile();
|
||||
|
||||
for (const [key, value] of keyValues) {
|
||||
|
||||
@@ -6,15 +6,16 @@ export class KnownError extends Error {}
|
||||
const indent = ' ';
|
||||
|
||||
export const handleCliError = (error: any) => {
|
||||
if (
|
||||
error instanceof Error
|
||||
&& !(error instanceof KnownError)
|
||||
) {
|
||||
if (error instanceof Error && !(error instanceof KnownError)) {
|
||||
if (error.stack) {
|
||||
console.error(dim(error.stack.split('\n').slice(1).join('\n')));
|
||||
}
|
||||
console.error(`\n${indent}${dim(`aicommits v${version}`)}`);
|
||||
console.error(`\n${indent}Please open a Bug report with the information above:`);
|
||||
console.error(`${indent}https://github.com/Nutlope/aicommits/issues/new/choose`);
|
||||
console.error(
|
||||
`\n${indent}Please open a Bug report with the information above:`
|
||||
);
|
||||
console.error(
|
||||
`${indent}https://github.com/Nutlope/aicommits/issues/new/choose`
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
import fs from 'fs/promises';
|
||||
|
||||
// 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
|
||||
);
|
||||
|
||||
@@ -2,7 +2,11 @@ import { execa } from 'execa';
|
||||
import { KnownError } from './error.js';
|
||||
|
||||
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) {
|
||||
throw new KnownError('The current directory must be a Git repository!');
|
||||
@@ -23,36 +27,22 @@ const filesToExclude = [
|
||||
|
||||
export const getStagedDiff = async (excludeFiles?: string[]) => {
|
||||
const diffCached = ['diff', '--cached', '--diff-algorithm=minimal'];
|
||||
const { stdout: files } = await execa(
|
||||
'git',
|
||||
[
|
||||
const { stdout: files } = await execa('git', [
|
||||
...diffCached,
|
||||
'--name-only',
|
||||
...filesToExclude,
|
||||
...(
|
||||
excludeFiles
|
||||
? excludeFiles.map(excludeFromDiff)
|
||||
: []
|
||||
),
|
||||
],
|
||||
);
|
||||
...(excludeFiles ? excludeFiles.map(excludeFromDiff) : []),
|
||||
]);
|
||||
|
||||
if (!files) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { stdout: diff } = await execa(
|
||||
'git',
|
||||
[
|
||||
const { stdout: diff } = await execa('git', [
|
||||
...diffCached,
|
||||
...filesToExclude,
|
||||
...(
|
||||
excludeFiles
|
||||
? excludeFiles.map(excludeFromDiff)
|
||||
: []
|
||||
),
|
||||
],
|
||||
);
|
||||
...(excludeFiles ? excludeFiles.map(excludeFromDiff) : []),
|
||||
]);
|
||||
|
||||
return {
|
||||
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' : ''
|
||||
}`;
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import https from 'https';
|
||||
import type { ClientRequest, IncomingMessage } from 'http';
|
||||
import type { CreateChatCompletionRequest, CreateChatCompletionResponse } from 'openai';
|
||||
import type {
|
||||
CreateChatCompletionRequest,
|
||||
CreateChatCompletionResponse,
|
||||
} from 'openai';
|
||||
import {
|
||||
type TiktokenModel,
|
||||
// encoding_for_model,
|
||||
@@ -16,8 +19,9 @@ const httpsPost = async (
|
||||
headers: Record<string, string>,
|
||||
json: unknown,
|
||||
timeout: number,
|
||||
proxy?: string,
|
||||
) => new Promise<{
|
||||
proxy?: string
|
||||
) =>
|
||||
new Promise<{
|
||||
request: ClientRequest;
|
||||
response: IncomingMessage;
|
||||
data: string;
|
||||
@@ -35,15 +39,11 @@ const httpsPost = async (
|
||||
'Content-Length': Buffer.byteLength(postContent),
|
||||
},
|
||||
timeout,
|
||||
agent: (
|
||||
proxy
|
||||
? createHttpsProxyAgent(proxy)
|
||||
: undefined
|
||||
),
|
||||
agent: proxy ? createHttpsProxyAgent(proxy) : undefined,
|
||||
},
|
||||
(response) => {
|
||||
const body: Buffer[] = [];
|
||||
response.on('data', chunk => body.push(chunk));
|
||||
response.on('data', (chunk) => body.push(chunk));
|
||||
response.on('end', () => {
|
||||
resolve({
|
||||
request,
|
||||
@@ -51,12 +51,16 @@ const httpsPost = async (
|
||||
data: Buffer.concat(body).toString(),
|
||||
});
|
||||
});
|
||||
},
|
||||
}
|
||||
);
|
||||
request.on('error', reject);
|
||||
request.on('timeout', () => {
|
||||
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);
|
||||
@@ -67,7 +71,7 @@ const createChatCompletion = async (
|
||||
apiKey: string,
|
||||
json: CreateChatCompletionRequest,
|
||||
timeout: number,
|
||||
proxy?: string,
|
||||
proxy?: string
|
||||
) => {
|
||||
const { response, data } = await httpsPost(
|
||||
'api.openai.com',
|
||||
@@ -77,13 +81,13 @@ const createChatCompletion = async (
|
||||
},
|
||||
json,
|
||||
timeout,
|
||||
proxy,
|
||||
proxy
|
||||
);
|
||||
|
||||
if (
|
||||
!response.statusCode
|
||||
|| response.statusCode < 200
|
||||
|| response.statusCode > 299
|
||||
!response.statusCode ||
|
||||
response.statusCode < 200 ||
|
||||
response.statusCode > 299
|
||||
) {
|
||||
let errorMessage = `OpenAI API Error: ${response.statusCode} - ${response.statusMessage}`;
|
||||
|
||||
@@ -101,7 +105,11 @@ const createChatCompletion = async (
|
||||
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));
|
||||
|
||||
@@ -131,7 +139,7 @@ export const generateCommitMessage = async (
|
||||
maxLength: number,
|
||||
type: CommitType,
|
||||
timeout: number,
|
||||
proxy?: string,
|
||||
proxy?: string
|
||||
) => {
|
||||
try {
|
||||
const completion = await createChatCompletion(
|
||||
@@ -157,18 +165,20 @@ export const generateCommitMessage = async (
|
||||
n: completions,
|
||||
},
|
||||
timeout,
|
||||
proxy,
|
||||
proxy
|
||||
);
|
||||
|
||||
return deduplicateMessages(
|
||||
completion.choices
|
||||
.filter(choice => choice.message?.content)
|
||||
.map(choice => sanitizeMessage(choice.message!.content)),
|
||||
.filter((choice) => choice.message?.content)
|
||||
.map((choice) => sanitizeMessage(choice.message!.content))
|
||||
);
|
||||
} catch (error) {
|
||||
const errorAsAny = error as any;
|
||||
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;
|
||||
|
||||
@@ -4,7 +4,8 @@ const commitTypeFormats: Record<CommitType, string> = {
|
||||
'': '<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> = {
|
||||
'': '',
|
||||
@@ -17,10 +18,11 @@ const commitTypes: Record<CommitType, string> = {
|
||||
* Conventional Changelog:
|
||||
* 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${
|
||||
JSON.stringify({
|
||||
conventional: `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)',
|
||||
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',
|
||||
@@ -30,19 +32,24 @@ const commitTypes: Record<CommitType, string> = {
|
||||
revert: 'Reverts a previous commit',
|
||||
feat: 'A new feature',
|
||||
fix: 'A bug fix',
|
||||
}, null, 2)
|
||||
}`,
|
||||
},
|
||||
null,
|
||||
2
|
||||
)}`,
|
||||
};
|
||||
|
||||
export const generatePrompt = (
|
||||
locale: string,
|
||||
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:',
|
||||
`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.',
|
||||
commitTypes[type],
|
||||
specifyCommitFormat(type),
|
||||
].filter(Boolean).join('\n');
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join('\n');
|
||||
|
||||
Reference in New Issue
Block a user