12.1 KB365 lines
Blame
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
8import type {EditedMessage} from './CommitInfoState';
9import type {CommitMessageFields, FieldConfig, FieldsBeingEdited} from './types';
10
11import {atom} from 'jotai';
12import {notEmpty} from 'shared/utils';
13import {temporaryCommitTitle} from '../CommitTitle';
14import {Internal} from '../Internal';
15import {codeReviewProvider} from '../codeReview/CodeReviewInfo';
16import {arraysEqual} from '../utils';
17import {OSSCommitMessageFieldSchema} from './OSSCommitMessageFieldsSchema';
18
19export 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 */
26export 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 */
33export function allFieldsBeingEdited(schema: Array<FieldConfig>): FieldsBeingEdited {
34 return Object.fromEntries(schema.map(config => [config.key, true]));
35}
36
37function trimEmpty(a: Array<string>): Array<string> {
38 return a.filter(s => s.trim() !== '');
39}
40
41function 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 */
60export 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
68export 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 */
85export 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
101export 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
107export 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 */
139export 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
163export 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 */
202export 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
224export 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
253function joinWithComma(tokens: Array<string>): string {
254 return tokens.join(', ');
255}
256
257/**
258 * Look through the message fields for a diff number
259 */
260export 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
271function 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
280const SL_COMMIT_MESSAGE_REGEX = /^(HG:.*)|(SL:.*)/gm;
281
282/**
283 * Extract fields from string commit message, based on the field schema.
284 */
285export 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 */
340export const commitMessageFieldsSchema = atom<Array<FieldConfig>>(get => {
341 const provider = get(codeReviewProvider);
342 return provider?.commitMessageFieldsSchema ?? getDefaultCommitMessageSchema();
343});
344
345export function getDefaultCommitMessageSchema() {
346 return Internal.CommitMessageFieldSchemaForGitHub ?? OSSCommitMessageFieldSchema;
347}
348
349export 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
359export function applyEditedFields(
360 message: CommitMessageFields,
361 editedMessage: Partial<CommitMessageFields>,
362): CommitMessageFields {
363 return {...message, ...editedMessage} as CommitMessageFields;
364}
365