292 lines
12 KiB
TypeScript
292 lines
12 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 = `
|
||
- 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: \`<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>",
|
||
"commitFeat": "<Header of commit for feature>",
|
||
"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.'
|
||
: ''
|
||
}
|
||
|
||
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
|
||
};
|