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,90 +22,97 @@ export default async (
excludeFiles: string[], excludeFiles: string[],
stageAll: boolean, stageAll: boolean,
commitType: string | undefined, commitType: string | undefined,
rawArgv: string[], rawArgv: string[]
) => (async () => { ) =>
intro(bgCyan(black(' aicommits '))); (async () => {
await assertGitRepo(); intro(bgCyan(black(' aicommits ')));
await assertGitRepo();
const detectingFiles = spinner(); const detectingFiles = spinner();
if (stageAll) { if (stageAll) {
// This should be equivalent behavior to `git commit --all` // This should be equivalent behavior to `git commit --all`
await execa('git', ['add', '--update']); await execa('git', ['add', '--update']);
} }
detectingFiles.start('Detecting staged files'); detectingFiles.start('Detecting staged files');
const staged = await getStagedDiff(excludeFiles); const staged = await getStagedDiff(excludeFiles);
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}`)
const { env } = process; .join('\n')}`
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,
generate: generate?.toString(),
type: commitType?.toString(),
});
const s = spinner();
s.start('The AI is analyzing your changes');
let messages: string[];
try {
messages = await generateCommitMessage(
config.OPENAI_KEY,
config.model,
config.locale,
staged.diff,
config.generate,
config['max-length'],
config.type,
config.timeout,
config.proxy,
); );
} finally {
s.stop('Changes analyzed');
}
if (messages.length === 0) { const { env } = process;
throw new KnownError('No commit messages were generated. Try again.'); const config = await getConfig({
} OPENAI_KEY: env.OPENAI_KEY || env.OPENAI_API_KEY,
proxy:
let message: string; env.https_proxy || env.HTTPS_PROXY || env.http_proxy || env.HTTP_PROXY,
if (messages.length === 1) { generate: generate?.toString(),
[message] = messages; type: commitType?.toString(),
const confirmed = await confirm({
message: `Use this commit message?\n\n ${message}\n`,
}); });
if (!confirmed || isCancel(confirmed)) { const s = spinner();
outro('Commit cancelled'); s.start('The AI is analyzing your changes');
return; let messages: string[];
} try {
} else { messages = await generateCommitMessage(
const selected = await select({ config.OPENAI_KEY,
message: `Pick a commit message to use: ${dim('(Ctrl+c to exit)')}`, config.model,
options: messages.map(value => ({ label: value, value })), config.locale,
}); staged.diff,
config.generate,
if (isCancel(selected)) { config['max-length'],
outro('Commit cancelled'); config.type,
return; config.timeout,
config.proxy
);
} finally {
s.stop('Changes analyzed');
} }
message = selected; if (messages.length === 0) {
} throw new KnownError('No commit messages were generated. Try again.');
}
await execa('git', ['commit', '-m', message, ...rawArgv]); let message: string;
if (messages.length === 1) {
[message] = messages;
const confirmed = await confirm({
message: `Use this commit message?\n\n ${message}\n`,
});
outro(`${green('✔')} Successfully committed!`); if (!confirmed || isCancel(confirmed)) {
})().catch((error) => { outro('Commit cancelled');
outro(`${red('✖')} ${error.message}`); return;
handleCliError(error); }
process.exit(1); } else {
}); const selected = await select({
message: `Pick a commit message to use: ${dim('(Ctrl+c to exit)')}`,
options: messages.map((value) => ({ label: value, value })),
});
if (isCancel(selected)) {
outro('Commit cancelled');
return;
}
message = selected;
}
await execa('git', ['commit', '-m', message, ...rawArgv]);
outro(`${green('✔')} Successfully committed!`);
})().catch((error) => {
outro(`${red('✖')} ${error.message}`);
handleCliError(error);
process.exit(1);
});

View File

@@ -3,35 +3,38 @@ 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) => { },
(async () => { (argv) => {
const { mode, keyValue: keyValues } = argv._; (async () => {
const { mode, keyValue: keyValues } = argv._;
if (mode === 'get') { if (mode === 'get') {
const config = await getConfig({}, true); const config = await getConfig({}, true);
for (const key of keyValues) { for (const key of keyValues) {
if (hasOwn(config, key)) { if (hasOwn(config, key)) {
console.log(`${key}=${config[key as keyof typeof config]}`); console.log(`${key}=${config[key as keyof typeof config]}`);
}
} }
return;
} }
return;
}
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;
} }
throw new KnownError(`Invalid mode: ${mode}`); throw new KnownError(`Invalid mode: ${mode}`);
})().catch((error) => { })().catch((error) => {
console.error(`${red('✖')} ${error.message}`); console.error(`${red('✖')} ${error.message}`);
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,72 +22,76 @@ const windowsHook = `
import(${JSON.stringify(pathToFileURL(hookPath))}) import(${JSON.stringify(pathToFileURL(hookPath))})
`.trim(); `.trim();
export default command({ export default command(
name: 'hook', {
parameters: ['<install/uninstall>'], name: 'hook',
}, (argv) => { parameters: ['<install/uninstall>'],
(async () => { },
const gitRepoPath = await assertGitRepo(); (argv) => {
const { installUninstall: mode } = argv._; (async () => {
const gitRepoPath = await assertGitRepo();
const { installUninstall: mode } = argv._;
const absoltueSymlinkPath = path.join(gitRepoPath, symlinkPath); const absoltueSymlinkPath = path.join(gitRepoPath, symlinkPath);
const hookExists = await fileExists(absoltueSymlinkPath); const hookExists = await fileExists(absoltueSymlinkPath);
if (mode === 'install') { if (mode === 'install') {
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
if (realpath === hookPath) { .realpath(absoltueSymlinkPath)
console.warn('The hook is already installed'); .catch(() => {});
return; 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 }); await fs.mkdir(path.dirname(absoltueSymlinkPath), { recursive: true });
if (isWindows) { if (isWindows) {
await fs.writeFile( await fs.writeFile(absoltueSymlinkPath, windowsHook);
absoltueSymlinkPath, } else {
windowsHook, await fs.symlink(hookPath, absoltueSymlinkPath, 'file');
); await fs.chmod(absoltueSymlinkPath, 0o755);
} else { }
await fs.symlink(hookPath, absoltueSymlinkPath, 'file'); console.log(`${green('✔')} Hook installed`);
await fs.chmod(absoltueSymlinkPath, 0o755);
}
console.log(`${green('✔')} Hook installed`);
return;
}
if (mode === 'uninstall') {
if (!hookExists) {
console.warn('Hook is not installed');
return; return;
} }
if (isWindows) { if (mode === 'uninstall') {
const scriptContent = await fs.readFile(absoltueSymlinkPath, 'utf8'); if (!hookExists) {
if (scriptContent !== windowsHook) {
console.warn('Hook is not installed'); console.warn('Hook is not installed');
return; return;
} }
} else {
const realpath = await fs.realpath(absoltueSymlinkPath); if (isWindows) {
if (realpath !== hookPath) { const scriptContent = await fs.readFile(absoltueSymlinkPath, 'utf8');
console.warn('Hook is not installed'); if (scriptContent !== windowsHook) {
return; console.warn('Hook is not installed');
return;
}
} else {
const realpath = await fs.realpath(absoltueSymlinkPath);
if (realpath !== hookPath) {
console.warn('Hook is not installed');
return;
}
} }
await fs.rm(absoltueSymlinkPath);
console.log(`${green('✔')} Hook uninstalled`);
return;
} }
await fs.rm(absoltueSymlinkPath); throw new KnownError(`Invalid mode: ${mode}`);
console.log(`${green('✔')} Hook uninstalled`); })().catch((error) => {
return; console.error(`${red('✖')} ${error.message}`);
} handleCliError(error);
process.exit(1);
throw new KnownError(`Invalid mode: ${mode}`); });
})().catch((error) => { }
console.error(`${red('✖')} ${error.message}`); );
handleCliError(error);
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,83 +8,89 @@ 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 () =>
if (!messageFilePath) { (async () => {
throw new KnownError('Commit message file path is missing. This file should be called from the "prepare-commit-msg" git hook'); if (!messageFilePath) {
} 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
if (commitSource) { if (commitSource) {
return; return;
} }
// All staged files can be ignored by our filter // All staged files can be ignored by our filter
const staged = await getStagedDiff(); const staged = await getStagedDiff();
if (!staged) { if (!staged) {
return; return;
} }
intro(bgCyan(black(' aicommits '))); intro(bgCyan(black(' aicommits ')));
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();
s.start('The AI is analyzing your changes');
let messages: string[];
try {
messages = await generateCommitMessage(
config.OPENAI_KEY,
config.model,
config.locale,
staged!.diff,
config.generate,
config['max-length'],
config.type,
config.timeout,
config.proxy
);
} finally {
s.stop('Changes analyzed');
}
/**
* When `--no-edit` is passed in, the base commit message is empty,
* and even when you use pass in comments via #, they are ignored.
*
* Note: `--no-edit` cannot be detected in argvs so this is the only way to check
*/
const baseMessage = await fs.readFile(messageFilePath, 'utf8');
const supportsComments = baseMessage !== '';
const hasMultipleMessages = messages.length > 1;
let instructions = '';
if (supportsComments) {
instructions = `# 🤖 AI generated commit${
hasMultipleMessages ? 's' : ''
}\n`;
}
if (hasMultipleMessages) {
if (supportsComments) {
instructions +=
'# Select one of the following messages by uncommeting:\n';
}
instructions += `\n${messages
.map((message) => `# ${message}`)
.join('\n')}`;
} else {
if (supportsComments) {
instructions += '# Edit the message below and commit:\n';
}
instructions += `\n${messages[0]}\n`;
}
await fs.appendFile(messageFilePath, instructions);
outro(`${green('✔')} Saved commit message!`);
})().catch((error) => {
outro(`${red('✖')} ${error.message}`);
handleCliError(error);
process.exit(1);
}); });
const s = spinner();
s.start('The AI is analyzing your changes');
let messages: string[];
try {
messages = await generateCommitMessage(
config.OPENAI_KEY,
config.model,
config.locale,
staged!.diff,
config.generate,
config['max-length'],
config.type,
config.timeout,
config.proxy,
);
} finally {
s.stop('Changes analyzed');
}
/**
* When `--no-edit` is passed in, the base commit message is empty,
* and even when you use pass in comments via #, they are ignored.
*
* Note: `--no-edit` cannot be detected in argvs so this is the only way to check
*/
const baseMessage = await fs.readFile(messageFilePath, 'utf8');
const supportsComments = baseMessage !== '';
const hasMultipleMessages = messages.length > 1;
let instructions = '';
if (supportsComments) {
instructions = `# 🤖 AI generated commit${hasMultipleMessages ? 's' : ''}\n`;
}
if (hasMultipleMessages) {
if (supportsComments) {
instructions += '# Select one of the following messages by uncommeting:\n';
}
instructions += `\n${messages.map(message => `# ${message}`).join('\n')}`;
} else {
if (supportsComments) {
instructions += '# Edit the message below and commit:\n';
}
instructions += `\n${messages[0]}\n`;
}
await fs.appendFile(
messageFilePath,
instructions,
);
outro(`${green('✔')} Saved commit message!`);
})().catch((error) => {
outro(`${red('✖')} ${error.message}`);
handleCliError(error);
process.exit(1);
});

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,
[ '--name-only',
...diffCached, ...filesToExclude,
'--name-only', ...(excludeFiles ? excludeFiles.map(excludeFromDiff) : []),
...filesToExclude, ]);
...(
excludeFiles
? excludeFiles.map(excludeFromDiff)
: []
),
],
);
if (!files) { if (!files) {
return; return;
} }
const { stdout: diff } = await execa( const { stdout: diff } = await execa('git', [
'git', ...diffCached,
[ ...filesToExclude,
...diffCached, ...(excludeFiles ? excludeFiles.map(excludeFromDiff) : []),
...filesToExclude, ]);
...(
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,58 +19,59 @@ 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<{ ) =>
request: ClientRequest; new Promise<{
response: IncomingMessage; request: ClientRequest;
data: string; response: IncomingMessage;
}>((resolve, reject) => { data: string;
const postContent = JSON.stringify(json); }>((resolve, reject) => {
const request = https.request( const postContent = JSON.stringify(json);
{ const request = https.request(
port: 443, {
hostname, port: 443,
path, hostname,
method: 'POST', path,
headers: { method: 'POST',
...headers, headers: {
'Content-Type': 'application/json', ...headers,
'Content-Length': Buffer.byteLength(postContent), 'Content-Type': 'application/json',
'Content-Length': Buffer.byteLength(postContent),
},
timeout,
agent: proxy ? createHttpsProxyAgent(proxy) : undefined,
}, },
timeout, (response) => {
agent: ( const body: Buffer[] = [];
proxy response.on('data', (chunk) => body.push(chunk));
? createHttpsProxyAgent(proxy) response.on('end', () => {
: undefined resolve({
), request,
}, response,
(response) => { data: Buffer.concat(body).toString(),
const body: Buffer[] = []; });
response.on('data', chunk => body.push(chunk));
response.on('end', () => {
resolve({
request,
response,
data: Buffer.concat(body).toString(),
}); });
}); }
}, );
); request.on('error', reject);
request.on('error', reject); request.on('timeout', () => {
request.on('timeout', () => { request.destroy();
request.destroy(); reject(
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`)); 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);
request.end(); request.end();
}); });
const createChatCompletion = async ( 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:', [
`Message language: ${locale}`, 'Generate a concise git commit message written in present tense for the following code diff with the given specifications below:',
`Commit message must be a maximum of ${maxLength} characters.`, `Message language: ${locale}`,
'Exclude anything unnecessary such as translation. Your entire response will be passed directly into git commit.', `Commit message must be a maximum of ${maxLength} characters.`,
commitTypes[type], 'Exclude anything unnecessary such as translation. Your entire response will be passed directly into git commit.',
specifyCommitFormat(type), commitTypes[type],
].filter(Boolean).join('\n'); specifyCommitFormat(type),
]
.filter(Boolean)
.join('\n');