UNPKG

@elsikora/commitizen-plugin-commitlint-ai

Version:
352 lines (348 loc) 18.1 kB
'use strict'; var OpenAI = require('openai'); var numeric_constant = require('../../domain/constant/numeric.constant.js'); var commitMessage_entity = require('../../domain/entity/commit-message.entity.js'); var azureOpenaiModel_enum = require('../../domain/enum/azure-openai-model.enum.js'); var commitBody_valueObject = require('../../domain/value-object/commit-body.value-object.js'); var commitHeader_valueObject = require('../../domain/value-object/commit-header.value-object.js'); /** * Azure OpenAI implementation of the LLM service */ class AzureOpenAILlmService { /** * Generate a commit message using Azure OpenAI * @param {ILlmPromptContext} context - The context for generating the commit message * @param {LLMConfiguration} configuration - The LLM configuration * @returns {Promise<CommitMessage>} Promise resolving to the generated commit message */ async generateCommitMessage(context, configuration) { // Extract Azure-specific configuration const credential = configuration.getApiKey().getValue(); const model = configuration.getModel() ?? azureOpenaiModel_enum.EAzureOpenAIModel.GPT_4O_2024_11_20; // Azure OpenAI requires endpoint and deployment name // The API key should be in format: "endpoint|api-key|deployment-name" const [endpoint, azureApiKey, deploymentName] = credential.split("|"); if (!endpoint || !azureApiKey || !deploymentName) { throw new Error("Azure OpenAI requires API key in format: 'endpoint|api-key|deployment-name'"); } const openai = new OpenAI({ apiKey: azureApiKey, baseURL: `${endpoint}/openai/deployments/${deploymentName}`, defaultHeaders: { "api-key": azureApiKey }, defaultQuery: { "api-version": "2024-10-01-preview" }, }); const systemPrompt = this.buildSystemPrompt(context); const userPrompt = this.buildUserPrompt(context); const response = await openai.chat.completions.create({ max_tokens: numeric_constant.OPENAI_MAX_TOKENS, messages: [ { content: systemPrompt, role: "system" }, { content: userPrompt, role: "user" }, ], model: model, // In Azure, this is typically the deployment name temperature: numeric_constant.OPENAI_TEMPERATURE, }); const content = response.choices[0]?.message?.content; if (!content) { throw new Error("No response from Azure OpenAI"); } return this.parseCommitMessage(content); } /** * Check if the service supports the given configuration * @param {LLMConfiguration} configuration - The LLM configuration to check * @returns {boolean} True if the service supports the configuration */ supports(configuration) { return configuration.getProvider() === "azure-openai"; } /** * Build the system prompt for Azure OpenAI * @param {ILlmPromptContext} context - The prompt context * @returns {string} The system prompt */ buildSystemPrompt(context) { let prompt = ""; // Check if this is a fix operation const isFixing = !!(context.rules && typeof context.rules === "object" && !Array.isArray(context.rules) && context.rules.validationErrors && context.rules.previousAttempt && context.diff === undefined); if (isFixing) { prompt = "You are a helpful assistant that fixes commit messages to comply with validation rules. You should maintain the original meaning and content while fixing only the format issues.\n\n"; } else { prompt = "You are a helpful assistant that generates conventional commit messages based on the provided context and rules.\n\n"; } // Add validation error context if present if (context.rules?.validationErrors && Array.isArray(context.rules.validationErrors)) { prompt += "IMPORTANT: The previous commit message had validation errors that must be fixed:\n"; for (const error of context.rules.validationErrors) { prompt += `- ${error}\n`; } prompt += "\nMake sure the new commit message fixes all these errors.\n\n"; } // Add all commitlint rules if (context.rules && typeof context.rules === "object" && !Array.isArray(context.rules)) { const formattedRules = this.formatCommitlintRules(context.rules); if (formattedRules) { prompt += "Commit message rules:\n" + formattedRules + "\n\n"; } } if (context.typeEnum && context.typeEnum.length > 0) { prompt += `Available commit types: ${context.typeEnum.join(", ")}\n`; } if (context.typeDescriptions) { prompt += "\nType descriptions:\n"; for (const [type, desc] of Object.entries(context.typeDescriptions)) { const emoji = desc.emoji ? ` ${desc.emoji}` : ""; prompt += `- ${type}: ${desc.description}${emoji}\n`; } } if (context.subject.maxLength) { prompt += `\nSubject must be at most ${context.subject.maxLength} characters.`; } if (context.subject.minLength) { prompt += `\nSubject must be at least ${context.subject.minLength} characters.`; } // Extract body/footer line length rules let bodyMaxLength; let footerMaxLength; if (context.rules && typeof context.rules === "object" && !Array.isArray(context.rules)) { // Extract body-max-line-length const bodyRule = context.rules["body-max-line-length"]; if (Array.isArray(bodyRule) && bodyRule.length >= numeric_constant.RULE_CONFIG_LENGTH && bodyRule[0] > numeric_constant.VALIDATION_LEVEL_DISABLED && typeof bodyRule[numeric_constant.RULE_VALUE_INDEX] === "number") { bodyMaxLength = bodyRule[numeric_constant.RULE_VALUE_INDEX]; } // Extract footer-max-line-length const footerRule = context.rules["footer-max-line-length"]; if (Array.isArray(footerRule) && footerRule.length >= numeric_constant.RULE_CONFIG_LENGTH && footerRule[0] > numeric_constant.VALIDATION_LEVEL_DISABLED && typeof footerRule[numeric_constant.RULE_VALUE_INDEX] === "number") { footerMaxLength = footerRule[numeric_constant.RULE_VALUE_INDEX]; } } // Add body formatting rules if line length rules exist if (bodyMaxLength || footerMaxLength) { prompt += "\n\nIMPORTANT: Body formatting rules:"; prompt += "\n- The 'body' field in the JSON corresponds to the commit message body/footer"; if (bodyMaxLength) { prompt += `\n- Each line in the body must be wrapped to not exceed ${bodyMaxLength} characters`; } if (footerMaxLength) { prompt += `\n- Footer lines must be wrapped to not exceed ${footerMaxLength} characters`; } prompt += "\n- The 'breaking' field also follows the same line length rules"; prompt += "\n- Use line breaks (\\n) to wrap long lines"; prompt += "\n- Empty lines between paragraphs are allowed"; } prompt += "\n\nGenerate a commit message in the following JSON format:\n"; prompt += '{\n "type": "commit type",\n "scope": "optional scope",\n "subject": "commit subject",\n "body": "optional body",\n "breaking": "optional breaking change description"\n}'; prompt += "\n\nIMPORTANT: Respond ONLY with the JSON object. Do not include markdown code blocks, explanations, or any other text. Just the raw JSON."; prompt += "\n\nIMPORTANT: Follow ALL the rules listed above. The commit message MUST pass validation."; return prompt; } /** * Build the user prompt for Azure OpenAI * @param {ILlmPromptContext} context - The prompt context * @returns {string} The user prompt */ buildUserPrompt(context) { let prompt = ""; // Check if this is a fix operation const isFixing = !!(context.rules && typeof context.rules === "object" && !Array.isArray(context.rules) && context.rules.validationErrors && context.rules.previousAttempt && context.diff === undefined); if (isFixing) { prompt = "Fix the following commit message to comply with the validation rules:\n\n"; // Include previous attempt if (context.rules?.previousAttempt && typeof context.rules.previousAttempt === "string") { prompt += `Commit message to fix:\n${context.rules.previousAttempt}\n\n`; } } else { prompt = "Generate a commit message for the following changes:\n\n"; // Include previous attempt if this is a retry with diff if (context.rules?.previousAttempt && typeof context.rules.previousAttempt === "string") { prompt += `Previous attempt (with errors):\n${context.rules.previousAttempt}\n\n`; } if (context.diff) { prompt += `Diff:\n${context.diff}\n\n`; } if (context.files) { prompt += `Files changed:\n${context.files}\n\n`; } } if (context.rules && typeof context.rules === "object" && !Array.isArray(context.rules) && context.rules.instructions && typeof context.rules.instructions === "string") { prompt += `${context.rules.instructions}\n\n`; } if (isFixing) { prompt += "Please fix the commit message to pass validation while keeping the same meaning and content."; } else { prompt += "Please generate an appropriate commit message following the conventional commit format."; } return prompt; } /** * Format commitlint rules into human-readable instructions * @param {Record<string, unknown>} rules - The commitlint rules object * @returns {string} Formatted rules as string */ formatCommitlintRules(rules) { const formattedRules = []; for (const [ruleName, ruleConfig] of Object.entries(rules)) { if (!Array.isArray(ruleConfig) || ruleConfig.length < numeric_constant.MIN_RULE_LENGTH) continue; const [level, condition, value] = ruleConfig; if (level === numeric_constant.VALIDATION_LEVEL_DISABLED) continue; // Skip disabled rules const isError = level === numeric_constant.VALIDATION_LEVEL_ERROR; const prefix = isError ? "MUST" : "SHOULD"; const conditionString = String(condition); switch (ruleName) { case "body-max-line-length": { if (typeof value === "number") { formattedRules.push(`Body lines ${prefix} be at most ${value} characters (wrap long lines with line breaks)`); } break; } case "footer-max-line-length": { if (typeof value === "number") { formattedRules.push(`Footer lines ${prefix} be at most ${value} characters (Note: the 'body' field is treated as footer, wrap long lines)`); } break; } case "header-max-length": { if (typeof value === "number") { formattedRules.push(`Header (type(scope): subject) ${prefix} be at most ${value} characters`); } break; } case "scope-case": { if (Array.isArray(value)) { formattedRules.push(`Scope ${prefix} be in ${value.join(" or ")} case`); } break; } case "scope-enum": { if (conditionString === "always" && Array.isArray(value)) { formattedRules.push(`Scope ${prefix} be one of: ${value.join(", ")}`); } break; } case "subject-case": { if (Array.isArray(value)) { formattedRules.push(`Subject ${prefix} be in ${value.join(" or ")} case`); } break; } case "subject-empty": { formattedRules.push(`Subject ${prefix} ${conditionString === "never" ? "not be empty" : "be empty"}`); break; } case "subject-full-stop": { formattedRules.push(`Subject ${prefix} ${conditionString === "never" ? "not end with a period" : "end with a period"}`); break; } case "subject-max-length": { if (typeof value === "number") { formattedRules.push(`Subject ${prefix} be at most ${value} characters`); } break; } case "subject-min-length": { if (typeof value === "number") { formattedRules.push(`Subject ${prefix} be at least ${value} characters`); } break; } case "type-case": { if (Array.isArray(value)) { formattedRules.push(`Type ${prefix} be in ${value.join(" or ")} case`); } break; } case "type-enum": { if (conditionString === "always" && Array.isArray(value)) { formattedRules.push(`Type ${prefix} be one of: ${value.join(", ")}`); } break; } default: { // Handle other rules generically if (condition && value !== undefined) { formattedRules.push(`${ruleName}: ${conditionString} ${JSON.stringify(value)}`); } } } } return formattedRules.join("\n"); } /** * Parse the commit message from the LLM response * @param {string} content - The response content * @returns {CommitMessage} The parsed commit message */ parseCommitMessage(content) { try { // Clean up the content - remove markdown code blocks if present let cleanContent = content.trim(); // Remove markdown code blocks with more specific pattern - fixed regex cleanContent = cleanContent.replace(/^```(?:json)?\s*/i, "").replace(/```$/m, ""); // Try to extract JSON if it's wrapped in other text - use safer approach const firstBrace = cleanContent.indexOf("{"); const lastBrace = cleanContent.lastIndexOf("}"); if (firstBrace !== -1 && lastBrace !== -1 && lastBrace > firstBrace) { cleanContent = cleanContent.slice(firstBrace, lastBrace + 1); } const parsed = JSON.parse(cleanContent); // Validate required fields if (!parsed.type || !parsed.subject) { throw new Error("Missing required fields: type and subject"); } const header = new commitHeader_valueObject.CommitHeader(parsed.type, parsed.subject, parsed.scope); // Strip "BREAKING CHANGE:" prefix if included in the breaking field let breakingChange = parsed.breaking; if (breakingChange?.startsWith("BREAKING CHANGE:")) { breakingChange = breakingChange.slice("BREAKING CHANGE:".length).trim(); } const body = new commitBody_valueObject.CommitBody(parsed.body, breakingChange); return new commitMessage_entity.CommitMessage(header, body); } catch { // Fallback: try to parse as plain text const lines = content.trim().split("\n"); if (lines.length === 0 || !lines[0]) { throw new Error("No content to parse"); } const headerLine = lines[0]; // Parse header: type(scope): subject const headerMatch = /^(\w+)(?:\(([^)]+)\))?: (.+)$/.exec(headerLine); if (!headerMatch) { throw new Error(`Invalid commit message format. Could not parse: "${headerLine}"`); } const HEADER_TYPE_INDEX = 1; const HEADER_SCOPE_INDEX = 2; const HEADER_SUBJECT_INDEX = 3; const type = headerMatch[HEADER_TYPE_INDEX]; const scope = headerMatch[HEADER_SCOPE_INDEX]; const subject = headerMatch[HEADER_SUBJECT_INDEX]; if (!type || !subject) { throw new Error("Missing required fields: type and subject"); } const header = new commitHeader_valueObject.CommitHeader(type, subject, scope); // Parse body and breaking changes let bodyContent = ""; let breakingChange; for (let index = 1; index < lines.length; index++) { const line = lines[index]; if (!line) continue; if (line.startsWith("BREAKING CHANGE:")) { breakingChange = line.slice("BREAKING CHANGE:".length).trim(); } else if (line.trim()) { bodyContent += line + "\n"; } } const body = new commitBody_valueObject.CommitBody(bodyContent.trim() || undefined, breakingChange); return new commitMessage_entity.CommitMessage(header, body); } } } exports.AzureOpenAILlmService = AzureOpenAILlmService; //# sourceMappingURL=azure-openai-llm.service.js.map