addons/isl/src/CommitInfoView/CommitMessageFields.tsblame
View source
b69ab311/**
b69ab312 * Copyright (c) Meta Platforms, Inc. and affiliates.
b69ab313 *
b69ab314 * This source code is licensed under the MIT license found in the
b69ab315 * LICENSE file in the root directory of this source tree.
b69ab316 */
b69ab317
b69ab318import type {EditedMessage} from './CommitInfoState';
b69ab319import type {CommitMessageFields, FieldConfig, FieldsBeingEdited} from './types';
b69ab3110
b69ab3111import {atom} from 'jotai';
b69ab3112import {notEmpty} from 'shared/utils';
b69ab3113import {temporaryCommitTitle} from '../CommitTitle';
b69ab3114import {Internal} from '../Internal';
b69ab3115import {codeReviewProvider} from '../codeReview/CodeReviewInfo';
b69ab3116import {arraysEqual} from '../utils';
b69ab3117import {OSSCommitMessageFieldSchema} from './OSSCommitMessageFieldsSchema';
b69ab3118
b69ab3119export function emptyCommitMessageFields(schema: Array<FieldConfig>): CommitMessageFields {
b69ab3120 return Object.fromEntries(schema.map(config => [config.key, config.type === 'field' ? [] : '']));
b69ab3121}
b69ab3122
b69ab3123/**
b69ab3124 * Construct value representing all fields are false: {title: false, description: false, ...}
b69ab3125 */
b69ab3126export function noFieldsBeingEdited(schema: Array<FieldConfig>): FieldsBeingEdited {
b69ab3127 return Object.fromEntries(schema.map(config => [config.key, false]));
b69ab3128}
b69ab3129
b69ab3130/**
b69ab3131 * Construct value representing all fields are being edited: {title: true, description: true, ...}
b69ab3132 */
b69ab3133export function allFieldsBeingEdited(schema: Array<FieldConfig>): FieldsBeingEdited {
b69ab3134 return Object.fromEntries(schema.map(config => [config.key, true]));
b69ab3135}
b69ab3136
b69ab3137function trimEmpty(a: Array<string>): Array<string> {
b69ab3138 return a.filter(s => s.trim() !== '');
b69ab3139}
b69ab3140
b69ab3141function fieldEqual(
b69ab3142 config: FieldConfig,
b69ab3143 a: Partial<CommitMessageFields>,
b69ab3144 b: Partial<CommitMessageFields>,
b69ab3145): boolean {
b69ab3146 return config.type === 'field'
b69ab3147 ? arraysEqual(
b69ab3148 trimEmpty((a[config.key] ?? []) as Array<string>),
b69ab3149 trimEmpty((b[config.key] ?? []) as Array<string>),
b69ab3150 )
b69ab3151 : a[config.key] === b[config.key];
b69ab3152}
b69ab3153
b69ab3154/**
b69ab3155 * Construct value representing which fields differ between two parsed messages, by comparing each field.
b69ab3156 * ```
b69ab3157 * findFieldsBeingEdited({title: 'hi', description: 'yo'}, {title: 'hey', description: 'yo'}) -> {title: true, description: false}
b69ab3158 * ```
b69ab3159 */
b69ab3160export function findFieldsBeingEdited(
b69ab3161 schema: Array<FieldConfig>,
b69ab3162 a: Partial<CommitMessageFields>,
b69ab3163 b: Partial<CommitMessageFields>,
b69ab3164): FieldsBeingEdited {
b69ab3165 return Object.fromEntries(schema.map(config => [config.key, !fieldEqual(config, a, b)]));
b69ab3166}
b69ab3167
b69ab3168export function anyEditsMade(
b69ab3169 schema: Array<FieldConfig>,
b69ab3170 latestMessage: CommitMessageFields,
b69ab3171 edited: Partial<CommitMessageFields>,
b69ab3172): boolean {
b69ab3173 return Object.keys(edited).some(key => {
b69ab3174 const config = schema.find(config => config.key === key);
b69ab3175 if (config == null) {
b69ab3176 return false;
b69ab3177 }
b69ab3178 return !fieldEqual(config, latestMessage, edited);
b69ab3179 });
b69ab3180}
b69ab3181
b69ab3182/** Given an edited message (Partial<CommitMessageFields>), remove any fields that haven't been meaningfully edited.
b69ab3183 * (exactly equals latest underlying message)
b69ab3184 */
b69ab3185export function removeNoopEdits(
b69ab3186 schema: Array<FieldConfig>,
b69ab3187 latestMessage: CommitMessageFields,
b69ab3188 edited: Partial<CommitMessageFields>,
b69ab3189): Partial<CommitMessageFields> {
b69ab3190 return Object.fromEntries(
b69ab3191 Object.entries(edited).filter(([key]) => {
b69ab3192 const config = schema.find(config => config.key === key);
b69ab3193 if (config == null) {
b69ab3194 return false;
b69ab3195 }
b69ab3196 return !fieldEqual(config, latestMessage, edited);
b69ab3197 }),
b69ab3198 );
b69ab3199}
b69ab31100
b69ab31101export function isFieldNonEmpty(field: string | Array<string>) {
b69ab31102 return Array.isArray(field)
b69ab31103 ? field.length > 0 && (field.length > 1 || field[0].trim().length > 0)
b69ab31104 : field && field.trim().length > 0;
b69ab31105}
b69ab31106
b69ab31107export function commitMessageFieldsToString(
b69ab31108 schema: Array<FieldConfig>,
b69ab31109 fields: CommitMessageFields,
b69ab31110 allowEmptyTitle?: boolean,
b69ab31111): string {
b69ab31112 return schema
b69ab31113 .filter(config => config.key === 'Title' || isFieldNonEmpty(fields[config.key]))
b69ab31114 .map(config => {
b69ab31115 const sep = config.type === 'field' ? ': ' : ':\n'; // long fields have keys on their own line, but fields can use the same line
b69ab31116 // stringified messages of the form Key: value, except the title or generic description don't need a label
b69ab31117 const prefix = config.key === 'Title' || config.key === 'Description' ? '' : config.key + sep;
b69ab31118
b69ab31119 if (config.key === 'Title') {
b69ab31120 const value = fields[config.key] as string;
b69ab31121 if (allowEmptyTitle !== true && value.trim().length === 0) {
b69ab31122 return temporaryCommitTitle();
b69ab31123 }
b69ab31124 }
b69ab31125
b69ab31126 const value =
b69ab31127 config.type === 'field'
b69ab31128 ? (config.formatValues ?? joinWithComma)(fields[config.key] as Array<string>)
b69ab31129 : fields[config.key];
b69ab31130 return prefix + value;
b69ab31131 })
b69ab31132 .join('\n\n');
b69ab31133}
b69ab31134
b69ab31135/**
b69ab31136 * Returns which fields prevent two messages from being merged without any fields being combined.
b69ab31137 * That is, the `key` for every field which is non-empty and different in both messages.
b69ab31138 */
b69ab31139export function findConflictingFieldsWhenMerging(
b69ab31140 schema: Array<FieldConfig>,
b69ab31141 a: CommitMessageFields,
b69ab31142 b: CommitMessageFields,
b69ab31143): Array<FieldConfig> {
b69ab31144 return schema
b69ab31145 .map(config => {
b69ab31146 const isANonEmpty = isFieldNonEmpty(a[config.key]);
b69ab31147 const isBNonEmpty = isFieldNonEmpty(b[config.key]);
b69ab31148 if (!isANonEmpty && !isBNonEmpty) {
b69ab31149 return null;
b69ab31150 } else if (!isANonEmpty || !isBNonEmpty) {
b69ab31151 return null;
b69ab31152 } else if (Array.isArray(a[config.key])) {
b69ab31153 const av = a[config.key] as Array<string>;
b69ab31154 const bv = b[config.key] as Array<string>;
b69ab31155 return arraysEqual(av, bv) ? null : config;
b69ab31156 } else {
b69ab31157 return a[config.key] === b[config.key] ? null : config;
b69ab31158 }
b69ab31159 })
b69ab31160 .filter(notEmpty);
b69ab31161}
b69ab31162
b69ab31163export function mergeCommitMessageFields(
b69ab31164 schema: Array<FieldConfig>,
b69ab31165 a: CommitMessageFields,
b69ab31166 b: CommitMessageFields,
b69ab31167): CommitMessageFields {
b69ab31168 return Object.fromEntries(
b69ab31169 schema
b69ab31170 .map(config => {
b69ab31171 const isANonEmpty = isFieldNonEmpty(a[config.key]);
b69ab31172 const isBNonEmpty = isFieldNonEmpty(b[config.key]);
b69ab31173 if (!isANonEmpty && !isBNonEmpty) {
b69ab31174 return undefined;
b69ab31175 } else if (!isANonEmpty || !isBNonEmpty) {
b69ab31176 return [config.key, isANonEmpty ? a[config.key] : b[config.key]];
b69ab31177 } else if (Array.isArray(a[config.key])) {
b69ab31178 const av = a[config.key] as Array<string>;
b69ab31179 const bv = b[config.key] as Array<string>;
b69ab31180 const merged = arraysEqual(av, bv) ? av : [...av, ...bv];
b69ab31181 return [
b69ab31182 config.key,
b69ab31183 config.type === 'field' && config.maxTokens != null
b69ab31184 ? merged.slice(0, config.maxTokens)
b69ab31185 : merged,
b69ab31186 ];
b69ab31187 } else {
b69ab31188 const av = a[config.key] as string;
b69ab31189 const bv = b[config.key] as string;
b69ab31190 const merged =
b69ab31191 av.trim() === bv.trim() ? av : av + (config.type === 'title' ? ', ' : '\n') + bv;
b69ab31192 return [config.key, merged];
b69ab31193 }
b69ab31194 })
b69ab31195 .filter(notEmpty),
b69ab31196 );
b69ab31197}
b69ab31198
b69ab31199/**
b69ab31200 * Merge two message fields, but always take A's fields if both are non-empty.
b69ab31201 */
b69ab31202export function mergeOnlyEmptyMessageFields(
b69ab31203 schema: Array<FieldConfig>,
b69ab31204 a: CommitMessageFields,
b69ab31205 b: CommitMessageFields,
b69ab31206): CommitMessageFields {
b69ab31207 return Object.fromEntries(
b69ab31208 schema
b69ab31209 .map(config => {
b69ab31210 const isANonEmpty = isFieldNonEmpty(a[config.key]);
b69ab31211 const isBNonEmpty = isFieldNonEmpty(b[config.key]);
b69ab31212 if (!isANonEmpty && !isBNonEmpty) {
b69ab31213 return undefined;
b69ab31214 } else if (!isANonEmpty || !isBNonEmpty) {
b69ab31215 return [config.key, isANonEmpty ? a[config.key] : b[config.key]];
b69ab31216 } else {
b69ab31217 return [config.key, a[config.key]];
b69ab31218 }
b69ab31219 })
b69ab31220 .filter(notEmpty),
b69ab31221 );
b69ab31222}
b69ab31223
b69ab31224export function mergeManyCommitMessageFields(
b69ab31225 schema: Array<FieldConfig>,
b69ab31226 fields: Array<CommitMessageFields>,
b69ab31227): CommitMessageFields {
b69ab31228 return Object.fromEntries(
b69ab31229 schema
b69ab31230 .map(config => {
b69ab31231 if (Array.isArray(fields[0][config.key])) {
b69ab31232 return [
b69ab31233 config.key,
b69ab31234 [...new Set(fields.flatMap(field => field[config.key]))].slice(
b69ab31235 0,
b69ab31236 (config.type === 'field' ? config.maxTokens : undefined) ?? Infinity,
b69ab31237 ),
b69ab31238 ];
b69ab31239 } else {
b69ab31240 const result = fields
b69ab31241 .map(field => field[config.key])
b69ab31242 .filter(value => ((value as string | undefined)?.trim().length ?? 0) > 0);
b69ab31243 if (result.length === 0) {
b69ab31244 return undefined;
b69ab31245 }
b69ab31246 return [config.key, result.join(config.type === 'title' ? ', ' : '\n')];
b69ab31247 }
b69ab31248 })
b69ab31249 .filter(notEmpty),
b69ab31250 );
b69ab31251}
b69ab31252
b69ab31253function joinWithComma(tokens: Array<string>): string {
b69ab31254 return tokens.join(', ');
b69ab31255}
b69ab31256
b69ab31257/**
b69ab31258 * Look through the message fields for a diff number
b69ab31259 */
b69ab31260export function findEditedDiffNumber(field: CommitMessageFields): string | undefined {
b69ab31261 if (Internal.diffFieldTag == null) {
b69ab31262 return undefined;
b69ab31263 }
b69ab31264 const found = field[Internal.diffFieldTag];
b69ab31265 if (Array.isArray(found)) {
b69ab31266 return found[0];
b69ab31267 }
b69ab31268 return found;
b69ab31269}
b69ab31270
b69ab31271function commaSeparated(s: string | undefined): Array<string> {
b69ab31272 if (s == null || s.trim() === '') {
b69ab31273 return [];
b69ab31274 }
b69ab31275 // TODO: remove duplicates
b69ab31276 const split = s.split(',').map(s => s.trim());
b69ab31277 return split;
b69ab31278}
b69ab31279
b69ab31280const SL_COMMIT_MESSAGE_REGEX = /^(HG:.*)|(SL:.*)/gm;
b69ab31281
b69ab31282/**
b69ab31283 * Extract fields from string commit message, based on the field schema.
b69ab31284 */
b69ab31285export function parseCommitMessageFields(
b69ab31286 schema: Array<FieldConfig>,
b69ab31287 title: string, // TODO: remove title and just pass title\ndescription in one thing
b69ab31288 description: string,
b69ab31289): CommitMessageFields {
b69ab31290 const map: Partial<Record<string, string>> = {};
b69ab31291 const sanitizedCommitMessage = (title + '\n' + description).replace(SL_COMMIT_MESSAGE_REGEX, '');
b69ab31292
b69ab31293 const sectionTags = schema.map(field => field.key);
b69ab31294 const TAG_SEPARATOR = ':';
b69ab31295 const sectionSeparatorRegex = new RegExp(`\n\\s*\\b(${sectionTags.join('|')})${TAG_SEPARATOR} ?`);
b69ab31296
b69ab31297 // The section names are in a capture group in the regex so the odd elements
b69ab31298 // in the array are the section names.
b69ab31299 const splitSections = sanitizedCommitMessage.split(sectionSeparatorRegex);
b69ab31300 for (let i = 1; i < splitSections.length; i += 2) {
b69ab31301 const sectionTag = splitSections[i];
b69ab31302 const sectionContent = splitSections[i + 1] || '';
b69ab31303
b69ab31304 // Special case: If a user types the name of a field in the text, a single section might be
b69ab31305 // discovered more than once.
b69ab31306 if (map[sectionTag]) {
b69ab31307 map[sectionTag] += '\n' + sectionTag + ':\n' + sectionContent.replace(/^\n/, '').trimEnd();
b69ab31308 } else {
b69ab31309 // If we captured the trailing \n in the regex, it could cause leading newlines to not capture.
b69ab31310 // So we instead need to manually trim the leading \n in the content, if it exists.
b69ab31311 map[sectionTag] = sectionContent.replace(/^\n/, '').trimEnd();
b69ab31312 }
b69ab31313 }
b69ab31314
b69ab31315 const result = Object.fromEntries(
b69ab31316 schema.map(config => {
b69ab31317 const found = map[config.key] ?? '';
b69ab31318 if (config.key === 'Description') {
b69ab31319 // special case: a field called "description" should contain the entire description,
b69ab31320 // in case you don't have any fields configured.
b69ab31321 // TODO: this should probably be a key on the schema description field instead,
b69ab31322 // or configured as part of the overall schema "parseMethod", to support formats other than "Key: Value"
b69ab31323 return ['Description', description];
b69ab31324 }
b69ab31325 return [
b69ab31326 config.key,
b69ab31327 config.type === 'field' ? (config.extractValues ?? commaSeparated)(found) : found,
b69ab31328 ];
b69ab31329 }),
b69ab31330 );
b69ab31331 // title won't get parsed automatically, manually insert it
b69ab31332 result.Title = title;
b69ab31333 return result;
b69ab31334}
b69ab31335
b69ab31336/**
b69ab31337 * Schema defining what fields we expect to be in a CommitMessageFields object,
b69ab31338 * and some information about those fields.
b69ab31339 */
b69ab31340export const commitMessageFieldsSchema = atom<Array<FieldConfig>>(get => {
b69ab31341 const provider = get(codeReviewProvider);
b69ab31342 return provider?.commitMessageFieldsSchema ?? getDefaultCommitMessageSchema();
b69ab31343});
b69ab31344
b69ab31345export function getDefaultCommitMessageSchema() {
b69ab31346 return Internal.CommitMessageFieldSchemaForGitHub ?? OSSCommitMessageFieldSchema;
b69ab31347}
b69ab31348
b69ab31349export function editedMessageSubset(
b69ab31350 message: CommitMessageFields,
b69ab31351 fieldsBeingEdited: FieldsBeingEdited,
b69ab31352): EditedMessage {
b69ab31353 const fields = Object.fromEntries(
b69ab31354 Object.entries(message).filter(([k]) => fieldsBeingEdited[k] ?? false),
b69ab31355 );
b69ab31356 return fields;
b69ab31357}
b69ab31358
b69ab31359export function applyEditedFields(
b69ab31360 message: CommitMessageFields,
b69ab31361 editedMessage: Partial<CommitMessageFields>,
b69ab31362): CommitMessageFields {
b69ab31363 return {...message, ...editedMessage} as CommitMessageFields;
b69ab31364}