@elsikora/commitizen-plugin-commitlint-ai
Version:
AI-powered Commitizen adapter with Commitlint integration
143 lines (140 loc) • 6.8 kB
JavaScript
import { exec } from 'node:child_process';
import { promisify } from 'node:util';
import chalk from 'chalk';
import { generateCommitMessage } from './llm/index.js';
const execAsync = promisify(exec);
/**
* Constructs a commit message from a CommitConfig object
* @param commitConfig The commit configuration
* @returns The formatted commit message
*/
function constructCommitMessage(commitConfig) {
const type = commitConfig.type;
const scope = commitConfig.scope ? `(${commitConfig.scope})` : "";
const subject = commitConfig.subject;
const header = `${type}${scope}: ${subject}`;
// Body with optional breaking change
let body = "";
if (commitConfig.isBreaking) {
body = `BREAKING CHANGE: ${commitConfig.breakingBody || "This commit introduces breaking changes."}\n\n`;
}
if (commitConfig.body) {
body += commitConfig.body;
}
// Footer with issue references
let footer = "";
if (commitConfig.issues && commitConfig.issues.length > 0) {
footer = `Issues: ${commitConfig.issues.join(", ")}`;
}
if (commitConfig.references && commitConfig.references.length > 0) {
if (footer)
footer += "\n";
footer += `References: ${commitConfig.references.join(", ")}`;
}
// Combine all parts
return [header, body, footer].filter(Boolean).join("\n\n");
}
/**
* Validates a commit message and retries with LLM if there are errors
* @param commitConfig The original commit configuration
* @param promptContext The prompt context used to generate the commit
* @returns A promise that resolves to a valid commit message or null if manual entry is needed
*/
async function validateAndFixCommitMessage(commitConfig, promptContext) {
// Initial commit message
const commitMessage = constructCommitMessage(commitConfig);
// Validate with commitlint
const validation = await validateWithCommitlint(commitMessage);
// If valid, return the message
if (validation.isValid) {
return commitMessage;
}
// If not valid and we have errors, try to fix with LLM
if (!validation.isValid && validation.errors) {
console.log(chalk.yellow("Commit message failed validation. Attempting to fix..."));
const MAX_RETRIES = 3;
let currentConfig = commitConfig;
const allErrors = [];
// Attempt to fix the commit message up to MAX_RETRIES times
// eslint-disable-next-line @elsikora-typescript/typedef
for (let attempt = 0; attempt < MAX_RETRIES; attempt++) {
// Add the current validation error to our history
if (validation.errors) {
allErrors.push(validation.errors);
}
try {
// Generate a fixed commit message with accumulated errors
currentConfig = await fixCommitMessageWithLLM(currentConfig, validation.errors ?? "", promptContext, allErrors.slice(0, -1));
// Construct and validate the new commit message
const fixedCommitMessage = constructCommitMessage(currentConfig);
const fixedValidation = await validateWithCommitlint(fixedCommitMessage);
// If valid, return the successful message
if (fixedValidation.isValid) {
console.log(chalk.green(`Commit message fixed successfully on attempt ${attempt + 1}!`));
return fixedCommitMessage;
}
// If we still have errors, continue with next attempt
if (fixedValidation.errors) {
console.log(chalk.yellow(`Fix attempt ${attempt + 1} still has errors. ${MAX_RETRIES - attempt - 1} retries left.`));
// Update the validation errors for the next iteration
validation.errors = fixedValidation.errors;
}
}
catch (error) {
console.error(chalk.red(`Error while trying to fix commit message (attempt ${attempt + 1}):`, error));
break; // Exit retry loop on error
}
}
// If we exhausted all retries and still have issues
console.log(chalk.red(`Unable to fix commit message automatically after ${MAX_RETRIES} attempts.`));
console.log(chalk.yellow("Switching to manual commit entry mode..."));
return null; // Signal to switch to manual mode
}
// Default case, return original even if there were issues
return commitMessage;
}
/**
* Validates a commit message with commitlint
* @param commitMessage The commit message to validate
* @returns A promise that resolves to an object with the validation result
*/
async function validateWithCommitlint(commitMessage) {
try {
// Create temporary file with commit message
const cmd = `echo "${commitMessage}" | npx commitlint`;
// eslint-disable-next-line @elsikora-typescript/no-unsafe-call
await execAsync(cmd);
return { isValid: true };
}
catch (error) {
// If commitlint exits with non-zero code, it means there are validation errors
const typedError = error;
return {
errors: typedError.stdout ?? typedError.stderr ?? typedError.message,
isValid: false,
};
}
}
/**
* Sends a commit message to the LLM for correction with provided errors
* @param commitConfig The original commit configuration
* @param errors The errors from commitlint
* @param promptContext The prompt context to use for regeneration
* @param previousErrors Optional accumulated errors from previous attempts
* @returns A promise that resolves to a new commit configuration
*/
async function fixCommitMessageWithLLM(commitConfig, errors, promptContext, previousErrors = []) {
// Create a history of all errors to help the LLM understand what needs fixing
const errorHistory = [...previousErrors, errors].map((error, index) => `Attempt ${index + 1} errors:\n${error}`).join("\n\n");
// Create an enhanced context that includes the original commit and errors
const enhancedContext = {
...promptContext,
// Add a note about the previous attempt and errors
diff: `${promptContext.diff ?? ""}\n\nCommit message failed validation with these errors:\n${errorHistory}\n\nOriginal commit structure:\n${JSON.stringify(commitConfig, null)}`,
};
console.log(chalk.yellow(`Commit message had validation errors. Asking LLM to fix... (Attempt ${previousErrors.length + 1})`));
// Generate a new commit message with the enhanced context
return await generateCommitMessage(enhancedContext);
}
export { constructCommitMessage, validateAndFixCommitMessage, validateWithCommitlint };
//# sourceMappingURL=commitlintValidator.js.map