blob: ffb8a36ce595d05e4f120a6d8addeff5f0f45d1b [file] [log] [blame]
Copybara854996b2021-09-07 19:36:02 +00001// Copyright 2019 The Chromium Authors. All rights reserved.
2// Use of this source code is governed by a BSD-style license that can be
3// found in the LICENSE file.
4
5/**
6 * @fileoverview This file collects helpers for managing various canonical
7 * formats used within Monorail's frontend. When converting between common
8 * Objects, for example, it's recommended to use the helpers in this file
9 * to ensure consistency across conversions.
10 *
11 * Converters between v0 and v3 object types are included in this file
12 * as well.
13 */
14
15import qs from 'qs';
16
17import {equalsIgnoreCase, capitalizeFirst} from './helpers.js';
18import {fromShortlink} from 'shared/federated.js';
19import {UserInputError} from 'shared/errors.js';
20import './typedef.js';
21
22/**
23 * Common restriction labels to do things users frequently want to do
24 * with restrictions.
25 * This code is a frontend replication of old Python server code that
26 * hardcoded specific restriction labels.
27 * @type {Array<LabelDef>}
28 */
29const FREQUENT_ISSUE_RESTRICTIONS = Object.freeze([
30 {
31 label: 'Restrict-View-EditIssue',
32 docstring: 'Only users who can edit the issue may access it',
33 },
34 {
35 label: 'Restrict-AddIssueComment-EditIssue',
36 docstring: 'Only users who can edit the issue may add comments',
37 },
38]);
39
40/**
41 * The set of actions that permissions on an issue can be applied to.
42 * For example, in the Restrict-View-Google label, "View" is an action.
43 * @type {Array<string>}
44 */
45const STANDARD_ISSUE_ACTIONS = [
46 'View', 'EditIssue', 'AddIssueComment', 'DeleteIssue', 'FlagSpam'];
47
48// A Regex defining the canonical String format used in Monorail for allowing
49// users to input structured localId and projectName values in free text inputs.
50// Match: projectName:localId format where projectName is optional.
51// ie: "monorail:1234" or "1234".
52const ISSUE_ID_REGEX = /(?:([a-z0-9-]+):)?(\d+)/i;
53
54// RFC 2821-compliant email address regex used by the server when validating
55// email addresses.
56// eslint-disable-next-line max-len
57const RFC_2821_EMAIL_REGEX = /^[-a-zA-Z0-9!#$%&'*+\/=?^_`{|}~]+(?:[.][-a-zA-Z0-9!#$%&'*+\/=?^_`{|}~]+)*@(?:(?:[0-9a-zA-Z](?:[-]*[0-9a-zA-Z]+)*)(?:\.[0-9a-zA-Z](?:[-]*[0-9a-zA-Z]+)*)*)\.(?:[a-zA-Z]{2,9})$/;
58
59/**
60 * Converts a displayName into a canonical UserRef Object format.
61 *
62 * @param {string} displayName The user's email address, used as a display name.
63 * @return {UserRef} UserRef formatted object that contains a
64 * user's displayName.
65 */
66export function displayNameToUserRef(displayName) {
67 if (displayName && !RFC_2821_EMAIL_REGEX.test(displayName)) {
68 throw new UserInputError(`Invalid email address: ${displayName}`);
69 }
70 return {displayName};
71}
72
73/**
74 * Converts a displayName into a canonical UserRef Object format.
75 *
76 * @param {string} user The user's email address, used as a display name,
77 * or their numeric user ID.
78 * @return {UserRef} UserRef formatted object that contains a
79 * user's displayName or userId.
80 */
81export function userIdOrDisplayNameToUserRef(user) {
82 if (RFC_2821_EMAIL_REGEX.test(user)) {
83 return {displayName: user};
84 }
85 const userId = Number.parseInt(user);
86 if (Number.isNaN(userId)) {
87 throw new UserInputError(`Invalid email address or user ID: ${user}`);
88 }
89 return {userId};
90}
91
92/**
93 * Converts an Object into a standard UserRef Object with only a displayName
94 * and userId. Used for cases when we need to use only the data required to
95 * identify a unique user, such as when requesting information related to a user
96 * through the API.
97 *
98 * @param {UserV0} user An Object representing a user, in the JSON format
99 * returned by the pRPC API.
100 * @return {UserRef} UserRef style Object.
101 */
102export function userToUserRef(user) {
103 if (!user) return {};
104 const {userId, displayName} = user;
105 return {userId, displayName};
106}
107
108/**
109 * Converts a User resource name to a numeric user ID.
110 * @param {string} name
111 * @return {number}
112 */
113export function userNameToId(name) {
114 return Number.parseInt(name.split('/')[1]);
115}
116
117/**
118 * Converts a v3 API User object to a v0 API UserRef.
119 * @param {User} user
120 * @return {UserRef}
121 */
122export function userV3ToRef(user) {
123 return {userId: userNameToId(user.name), displayName: user.displayName};
124}
125
126/**
127 * Convert a UserRef style Object to a userId string.
128 *
129 * @param {UserRef} userRef Object expected to contain a userId key.
130 * @return {number} the unique ID of the user.
131 */
132export function userRefToId(userRef) {
133 return userRef && userRef.userId;
134}
135
136/**
137 * Extracts the displayName property from a UserRef Object.
138 *
139 * @param {UserRef} userRef UserRef Object uniquely identifying a user.
140 * @return {string} The user's display name (email address).
141 */
142export function userRefToDisplayName(userRef) {
143 return userRef && userRef.displayName;
144}
145
146/**
147 * Converts an Array of UserRefs to an Array of display name Strings.
148 *
149 * @param {Array<UserRef>} userRefs Array of UserRefs.
150 * @return {Array<string>} Array of display names.
151 */
152export function userRefsToDisplayNames(userRefs) {
153 if (!userRefs) return [];
154 return userRefs.map(userRefToDisplayName);
155}
156
157/**
158 * Takes an Array of UserRefs and keeps only UserRefs where ID
159 * is known.
160 *
161 * @param {Array<UserRef>} userRefs Array of UserRefs.
162 * @return {Array<UserRef>} Filtered Array IDs guaranteed.
163 */
164export function userRefsWithIds(userRefs) {
165 if (!userRefs) return [];
166 return userRefs.filter((u) => u.userId);
167}
168
169/**
170 * Takes an Array of UserRefs and returns displayNames for
171 * only those refs with IDs specified.
172 *
173 * @param {Array<UserRef>} userRefs Array of UserRefs.
174 * @return {Array<string>} Array of user displayNames.
175 */
176export function filteredUserDisplayNames(userRefs) {
177 if (!userRefs) return [];
178 return userRefsToDisplayNames(userRefsWithIds(userRefs));
179}
180
181/**
182 * Takes in the name of a label and turns it into a LabelRef Object.
183 *
184 * @param {string} label The name of a label.
185 * @return {LabelRef}
186 */
187export function labelStringToRef(label) {
188 return {label};
189}
190
191/**
192 * Takes in the name of a label and turns it into a LabelRef Object.
193 *
194 * @param {LabelRef} labelRef
195 * @return {string} The name of the label.
196 */
197export function labelRefToString(labelRef) {
198 if (!labelRef) return;
199 return labelRef.label;
200}
201
202/**
203 * Converts an Array of LabelRef Objects to label name Strings.
204 *
205 * @param {Array<LabelRef>} labelRefs Array of LabelRef Objects.
206 * @return {Array<string>} Array of label names.
207 */
208export function labelRefsToStrings(labelRefs) {
209 if (!labelRefs) return [];
210 return labelRefs.map(labelRefToString);
211}
212
213/**
214 * Filters a list of labels into a list of only labels with one word.
215 *
216 * @param {Array<LabelRef>} labelRefs
217 * @return {Array<LabelRef>} only the LabelRefs that do not have multiple words.
218 */
219export function labelRefsToOneWordLabels(labelRefs) {
220 if (!labelRefs) return [];
221 return labelRefs.filter(({label}) => {
222 return isOneWordLabel(label);
223 });
224}
225
226/**
227 * Checks whether a particular label is one word.
228 *
229 * @param {string} label the name of the label being checked.
230 * @return {boolean} Whether the label is one word or not.
231 */
232export function isOneWordLabel(label = '') {
233 const words = label.split('-');
234 return words.length === 1;
235}
236
237/**
238 * Creates a LabelDef Object for a restriction label given an action
239 * and a permission.
240 * @param {string} action What action a restriction is applied to.
241 * eg. "View", "EditIssue", "AddIssueComment".
242 * @param {string} permission The permission group that has access to
243 * the restricted behavior. eg. "Google".
244 * @return {LabelDef}
245 */
246export function _makeRestrictionLabel(action, permission) {
247 const perm = capitalizeFirst(permission);
248 return {
249 label: `Restrict-${action}-${perm}`,
250 docstring: `Permission ${perm} needed to use ${action}`,
251 };
252}
253
254/**
255 * Given a list of custom permissions defined for a project, this function
256 * generates simulated LabelDef objects for those permissions + default
257 * restriction labels that all projects should have.
258 * @param {Array<string>=} customPermissions
259 * @param {Array<string>=} actions
260 * @param {Array<LabelDef>=} defaultRestrictionLabels Configurable default
261 * restriction labels to include regardless of custom permissions.
262 * @return {Array<LabelDef>}
263 */
264export function restrictionLabelsForPermissions(customPermissions = [],
265 actions = STANDARD_ISSUE_ACTIONS,
266 defaultRestrictionLabels = FREQUENT_ISSUE_RESTRICTIONS) {
267 const labels = [];
268 actions.forEach((action) => {
269 customPermissions.forEach((permission) => {
270 labels.push(_makeRestrictionLabel(action, permission));
271 });
272 });
273 return [...labels, ...defaultRestrictionLabels];
274}
275
276/**
277 * Converts a custom field name in to the prefix format used in
278 * enum type field values. Monorail defines the enum options for
279 * a custom field as labels.
280 *
281 * @param {string} fieldName Name of a custom field.
282 * @return {string} The label prefixes for enum choices
283 * associated with the field.
284 */
285export function fieldNameToLabelPrefix(fieldName) {
286 return `${fieldName.toLowerCase()}-`;
287}
288
289/**
290 * Finds all prefixes in a label's name, delimited by '-'. A given label
291 * can have multiple possible prefixes, one for each instance of '-'.
292 * Labels that share the same prefix are implicitly treated like
293 * enum fields in certain parts of Monorail's UI.
294 *
295 * @param {string} label The name of the label.
296 * @return {Array<string>} All prefixes in the label.
297 */
298export function labelNameToLabelPrefixes(label) {
299 if (!label) return;
300 const prefixes = [];
301 for (let i = 0; i < label.length; i++) {
302 if (label[i] === '-') {
303 prefixes.push(label.substring(0, i));
304 }
305 }
306 return prefixes;
307}
308
309/**
310 * Truncates a label to include only the label's value, delimited
311 * by '-'.
312 *
313 * @param {string} label The name of the label.
314 * @param {string} fieldName The field name that the label is having a
315 * value extracted for.
316 * @return {string} The label's value.
317 */
318export function labelNameToLabelValue(label, fieldName) {
319 if (!label || !fieldName || isOneWordLabel(label)) return null;
320 const prefix = fieldName.toLowerCase() + '-';
321 if (!label.toLowerCase().startsWith(prefix)) return null;
322
323 return label.substring(prefix.length);
324}
325
326/**
327 * Converts a FieldDef to a v3 FieldDef resource name.
328 * @param {string} projectName The name of the project.
329 * @param {FieldDef} fieldDef A FieldDef Object from the pRPC API proto objects.
330 * @return {string} The v3 FieldDef name, e.g. 'projects/proj/fieldDefs/fieldId'
331 */
332export function fieldDefToName(projectName, fieldDef) {
333 return `projects/${projectName}/fieldDefs/${fieldDef.fieldRef.fieldId}`;
334}
335
336/**
337 * Extracts just the name of the status from a StatusRef Object.
338 *
339 * @param {StatusRef} statusRef
340 * @return {string} The name of the status.
341 */
342export function statusRefToString(statusRef) {
343 return statusRef.status;
344}
345
346/**
347 * Extracts the name of multiple statuses from multiple StatusRef Objects.
348 *
349 * @param {Array<StatusRef>} statusRefs
350 * @return {Array<string>} The names of the statuses inputted.
351 */
352export function statusRefsToStrings(statusRefs) {
353 return statusRefs.map(statusRefToString);
354}
355
356/**
357 * Takes the name of a component and converts it into a ComponentRef
358 * Object.
359 *
360 * @param {string} path Name of the component.
361 * @return {ComponentRef}
362 */
363export function componentStringToRef(path) {
364 return {path};
365}
366
367/**
368 * Extracts just the name of a component from a ComponentRef.
369 *
370 * @param {ComponentRef} componentRef
371 * @return {string} The name of the component.
372 */
373export function componentRefToString(componentRef) {
374 return componentRef && componentRef.path;
375}
376
377/**
378 * Extracts the names of multiple components from multiple refs.
379 *
380 * @param {Array<ComponentRef>} componentRefs
381 * @return {Array<string>} Array of component names.
382 */
383export function componentRefsToStrings(componentRefs) {
384 if (!componentRefs) return [];
385 return componentRefs.map(componentRefToString);
386}
387
388/**
389 * Takes a String with a project name and issue ID in Monorail's canonical
390 * IssueRef format and converts it into an IssueRef Object.
391 *
392 * @param {IssueRefString} idStr A String of the format projectName:1234, a
393 * standard issue ID input format used across Monorail.
394 * @param {string=} defaultProjectName The implied projectName if none is
395 * specified.
396 * @return {IssueRef}
397 * @throws {UserInputError} If the IssueRef string is invalidly formatted.
398 */
399export function issueStringToRef(idStr, defaultProjectName) {
400 if (!idStr) return {};
401
402 // If the string includes a slash, it's an external tracker ref.
403 if (idStr.includes('/')) {
404 return {extIdentifier: idStr};
405 }
406
407 const matches = idStr.match(ISSUE_ID_REGEX);
408 if (!matches) {
409 throw new UserInputError(
410 `Invalid issue ref: ${idStr}. Expected [projectName:]issueId.`);
411 }
412 const projectName = matches[1] ? matches[1] : defaultProjectName;
413
414 if (!projectName) {
415 throw new UserInputError(
416 `Issue ref must include a project name or specify a default project.`);
417 }
418
419 const localId = Number.parseInt(matches[2]);
420 return {localId, projectName};
421}
422
423/**
424 * Takes an IssueRefString and converts it into an IssueRef Object, checking
425 * that it's not the same as another specified issueRef. ie: validates that an
426 * inputted blocking issue is not the same as the issue being blocked.
427 *
428 * @param {IssueRef} issueRef The issue that the IssueRefString is being
429 * compared to.
430 * @param {IssueRefString} idStr A String of the format projectName:1234, a
431 * standard issue ID input format used across Monorail.
432 * @return {IssueRef}
433 * @throws {UserInputError} If the IssueRef string is invalidly formatted
434 * or if the issue is equivalent to the linked issue.
435 */
436export function issueStringToBlockingRef(issueRef, idStr) {
437 // TODO(zhangtiff): Consider simplifying this helper function to only validate
438 // that an issue does not block itself rather than also doing string parsing.
439 const result = issueStringToRef(idStr, issueRef.projectName);
440 if (result.projectName === issueRef.projectName &&
441 result.localId === issueRef.localId) {
442 throw new UserInputError(
443 `Invalid issue ref: ${idStr
444 }. Cannot merge or block an issue on itself.`);
445 }
446 return result;
447}
448
449/**
450 * Converts an IssueRef into a canonical String format. ie: "project:1234"
451 *
452 * @param {IssueRef} ref
453 * @param {string=} projectName The current project context. The
454 * generated String excludes the projectName if it matches the
455 * project the user is currently viewing, to create simpler
456 * issue ID links.
457 * @return {IssueRefString} A String representing the pieces of an IssueRef.
458 */
459export function issueRefToString(ref, projectName = undefined) {
460 if (!ref) return '';
461
462 if (ref.hasOwnProperty('extIdentifier')) {
463 return ref.extIdentifier;
464 }
465
466 if (projectName && projectName.length &&
467 equalsIgnoreCase(ref.projectName, projectName)) {
468 return `${ref.localId}`;
469 }
470 return `${ref.projectName}:${ref.localId}`;
471}
472
473/**
474 * Converts a full Issue Object into only the pieces of its data needed
475 * to define an IssueRef. Useful for cases when we don't want to send excess
476 * information to ifentify an Issue.
477 *
478 * @param {Issue} issue A full Issue Object.
479 * @return {IssueRef} Just the ID part of the Issue Object.
480 */
481export function issueToIssueRef(issue) {
482 if (!issue) return {};
483
484 return {localId: issue.localId,
485 projectName: issue.projectName};
486}
487
488/**
489 * Converts a full Issue Object into an IssueRefString
490 *
491 * @param {Issue} issue A full Issue Object.
492 * @param {string=} defaultProjectName The default project the String should
493 * assume.
494 * @return {IssueRefString} A String with all the data needed to
495 * construct an IssueRef.
496 */
497export function issueToIssueRefString(issue, defaultProjectName = undefined) {
498 if (!issue) return '';
499
500 const ref = issueToIssueRef(issue);
501 return issueRefToString(ref, defaultProjectName);
502}
503
504/**
505 * Creates a link to a particular issue specified in an IssueRef.
506 *
507 * @param {IssueRef} ref The issue that the generated URL will point to.
508 * @param {Object} queryParams The URL params for the URL.
509 * @return {string} The URL for the issue's page as a relative path.
510 */
511export function issueRefToUrl(ref, queryParams = {}) {
512 const queryParamsCopy = {...queryParams};
513
514 if (!ref) return '';
515
516 if (ref.extIdentifier) {
517 const extRef = fromShortlink(ref.extIdentifier);
518 if (!extRef) {
519 console.error(`No tracker found for reference: ${ref.extIdentifier}`);
520 return '';
521 }
522 return extRef.toURL();
523 }
524
525 let paramString = '';
526 if (Object.keys(queryParamsCopy).length) {
527 delete queryParamsCopy.id;
528
529 paramString = `&${qs.stringify(queryParamsCopy)}`;
530 }
531
532 return `/p/${ref.projectName}/issues/detail?id=${ref.localId}${paramString}`;
533}
534
535/**
536 * Converts multiple IssueRef Objects into Strings in the canonical IssueRef
537 * String form expeced by Monorail.
538 *
539 * @param {Array<IssueRef>} arr Array of IssueRefs to convert to Strings.
540 * @param {string} projectName The default project name.
541 * @return {Array<IssueRefString>} Array of Strings where each entry is
542 * represents one IssueRef.
543 */
544export function issueRefsToStrings(arr, projectName) {
545 if (!arr || !arr.length) return [];
546 return arr.map((ref) => issueRefToString(ref, projectName));
547}
548
549/**
550 * Converts an issue name in the v3 API to an IssueRef in the v0 API.
551 * @param {string} name The v3 Issue name, e.g. 'projects/proj-name/issues/123'
552 * @return {IssueRef} An IssueRef.
553 */
554export function issueNameToRef(name) {
555 const nameParts = name.split('/');
556 return {
557 projectName: nameParts[1],
558 localId: parseInt(nameParts[3]),
559 };
560}
561
562/**
563 * Converts an issue name in the v3 API to an IssueRefString in the v0 API.
564 * @param {string} name The v3 Issue name, e.g. 'projects/proj-name/issues/123'
565 * @return {IssueRefString} A String with all the data needed to
566 * construct an IssueRef.
567 */
568export function issueNameToRefString(name) {
569 const nameParts = name.split('/');
570 return `${nameParts[1]}:${nameParts[3]}`;
571}
572
573/**
574 * Converts an v0 Issue to a v3 Issue name.
575 * @param {Issue} issue An Issue Object from the pRPC API issue_objects.proto.
576 * @return {string} The v3 Issue name, e.g. 'projects/proj-name/issues/123'
577 */
578export function issueToName(issue) {
579 return `projects/${issue.projectName}/issues/${issue.localId}`;
580}
581
582/**
583 * Since Monorail stores issue descriptions and description updates as comments,
584 * this function exists to filter a list of comments to get only those comments
585 * that are marked as descriptions.
586 *
587 * @param {Array<IssueComment>} comments List of many comments, usually all
588 * comments associated with an issue.
589 * @return {Array<IssueComment>} List of only the comments that are
590 * descriptions.
591 */
592export function commentListToDescriptionList(comments) {
593 if (!comments) return [];
594 // First comment is always a description, even if it doesn't have a
595 // descriptionNum.
596 return comments.filter((c, i) => !i || c.descriptionNum);
597}
598
599/**
600 * Wraps a String value for a field and a FieldRef into a FieldValue
601 * Object.
602 *
603 * @param {FieldRef} fieldRef A reference to the custom field that this
604 * value is tied to.
605 * @param {string} value The value associated with the FieldRef.
606 * @return {FieldValue}
607 */
608export function valueToFieldValue(fieldRef, value) {
609 return {fieldRef, value};
610}