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