| 1 | /** |
| 2 | * Copyright (c) Meta Platforms, Inc. and affiliates. |
| 3 | * |
| 4 | * This source code is licensed under the MIT license found in the |
| 5 | * LICENSE file in the root directory of this source tree. |
| 6 | */ |
| 7 | |
| 8 | import type {EditedMessage} from './CommitInfoState'; |
| 9 | import type {CommitMessageFields, FieldConfig, FieldsBeingEdited} from './types'; |
| 10 | |
| 11 | import {atom} from 'jotai'; |
| 12 | import {notEmpty} from 'shared/utils'; |
| 13 | import {temporaryCommitTitle} from '../CommitTitle'; |
| 14 | import {Internal} from '../Internal'; |
| 15 | import {codeReviewProvider} from '../codeReview/CodeReviewInfo'; |
| 16 | import {arraysEqual} from '../utils'; |
| 17 | import {OSSCommitMessageFieldSchema} from './OSSCommitMessageFieldsSchema'; |
| 18 | |
| 19 | export function emptyCommitMessageFields(schema: Array<FieldConfig>): CommitMessageFields { |
| 20 | return Object.fromEntries(schema.map(config => [config.key, config.type === 'field' ? [] : ''])); |
| 21 | } |
| 22 | |
| 23 | /** |
| 24 | * Construct value representing all fields are false: {title: false, description: false, ...} |
| 25 | */ |
| 26 | export function noFieldsBeingEdited(schema: Array<FieldConfig>): FieldsBeingEdited { |
| 27 | return Object.fromEntries(schema.map(config => [config.key, false])); |
| 28 | } |
| 29 | |
| 30 | /** |
| 31 | * Construct value representing all fields are being edited: {title: true, description: true, ...} |
| 32 | */ |
| 33 | export function allFieldsBeingEdited(schema: Array<FieldConfig>): FieldsBeingEdited { |
| 34 | return Object.fromEntries(schema.map(config => [config.key, true])); |
| 35 | } |
| 36 | |
| 37 | function trimEmpty(a: Array<string>): Array<string> { |
| 38 | return a.filter(s => s.trim() !== ''); |
| 39 | } |
| 40 | |
| 41 | function fieldEqual( |
| 42 | config: FieldConfig, |
| 43 | a: Partial<CommitMessageFields>, |
| 44 | b: Partial<CommitMessageFields>, |
| 45 | ): boolean { |
| 46 | return config.type === 'field' |
| 47 | ? arraysEqual( |
| 48 | trimEmpty((a[config.key] ?? []) as Array<string>), |
| 49 | trimEmpty((b[config.key] ?? []) as Array<string>), |
| 50 | ) |
| 51 | : a[config.key] === b[config.key]; |
| 52 | } |
| 53 | |
| 54 | /** |
| 55 | * Construct value representing which fields differ between two parsed messages, by comparing each field. |
| 56 | * ``` |
| 57 | * findFieldsBeingEdited({title: 'hi', description: 'yo'}, {title: 'hey', description: 'yo'}) -> {title: true, description: false} |
| 58 | * ``` |
| 59 | */ |
| 60 | export function findFieldsBeingEdited( |
| 61 | schema: Array<FieldConfig>, |
| 62 | a: Partial<CommitMessageFields>, |
| 63 | b: Partial<CommitMessageFields>, |
| 64 | ): FieldsBeingEdited { |
| 65 | return Object.fromEntries(schema.map(config => [config.key, !fieldEqual(config, a, b)])); |
| 66 | } |
| 67 | |
| 68 | export function anyEditsMade( |
| 69 | schema: Array<FieldConfig>, |
| 70 | latestMessage: CommitMessageFields, |
| 71 | edited: Partial<CommitMessageFields>, |
| 72 | ): boolean { |
| 73 | return Object.keys(edited).some(key => { |
| 74 | const config = schema.find(config => config.key === key); |
| 75 | if (config == null) { |
| 76 | return false; |
| 77 | } |
| 78 | return !fieldEqual(config, latestMessage, edited); |
| 79 | }); |
| 80 | } |
| 81 | |
| 82 | /** Given an edited message (Partial<CommitMessageFields>), remove any fields that haven't been meaningfully edited. |
| 83 | * (exactly equals latest underlying message) |
| 84 | */ |
| 85 | export function removeNoopEdits( |
| 86 | schema: Array<FieldConfig>, |
| 87 | latestMessage: CommitMessageFields, |
| 88 | edited: Partial<CommitMessageFields>, |
| 89 | ): Partial<CommitMessageFields> { |
| 90 | return Object.fromEntries( |
| 91 | Object.entries(edited).filter(([key]) => { |
| 92 | const config = schema.find(config => config.key === key); |
| 93 | if (config == null) { |
| 94 | return false; |
| 95 | } |
| 96 | return !fieldEqual(config, latestMessage, edited); |
| 97 | }), |
| 98 | ); |
| 99 | } |
| 100 | |
| 101 | export function isFieldNonEmpty(field: string | Array<string>) { |
| 102 | return Array.isArray(field) |
| 103 | ? field.length > 0 && (field.length > 1 || field[0].trim().length > 0) |
| 104 | : field && field.trim().length > 0; |
| 105 | } |
| 106 | |
| 107 | export function commitMessageFieldsToString( |
| 108 | schema: Array<FieldConfig>, |
| 109 | fields: CommitMessageFields, |
| 110 | allowEmptyTitle?: boolean, |
| 111 | ): string { |
| 112 | return schema |
| 113 | .filter(config => config.key === 'Title' || isFieldNonEmpty(fields[config.key])) |
| 114 | .map(config => { |
| 115 | const sep = config.type === 'field' ? ': ' : ':\n'; // long fields have keys on their own line, but fields can use the same line |
| 116 | // stringified messages of the form Key: value, except the title or generic description don't need a label |
| 117 | const prefix = config.key === 'Title' || config.key === 'Description' ? '' : config.key + sep; |
| 118 | |
| 119 | if (config.key === 'Title') { |
| 120 | const value = fields[config.key] as string; |
| 121 | if (allowEmptyTitle !== true && value.trim().length === 0) { |
| 122 | return temporaryCommitTitle(); |
| 123 | } |
| 124 | } |
| 125 | |
| 126 | const value = |
| 127 | config.type === 'field' |
| 128 | ? (config.formatValues ?? joinWithComma)(fields[config.key] as Array<string>) |
| 129 | : fields[config.key]; |
| 130 | return prefix + value; |
| 131 | }) |
| 132 | .join('\n\n'); |
| 133 | } |
| 134 | |
| 135 | /** |
| 136 | * Returns which fields prevent two messages from being merged without any fields being combined. |
| 137 | * That is, the `key` for every field which is non-empty and different in both messages. |
| 138 | */ |
| 139 | export function findConflictingFieldsWhenMerging( |
| 140 | schema: Array<FieldConfig>, |
| 141 | a: CommitMessageFields, |
| 142 | b: CommitMessageFields, |
| 143 | ): Array<FieldConfig> { |
| 144 | return schema |
| 145 | .map(config => { |
| 146 | const isANonEmpty = isFieldNonEmpty(a[config.key]); |
| 147 | const isBNonEmpty = isFieldNonEmpty(b[config.key]); |
| 148 | if (!isANonEmpty && !isBNonEmpty) { |
| 149 | return null; |
| 150 | } else if (!isANonEmpty || !isBNonEmpty) { |
| 151 | return null; |
| 152 | } else if (Array.isArray(a[config.key])) { |
| 153 | const av = a[config.key] as Array<string>; |
| 154 | const bv = b[config.key] as Array<string>; |
| 155 | return arraysEqual(av, bv) ? null : config; |
| 156 | } else { |
| 157 | return a[config.key] === b[config.key] ? null : config; |
| 158 | } |
| 159 | }) |
| 160 | .filter(notEmpty); |
| 161 | } |
| 162 | |
| 163 | export function mergeCommitMessageFields( |
| 164 | schema: Array<FieldConfig>, |
| 165 | a: CommitMessageFields, |
| 166 | b: CommitMessageFields, |
| 167 | ): CommitMessageFields { |
| 168 | return Object.fromEntries( |
| 169 | schema |
| 170 | .map(config => { |
| 171 | const isANonEmpty = isFieldNonEmpty(a[config.key]); |
| 172 | const isBNonEmpty = isFieldNonEmpty(b[config.key]); |
| 173 | if (!isANonEmpty && !isBNonEmpty) { |
| 174 | return undefined; |
| 175 | } else if (!isANonEmpty || !isBNonEmpty) { |
| 176 | return [config.key, isANonEmpty ? a[config.key] : b[config.key]]; |
| 177 | } else if (Array.isArray(a[config.key])) { |
| 178 | const av = a[config.key] as Array<string>; |
| 179 | const bv = b[config.key] as Array<string>; |
| 180 | const merged = arraysEqual(av, bv) ? av : [...av, ...bv]; |
| 181 | return [ |
| 182 | config.key, |
| 183 | config.type === 'field' && config.maxTokens != null |
| 184 | ? merged.slice(0, config.maxTokens) |
| 185 | : merged, |
| 186 | ]; |
| 187 | } else { |
| 188 | const av = a[config.key] as string; |
| 189 | const bv = b[config.key] as string; |
| 190 | const merged = |
| 191 | av.trim() === bv.trim() ? av : av + (config.type === 'title' ? ', ' : '\n') + bv; |
| 192 | return [config.key, merged]; |
| 193 | } |
| 194 | }) |
| 195 | .filter(notEmpty), |
| 196 | ); |
| 197 | } |
| 198 | |
| 199 | /** |
| 200 | * Merge two message fields, but always take A's fields if both are non-empty. |
| 201 | */ |
| 202 | export function mergeOnlyEmptyMessageFields( |
| 203 | schema: Array<FieldConfig>, |
| 204 | a: CommitMessageFields, |
| 205 | b: CommitMessageFields, |
| 206 | ): CommitMessageFields { |
| 207 | return Object.fromEntries( |
| 208 | schema |
| 209 | .map(config => { |
| 210 | const isANonEmpty = isFieldNonEmpty(a[config.key]); |
| 211 | const isBNonEmpty = isFieldNonEmpty(b[config.key]); |
| 212 | if (!isANonEmpty && !isBNonEmpty) { |
| 213 | return undefined; |
| 214 | } else if (!isANonEmpty || !isBNonEmpty) { |
| 215 | return [config.key, isANonEmpty ? a[config.key] : b[config.key]]; |
| 216 | } else { |
| 217 | return [config.key, a[config.key]]; |
| 218 | } |
| 219 | }) |
| 220 | .filter(notEmpty), |
| 221 | ); |
| 222 | } |
| 223 | |
| 224 | export function mergeManyCommitMessageFields( |
| 225 | schema: Array<FieldConfig>, |
| 226 | fields: Array<CommitMessageFields>, |
| 227 | ): CommitMessageFields { |
| 228 | return Object.fromEntries( |
| 229 | schema |
| 230 | .map(config => { |
| 231 | if (Array.isArray(fields[0][config.key])) { |
| 232 | return [ |
| 233 | config.key, |
| 234 | [...new Set(fields.flatMap(field => field[config.key]))].slice( |
| 235 | 0, |
| 236 | (config.type === 'field' ? config.maxTokens : undefined) ?? Infinity, |
| 237 | ), |
| 238 | ]; |
| 239 | } else { |
| 240 | const result = fields |
| 241 | .map(field => field[config.key]) |
| 242 | .filter(value => ((value as string | undefined)?.trim().length ?? 0) > 0); |
| 243 | if (result.length === 0) { |
| 244 | return undefined; |
| 245 | } |
| 246 | return [config.key, result.join(config.type === 'title' ? ', ' : '\n')]; |
| 247 | } |
| 248 | }) |
| 249 | .filter(notEmpty), |
| 250 | ); |
| 251 | } |
| 252 | |
| 253 | function joinWithComma(tokens: Array<string>): string { |
| 254 | return tokens.join(', '); |
| 255 | } |
| 256 | |
| 257 | /** |
| 258 | * Look through the message fields for a diff number |
| 259 | */ |
| 260 | export function findEditedDiffNumber(field: CommitMessageFields): string | undefined { |
| 261 | if (Internal.diffFieldTag == null) { |
| 262 | return undefined; |
| 263 | } |
| 264 | const found = field[Internal.diffFieldTag]; |
| 265 | if (Array.isArray(found)) { |
| 266 | return found[0]; |
| 267 | } |
| 268 | return found; |
| 269 | } |
| 270 | |
| 271 | function commaSeparated(s: string | undefined): Array<string> { |
| 272 | if (s == null || s.trim() === '') { |
| 273 | return []; |
| 274 | } |
| 275 | // TODO: remove duplicates |
| 276 | const split = s.split(',').map(s => s.trim()); |
| 277 | return split; |
| 278 | } |
| 279 | |
| 280 | const SL_COMMIT_MESSAGE_REGEX = /^(HG:.*)|(SL:.*)/gm; |
| 281 | |
| 282 | /** |
| 283 | * Extract fields from string commit message, based on the field schema. |
| 284 | */ |
| 285 | export function parseCommitMessageFields( |
| 286 | schema: Array<FieldConfig>, |
| 287 | title: string, // TODO: remove title and just pass title\ndescription in one thing |
| 288 | description: string, |
| 289 | ): CommitMessageFields { |
| 290 | const map: Partial<Record<string, string>> = {}; |
| 291 | const sanitizedCommitMessage = (title + '\n' + description).replace(SL_COMMIT_MESSAGE_REGEX, ''); |
| 292 | |
| 293 | const sectionTags = schema.map(field => field.key); |
| 294 | const TAG_SEPARATOR = ':'; |
| 295 | const sectionSeparatorRegex = new RegExp(`\n\\s*\\b(${sectionTags.join('|')})${TAG_SEPARATOR} ?`); |
| 296 | |
| 297 | // The section names are in a capture group in the regex so the odd elements |
| 298 | // in the array are the section names. |
| 299 | const splitSections = sanitizedCommitMessage.split(sectionSeparatorRegex); |
| 300 | for (let i = 1; i < splitSections.length; i += 2) { |
| 301 | const sectionTag = splitSections[i]; |
| 302 | const sectionContent = splitSections[i + 1] || ''; |
| 303 | |
| 304 | // Special case: If a user types the name of a field in the text, a single section might be |
| 305 | // discovered more than once. |
| 306 | if (map[sectionTag]) { |
| 307 | map[sectionTag] += '\n' + sectionTag + ':\n' + sectionContent.replace(/^\n/, '').trimEnd(); |
| 308 | } else { |
| 309 | // If we captured the trailing \n in the regex, it could cause leading newlines to not capture. |
| 310 | // So we instead need to manually trim the leading \n in the content, if it exists. |
| 311 | map[sectionTag] = sectionContent.replace(/^\n/, '').trimEnd(); |
| 312 | } |
| 313 | } |
| 314 | |
| 315 | const result = Object.fromEntries( |
| 316 | schema.map(config => { |
| 317 | const found = map[config.key] ?? ''; |
| 318 | if (config.key === 'Description') { |
| 319 | // special case: a field called "description" should contain the entire description, |
| 320 | // in case you don't have any fields configured. |
| 321 | // TODO: this should probably be a key on the schema description field instead, |
| 322 | // or configured as part of the overall schema "parseMethod", to support formats other than "Key: Value" |
| 323 | return ['Description', description]; |
| 324 | } |
| 325 | return [ |
| 326 | config.key, |
| 327 | config.type === 'field' ? (config.extractValues ?? commaSeparated)(found) : found, |
| 328 | ]; |
| 329 | }), |
| 330 | ); |
| 331 | // title won't get parsed automatically, manually insert it |
| 332 | result.Title = title; |
| 333 | return result; |
| 334 | } |
| 335 | |
| 336 | /** |
| 337 | * Schema defining what fields we expect to be in a CommitMessageFields object, |
| 338 | * and some information about those fields. |
| 339 | */ |
| 340 | export const commitMessageFieldsSchema = atom<Array<FieldConfig>>(get => { |
| 341 | const provider = get(codeReviewProvider); |
| 342 | return provider?.commitMessageFieldsSchema ?? getDefaultCommitMessageSchema(); |
| 343 | }); |
| 344 | |
| 345 | export function getDefaultCommitMessageSchema() { |
| 346 | return Internal.CommitMessageFieldSchemaForGitHub ?? OSSCommitMessageFieldSchema; |
| 347 | } |
| 348 | |
| 349 | export function editedMessageSubset( |
| 350 | message: CommitMessageFields, |
| 351 | fieldsBeingEdited: FieldsBeingEdited, |
| 352 | ): EditedMessage { |
| 353 | const fields = Object.fromEntries( |
| 354 | Object.entries(message).filter(([k]) => fieldsBeingEdited[k] ?? false), |
| 355 | ); |
| 356 | return fields; |
| 357 | } |
| 358 | |
| 359 | export function applyEditedFields( |
| 360 | message: CommitMessageFields, |
| 361 | editedMessage: Partial<CommitMessageFields>, |
| 362 | ): CommitMessageFields { |
| 363 | return {...message, ...editedMessage} as CommitMessageFields; |
| 364 | } |
| 365 | |