From 6e665f3bc5438d50220bb44485ea0b80620b451c Mon Sep 17 00:00:00 2001 From: Aaroh Mankad Date: Fri, 19 May 2023 18:23:27 -0700 Subject: [PATCH 1/5] Refactor commit message prompt generator function name --- src/utils/openai.ts | 57 +++++++++++++++++++++++++++++++++++++++++++-- src/utils/prompt.ts | 2 +- 2 files changed, 56 insertions(+), 3 deletions(-) diff --git a/src/utils/openai.ts b/src/utils/openai.ts index 9dd31514..22851000 100644 --- a/src/utils/openai.ts +++ b/src/utils/openai.ts @@ -8,7 +8,7 @@ import { import createHttpsProxyAgent from 'https-proxy-agent'; import { KnownError } from './error.js'; import type { CommitType } from './config.js'; -import { generatePrompt } from './prompt.js'; +import { generateCommitPrompt } from './prompt.js'; const httpsPost = async ( hostname: string, @@ -141,7 +141,60 @@ export const generateCommitMessage = async ( messages: [ { role: 'system', - content: generatePrompt(locale, maxLength, type), + content: generateCommitPrompt(locale, maxLength, type), + }, + { + role: 'user', + content: diff, + }, + ], + temperature: 0.7, + top_p: 1, + frequency_penalty: 0, + presence_penalty: 0, + max_tokens: 200, + stream: false, + n: completions, + }, + timeout, + proxy, + ); + + return deduplicateMessages( + completion.choices + .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 errorAsAny; + } +}; + +export const generatePullRequest = async ( + apiKey: string, + model: TiktokenModel, + locale: string, + diff: string, + completions: number, + maxLength: number, + type: CommitType, + timeout: number, + proxy?: string, +) => { + try { + const completion = await createChatCompletion( + apiKey, + { + model, + messages: [ + { + role: 'system', + content: generateCommitPrompt(locale, maxLength, type), }, { role: 'user', diff --git a/src/utils/prompt.ts b/src/utils/prompt.ts index f2518d87..223d40f0 100644 --- a/src/utils/prompt.ts +++ b/src/utils/prompt.ts @@ -34,7 +34,7 @@ const commitTypes: Record = { }`, }; -export const generatePrompt = ( +export const generateCommitPrompt = ( locale: string, maxLength: number, type: CommitType, From eab433f4b4a6a3d8f3b2c88700f5651ef70a8e7f Mon Sep 17 00:00:00 2001 From: Aaroh Mankad Date: Fri, 19 May 2023 18:48:23 -0700 Subject: [PATCH 2/5] add aipr command for generating PRs --- src/commands/aipr.ts | 105 +++++++++++++++++++++++++++++++++++++++++++ src/utils/git.ts | 44 ++++++++++++++++++ src/utils/openai.ts | 6 +-- src/utils/prompt.ts | 8 ++++ 4 files changed, 159 insertions(+), 4 deletions(-) create mode 100644 src/commands/aipr.ts diff --git a/src/commands/aipr.ts b/src/commands/aipr.ts new file mode 100644 index 00000000..604560a3 --- /dev/null +++ b/src/commands/aipr.ts @@ -0,0 +1,105 @@ +import { execa } from 'execa'; +import { + black, dim, green, red, bgCyan, +} from 'kolorist'; +import { + intro, outro, spinner, select, confirm, isCancel, +} from '@clack/prompts'; +import { + assertGitRepo, + getDetectedMessage, + getStagedDiffFromTrunk, +} from '../utils/git.js'; +import { getConfig } from '../utils/config.js'; +import { generatePullRequest } from '../utils/openai.js'; +import { KnownError, handleCliError } from '../utils/error.js'; + +export default async ( + generate: number | undefined, + trunkBranch: string | undefined, + excludeFiles: string[], + stageAll: boolean, + rawArgv: string[], +) => (async () => { + intro(bgCyan(black(' aicommits '))); + await assertGitRepo(); + + const detectingFiles = spinner(); + + if (stageAll) { + // This should be equivalent behavior to `git commit --all` + await execa('git', ['add', '--update']); + } + + detectingFiles.start('Detecting staged files'); + const staged = await getStagedDiffFromTrunk(trunkBranch, excludeFiles); + + 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.'); + } + + 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, + generate: generate?.toString(), + }); + + const s = spinner(); + s.start('The AI is analyzing your changes'); + let messages: string[]; + try { + messages = await generatePullRequest( + config.OPENAI_KEY, + config.model, + config.locale, + staged.diff, + config.generate, + config.timeout, + config.proxy, + ); + } finally { + s.stop('Changes analyzed'); + } + + if (messages.length === 0) { + throw new KnownError('A PR was not generated. Try again.'); + } + + let message: string; + if (messages.length === 1) { + [message] = messages; + const confirmed = await confirm({ + message: `Use this PR?\n\n ${message}\n`, + }); + + if (!confirmed || isCancel(confirmed)) { + outro('PR cancelled'); + return; + } + } else { + const selected = await select({ + message: `Pick a PR to use: ${dim('(Ctrl+c to exit)')}`, + options: messages.map(value => ({ label: value, value })), + }); + + if (isCancel(selected)) { + outro('PR cancelled'); + return; + } + + message = selected; + } + + await execa('gh', ['pr', 'create', '-b', message, ...rawArgv]); + + outro(`${green('✔')} Successfully created!`); +})().catch((error) => { + outro(`${red('✖')} ${error.message}`); + handleCliError(error); + process.exit(1); +}); diff --git a/src/utils/git.ts b/src/utils/git.ts index b2026aac..fe244fbb 100644 --- a/src/utils/git.ts +++ b/src/utils/git.ts @@ -60,4 +60,48 @@ export const getStagedDiff = async (excludeFiles?: string[]) => { }; }; +export const getStagedDiffFromTrunk = async (trunkBranch?: string, excludeFiles?: string[]) => { + const diffCached = ['diff', '--cached']; + const trunk = trunkBranch ?? 'main'; + const branchesToCompare = `${trunk}..HEAD`; + + const { stdout: files } = await execa( + 'git', + [ + ...diffCached, + branchesToCompare, + '--name-only', + ...filesToExclude, + ...( + excludeFiles + ? excludeFiles.map(excludeFromDiff) + : [] + ), + ], + ); + + if (!files) { + return; + } + + const { stdout: diff } = await execa( + 'git', + [ + ...diffCached, + branchesToCompare, + ...filesToExclude, + ...( + excludeFiles + ? excludeFiles.map(excludeFromDiff) + : [] + ), + ], + ); + + return { + files: files.split('\n'), + diff, + }; +}; + export const getDetectedMessage = (files: string[]) => `Detected ${files.length.toLocaleString()} staged file${files.length > 1 ? 's' : ''}`; diff --git a/src/utils/openai.ts b/src/utils/openai.ts index 22851000..2d44d613 100644 --- a/src/utils/openai.ts +++ b/src/utils/openai.ts @@ -8,7 +8,7 @@ import { import createHttpsProxyAgent from 'https-proxy-agent'; import { KnownError } from './error.js'; import type { CommitType } from './config.js'; -import { generateCommitPrompt } from './prompt.js'; +import { generateCommitPrompt, generatePullRequestPrompt } from './prompt.js'; const httpsPost = async ( hostname: string, @@ -181,8 +181,6 @@ export const generatePullRequest = async ( locale: string, diff: string, completions: number, - maxLength: number, - type: CommitType, timeout: number, proxy?: string, ) => { @@ -194,7 +192,7 @@ export const generatePullRequest = async ( messages: [ { role: 'system', - content: generateCommitPrompt(locale, maxLength, type), + content: generatePullRequestPrompt(locale), }, { role: 'user', diff --git a/src/utils/prompt.ts b/src/utils/prompt.ts index 223d40f0..5074c897 100644 --- a/src/utils/prompt.ts +++ b/src/utils/prompt.ts @@ -46,3 +46,11 @@ export const generateCommitPrompt = ( commitTypes[type], specifyCommitFormat(type), ].filter(Boolean).join('\n'); + +export const generatePullRequestPrompt = ( + locale: string, +) => [ + 'Generate the content for a descriptive Github Pull Request written in present tense for the following code diff with the given specifications below:', + `Message language: ${locale}`, + 'Exclude anything unnecessary such as translation. Use Markdown syntax if you want to emphasize certain changes, list out lists of changes, or separate the content with headings. Assume your audience is a team member who has no context on what you\'re working on. Your entire response will be passed directly into gh pr create.', +].filter(Boolean).join('\n'); From ffcf04086326493fd7babd2b06fbd7a01bdcd428 Mon Sep 17 00:00:00 2001 From: Aaroh Mankad Date: Fri, 19 May 2023 20:43:09 -0700 Subject: [PATCH 3/5] Add prCommand to cli commands --- src/cli.ts | 2 + src/commands/aipr.ts | 225 ++++++++++++++++++++++++++----------------- src/utils/git.ts | 5 +- 3 files changed, 143 insertions(+), 89 deletions(-) diff --git a/src/cli.ts b/src/cli.ts index 129bac3d..75960da2 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -4,6 +4,7 @@ import aicommits from './commands/aicommits.js'; import prepareCommitMessageHook from './commands/prepare-commit-msg-hook.js'; import configCommand from './commands/config.js'; import hookCommand, { isCalledFromGitHook } from './commands/hook.js'; +import prCommand from './commands/aipr.js'; const rawArgv = process.argv.slice(2); @@ -45,6 +46,7 @@ cli( commands: [ configCommand, hookCommand, + prCommand, ], help: { diff --git a/src/commands/aipr.ts b/src/commands/aipr.ts index 604560a3..d7d33c3d 100644 --- a/src/commands/aipr.ts +++ b/src/commands/aipr.ts @@ -1,9 +1,19 @@ +import { command } from 'cleye'; import { execa } from 'execa'; import { - black, dim, green, red, bgCyan, + 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, @@ -14,92 +24,135 @@ import { getConfig } from '../utils/config.js'; import { generatePullRequest } from '../utils/openai.js'; import { KnownError, handleCliError } from '../utils/error.js'; -export default async ( - generate: number | undefined, - trunkBranch: string | undefined, - excludeFiles: string[], - stageAll: boolean, - rawArgv: string[], -) => (async () => { - intro(bgCyan(black(' aicommits '))); - await assertGitRepo(); - - const detectingFiles = spinner(); - - if (stageAll) { - // This should be equivalent behavior to `git commit --all` - await execa('git', ['add', '--update']); - } - - detectingFiles.start('Detecting staged files'); - const staged = await getStagedDiffFromTrunk(trunkBranch, excludeFiles); - - 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.'); - } - - 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, - generate: generate?.toString(), - }); - - const s = spinner(); - s.start('The AI is analyzing your changes'); - let messages: string[]; - try { - messages = await generatePullRequest( - config.OPENAI_KEY, - config.model, - config.locale, - staged.diff, - config.generate, - config.timeout, - config.proxy, - ); - } finally { - s.stop('Changes analyzed'); - } - - if (messages.length === 0) { - throw new KnownError('A PR was not generated. Try again.'); - } - - let message: string; - if (messages.length === 1) { - [message] = messages; - const confirmed = await confirm({ - message: `Use this PR?\n\n ${message}\n`, - }); +const rawArgv = process.argv.slice(2); - if (!confirmed || isCancel(confirmed)) { - outro('PR cancelled'); - return; - } - } else { - const selected = await select({ - message: `Pick a PR to use: ${dim('(Ctrl+c to exit)')}`, - options: messages.map(value => ({ label: value, value })), - }); +export default command( + { + name: 'pr', + /** + * Since this is a wrapper around `gh pr create`, + * flags should not overlap with it + * https://cli.github.com/manual/gh_pr_create + */ + flags: { + generate: { + type: Number, + description: 'Number of messages to generate (Warning: generating multiple costs more) (default: 1)', + alias: 'g', + }, + trunkBranch: { + type: String, + description: 'The branch into which you want your code merged', + alias: 'B', + }, + exclude: { + type: [String], + description: 'Files to exclude from AI analysis', + alias: 'x', + }, + all: { + type: Boolean, + description: 'Automatically stage changes in tracked files for the commit', + alias: 'A', + default: false, + }, + }, + }, + (argv) => { + (async () => { + const { + all: stageAll, + exclude: excludeFiles, + trunkBranch: trunk, + generate, + } = argv.flags; + + intro(bgCyan(black(' aipr '))); + await assertGitRepo(); + + const detectingFiles = spinner(); + + if (stageAll) { + // This should be equivalent behavior to `git commit --all` + await execa('git', ['add', '--update']); + } + + detectingFiles.start('Detecting staged files'); + const staged = await getStagedDiffFromTrunk(trunk, excludeFiles); + + 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.', + ); + } - if (isCancel(selected)) { - outro('PR cancelled'); - return; - } + detectingFiles.stop(`${getDetectedMessage(staged.files)}:\n${staged.files.map(file => ` ${file}`).join('\n')}`); - message = selected; - } + 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, + generate: generate?.toString(), + }); - await execa('gh', ['pr', 'create', '-b', message, ...rawArgv]); + const s = spinner(); + s.start('The AI is analyzing your changes'); + let messages: string[]; + try { + messages = await generatePullRequest( + config.OPENAI_KEY, + config.model, + config.locale, + staged.diff, + config.generate, + config.timeout, + config.proxy, + ); + } finally { + s.stop('Changes analyzed'); + } - outro(`${green('✔')} Successfully created!`); -})().catch((error) => { - outro(`${red('✖')} ${error.message}`); - handleCliError(error); - process.exit(1); -}); + if (messages.length === 0) { + throw new KnownError('A PR was not generated. Try again.'); + } + + let message: string; + if (messages.length === 1) { + [message] = messages; + const confirmed = await confirm({ + message: `Use this PR?\n\n ${message}\n`, + }); + + if (!confirmed || isCancel(confirmed)) { + outro('PR cancelled'); + return; + } + } else { + const selected = await select({ + message: `Pick a PR to use: ${dim('(Ctrl+c to exit)')}`, + options: messages.map(value => ({ label: value, value })), + }); + + if (isCancel(selected)) { + outro('PR cancelled'); + return; + } + + message = selected; + } + + await execa('gh', ['pr', 'create', '-b', message, ...rawArgv]); + + outro(`${green('✔')} Successfully created!`); + })().catch((error) => { + console.error(`${red('✖')} ${error.message}`); + handleCliError(error); + process.exit(1); + }); + }, +); diff --git a/src/utils/git.ts b/src/utils/git.ts index fe244fbb..4f77fbb3 100644 --- a/src/utils/git.ts +++ b/src/utils/git.ts @@ -61,14 +61,13 @@ export const getStagedDiff = async (excludeFiles?: string[]) => { }; export const getStagedDiffFromTrunk = async (trunkBranch?: string, excludeFiles?: string[]) => { - const diffCached = ['diff', '--cached']; const trunk = trunkBranch ?? 'main'; const branchesToCompare = `${trunk}..HEAD`; const { stdout: files } = await execa( 'git', [ - ...diffCached, + 'diff', branchesToCompare, '--name-only', ...filesToExclude, @@ -87,7 +86,7 @@ export const getStagedDiffFromTrunk = async (trunkBranch?: string, excludeFiles? const { stdout: diff } = await execa( 'git', [ - ...diffCached, + 'diff', branchesToCompare, ...filesToExclude, ...( From 069d6108d26a38c9c0206887aa457f44f9af64bc Mon Sep 17 00:00:00 2001 From: Aaroh Mankad Date: Fri, 19 May 2023 21:00:12 -0700 Subject: [PATCH 4/5] Update prompt guidelines for generating PR description --- src/commands/aipr.ts | 4 +--- src/utils/openai.ts | 2 +- src/utils/prompt.ts | 2 +- 3 files changed, 3 insertions(+), 5 deletions(-) diff --git a/src/commands/aipr.ts b/src/commands/aipr.ts index d7d33c3d..ed6e4af4 100644 --- a/src/commands/aipr.ts +++ b/src/commands/aipr.ts @@ -24,8 +24,6 @@ import { getConfig } from '../utils/config.js'; import { generatePullRequest } from '../utils/openai.js'; import { KnownError, handleCliError } from '../utils/error.js'; -const rawArgv = process.argv.slice(2); - export default command( { name: 'pr', @@ -146,7 +144,7 @@ export default command( message = selected; } - await execa('gh', ['pr', 'create', '-b', message, ...rawArgv]); + await execa('gh', ['pr', 'create', '-b', `"${message}"`, '-t', `"${message.split('\n')[0]}"`]); outro(`${green('✔')} Successfully created!`); })().catch((error) => { diff --git a/src/utils/openai.ts b/src/utils/openai.ts index 2d44d613..15a2f66c 100644 --- a/src/utils/openai.ts +++ b/src/utils/openai.ts @@ -214,7 +214,7 @@ export const generatePullRequest = async ( return deduplicateMessages( completion.choices .filter(choice => choice.message?.content) - .map(choice => sanitizeMessage(choice.message!.content)), + .map(choice => choice.message!.content), ); } catch (error) { const errorAsAny = error as any; diff --git a/src/utils/prompt.ts b/src/utils/prompt.ts index 5074c897..edce683c 100644 --- a/src/utils/prompt.ts +++ b/src/utils/prompt.ts @@ -52,5 +52,5 @@ export const generatePullRequestPrompt = ( ) => [ 'Generate the content for a descriptive Github Pull Request written in present tense for the following code diff with the given specifications below:', `Message language: ${locale}`, - 'Exclude anything unnecessary such as translation. Use Markdown syntax if you want to emphasize certain changes, list out lists of changes, or separate the content with headings. Assume your audience is a team member who has no context on what you\'re working on. Your entire response will be passed directly into gh pr create.', + 'Exclude anything unnecessary such as translation. Use Markdown syntax if you want to emphasize certain changes, list out lists of changes, or separate the content with headings and newlines. Assume your audience is a team member who has no context on what you\'re working on. Your entire response will be passed directly into gh pr create.', ].filter(Boolean).join('\n'); From 48f9c7acb606688b1b181c5b225ecb87d5c752fe Mon Sep 17 00:00:00 2001 From: Aaroh Mankad Date: Fri, 19 May 2023 21:11:37 -0700 Subject: [PATCH 5/5] add -B flag to gh pr command --- src/commands/aipr.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/commands/aipr.ts b/src/commands/aipr.ts index ed6e4af4..23f67aa7 100644 --- a/src/commands/aipr.ts +++ b/src/commands/aipr.ts @@ -144,7 +144,7 @@ export default command( message = selected; } - await execa('gh', ['pr', 'create', '-b', `"${message}"`, '-t', `"${message.split('\n')[0]}"`]); + await execa('gh', ['pr', 'create', '-b', `"${message}"`, '-t', `"${message.split('\n')[0]}"`, '-B', trunk ?? 'main']); outro(`${green('✔')} Successfully created!`); })().catch((error) => {