feat: config command (#71)

This commit is contained in:
Hiroki Osame
2023-02-21 22:04:40 +09:00
parent 05f70e344e
commit 0d3f35c135
7 changed files with 275 additions and 190 deletions

View File

@@ -8,102 +8,111 @@ import {
import { cli } from 'cleye';
import { description, version } from '../package.json';
import {
getConfig,
assertGitRepo,
getStagedDiff,
getDetectedMessage,
generateCommitMessage,
} from './utils.js';
} from './utils/git.js';
import { getConfig } from './utils/config.js';
import { generateCommitMessage } from './utils/openai.js';
import configCommand from './commands/config.js';
const rawArgv = process.argv.slice(2);
const argv = cli({
name: 'aicommits',
cli(
{
name: 'aicommits',
version,
version,
/**
* Since this is a wrapper around `git commit`,
* flags should not overlap with it
* https://git-scm.com/docs/git-commit
*/
flags: {
generate: {
type: Number,
description: 'Number of messages to generate. (Warning: generating multiple costs more)',
alias: 'g',
default: 1,
/**
* Since this is a wrapper around `git commit`,
* flags should not overlap with it
* https://git-scm.com/docs/git-commit
*/
flags: {
generate: {
type: Number,
description: 'Number of messages to generate. (Warning: generating multiple costs more)',
alias: 'g',
default: 1,
},
},
commands: [
configCommand,
],
help: {
description,
},
ignoreArgv: type => type === 'unknown-flag' || type === 'argument',
},
(argv) => {
(async () => {
intro(bgCyan(black(' aicommits ')));
help: {
description,
await assertGitRepo();
const detectingFiles = spinner();
detectingFiles.start('Detecting staged files');
const staged = await getStagedDiff();
if (!staged) {
throw new Error('No staged changes found. Make sure to stage your changes with `git add`.');
}
detectingFiles.stop(`${getDetectedMessage(staged.files)}:\n${
staged.files.map(file => ` ${file}`).join('\n')
}`);
const config = await getConfig();
const OPENAI_KEY = process.env.OPENAI_KEY ?? process.env.OPENAI_API_KEY ?? config.OPENAI_KEY;
if (!OPENAI_KEY) {
throw new Error('Please set your OpenAI API key in ~/.aicommits');
}
const s = spinner();
s.start('The AI is analyzing your changes');
const messages = await generateCommitMessage(
OPENAI_KEY,
staged.diff,
argv.flags.generate,
);
s.stop('Changes analyzed');
let message;
if (messages.length === 1) {
[message] = messages;
const confirmed = await confirm({
message: `Use this commit message?\n\n ${message}\n`,
});
if (!confirmed || isCancel(confirmed)) {
outro('Commit cancelled');
return;
}
} 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}`);
process.exit(1);
});
},
ignoreArgv: type => type === 'unknown-flag' || type === 'argument',
}, undefined, rawArgv);
(async () => {
intro(bgCyan(black(' aicommits ')));
await assertGitRepo();
const detectingFiles = spinner();
detectingFiles.start('Detecting staged files');
const staged = await getStagedDiff();
if (!staged) {
throw new Error('No staged changes found. Make sure to stage your changes with `git add`.');
}
detectingFiles.stop(`${getDetectedMessage(staged.files)}:\n${
staged.files.map(file => ` ${file}`).join('\n')
}`);
const config = await getConfig();
const OPENAI_KEY = process.env.OPENAI_KEY ?? process.env.OPENAI_API_KEY ?? config.OPENAI_KEY;
if (!OPENAI_KEY) {
throw new Error('Please set your OpenAI API key in ~/.aicommits');
}
const s = spinner();
s.start('The AI is analyzing your changes');
const messages = await generateCommitMessage(
OPENAI_KEY,
staged.diff,
argv.flags.generate,
);
s.stop('Changes analyzed');
let message;
if (messages.length === 1) {
[message] = messages;
const confirmed = await confirm({
message: `Use this commit message?\n\n ${message}\n`,
});
if (!confirmed || isCancel(confirmed)) {
outro('Commit cancelled');
return;
}
} 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}`);
process.exit(1);
});
rawArgv,
);

33
src/commands/config.ts Normal file
View File

@@ -0,0 +1,33 @@
import { command } from 'cleye';
import { red } from 'kolorist';
import { getConfig, setConfigs } from '../utils/config.js';
export default command({
name: 'config',
parameters: ['<mode>', '<key=value...>'],
}, (argv) => {
(async () => {
const { mode, keyValue: keyValues } = argv._;
if (mode === 'get') {
const config = await getConfig();
for (const key of keyValues) {
console.log(`${key}=${config[key as keyof typeof config]}`);
}
return;
}
if (mode === 'set') {
await setConfigs(
keyValues.map(keyValue => keyValue.split('=') as [string, string]),
);
return;
}
throw new Error(`Invalid mode: ${mode}`);
})().catch((error) => {
console.error(`${red('✖')} ${error.message}`);
process.exit(1); // eslint-disable-line unicorn/no-process-exit
});
});

View File

@@ -1,101 +0,0 @@
import fs from 'fs/promises';
import path from 'path';
import os from 'os';
import ini from 'ini';
import { execa } from 'execa';
import { Configuration, OpenAIApi } from 'openai';
const fileExists = (filePath: string) => fs.access(filePath).then(() => true, () => false);
type ConfigType = {
OPENAI_KEY?: string;
};
export const getConfig = async (): Promise<ConfigType> => {
const configPath = path.join(os.homedir(), '.aicommits');
const configExists = await fileExists(configPath);
if (!configExists) {
return {};
}
const configString = await fs.readFile(configPath, 'utf8');
return ini.parse(configString);
};
export const assertGitRepo = async () => {
const { stdout } = await execa('git', ['rev-parse', '--is-inside-work-tree'], { reject: false });
if (stdout !== 'true') {
throw new Error('The current directory must be a Git repository!');
}
};
const excludeFromDiff = [
'package-lock.json',
'yarn.lock',
'pnpm-lock.yaml',
].map(file => `:(exclude)${file}`);
export const getStagedDiff = async () => {
const diffCached = ['diff', '--cached'];
const { stdout: files } = await execa(
'git',
[...diffCached, '--name-only', ...excludeFromDiff],
);
if (!files) {
return;
}
const { stdout: diff } = await execa(
'git',
[...diffCached, ...excludeFromDiff],
);
return {
files: files.split('\n'),
diff,
};
};
export const getDetectedMessage = (files: string[]) => `Detected ${files.length.toLocaleString()} staged file${files.length > 1 ? 's' : ''}`;
const sanitizeMessage = (message: string) => message.trim().replace(/[\n\r]/g, '').replace(/(\w)\.$/, '$1');
const promptTemplate = 'Write an insightful but concise Git commit message in a complete sentence in imperative present tense for the following diff without prefacing it with anything:';
export const generateCommitMessage = async (
apiKey: string,
diff: string,
completions: number,
) => {
const prompt = `${promptTemplate}\n${diff}`;
// Accounting for GPT-3's input req of 4k tokens (approx 8k chars)
if (prompt.length > 8000) {
throw new Error('The diff is too large for the OpenAI API');
}
const openai = new OpenAIApi(new Configuration({ apiKey }));
try {
const completion = await openai.createCompletion({
model: 'text-davinci-003',
prompt,
temperature: 0.7,
top_p: 1,
frequency_penalty: 0,
presence_penalty: 0,
max_tokens: 200,
stream: false,
n: completions,
});
return completion.data.choices
.filter(choice => choice.text)
.map(choice => sanitizeMessage(choice.text!));
} catch (error) {
const errorAsAny = error as any;
errorAsAny.message = `OpenAI API Error: ${errorAsAny.message} - ${errorAsAny.response.statusText}`;
throw errorAsAny;
}
};

63
src/utils/config.ts Normal file
View File

@@ -0,0 +1,63 @@
import fs from 'fs/promises';
import path from 'path';
import os from 'os';
import ini from 'ini';
const keyValidators = {
OPENAI_KEY(key: string) {
if (!key) {
return 'Cannot be empty';
}
if (!key.startsWith('sk-')) {
return 'Must start with "sk-"';
}
if (key.length !== 51) {
return 'Must be 51 characters long';
}
},
} as const;
type ValidKeys = keyof typeof keyValidators;
type ConfigType = {
[key in ValidKeys]?: string;
};
const configPath = path.join(os.homedir(), '.aicommits');
const fileExists = (filePath: string) => fs.access(filePath).then(() => true, () => false);
export const getConfig = async (): Promise<ConfigType> => {
const configExists = await fileExists(configPath);
if (!configExists) {
return {};
}
const configString = await fs.readFile(configPath, 'utf8');
return ini.parse(configString);
};
const { hasOwnProperty } = Object.prototype;
const hasOwn = (object: unknown, key: PropertyKey) => hasOwnProperty.call(object, key);
export const setConfigs = async (
keyValues: [key: string, value: string][],
) => {
const config = await getConfig();
for (const [key, value] of keyValues) {
if (!hasOwn(keyValidators, key)) {
throw new Error(`Invalid config property: ${key}`);
}
const isInvalid = keyValidators[key as ValidKeys](value);
if (isInvalid) {
throw new Error(`Invalid value for ${key}: ${isInvalid}`);
}
config[key as ValidKeys] = value;
}
await fs.writeFile(configPath, ini.stringify(config), 'utf8');
};

39
src/utils/git.ts Normal file
View File

@@ -0,0 +1,39 @@
import { execa } from 'execa';
export const assertGitRepo = async () => {
const { stdout } = await execa('git', ['rev-parse', '--is-inside-work-tree'], { reject: false });
if (stdout !== 'true') {
throw new Error('The current directory must be a Git repository!');
}
};
const excludeFromDiff = [
'package-lock.json',
'yarn.lock',
'pnpm-lock.yaml',
].map(file => `:(exclude)${file}`);
export const getStagedDiff = async () => {
const diffCached = ['diff', '--cached'];
const { stdout: files } = await execa(
'git',
[...diffCached, '--name-only', ...excludeFromDiff],
);
if (!files) {
return;
}
const { stdout: diff } = await execa(
'git',
[...diffCached, ...excludeFromDiff],
);
return {
files: files.split('\n'),
diff,
};
};
export const getDetectedMessage = (files: string[]) => `Detected ${files.length.toLocaleString()} staged file${files.length > 1 ? 's' : ''}`;

41
src/utils/openai.ts Normal file
View File

@@ -0,0 +1,41 @@
import { Configuration, OpenAIApi } from 'openai';
const sanitizeMessage = (message: string) => message.trim().replace(/[\n\r]/g, '').replace(/(\w)\.$/, '$1');
const promptTemplate = 'Write an insightful but concise Git commit message in a complete sentence in present tense for the following diff without prefacing it with anything:';
export const generateCommitMessage = async (
apiKey: string,
diff: string,
completions: number,
) => {
const prompt = `${promptTemplate}\n${diff}`;
// Accounting for GPT-3's input req of 4k tokens (approx 8k chars)
if (prompt.length > 8000) {
throw new Error('The diff is too large for the OpenAI API');
}
const openai = new OpenAIApi(new Configuration({ apiKey }));
try {
const completion = await openai.createCompletion({
model: 'text-davinci-003',
prompt,
temperature: 0.7,
top_p: 1,
frequency_penalty: 0,
presence_penalty: 0,
max_tokens: 200,
stream: false,
n: completions,
});
return completion.data.choices
.filter(choice => choice.text)
.map(choice => sanitizeMessage(choice.text!));
} catch (error) {
const errorAsAny = error as any;
errorAsAny.message = `OpenAI API Error: ${errorAsAny.message} - ${errorAsAny.response.statusText}`;
throw errorAsAny;
}
};