pr-commit-ai-agent
Version:
A CLI tool powered by AI to streamline Git workflows by generating commit messages, branch names, and pull requests.
1,306 lines (1,264 loc) • 90 kB
JavaScript
"use strict";
var __create = Object.create;
var __defProp = Object.defineProperty;
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
var __getOwnPropNames = Object.getOwnPropertyNames;
var __getProtoOf = Object.getPrototypeOf;
var __hasOwnProp = Object.prototype.hasOwnProperty;
var __export = (target, all) => {
for (var name in all)
__defProp(target, name, { get: all[name], enumerable: true });
};
var __copyProps = (to, from, except, desc) => {
if (from && typeof from === "object" || typeof from === "function") {
for (let key of __getOwnPropNames(from))
if (!__hasOwnProp.call(to, key) && key !== except)
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
}
return to;
};
var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(
// If the importer is in node compatibility mode or this is not an ESM
// file that has been converted to a CommonJS file using a Babel-
// compatible transform (i.e. "__esModule" has not been set), then set
// "default" to the CommonJS "module.exports" for node compatibility.
isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target,
mod
));
// bin/run.ts
var import_yargs = __toESM(require("yargs"));
var import_config6 = require("dotenv/config");
// src/commands/info.ts
var info_exports = {};
__export(info_exports, {
aliases: () => aliases,
builder: () => builder,
command: () => command,
describe: () => describe,
handler: () => handler
});
// src/logger.ts
var import_consola = require("consola");
var logger = (0, import_consola.createConsola)({});
// src/commands/info.ts
var process2 = __toESM(require("process"));
var import_picocolors = require("picocolors");
// src/config.ts
var import_config = require("dotenv/config");
var import_conf = __toESM(require("conf"));
var schema = {
llmProvider: {
type: "string",
enum: ["openai", "anthropic", "deepseek", "ollama", "gemini"],
default: "openai"
},
model: {
type: "string",
default: ""
},
openai: {
type: "object",
properties: {
apiKey: {
type: "string",
default: ""
},
baseURL: {
type: "string",
format: "uri",
default: "https://api.openai.com/v1"
}
}
},
anthropic: {
type: "object",
properties: {
apiKey: {
type: "string",
default: ""
}
}
},
deepseek: {
type: "object",
properties: {
apiKey: {
type: "string",
default: ""
},
baseURL: {
type: "string",
format: "uri",
default: "https://api.deepseek.com/v1"
}
}
},
ollama: {
type: "object",
properties: {
baseURL: {
type: "string",
format: "uri",
default: "http://localhost:11434/api/generate"
},
apiKey: {
type: "string",
default: ""
}
}
},
gemini: {
type: "object",
properties: {
apiKey: {
type: "string",
default: ""
}
}
}
};
var configInstance = new import_conf.default({
projectName: "pr-commit-ai-agent",
schema
});
function initializeConfig() {
if (process.env.LLM_PROVIDER) {
configInstance.set("llmProvider", process.env.LLM_PROVIDER.toLowerCase());
}
if (process.env.MODEL) {
configInstance.set("model", process.env.MODEL);
}
if (process.env.OPENAI_API_KEY) {
configInstance.set("openai.apiKey", process.env.OPENAI_API_KEY);
}
if (process.env.OPENAI_BASE_URL) {
configInstance.set("openai.baseURL", process.env.OPENAI_BASE_URL);
}
if (process.env.ANTHROPIC_API_KEY) {
configInstance.set("anthropic.apiKey", process.env.ANTHROPIC_API_KEY);
}
if (process.env.DEEPSEEK_API_KEY) {
configInstance.set("deepseek.apiKey", process.env.DEEPSEEK_API_KEY);
}
if (process.env.DEEPSEEK_BASE_URL) {
configInstance.set("deepseek.baseURL", process.env.DEEPSEEK_BASE_URL);
}
if (process.env.OLLAMA_API_KEY) {
configInstance.set("ollama.apiKey", process.env.OLLAMA_API_KEY);
}
if (process.env.OLLAMA_BASE_URL) {
configInstance.set("ollama.baseURL", process.env.OLLAMA_BASE_URL);
}
if (process.env.GEMINI_API_KEY) {
configInstance.set("gemini.apiKey", process.env.GEMINI_API_KEY);
}
}
initializeConfig();
var config = {
llmProvider: configInstance.get("llmProvider"),
model: configInstance.get("model"),
openai: {
apiKey: configInstance.get("openai.apiKey"),
baseURL: configInstance.get("openai.baseURL")
},
anthropic: {
apiKey: configInstance.get("anthropic.apiKey")
},
deepseek: {
apiKey: configInstance.get("deepseek.apiKey"),
baseURL: configInstance.get("deepseek.baseURL")
},
ollama: {
baseURL: configInstance.get("ollama.baseURL"),
apiKey: configInstance.get("ollama.apiKey")
},
gemini: {
apiKey: configInstance.get("gemini.apiKey")
}
};
// src/commands/info.ts
var command = "info";
var describe = "Basic command to display information.";
var aliases = ["i"];
function builder(yargs2) {
return yargs2.option("full", {
type: "boolean",
alias: "f",
default: true
});
}
async function handler(argv) {
logger.info((0, import_picocolors.bold)((0, import_picocolors.red)("[INFO] Basic command to display information.")));
logger.info((0, import_picocolors.green)("[INFO] Node:"), (0, import_picocolors.bold)(process2.version));
logger.info((0, import_picocolors.yellow)("[INFO] Processor architecture:"), process2.arch);
logger.info((0, import_picocolors.blue)("[INFO] Current dir:"), process2.cwd());
logger.info((0, import_picocolors.gray)("[INFO] Memory usage:"), process2.memoryUsage());
logger.info((0, import_picocolors.gray)("[INFO] Argv:"), argv);
if (argv.full) {
logger.box((0, import_picocolors.gray)((0, import_picocolors.bold)("[INFO] Process config:")), process2.config);
}
logger.info(`[INFO] Config path: ${configInstance.path}`);
}
// src/commands/create.ts
var create_exports = {};
__export(create_exports, {
aliases: () => aliases2,
builder: () => builder2,
command: () => command2,
describe: () => describe2,
handler: () => handler2
});
var process3 = __toESM(require("process"));
var import_picocolors3 = require("picocolors");
var import_simple_git = require("simple-git");
// src/services/llm.ts
var import_openai = require("openai");
var import_sdk = __toESM(require("@anthropic-ai/sdk"));
var import_picocolors2 = require("picocolors");
var fs = __toESM(require("fs/promises"));
var path = __toESM(require("path"));
var os = __toESM(require("os"));
var import_uuid = require("uuid");
// src/services/prompts.ts
function getSystemPrompt() {
return `You are a senior software architect and code reviewer. Analyze the provided git diff to generate the following structured analysis:
Skip any sections or sub-section that are not relevant or not needed or not application or does not require changes to be mentioned.
## 1. Commit Message
- Format: type(scope): concise summary of main functionality
- Example: "feat(logs): implement log viewer with options to delete and view logs"
- First line: 50-120 characters, written in imperative mood
- Follow with 3-5 bullet points that:
- Each begin with a past tense action verb (Added, Implemented, Fixed, Updated, etc.)
- Describe specific components or functionality added/changed
- Highlight important implementation details or user-facing changes
- Are ordered from most significant to least significant change
- Example:
\`\`\`
- Added a command to view LLM request logs with user prompts.
- Implemented functionality to delete all logs with confirmation.
- Enhanced log file handling to display entries and their details.
- Updated log entry structure to include token usage and cost estimates.
\`\`\`
## 2. Pull Request Title
- Create a precise title (60-100 characters) with appropriate type prefix
- Clearly communicate the primary purpose of the changes
- Example: "feat(user-profile): implement image upload with client-side compression"
## 3. Pull Request Description
- Begin with a Technical Summary (2-3 sentences): Concise overview of the core functionality being changed
- Problem Statement: Specific issue being addressed
- Changes Made:
- Use bullet points with past tense action verbs (Added, Implemented, Fixed, etc.)
- Describe each significant change or addition in detail
- Order from most important to least important
- Group related changes under sub-categories if needed
- Example:
\`\`\`
- Added a command to view LLM request logs with user prompts
- Implemented functionality to delete all logs with confirmation
- Enhanced log file handling to display entries and their details
- Updated log entry structure to include token usage and cost estimates
\`\`\`
## 4. Change Classification
Categorize using specific prefixes with detailed scope:
- feat: New functionality or feature implementation
- Example: "feat(auth): implement multi-factor authentication"
- Include scope indicating the system area affected
- Describe user-facing changes explicitly
- Note dependencies on other features
- fix: Bug correction with clear description of the issue
- Example: "fix(checkout): prevent duplicate order submission"
- Include root cause analysis
- Reference issue tracker ID when applicable
- Document verification steps
- refactor: Code restructuring without behavioral changes
- Example: "refactor(api): convert REST endpoints to use repository pattern"
- Specify architectural patterns introduced/removed
- Note test coverage to verify behavior preservation
- Highlight technical debt addressed
- docs: Documentation updates or improvements
- Example: "docs(api): update user authentication API reference"
- Specify documentation type (API, user guide, developer notes)
- Note target audience
- Include validation steps if applicable
- perf: Performance optimizations (with measurable impact)
- Example: "perf(search): reduce query latency by 40% with index optimization"
- Include baseline performance metrics
- Document improvement methodology
- Note test environment details
- security: Security vulnerability patches or hardening
- Example: "security(auth): fix JWT validation to prevent token forgery"
- Note vulnerability type (OWASP category)
- Document attack vector being addressed
- Include verification methodology
- test: Test coverage improvements or testing framework changes
- Example: "test(payment): add integration tests for refund workflows"
- Specify test types added/modified
- Note coverage percentage changes
- Document test environment requirements
- build: Build system or external dependency changes
- Example: "build(deps): upgrade webpack to v5.75.0"
- Document build performance impact
- Note breaking changes in dependencies
- Include verification steps
- ci: Continuous integration configuration updates
- Example: "ci(pipeline): add accessibility testing to PR checks"
- Document pipeline performance impact
- Note changes to development workflow
- Include verification methodology
- chore: Regular maintenance tasks or dependency updates
- Example: "chore(deps): update non-critical dependencies"
- Group related maintenance tasks
- Note impact on development experience
- Document follow-up tasks if applicable
- style: Code formatting or style adjustments (no functional changes)
- Example: "style(components): apply consistent naming convention"
- Reference style guide being followed
- Note automation tools used
- Document scope of changes
## 5. Provide actionable feedback that addresses both immediate code quality and long-term maintainability. Use concrete examples when suggesting improvements.
## 6. Use correct sentence case Make sure the commit message and PR title are clear and concise, and follow the provided guidelines and follows @semantic-release/commit-analyzer angular convention.
`;
}
// src/services/llm.ts
var import_llm_cost = require("llm-cost");
var import_google = require("@ai-sdk/google");
var import_ai = require("ai");
var openaiClient = null;
var anthropicClient = null;
var geminiClient = null;
var logDir = path.join(os.homedir(), ".llm-logs");
function initializeClients() {
try {
if (config.openai?.apiKey) {
openaiClient = new import_openai.OpenAI({
apiKey: config.openai.apiKey,
baseURL: config.openai.baseURL
});
logger.debug("[LLM-INIT] OpenAI client initialized successfully");
} else {
logger.debug("[LLM-INIT] Skipping OpenAI client initialization: No API key provided");
}
if (config.anthropic?.apiKey) {
anthropicClient = new import_sdk.default({
apiKey: config.anthropic.apiKey
});
logger.debug("[LLM-INIT] Anthropic client initialized successfully");
} else {
logger.debug("[LLM-INIT] Skipping Anthropic client initialization: No API key provided");
}
if (config.gemini?.apiKey) {
geminiClient = (0, import_google.createGoogleGenerativeAI)({
apiKey: config.gemini.apiKey
});
logger.debug("[LLM-INIT] Google Gemini client initialized successfully");
} else {
logger.debug("[LLM-INIT] Skipping Google Gemini client initialization: No API key provided");
}
} catch (error) {
logger.error((0, import_picocolors2.red)(`[LLM-INIT] Failed to initialize LLM clients: ${error.message}`));
throw new Error(`LLM client initialization failed: ${error.message}`);
}
}
async function ensureLogDirectory() {
try {
await fs.mkdir(logDir, { recursive: true });
logger.debug(`[LLM-LOGS] Log directory ensured at: ${logDir}`);
} catch (error) {
logger.error((0, import_picocolors2.red)(`[LLM-LOGS] Failed to create log directory: ${error.message}`));
throw new Error(`Log directory creation failed: ${error.message}`);
}
}
async function logRequest(logEntry) {
try {
const requestFileName = `request-${(/* @__PURE__ */ new Date()).toISOString().split("T")[0]}-${logEntry.id}.json`;
const requestFilePath = path.join(logDir, requestFileName);
await fs.writeFile(requestFilePath, JSON.stringify(logEntry, null, 2), { encoding: "utf8" });
logger.info((0, import_picocolors2.green)(`[LLM-LOGS] Request saved as ${requestFilePath} (ID: ${logEntry.id})`));
return logEntry.id;
} catch (error) {
logger.error((0, import_picocolors2.red)(`[LLM-LOGS] Failed to log LLM request: ${error.message}`));
return logEntry.id;
}
}
function getModelForProvider(provider2, customModel) {
if (customModel) return customModel;
switch (provider2) {
case "openai":
return config.model || "gpt-3.5-turbo";
case "anthropic":
return config.model || "claude-3-sonnet-20240229";
case "deepseek":
return config.model || "deepseek-chat";
case "ollama":
return config.model || "llama2";
case "gemini":
return config.model || "gemini-1.5-pro";
default:
return "unknown";
}
}
async function logTokensAndCost(model2, input, output) {
try {
if (output) {
const inputOutputCost = await (0, import_llm_cost.tokenizeAndEstimateCost)({
model: model2,
input,
output
});
logger.info(
(0, import_picocolors2.yellow)(
`[LLM-TOKENS] Input tokens: ${inputOutputCost.inputTokens}, Output tokens: ${inputOutputCost.outputTokens}, Cost: ${inputOutputCost.cost}`
)
);
return {
inputTokens: inputOutputCost.inputTokens,
outputTokens: inputOutputCost.outputTokens,
totalTokens: inputOutputCost.inputTokens + inputOutputCost.outputTokens,
cost: inputOutputCost.cost
};
}
const inputToken = await (0, import_llm_cost.tokenizeAndEstimateCost)({
model: model2,
input
});
logger.info((0, import_picocolors2.yellow)(`[LLM-TOKENS] Input tokens: ${inputToken.inputTokens}`));
return {
inputTokens: inputToken.inputTokens,
outputTokens: 0,
totalTokens: inputToken.inputTokens,
cost: inputToken.cost
};
} catch (error) {
logger.warn((0, import_picocolors2.yellow)(`[LLM-TOKENS] Failed to calculate token usage: ${error.message}`));
return void 0;
}
}
async function openaiGenerate(options) {
if (!openaiClient) {
throw new Error("OpenAI client not initialized. Check your API key.");
}
const model2 = options.model || config.model || "gpt-3.5-turbo";
logger.info((0, import_picocolors2.yellow)(`[OPENAI] Making completion request with model: ${model2}`));
await logTokensAndCost(model2, options.prompt);
logger.debug(
`[OPENAI] Request params: temperature=${options.temperature || 0.1}, maxTokens=${options.maxTokens || 1e6}`
);
const clientBaseUrl = openaiClient.baseURL;
let response;
let outputText = "";
try {
if (!clientBaseUrl.includes("api.openai.com")) {
response = await openaiClient.chat.completions.create({
model: model2,
messages: [
{
role: "user",
content: options.prompt
}
],
temperature: options.temperature || 0.1,
response_format: { type: "json_object" }
});
outputText = response?.choices?.[0]?.message?.content || "";
} else {
response = await openaiClient.responses.create({
model: model2,
input: [
{
role: "user",
content: options.prompt,
type: "message"
}
],
temperature: options.temperature || 0.1
});
outputText = response.output_text || "";
}
const tokenUsage = await logTokensAndCost(model2, options.prompt, outputText);
if (outputText.startsWith("```json\n")) {
outputText = outputText.substring(8);
}
if (outputText.endsWith("\n```")) {
outputText = outputText.substring(0, outputText.length - 4);
}
return { text: outputText, response, tokenUsage };
} catch (error) {
throw new Error(`OpenAI API error: ${error.message}`);
}
}
async function anthropicGenerate(options) {
if (!anthropicClient) {
throw new Error("Anthropic client not initialized. Check your API key.");
}
const model2 = options.model || config.model || "claude-3-sonnet-20240229";
logger.info((0, import_picocolors2.yellow)(`[ANTHROPIC] Making completion request with model: ${model2}`));
await logTokensAndCost(model2, options.prompt);
logger.debug(
`[ANTHROPIC] Request params: temperature=${options.temperature || 0.1}, maxTokens=${options.maxTokens || 1e6}`
);
try {
const messages = [
{
role: "user",
content: options.prompt
}
];
const response = await anthropicClient.messages.create({
model: model2,
messages,
temperature: options.temperature || 0.1,
max_tokens: options.maxTokens || 1e6,
thinking: {
type: "disabled"
}
});
const responseText = response.content[0]?.text || "";
const tokenUsage = await logTokensAndCost(model2, options.prompt, responseText);
return { text: responseText, response, tokenUsage };
} catch (error) {
throw new Error(`Anthropic API error: ${error.message}`);
}
}
async function deepseekGenerate(options) {
if (!config.deepseek?.apiKey) {
throw new Error("DeepSeek API key not configured.");
}
const model2 = options.model || config.model || "deepseek-chat";
const apiUrl = config.deepseek?.baseURL || "https://api.deepseek.com/v1/chat/completions";
logger.info((0, import_picocolors2.yellow)(`[DEEPSEEK] Making completion request with model: ${model2}`));
await logTokensAndCost(model2, options.prompt);
logger.debug(
`[DEEPSEEK] Request params: temperature=${options.temperature || 0.1}, maxTokens=${options.maxTokens || 1e6}, url=${apiUrl}`
);
try {
const response = await fetch(apiUrl, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${config.deepseek.apiKey}`
},
body: JSON.stringify({
model: model2,
messages: [
{
role: "user",
content: options.prompt
}
],
temperature: options.temperature || 0.1,
max_tokens: options.maxTokens || 1e6
})
});
if (!response.ok) {
throw new Error(`DeepSeek API error: ${response.status} ${response.statusText}`);
}
const data = await response.json();
if (!data || !data.choices || !data.choices[0]?.message) {
throw new Error("Invalid response format from DeepSeek API");
}
const responseText = data.choices[0].message.content || "";
const tokenUsage = await logTokensAndCost(model2, options.prompt, responseText);
return { text: responseText, response, tokenUsage };
} catch (error) {
throw new Error(`DeepSeek API error: ${error.message}`);
}
}
async function ollamaGenerate(options) {
const model2 = options.model || config.model || "llama2";
const apiUrl = config.ollama?.baseURL || "http://localhost:11434/api/generate";
logger.info((0, import_picocolors2.yellow)(`[OLLAMA] Making completion request with model: ${model2}`));
await logTokensAndCost(model2, options.prompt);
logger.debug(`[OLLAMA] Request params: temperature=${options.temperature || 0.1}, url=${apiUrl}`);
try {
const body = JSON.stringify({
model: model2,
stream: false,
prompt: options.prompt,
format: "json",
// system: '',
options: {
temperature: options.temperature || 0.1,
num_ctx: 32768,
// num_batch: 8,
top_p: 0.8
}
});
const headers = {
"Content-Type": "application/json"
};
if (config.ollama?.apiKey) {
headers.Authorization = `Bearer ${config.ollama.apiKey}`;
}
const response = await fetch(apiUrl, {
method: "POST",
headers,
body
});
if (!response.ok) {
throw new Error(`Ollama API error: ${response.status} ${response.statusText}`);
}
const data = await response.json();
if (!data || typeof data !== "object" || !("response" in data)) {
throw new Error("Invalid response format from Ollama API");
}
const responseText = data.response || "";
const tokenUsage = await logTokensAndCost(model2, options.prompt, responseText);
return { response: data, text: responseText, tokenUsage };
} catch (error) {
throw new Error(`Ollama API error: ${error.message}`);
}
}
async function geminiGenerate(options) {
if (!geminiClient) {
throw new Error("Gemini client not initialized. Check your API key.");
}
const model2 = options.model || config.model || "gemini-1.5-pro";
logger.info((0, import_picocolors2.yellow)(`[GEMINI] Making completion request with model: ${model2}`));
await logTokensAndCost(model2, options.prompt);
logger.debug(`[GEMINI] Request params: temperature=${options.temperature || 0.1}, model=${model2}`);
try {
const response = await (0, import_ai.generateObject)({
model: geminiClient(model2, {
structuredOutputs: true
}),
schema: (0, import_ai.jsonSchema)({
type: "object"
}),
prompt: options.prompt,
temperature: options.temperature || 0.1
});
const object = response.object ?? {};
const tokenUsage = await logTokensAndCost(model2, options.prompt, JSON.stringify(object));
return { response, text: JSON.stringify(object), tokenUsage };
} catch (error) {
throw new Error(`Gemini API error: ${error.message}`);
}
}
var generateCompletion = async (provider2, options) => {
logger.info((0, import_picocolors2.yellow)(`[LLM] Generating completion using ${provider2}...`));
if (!openaiClient && !anthropicClient && !geminiClient) {
initializeClients();
}
await ensureLogDirectory().catch((err) => {
logger.warn((0, import_picocolors2.yellow)(`[LLM] Failed to ensure log directory, but will continue: ${err.message}`));
});
const startTime = Date.now();
const model2 = getModelForProvider(provider2, options.model);
const requestId = (0, import_uuid.v4)();
options.prompt = getSystemPrompt() + options.prompt;
const logEntry = {
id: requestId,
timestamp: (/* @__PURE__ */ new Date()).toISOString(),
provider: provider2,
model: model2,
options: { ...options }
};
try {
let response = {};
let text = "";
let tokenUsage;
switch (provider2) {
case "openai": {
const openaiResult = await openaiGenerate(options);
response = openaiResult.response;
text = openaiResult.text;
tokenUsage = openaiResult.tokenUsage;
break;
}
case "anthropic": {
const anthropicResult = await anthropicGenerate(options);
response = anthropicResult.response;
text = anthropicResult.text;
tokenUsage = anthropicResult.tokenUsage;
break;
}
case "deepseek": {
const deepseekResult = await deepseekGenerate(options);
response = deepseekResult.response;
text = deepseekResult.text;
tokenUsage = deepseekResult.tokenUsage;
break;
}
case "ollama": {
const ollamaResult = await ollamaGenerate(options);
response = ollamaResult.response;
text = ollamaResult.text;
tokenUsage = ollamaResult.tokenUsage;
break;
}
case "gemini": {
const geminiResult = await geminiGenerate(options);
response = geminiResult.response;
text = geminiResult.text;
tokenUsage = geminiResult.tokenUsage;
break;
}
default:
throw new Error(`Unsupported LLM provider: ${provider2}`);
}
logEntry.response = response;
logEntry.text = text;
logEntry.executionTimeMs = Date.now() - startTime;
if (tokenUsage) {
logEntry.tokenUsage = {
inputTokens: tokenUsage.inputTokens,
outputTokens: tokenUsage.outputTokens,
totalTokens: tokenUsage.totalTokens,
cost: tokenUsage.cost
};
}
if (options.logRequest) {
await logRequest(logEntry).catch((err) => {
logger.warn((0, import_picocolors2.yellow)(`[LLM] Failed to log request, but will continue: ${err.message}`));
});
}
return { text, response, requestId };
} catch (error) {
logEntry.error = error.message;
logEntry.executionTimeMs = Date.now() - startTime;
if (options.logRequest) {
await logRequest(logEntry).catch((err) => {
logger.warn((0, import_picocolors2.yellow)(`[LLM] Failed to log error request, but will continue: ${err.message}`));
});
}
logger.error((0, import_picocolors2.red)(`[LLM] Error generating completion with ${provider2}: ${error.message}`));
throw error;
}
};
initializeClients();
ensureLogDirectory().catch((err) => logger.error((0, import_picocolors2.red)(`[LLM-LOGS] Log directory initialization error: ${err.message}`)));
// src/commands/create.ts
var import_execa = require("execa");
var command2 = "create";
var describe2 = "Generate commit messages and create a PR using AI";
var aliases2 = ["c"];
function builder2(yargs2) {
return yargs2.option("yes", {
type: "boolean",
alias: "y",
describe: "Automatically answer yes to all confirmations",
default: false
}).option("log-request", {
type: "boolean",
describe: "Log AI requests for debugging purposes",
default: false
}).option("pr", {
type: "boolean",
describe: "Create a branch and PR",
default: false
}).option("provider", {
type: "string",
describe: "LLM provider to use (e.g., openai, ollama)",
default: config.llmProvider
}).option("model", {
type: "string",
describe: "LLM model to use (e.g., gpt-3.5-turbo, gpt-4)",
default: config.model
}).option("draft", {
type: "boolean",
describe: "Create the PR as a draft",
default: false
});
}
var globalConfirm;
var globalLogRequest = false;
var model;
var provider;
var commitsOptimizedInSession = false;
var commitsCreatedInSession = false;
var PR_AGENT_NOTE_NAMESPACE = "pr-agent";
var PR_AGENT_NOTE_MESSAGE = "created-by-pr-agent";
function initializeGlobals(argv) {
globalConfirm = async (message, options = { type: "confirm" }) => {
if (argv.yes) {
logger.info((0, import_picocolors3.yellow)(`[Auto-confirmed] ${message}`));
return true;
}
return await logger.prompt((0, import_picocolors3.green)(message), options);
};
globalLogRequest = argv["log-request"] ?? false;
provider = argv.provider;
model = argv.model;
}
async function performGitOperation(operation, errorMessage) {
try {
return await operation();
} catch (error) {
logger.error((0, import_picocolors3.red)(`${errorMessage}: ${error.message}`));
return null;
}
}
async function checkForExistingPR(branchName) {
if (!branchName) {
logger.debug("[PR-CHECK] No branch name provided to checkForExistingPR.");
return null;
}
try {
const { exitCode: ghExitCode } = await (0, import_execa.execa)("gh", ["--version"], { reject: false });
if (ghExitCode !== 0) {
logger.debug("[PR-CHECK] GitHub CLI not available for PR check");
return null;
}
const { stdout: prJson, exitCode } = await (0, import_execa.execa)(
"gh",
["pr", "list", "--head", branchName, "--state", "open", "--json", "url,number,title", "--limit", "1"],
{ reject: false }
);
if (exitCode !== 0 || !prJson) {
logger.debug(`[PR-CHECK] No PR information available for branch ${branchName}`);
return null;
}
const prs = JSON.parse(prJson);
if (Array.isArray(prs) && prs.length > 0) {
logger.debug(`[PR-CHECK] Found existing PR for branch ${branchName}: #${prs[0].number}`);
return prs[0];
}
return null;
} catch (error) {
logger.debug(`[PR-CHECK] Error checking for existing PR: ${error.message}`);
return null;
}
}
async function handler2(argv) {
initializeGlobals(argv);
const baseUrl = config?.[provider]?.baseURL;
logger.info((0, import_picocolors3.green)("[CONFIG] Current settings:"));
logger.info(`[CONFIG] Provider: ${provider}`);
logger.info(`[CONFIG] Model: ${model}`);
if (baseUrl) {
logger.info(`[CONFIG] API URL: ${baseUrl}`);
}
const ready = await globalConfirm(`Are you ready to create an AI PR?`);
if (!ready) return;
try {
const currentDir = process3.cwd();
if (!currentDir) {
logger.error((0, import_picocolors3.red)("[INIT] Failed to get current working directory."));
return;
}
const git = (0, import_simple_git.simpleGit)({
baseDir: currentDir,
binary: "git",
maxConcurrentProcesses: 6
});
const status = await performGitOperation(() => git.status(), "[GIT] Failed to get git status");
if (!status) return;
logger.info((0, import_picocolors3.yellow)("[WORKFLOW] Next step: Determine the target branch for your PR"));
const proceedWithBranch = await globalConfirm("Would you like to proceed with determining the target branch?");
if (!proceedWithBranch) {
logger.info((0, import_picocolors3.yellow)("[WORKFLOW] Process cancelled by user"));
return;
}
const upstreamBranch = await performGitOperation(
() => getUpstreamBranch(git, globalConfirm),
"[GIT] Failed to determine upstream branch"
);
if (!upstreamBranch) return;
if (!status.isClean()) {
logger.info((0, import_picocolors3.yellow)("[WORKFLOW] Next step: Handle uncommitted changes in your working directory"));
const proceedWithChanges = await globalConfirm("Would you like to commit your uncommitted changes?");
if (!proceedWithChanges) {
logger.info((0, import_picocolors3.yellow)("[WORKFLOW] Process cancelled by user"));
return;
}
await performGitOperation(
() => handleUncommittedChanges(git, status, globalConfirm),
"[GIT] Failed to handle uncommitted changes"
);
} else {
logger.info((0, import_picocolors3.green)("[GIT] Working directory is clean"));
}
await performGitOperation(
() => optimizeCommitMessages(git, upstreamBranch, globalConfirm),
"[GIT] Failed to optimize commit messages"
);
if (!argv?.pr) {
logger.info((0, import_picocolors3.yellow)("[WORKFLOW] Skipping PR creation as --pr flag is not set"));
return;
}
logger.info((0, import_picocolors3.yellow)("[WORKFLOW] Final step: Create a new branch and push PR to remote"));
const proceedWithPR = await globalConfirm("Would you like to proceed with creating a PR?");
if (!proceedWithPR) {
logger.info((0, import_picocolors3.yellow)("[WORKFLOW] PR creation cancelled by user"));
return;
}
await performGitOperation(
() => createAndPushPR(git, upstreamBranch, argv?.draft, globalConfirm),
"[GIT] Failed to create and push PR"
);
} catch (e) {
logger.error((0, import_picocolors3.red)(`[ERROR] Unexpected error occurred: ${e.message}`));
}
}
var ignoredFiles = ["pnpm-lock.yaml", "yarn.lock", "package-lock.json", "tsconfig.json"];
async function getUpstreamBranch(git, confirm) {
try {
logger.info((0, import_picocolors3.green)("[BRANCH] Attempting to determine the upstream branch..."));
let branchInfo;
try {
branchInfo = await git.branch();
if (!branchInfo || !branchInfo.current) {
logger.error((0, import_picocolors3.red)("[BRANCH] Branch information is undefined or missing current branch."));
throw new Error("Could not determine current branch");
}
} catch (error) {
logger.error((0, import_picocolors3.red)(`[BRANCH] Failed to get branch information: ${error.message}`));
throw new Error("Could not determine current branch");
}
const trackingBranch = await git.revparse(["--abbrev-ref", "--symbolic-full-name", "@{u}"]).catch(() => {
logger.info((0, import_picocolors3.yellow)("[BRANCH] No tracking branch found"));
return null;
});
if (branchInfo.current && trackingBranch) {
logger.info((0, import_picocolors3.green)(`[BRANCH] Found tracking branch: ${trackingBranch}`));
const confirmTracking = await confirm(`Use "${trackingBranch}" as the target branch?`);
if (confirmTracking) {
return trackingBranch;
} else {
logger.info((0, import_picocolors3.yellow)("[BRANCH] You chose to select a different target branch"));
}
}
logger.info((0, import_picocolors3.yellow)("[BRANCH] Fetching available remote branches..."));
let remoteBranches;
try {
remoteBranches = await git.branch(["--remotes"]);
if (!remoteBranches || !remoteBranches.all) {
logger.error((0, import_picocolors3.red)("[BRANCH] Remote branches information is undefined."));
throw new Error("Could not retrieve remote branches");
}
} catch (error) {
logger.error((0, import_picocolors3.red)(`[BRANCH] Failed to fetch remote branches: ${error.message}`));
throw new Error("Could not retrieve remote branches");
}
const branches = remoteBranches.all.filter((branch) => branch && !branch.includes("HEAD ->")).map((branch) => branch.trim());
if (!branches || branches.length === 0) {
throw new Error("No remote branches found");
}
const targetBranch = await logger.prompt((0, import_picocolors3.yellow)("[BRANCH] Select target branch for PR:"), {
type: "select",
options: branches
});
if (!targetBranch) {
throw new Error("No branch selected");
}
logger.info((0, import_picocolors3.green)(`[BRANCH] Selected target branch: ${targetBranch}`));
const confirmSelected = await confirm(`Confirm "${targetBranch}" as your target branch?`);
if (!confirmSelected) {
logger.info((0, import_picocolors3.yellow)("[BRANCH] Branch selection cancelled. Please start over."));
process3.exit(0);
}
return targetBranch;
} catch (error) {
logger.error((0, import_picocolors3.red)(`[BRANCH] Failed to determine upstream branch: ${error.message}`));
throw error;
}
}
async function handleUncommittedChanges(git, status, confirm) {
logger.info((0, import_picocolors3.yellow)("[COMMIT] Found uncommitted changes in the working directory"));
const proceedWithAnalysis = await confirm("Analyze changes with AI to generate a commit message?");
if (!proceedWithAnalysis) {
logger.info((0, import_picocolors3.yellow)("[COMMIT] Commit creation cancelled"));
process3.exit(0);
}
logger.info((0, import_picocolors3.yellow)("[COMMIT] Collecting modified file details for analysis..."));
const modifiedFiles = status && Array.isArray(status.modified) ? status.modified.filter((e) => e && !ignoredFiles.includes(e)) : [];
if (!modifiedFiles || modifiedFiles.length === 0) {
logger.info((0, import_picocolors3.yellow)("[COMMIT] No modified files to analyze."));
return;
}
const tempModified = [];
for (const file of modifiedFiles) {
if (!file) continue;
logger.info((0, import_picocolors3.yellow)(`[COMMIT] Analyzing changes in: ${file}`));
try {
const stagedDiff = await git.diff(["-U3", "--minimal", "--staged", file]);
const unstagedDiff = await git.diff(["-U3", "--minimal", file]);
const diff = stagedDiff + unstagedDiff;
tempModified.push(`
filename: ${file}
diff changes: ${diff}
`);
} catch (error) {
logger.warn((0, import_picocolors3.yellow)(`[COMMIT] Failed to get diff for ${file}: ${error.message}`));
}
}
logger.info((0, import_picocolors3.yellow)("[COMMIT] Generating commit message with AI..."));
const confirmAiRequest = await confirm("Send changes to AI for commit message suggestion?");
if (!confirmAiRequest) {
logger.info((0, import_picocolors3.yellow)("[COMMIT] AI message generation cancelled"));
process3.exit(0);
}
if (!tempModified || tempModified.length === 0) {
logger.error((0, import_picocolors3.red)("[COMMIT] No diffs available for AI analysis."));
return;
}
const commitPrompt = `
Provide a better multi-line commit message with summary and bullet points for all changes following the ## 1. Commit Message format in the prompt.
Format your response as a JSON object with structure:
{
"commitMessage": "type(scope): summary of changes detailed explanation of changes...
bullet points of changes"
}
Git diff changes are as follows:
${tempModified.join("")}
`;
logger.info((0, import_picocolors3.green)("[COMMIT] Sending changes to LLM for commit suggestion..."));
const res = await generateCompletion(provider, {
model,
logRequest: globalLogRequest,
prompt: commitPrompt
});
let commitData;
try {
commitData = JSON.parse(res.text);
if (!commitData.commitMessage) {
logger.error((0, import_picocolors3.red)("[COMMIT] No commit message found in LLM response"));
logger.debug("[COMMIT] Raw response:", res);
throw new Error("Invalid LLM response format");
}
logger.info((0, import_picocolors3.green)("[COMMIT] Got commit suggestion:"));
logger.info(`
---------------------------
Commit Message:
${commitData.commitMessage}
---------------------------
`);
const commitConfirm = await confirm("Proceed with this commit?");
if (commitConfirm) {
logger.info((0, import_picocolors3.yellow)("[COMMIT] Adding all changes to git..."));
try {
await git.add(".");
} catch (error) {
logger.error((0, import_picocolors3.red)(`[COMMIT] Failed to add changes to git staging area: ${error.message}`));
throw new Error("Failed to stage changes");
}
logger.info((0, import_picocolors3.yellow)("[COMMIT] Creating commit with the suggested message..."));
try {
const commitResult = await git.commit(commitData.commitMessage);
if (commitResult.commit) {
await markCommitAsCreatedByTool(git, commitResult.commit);
commitsCreatedInSession = true;
}
} catch (error) {
logger.error((0, import_picocolors3.red)(`[COMMIT] Failed to create commit: ${error.message}`));
throw new Error("Commit creation failed");
}
logger.success((0, import_picocolors3.green)("[COMMIT] Changes committed successfully!"));
} else {
logger.info((0, import_picocolors3.yellow)("[COMMIT] Commit cancelled"));
process3.exit(0);
}
} catch (e) {
logger.debug("[COMMIT] Raw response:", res);
logger.error((0, import_picocolors3.red)(`[COMMIT] Failed to parse LLM response as JSON: ${e.message}`));
process3.exit(0);
}
return commitData;
}
async function markCommitAsCreatedByTool(git, commitHash) {
if (!commitHash) {
logger.debug("No commit hash provided to markCommitAsCreatedByTool.");
return;
}
try {
logger.debug(`Marking commit ${commitHash} as created by PR Agent`);
await git.raw(["notes", "--ref", PR_AGENT_NOTE_NAMESPACE, "add", "-m", PR_AGENT_NOTE_MESSAGE, commitHash]);
logger.debug(`Successfully marked commit ${commitHash}`);
} catch (error) {
logger.debug(`Failed to mark commit with git notes: ${error.message}`);
}
}
async function isCommitCreatedByTool(git, commitHash) {
if (!commitHash) {
logger.debug("No commit hash provided to isCommitCreatedByTool.");
return false;
}
try {
const notes = await git.raw(["notes", "--ref", PR_AGENT_NOTE_NAMESPACE, "show", commitHash]).catch(() => "");
return notes.includes(PR_AGENT_NOTE_MESSAGE);
} catch (error) {
logger.debug(`Failed to check git notes: ${error.message}`);
return false;
}
}
async function optimizeCommitMessages(git, upstreamBranch, confirm) {
logger.info((0, import_picocolors3.yellow)("[OPTIMIZE] Starting commit message optimization process..."));
if (!upstreamBranch) {
logger.error((0, import_picocolors3.red)("[OPTIMIZE] Upstream branch is undefined."));
return;
}
logger.info((0, import_picocolors3.yellow)("[OPTIMIZE] Fetching commits information..."));
let commits;
try {
commits = await git.log({
from: upstreamBranch,
to: "HEAD"
});
} catch (error) {
logger.error((0, import_picocolors3.red)(`[OPTIMIZE] Failed to get commit logs: ${error.message}`));
throw new Error("Could not retrieve commit history");
}
if (!commits || !Array.isArray(commits.all) || !commits.all.length) {
logger.info((0, import_picocolors3.yellow)("[OPTIMIZE] No commits to optimize"));
return;
}
logger.info((0, import_picocolors3.green)(`[OPTIMIZE] Found ${commits.all.length} commit(s) in the branch`));
logger.info((0, import_picocolors3.yellow)("[OPTIMIZE] Next step: Optimize existing commit messages"));
const proceedWithOptimize = await globalConfirm("Would you like to optimize your commit messages?");
if (!proceedWithOptimize) {
logger.info((0, import_picocolors3.yellow)("[OPTIMIZE] Skipping commit message optimization"));
return;
}
const lastCommit = commits?.all?.[0];
if (!lastCommit || !lastCommit.hash) {
logger.info((0, import_picocolors3.yellow)("[OPTIMIZE] No commits found to optimize"));
return;
}
const isToolCommit = await isCommitCreatedByTool(git, lastCommit.hash);
if (isToolCommit) {
logger.info((0, import_picocolors3.yellow)(`[OPTIMIZE] Last commit was already created by PR Agent, skipping optimization`));
return;
}
logger.info(
(0, import_picocolors3.yellow)(`[OPTIMIZE] Will optimize the last commit: ${lastCommit.hash.substring(0, 7)} - ${lastCommit.message}`)
);
let revList;
try {
revList = await git.raw(["rev-list", "--parents", "-n", "1", lastCommit.hash]);
if (!revList) {
logger.error((0, import_picocolors3.red)("[OPTIMIZE] Failed to get parent hashes for last commit."));
return;
}
} catch (error) {
logger.error((0, import_picocolors3.red)(`[OPTIMIZE] Failed to check commit parents: ${error.message}`));
throw new Error("Could not determine if commit is a merge commit");
}
const parentHashes = revList.trim().split(" ");
if (!parentHashes || parentHashes.length === 0) {
logger.error((0, import_picocolors3.red)("[OPTIMIZE] Parent hashes are undefined or empty."));
return;
}
if (parentHashes.length > 2) {
logger.info((0, import_picocolors3.yellow)(`[OPTIMIZE] Cannot optimize merge commit: ${lastCommit.hash.substring(0, 7)}`));
return;
}
const continueOptimization = await confirm("Continue with commit message optimization?");
if (!continueOptimization) {
logger.info((0, import_picocolors3.yellow)("[OPTIMIZE] Commit message optimization cancelled"));
return;
}
logger.info((0, import_picocolors3.yellow)(`[OPTIMIZE] Getting full diff context from ${upstreamBranch} to HEAD for better analysis...`));
let fullDiff;
try {
fullDiff = await git.diff([
"-U3",
"--minimal",
upstreamBranch,
"HEAD",
...ignoredFiles.map((file) => `:(exclude)${file}`),
":(exclude)*.generated.*",
":(exclude)*.lock",
":(exclude)tsconfig.json",
":(exclude)tsconfig.*.json",
":(exclude)*.svg",
":(exclude)*.png",
":(exclude)*.jpg",
":(exclude)*.jpeg"
]);
if (typeof fullDiff !== "string") {
logger.error((0, import_picocolors3.red)("[OPTIMIZE] Full diff is not a string."));
return;
}
} catch (error) {
logger.error((0, import_picocolors3.red)(`[OPTIMIZE] Failed to get full branch diff: ${error.message}`));
throw new Error("Could not retrieve branch diff for analysis");
}
logger.info((0, import_picocolors3.yellow)(`[OPTIMIZE] Also analyzing the specific commit: ${lastCommit.hash.substring(0, 7)}`));
let commitDiff;
try {
commitDiff = await git.show([
"-U3",
"--minimal",
lastCommit.hash,
...ignoredFiles.map((file) => `:(exclude)${file}`),
":(exclude)*.generated.*",
":(exclude)*.lock",
":(exclude)tsconfig.json",
":(exclude)tsconfig.*.json",
":(exclude)*.svg",
":(exclude)*.png",
":(exclude)*.jpg",
":(exclude)*.jpeg"
]);
if (typeof commitDiff !== "string") {
logger.warn((0, import_picocolors3.yellow)("[OPTIMIZE] Commit diff is not a string."));
commitDiff = "Failed to retrieve specific commit diff";
}
} catch (error) {
logger.error((0, import_picocolors3.red)(`[OPTIMIZE] Failed to get commit diff: ${error.message}`));
logger.info((0, import_picocolors3.yellow)("[OPTIMIZE] Will continue with just the full branch diff for analysis"));
commitDiff = "Failed to retrieve specific commit diff";
}
const promptMsg = `
First, analyze the current commit message and determine if it needs improvement based on conventional commit best practices.
Then, analyze both the full branch diff and the specific commit diff to get complete context about the changes.
If the commit needs improvement, provide a better commit message that clearly describes the change using the conventional commit format
(type(scope): description). Types include: feat, fix, docs, style, refactor, perf, test, build, ci, chore.
The message should be concise, clear, and follow best practices.
Format your response as a JSON object with the following structure:
{
"needsImprovement": true|false,
"reason": "Brief explanation of why the commit needs improvement or why it's already sufficient",
"improvedCommitMessage": "type(scope): summary of changes detailed explanation of changes..."
}
The "improvedCommitMessage" should only be provided if "needsImprovement" is true, and should be max 120 characters.
Current commit message: "${lastCommit.message}"
Specific commit diff:
${commitDiff}
Full branch context (all changes from upstream to HEAD):
${fullDiff}
`;
logger.info((0, import_picocolors3.yellow)(`[OPTIMIZE] Requesting commit message analysis from AI with comprehensive context...`));
const res = await generateCompletion(provider, {
model,
logRequest: globalLogRequest,
prompt: promptMsg
});
try {
const analysis = JSON.parse(res.text);
if (!analysis || typeof analysis !== "object") {
logger.error((0, import_picocolors3.red)("[OPTIMIZE] AI analysis response is not an object."));
throw new Error("Invalid AI analysis response");
}
if (analysis.needsImprovement) {
logger.info((0, import_picocolors3.green)(`[OPTIMIZE] AI suggests improving the last commit message`));
logger.info(`
---------------------------
Commit message analysis:
Current commit message: ${lastCommit.message}
Improved commit message: ${analysis.improvedCommitMessage}
Reason for improvement: ${analysis.reason}
---------------------------
`);
const amendConfirm = await confirm(`Amend commit ${lastCommit.hash.substring(0, 7)} with the improved message?`);
if (amendConfirm) {
logger.info((0, import_picocolors3.green)(`[OPTIMIZE] Amending last commit ${lastCommit.hash.substring(0, 7)}...`));
try {
await git.raw(["commit", "--amend", "-m", analysis.improvedCommitMessage]);
await markCommitAsCreatedByTool(git, "HEAD");
commitsOptimizedInSession = true;
logger.success((0, import_picocolors3.green)(`[OPTIMIZE] Last commit amended successfully`));
} catch (error) {
logger.error((0, import_picocolors3.red)(`[OPTIMIZE] Failed to amend commit: ${error.message}`));
throw new Error("Could not amend commit message");
}
} else {
logger.info((0, import_picocolors3.yellow)(`[OPTIMIZE] Skipping amendment for last commit`));
}
} else {
await markCommitAsCreatedByTool(git, lastCommit.hash);
commitsOptimizedInSession = true;
logger.info((0, import_picocolors3.yellow)(`[OPTIMIZE] No changes needed for last commit: ${analysis.reason}`));
}
} catch (e) {
logger.error(