pullcraft
Version:
A CLI tool to create pull requests on GitHub by comparing branches and using OpenAI to generate PR text.
446 lines • 16.9 kB
JavaScript
"use strict";
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.PullCraft = void 0;
const simple_git_1 = require("simple-git");
const prompt_js_1 = require("./prompt.js");
const cosmiconfig_1 = require("cosmiconfig");
const openai_1 = require("openai");
const child_process_1 = __importDefault(require("child_process"));
const githubClient_js_1 = require("./githubClient.js");
const fs_1 = __importDefault(require("fs"));
const configName = 'pullcraft';
const defaultExclusions = [
'***package-lock.json',
'***pnpm-lock.yaml',
'***yarn.lock',
'**/*.jpg',
'**/*.jpeg',
'**/*.png',
'**/*.gif',
'**/*.bmp',
'**/*.tiff',
'**/*.svg',
'**/*.pdf'
];
const githubStrategy = 'gh';
const defaultOpenPr = true;
const openaiDefaults = {
url: 'https://api.openai.com/v1/chat/completions',
model: 'gpt-4o',
systemPrompt: prompt_js_1.systemPrompt,
titleTemplate: prompt_js_1.titleTemplate,
bodyTemplate: prompt_js_1.bodyTemplate,
max_tokens: 3000,
n: 1,
stop: null,
temperature: 0.2
};
const baseDefault = 'develop';
const placeholderPattern = '__KEY__';
const diffThreshold = 400;
function filterUndefined(obj) {
return Object.fromEntries(Object.entries(obj || {}).filter(([_, v]) => v !== undefined));
}
class PullCraft {
githubToken;
gitHubClient;
exclusions;
openai;
git;
config;
openPr;
githubStrategy;
openaiConfig;
baseDefault;
placeholderPattern;
commanderOptions;
replacements = {};
standardReplacements = {};
diffThreshold;
dumpTo;
hint;
constructor(commanderOptions) {
const explorer = (0, cosmiconfig_1.cosmiconfigSync)(configName, { searchStrategy: 'global' });
const config = explorer.search();
const configOptions = config?.config || {};
configOptions.openai = configOptions.openai || {};
// Merge options: commanderOptions > configOptions > defaults
// console.log('commanderOptions', commanderOptions);
const mergedOptions = {
openPr: commanderOptions.openPr || configOptions.openPr || defaultOpenPr,
exclusions: (commanderOptions.exclusions ||
configOptions.exclusions ||
defaultExclusions).map((exclusion) => `:(exclude)${exclusion}`),
baseDefault: commanderOptions.baseDefault ||
configOptions.baseDefault ||
baseDefault,
openaiConfig: Object.assign(openaiDefaults, filterUndefined(configOptions.openai), filterUndefined(commanderOptions.openai), {
apiKey: commanderOptions.openai?.apiKey ||
configOptions.openai?.apiKey ||
process.env.OPENAI_API_KEY
}),
githubStrategy: commanderOptions.githubStrategy ||
configOptions.githubStrategy ||
githubStrategy,
githubToken: commanderOptions.githubToken ||
configOptions.githubToken ||
process.env.GITHUB_TOKEN,
placeholderPattern: commanderOptions.placeholderPattern ||
configOptions.placeholderPattern ||
placeholderPattern,
diffThreshold: commanderOptions.diffThreshold ||
configOptions.diffThreshold ||
diffThreshold,
dumpTo: commanderOptions.dumpTo || configOptions.dumpTo || null
};
// Assign merged options to instance variables
this.openPr = mergedOptions.openPr;
this.exclusions = mergedOptions.exclusions;
this.baseDefault = mergedOptions.baseDefault;
this.openaiConfig = mergedOptions.openaiConfig;
this.githubStrategy = mergedOptions.githubStrategy;
this.githubToken = mergedOptions.githubToken;
this.placeholderPattern = mergedOptions.placeholderPattern;
this.diffThreshold = mergedOptions.diffThreshold;
this.dumpTo = mergedOptions.dumpTo;
this.hint = commanderOptions.hint;
// Set the OpenAI API key
if (!this.openaiConfig.apiKey) {
throw new Error('Error: OPENAI_API_KEY is not set');
}
this.openai = new openai_1.OpenAI({ apiKey: this.openaiConfig.apiKey });
// Set the GitHub client
if (this.githubStrategy !== 'gh' && this.githubStrategy !== 'octokit') {
throw new Error('Error: githubStrategy must be \'gh\' or \'octokit\'. Defaults to \'gh\'.');
}
if (!this.githubToken && this.githubStrategy === 'octokit') {
throw new Error('Error: GITHUB_TOKEN is not set');
}
this.gitHubClient =
this.githubStrategy === 'gh' && this.isGhCliAvailable()
? new githubClient_js_1.GhClient()
: new githubClient_js_1.OctokitClient(this.githubToken);
// Set the Git client
this.git = (0, simple_git_1.simpleGit)();
}
replacePlaceholders(template, replacements, placeholderPattern = this.placeholderPattern) {
return template.replace(new RegExp(Object.keys(replacements)
.map((key) => placeholderPattern.replace('KEY', key))
.join('|'), 'g'), (match) => {
if (match && placeholderPattern) {
const key = match.replace(new RegExp(placeholderPattern.replace('KEY', '(.*)')), '$1');
return Object.prototype.hasOwnProperty.call(replacements, key)
? replacements[key]
: match;
}
else {
return match;
}
});
}
isGhCliAvailable() {
try {
child_process_1.default.execSync('gh auth status');
return true;
}
catch {
return false;
}
}
async openUrl(url) {
if (!url) {
console.error('Error: Please provide a value for the argument.');
throw new Error('Error: URL is required');
}
if (this.openPr === false)
return;
try {
const osType = process.platform;
console.log(`Opening URL: ${url} on ${osType}`);
// console.log(JSON.stringify({url}, null, 2));
switch (osType) {
case 'linux':
// Linux
return child_process_1.default.exec(`xdg-open "${url}"`);
case 'darwin':
// macOS
return child_process_1.default.exec(`open "${url}"`);
case 'win32':
// Windows
return child_process_1.default.exec(`start "${url}"`);
default:
console.error('Unsupported OS');
}
}
catch (error) {
console.error(`Error opening URL: ${error.message}`);
throw error;
}
}
async createPr(baseBranch = this.baseDefault, compareBranch) {
try {
compareBranch =
compareBranch ||
(await this.git.revparse(['--abbrev-ref', 'HEAD'])).trim();
console.log(`Comparing branches: ${baseBranch} and ${compareBranch}`);
const repoInfo = await this.getRepoInfo();
if (!repoInfo) {
console.error('Error: Repository information could not be retrieved.');
return;
}
const { owner, repo } = repoInfo;
this.standardReplacements = {
...this.standardReplacements,
owner,
repo
};
let { response, exit = false } = await this.differ(baseBranch, compareBranch);
if (exit) {
return;
}
// console.log('creatPr->differ->response', response);
if (!response) {
console.error('Error: Response could not be retrieved.');
return;
}
try {
response = JSON.parse(response);
}
catch (error) {
console.log(error);
console.log(JSON.stringify(response));
console.error('Error: AI Response could not be parsed.', error.message);
return;
}
const { title, body } = response;
if (!body) {
console.error('Error: PR body could not be retrieved.');
return;
}
if (!title) {
console.error('Error: PR title could not be retrieved.');
return;
}
const existingPrs = await this.gitHubClient.listPulls({
owner,
repo,
base: baseBranch,
head: compareBranch
});
if (existingPrs.length > 0) {
const pullNumber = existingPrs[0].number;
console.log(`Updating existing PR #${pullNumber}...`);
await this.gitHubClient.updatePull({
owner,
repo,
pullNumber,
title,
body
});
await this.openUrl('https://github.com/' + owner + '/' + repo + '/pull/' + pullNumber);
}
else {
console.log('Creating a new PR...');
const response = await this.gitHubClient.createPull({
owner,
repo,
title,
body,
base: baseBranch,
head: compareBranch
});
await this.openUrl(response.data.html_url.trim().replace('\n', ''));
}
}
catch (error) {
console.error(`Error creating PR: ${error.message}`);
}
}
async getRepoInfo() {
try {
const repoUrl = await this.git.raw([
'config',
'--get',
'remote.origin.url'
]);
const match = repoUrl
.trim()
.match(/github\.com[:/](.+?)\/(.+?)(\.git)?$/);
if (match) {
return { owner: match[1], repo: match[2] };
}
throw new Error(`Failed to get repo info from ${repoUrl}`);
}
catch (error) {
console.error('Failed to get repo info');
throw error;
}
}
async getNewFiles(baseBranch, compareBranch) {
try {
// Fetch only new files
const newFilenames = await this.git.raw([
'diff',
'--name-only',
'--diff-filter=A', // Filter for added files
baseBranch,
compareBranch
]);
const newFiles = newFilenames.split('\n').filter(Boolean);
let totalNewFiles = '';
for (const file of newFiles) {
const fileDiff = await this.git.raw([
'diff',
baseBranch,
compareBranch,
'--',
file
]);
const lineCount = fileDiff.split('\n').length;
if (lineCount <= this.diffThreshold) {
totalNewFiles += fileDiff;
}
else {
totalNewFiles += `\n\n\nFile ${file} is too large to display in the diff. Skipping.\n\n\n`;
}
}
return totalNewFiles;
}
catch (error) {
console.error(`Error getting new files: ${error.message}`);
throw error;
}
}
async getModifiedFiles(baseBranch, compareBranch) {
try {
// Fetch only modified files
const modifiedFilenames = await this.git.raw([
'diff',
'--name-only',
'--diff-filter=M', // Filter for modified files
baseBranch,
compareBranch
]);
const modifiedFiles = modifiedFilenames.split('\n').filter(Boolean);
let totalModifiedFiles = '';
for (const file of modifiedFiles) {
const fileDiff = await this.git.raw([
'diff',
baseBranch,
compareBranch,
'--',
file
]);
const lineCount = fileDiff.split('\n').length;
if (lineCount <= this.diffThreshold) {
totalModifiedFiles += fileDiff;
}
else {
console.log(`File ${file} is too large to display in the diff. Skipping.`);
totalModifiedFiles += `\n\n\nFile ${file} is too large to display in the diff. Skipping.\n\n\n`;
}
}
return totalModifiedFiles;
}
catch (error) {
console.error(`Error getting modified files: ${error.message}`);
throw error;
}
}
async getFilenames(baseBranch, compareBranch) {
try {
// console.log('EXLCUSIONS FILENAMES', this.exclusions);
const outcome = await this.git.raw([
'diff',
'--name-only',
baseBranch,
compareBranch
]);
return outcome;
}
catch (error) {
console.error(`Error getting filenames: ${error.message}`);
throw error;
}
}
dump(diff, location = 'diffdump.txt') {
location = this.dumpTo || location;
fs_1.default.writeFileSync(location, diff);
}
async differ(baseBranch = 'develop', compareBranch) {
try {
compareBranch =
compareBranch ||
(await this.git.revparse(['--abbrev-ref', 'HEAD'])).trim();
const diff = await this.getModifiedFiles(baseBranch, compareBranch);
const newFiles = await this.getNewFiles(baseBranch, compareBranch);
const filenames = await this.getFilenames(baseBranch, compareBranch);
if (!diff && !newFiles) {
return {
response: 'No changes found between the specified branches.',
exit: true
};
}
if (this.dumpTo) {
this.dump(diff);
return { response: `Diff dumped to ${this.dumpTo}`, exit: true };
}
this.standardReplacements = {
...this.standardReplacements,
baseBranch,
compareBranch
};
const finalPrompt = this.buildTextPrompt({ diff, newFiles, filenames });
const response = await this.gptCall(finalPrompt);
return { response, exit: false };
}
catch (error) {
console.error(`Error generating PR body: ${error.message}`);
}
}
buildTextPrompt({ diff, newFiles, filenames }) {
const replace = (template) => {
return this.replacePlaceholders(template, {
...this.replacements,
...this.standardReplacements
}, this.placeholderPattern);
};
// console.log(this.openaiConfig.titleTemplate, this.openaiConfig.bodyTemplate);
const title = replace(this.openaiConfig.titleTemplate);
const body = replace(this.openaiConfig.bodyTemplate);
return `json TEMPLATE:\n{\n"title": ${title},\n"body": ${body}\n}\n
\n--------\nDIFF: \n\`\`\`diff\n${diff} \`\`\`\n
\n--------\nNEW_FILES:\n\`\`\`diff\n${newFiles}\`\`\`\n
\n--------\nFILENAMES:\n${filenames}`;
}
async gptCall(prompt) {
try {
const response = await this.openai.chat.completions.create({
model: 'gpt-4-turbo',
max_tokens: 1500,
n: 1,
stop: null,
temperature: 0.2,
messages: [
{
role: 'system',
content: this.openaiConfig.systemPrompt +
(this.hint ? prompt_js_1.hintPrompt + this.hint : '')
},
{ role: 'user', content: prompt }
],
response_format: { type: 'json_object' }
});
return response.choices[0].message?.content || '';
}
catch (error) {
console.error(`Error calling OpenAI API: ${error.message}`);
}
}
}
exports.PullCraft = PullCraft;
exports.default = PullCraft;
//# sourceMappingURL=index.js.map