Files
opencommit/src/modules/commitlint/prompts.ts
frauniki 45aed936b1 ♻️ refactor: clean up code formatting and improve readability
- Fix inconsistent indentation across multiple engine files
- Remove trailing whitespace and add missing newlines
- Improve code formatting in prompt generation functions
- Break long lines for better readability
- Standardize spacing and brackets placement
2025-06-15 17:29:12 +09:00

306 lines
13 KiB
TypeScript

import chalk from 'chalk';
import { OpenAI } from 'openai';
import { outro } from '@clack/prompts';
import {
PromptConfig,
QualifiedConfig,
RuleConfigSeverity,
RuleConfigTuple
} from '@commitlint/types';
import { getConfig } from '../../commands/config';
import { i18n, I18nLocals } from '../../i18n';
import { IDENTITY, INIT_DIFF_PROMPT } from '../../prompts';
const config = getConfig();
const translation = i18n[(config.OCO_LANGUAGE as I18nLocals) || 'en'];
type DeepPartial<T> = {
[P in keyof T]?: {
[K in keyof T[P]]?: T[P][K];
};
};
type PromptFunction = (
applicable: string,
value: any,
prompt: DeepPartial<PromptConfig>
) => string;
type PromptResolverFunction = (
key: string,
applicable: string,
value: any,
prompt?: DeepPartial<PromptConfig>
) => string;
/**
* Extracts more contexte for each type-enum.
* IDEA: replicate the concept for scopes and refactor to a generic feature.
*/
const getTypeRuleExtraDescription = (
type: string,
prompt?: DeepPartial<PromptConfig>
) => prompt?.questions?.type?.enum?.[type]?.description;
/*
IDEA: Compress llm readable prompt for each section of commit message: one line for header, one line for scope, etc.
- The type must be in lowercase and should be one of the following values: featuring, fixing, documenting, styling, refactoring, testing, chores, perf, build, ci, revert.
- The scope should not be empty and provide context for the change (e.g., module or file changed).
- The subject should not be empty, should not end with a period, and should provide a concise description of the change. It should not be in sentence-case, start-case, pascal-case, or upper-case.
*/
const llmReadableRules: {
[ruleName: string]: PromptResolverFunction;
} = {
blankline: (key, applicable) =>
`There should ${applicable} be a blank line at the beginning of the ${key}.`,
caseRule: (key, applicable, value: string | Array<string>) =>
`The ${key} should ${applicable} be in ${
Array.isArray(value)
? `one of the following case:
- ${value.join('\n - ')}.`
: `${value} case.`
}`,
emptyRule: (key, applicable) => `The ${key} should ${applicable} be empty.`,
enumRule: (key, applicable, value: string | Array<string>) =>
`The ${key} should ${applicable} be one of the following values:
- ${Array.isArray(value) ? value.join('\n - ') : value}.`,
enumTypeRule: (key, applicable, value: string | Array<string>, prompt) =>
`The ${key} should ${applicable} be one of the following values:
- ${
Array.isArray(value)
? value
.map((v) => {
const description = getTypeRuleExtraDescription(v, prompt);
if (description) {
return `${v} (${description})`;
} else return v;
})
.join('\n - ')
: value
}.`,
fullStopRule: (key, applicable, value: string) =>
`The ${key} should ${applicable} end with '${value}'.`,
maxLengthRule: (key, applicable, value: string) =>
`The ${key} should ${applicable} have ${value} characters or less.`,
minLengthRule: (key, applicable, value: string) =>
`The ${key} should ${applicable} have ${value} characters or more.`
};
/**
* TODO: Validate rules to every rule in the @commitlint configuration.
* IDEA: Plugins can extend the list of rule. Provide user with a way to infer or extend when "No prompt handler for rule".
*/
const rulesPrompts: {
[ruleName: string]: PromptFunction;
} = {
'body-case': (applicable: string, value: string | Array<string>) =>
llmReadableRules.caseRule('body', applicable, value),
'body-empty': (applicable: string) =>
llmReadableRules.emptyRule('body', applicable, undefined),
'body-full-stop': (applicable: string, value: string) =>
llmReadableRules.fullStopRule('body', applicable, value),
'body-leading-blank': (applicable: string) =>
llmReadableRules.blankline('body', applicable, undefined),
'body-max-length': (applicable: string, value: string) =>
llmReadableRules.maxLengthRule('body', applicable, value),
'body-max-line-length': (applicable: string, value: string) =>
`Each line of the body should ${applicable} have ${value} characters or less.`,
'body-min-length': (applicable: string, value: string) =>
llmReadableRules.minLengthRule('body', applicable, value),
'footer-case': (applicable: string, value: string | Array<string>) =>
llmReadableRules.caseRule('footer', applicable, value),
'footer-empty': (applicable: string) =>
llmReadableRules.emptyRule('footer', applicable, undefined),
'footer-leading-blank': (applicable: string) =>
llmReadableRules.blankline('footer', applicable, undefined),
'footer-max-length': (applicable: string, value: string) =>
llmReadableRules.maxLengthRule('footer', applicable, value),
'footer-max-line-length': (applicable: string, value: string) =>
`Each line of the footer should ${applicable} have ${value} characters or less.`,
'footer-min-length': (applicable: string, value: string) =>
llmReadableRules.minLengthRule('footer', applicable, value),
'header-case': (applicable: string, value: string | Array<string>) =>
llmReadableRules.caseRule('header', applicable, value),
'header-full-stop': (applicable: string, value: string) =>
llmReadableRules.fullStopRule('header', applicable, value),
'header-max-length': (applicable: string, value: string) =>
llmReadableRules.maxLengthRule('header', applicable, value),
'header-min-length': (applicable: string, value: string) =>
llmReadableRules.minLengthRule('header', applicable, value),
'references-empty': (applicable: string) =>
llmReadableRules.emptyRule('references section', applicable, undefined),
'scope-case': (applicable: string, value: string | Array<string>) =>
llmReadableRules.caseRule('scope', applicable, value),
'scope-empty': (applicable: string) =>
llmReadableRules.emptyRule('scope', applicable, undefined),
'scope-enum': (applicable: string, value: string | Array<string>) =>
llmReadableRules.enumRule('type', applicable, value),
'scope-max-length': (applicable: string, value: string) =>
llmReadableRules.maxLengthRule('scope', applicable, value),
'scope-min-length': (applicable: string, value: string) =>
llmReadableRules.minLengthRule('scope', applicable, value),
'signed-off-by': (applicable: string, value: string) =>
`The commit message should ${applicable} have a "Signed-off-by" line with the value "${value}".`,
'subject-case': (applicable: string, value: string | Array<string>) =>
llmReadableRules.caseRule('subject', applicable, value),
'subject-empty': (applicable: string) =>
llmReadableRules.emptyRule('subject', applicable, undefined),
'subject-full-stop': (applicable: string, value: string) =>
llmReadableRules.fullStopRule('subject', applicable, value),
'subject-max-length': (applicable: string, value: string) =>
llmReadableRules.maxLengthRule('subject', applicable, value),
'subject-min-length': (applicable: string, value: string) =>
llmReadableRules.minLengthRule('subject', applicable, value),
'type-case': (applicable: string, value: string | Array<string>) =>
llmReadableRules.caseRule('type', applicable, value),
'type-empty': (applicable: string) =>
llmReadableRules.emptyRule('type', applicable, undefined),
'type-enum': (applicable: string, value: string | Array<string>, prompt) =>
llmReadableRules.enumTypeRule('type', applicable, value, prompt),
'type-max-length': (applicable: string, value: string) =>
llmReadableRules.maxLengthRule('type', applicable, value),
'type-min-length': (applicable: string, value: string) =>
llmReadableRules.minLengthRule('type', applicable, value)
};
const getPrompt = (
ruleName: string,
ruleConfig: RuleConfigTuple<unknown>,
prompt: DeepPartial<PromptConfig>
) => {
const [severity, applicable, value] = ruleConfig;
// Should we exclude "Disabled" properties?
// Is this used to disable a subjacent rule when extending presets?
if (severity === RuleConfigSeverity.Disabled) return null;
const promptFn = rulesPrompts[ruleName];
if (promptFn) {
return promptFn(applicable, value, prompt);
}
// Plugins may add their custom rules.
// We might want to call OpenAI to build this rule's llm-readable prompt.
outro(`${chalk.red('✖')} No prompt handler for rule "${ruleName}".`);
return `Please manualy set the prompt for rule "${ruleName}".`;
};
export const inferPromptsFromCommitlintConfig = (
config: QualifiedConfig
): string[] => {
const { rules, prompt } = config;
if (!rules) return [];
return Object.keys(rules)
.map((ruleName) =>
getPrompt(ruleName, rules[ruleName] as RuleConfigTuple<unknown>, prompt)
)
.filter((prompt) => prompt !== null) as string[];
};
/**
* Breaking down commit message structure for conventional commit, and mapping bits with
* ubiquitous language from @commitlint.
* While gpt-4 does this on it self, gpt-3.5 can't map this on his own atm.
*/
const STRUCTURE_OF_COMMIT = config.OCO_OMIT_SCOPE
? `
- Header of commit is composed of type and subject: <type-of-commit>: <subject-of-commit>
- Description of commit is composed of body and footer (optional): <body-of-commit>\n<footer(s)-of-commit>`
: `
- Header of commit is composed of type, scope, subject: <type-of-commit>(<scope-of-commit>): <subject-of-commit>
- Description of commit is composed of body and footer (optional): <body-of-commit>\n<footer(s)-of-commit>`;
// Prompt to generate LLM-readable rules based on @commitlint rules.
const GEN_COMMITLINT_CONSISTENCY_PROMPT = (
prompts: string[]
): OpenAI.Chat.Completions.ChatCompletionMessageParam[] => [
{
role: 'system',
content: `${IDENTITY} Your mission is to create clean and comprehensive commit messages for two different changes in a single codebase and output them in the provided JSON format: one for a bug fix and another for a new feature.
Here are the specific requirements and conventions that should be strictly followed:
Commit Message Conventions:
- The commit message consists of three parts: Header, Body, and Footer.
- Header:
- Format: ${
config.OCO_OMIT_SCOPE
? '`<type>: <subject>`'
: '`<type>(<scope>): <subject>`'
}
- ${prompts.join('\n- ')}
JSON Output Format:
- The JSON output should contain the commit messages for a bug fix and a new feature in the following format:
\`\`\`json
{
"localLanguage": "${translation.localLanguage}",
"commitFix": "<Header of commit for bug fix with scope>",
"commitFeat": "<Header of commit for feature with scope>",
"commitFixOmitScope": "<Header of commit for bug fix without scope>",
"commitFeatOmitScope": "<Header of commit for feature without scope>",
"commitDescription": "<Description of commit for both the bug fix and the feature>"
}
\`\`\`
- The "commitDescription" should not include the commit message's header, only the description.
- Description should not be more than 74 characters.
Additional Details:
- Changing the variable 'port' to uppercase 'PORT' is considered a bug fix.
- Allowing the server to listen on a port specified through the environment variable is considered a new feature.
Example Git Diff is to follow:`
},
INIT_DIFF_PROMPT
];
/**
* Prompt to have LLM generate a message using @commitlint rules.
*
* @param language
* @param prompts
* @returns
*/
const INIT_MAIN_PROMPT = (
language: string,
prompts: string[]
): OpenAI.Chat.Completions.ChatCompletionMessageParam => ({
role: 'system',
content: `${IDENTITY} Your mission is to create clean and comprehensive commit messages in the given @commitlint convention and explain WHAT were the changes ${
config.OCO_WHY ? 'and WHY the changes were done' : ''
}. I'll send you an output of 'git diff --staged' command, and you convert it into a commit message.
${
config.OCO_EMOJI
? 'Use GitMoji convention to preface the commit.'
: 'Do not preface the commit with anything.'
}
${
config.OCO_DESCRIPTION
? 'Add a short description of WHY the changes are done after the commit message. Don\'t start it with "This commit", just describe the changes.'
: "Don't add any descriptions to the commit, only commit message."
}
Use the present tense. Use ${language} to answer.
${
config.OCO_ONE_LINE_COMMIT
? 'Craft a concise commit message that encapsulates all changes made, with an emphasis on the primary updates. If the modifications share a common theme or scope, mention it succinctly; otherwise, leave the scope out to maintain focus. The goal is to provide a clear and unified overview of the changes in a one single message, without diverging into a list of commit per file change.'
: ''
}
${
config.OCO_OMIT_SCOPE
? 'Do not include a scope in the commit message format. Use the format: <type>: <subject>'
: ''
}
You will strictly follow the following conventions to generate the content of the commit message:
- ${prompts.join('\n- ')}
The conventions refers to the following structure of commit message:
${STRUCTURE_OF_COMMIT}`
});
export const commitlintPrompts = {
INIT_MAIN_PROMPT,
GEN_COMMITLINT_CONSISTENCY_PROMPT
};