Project import generated by Copybara.

GitOrigin-RevId: d9e9e3fb4e31372ec1fb43b178994ca78fa8fe70
diff --git a/static_src/shared/consts/approval.js b/static_src/shared/consts/approval.js
new file mode 100644
index 0000000..772025d
--- /dev/null
+++ b/static_src/shared/consts/approval.js
@@ -0,0 +1,79 @@
+// Copyright 2019 The Chromium Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+/**
+ * @fileoverview A file containing common constants used in the Approvals
+ * feature.
+ */
+
+// Only approvers are allowed to set an approval state to one of these states.
+export const APPROVER_RESTRICTED_STATUSES = new Set(
+    ['NA', 'Approved', 'NotApproved']);
+
+// Map the internal enum names used in Monorail's backend from approval
+// statuses to user friendly names.
+export const STATUS_ENUM_TO_TEXT = {
+  '': 'NotSet',
+  'NEEDS_REVIEW': 'NeedsReview',
+  'NA': 'NA',
+  'REVIEW_REQUESTED': 'ReviewRequested',
+  'REVIEW_STARTED': 'ReviewStarted',
+  'NEED_INFO': 'NeedInfo',
+  'APPROVED': 'Approved',
+  'NOT_APPROVED': 'NotApproved',
+};
+
+// Reverse mapping of user friendly names to internal enum names.
+// Note that NotSet -> NOT_SET maps differently in reverse because
+// the backend sends an empty message to communicate NOT_SET.
+export const TEXT_TO_STATUS_ENUM = {
+  'NotSet': 'NOT_SET',
+  'NeedsReview': 'NEEDS_REVIEW',
+  'NA': 'NA',
+  'ReviewRequested': 'REVIEW_REQUESTED',
+  'ReviewStarted': 'REVIEW_STARTED',
+  'NeedInfo': 'NEED_INFO',
+  'Approved': 'APPROVED',
+  'NotApproved': 'NOT_APPROVED',
+};
+
+// Statuses mapped to CSS classes used to apply custom styles per
+// status like background colors.
+export const STATUS_CLASS_MAP = {
+  'NotSet': 'status-notset',
+  'NeedsReview': 'status-notset',
+  'NA': 'status-na',
+  'ReviewRequested': 'status-pending',
+  'ReviewStarted': 'status-pending',
+  'NeedInfo': 'status-pending',
+  'Approved': 'status-approved',
+  'NotApproved': 'status-rejected',
+};
+
+// Hardcoded frontent documentation for each approval status.
+export const STATUS_DOCSTRING_MAP = {
+  'NotSet': '',
+  'NeedsReview': 'Review/survey not started',
+  'NA': 'Approval gate not required',
+  'ReviewRequested': 'Approval requested',
+  'ReviewStarted': 'Approval in progress',
+  'NeedInfo': 'Approval review needs more information',
+  'Approved': 'Approved for Launch',
+  'NotApproved': 'Not Approved for Launch',
+};
+
+// The Material Design icon names that are attached to each
+// CSS class.
+export const CLASS_ICON_MAP = {
+  'status-na': 'remove',
+  'status-notset': 'warning',
+  'status-pending': 'autorenew',
+  'status-approved': 'done',
+  'status-rejected': 'close',
+};
+
+// Statuses formated as an Array rather than an Object for ease of use
+// by components.
+export const APPROVAL_STATUSES = Object.keys(STATUS_CLASS_MAP).map(
+    (status) => ({status, docstring: STATUS_DOCSTRING_MAP[status], rank: 1}));
diff --git a/static_src/shared/consts/index.js b/static_src/shared/consts/index.js
new file mode 100644
index 0000000..bb196b3
--- /dev/null
+++ b/static_src/shared/consts/index.js
@@ -0,0 +1,4 @@
+// Copyright 2020 The Chromium Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+export const SERVER_LIST_ISSUES_LIMIT = 100000;
diff --git a/static_src/shared/consts/permissions.js b/static_src/shared/consts/permissions.js
new file mode 100644
index 0000000..8c8ef1b
--- /dev/null
+++ b/static_src/shared/consts/permissions.js
@@ -0,0 +1,11 @@
+// Copyright 2019 The Chromium Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+export const ISSUE_EDIT_PERMISSION = 'editissue';
+export const ISSUE_EDIT_SUMMARY_PERMISSION = 'editissuesummary';
+export const ISSUE_EDIT_STATUS_PERMISSION = 'editissuestatus';
+export const ISSUE_EDIT_OWNER_PERMISSION = 'editissueowner';
+export const ISSUE_EDIT_CC_PERMISSION = 'editissuecc';
+export const ISSUE_DELETE_PERMISSION = 'deleteissue';
+export const ISSUE_FLAGSPAM_PERMISSION = 'flagspam';
diff --git a/static_src/shared/converters.js b/static_src/shared/converters.js
new file mode 100644
index 0000000..308df2d
--- /dev/null
+++ b/static_src/shared/converters.js
@@ -0,0 +1,102 @@
+// Copyright 2020 The Chromium Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+// Based on: https://source.chromium.org/chromium/infra/infra/+/main:appengine/monorail/project/project_constants.py;l=13
+const PROJECT_NAME_PATTERN = '[a-z0-9][-a-z0-9]*[a-z0-9]';
+const USER_ID_PATTERN = '\\d+';
+
+const PROJECT_MEMBER_NAME_REGEX = new RegExp(
+    `projects/(${PROJECT_NAME_PATTERN})/members/(${USER_ID_PATTERN})`);
+
+const USER_NAME_REGEX = new RegExp(`users/(${USER_ID_PATTERN})`);
+
+const PROJECT_NAME_REGEX = new RegExp(`projects/(${PROJECT_NAME_PATTERN})`);
+
+
+/**
+ * Custom error class for handling invalidly formatted resource names.
+ */
+export class ResourceNameError extends Error {
+  /** @override */
+  constructor(message) {
+    super(message || 'Invalid resource name format');
+  }
+}
+
+/**
+ * Returns a FieldMask given an array of string paths.
+ * https://developers.google.com/protocol-buffers/docs/reference/csharp/class/google/protobuf/well-known-types/field-mask#paths
+ * https://source.chromium.org/chromium/chromium/src/+/main:third_party/protobuf/python/google/protobuf/internal/well_known_types.py;l=425;drc=e10d98917fee771b0947a57468d1cadac446bc42
+ * @param {Array<string>} paths The given paths to turn into a field mask.
+ *   These should be a comma separated list of camel case strings.
+ * @return {string}
+ */
+export function pathsToFieldMask(paths) {
+  return paths.join(',');
+}
+
+/**
+ * Extract a User ID from a User resource name.
+ * @param {UserName} user User resource name.
+ * @return {string} User ID.
+ * @throws {Error} if the User resource name is invalid.
+ */
+export function extractUserId(user) {
+  const matches = user.match(USER_NAME_REGEX);
+  if (!matches) {
+    throw new ResourceNameError();
+  }
+  return matches[1];
+}
+
+/**
+ * Extract a project's displayName from a Project resource name.
+ * @param {ProjectName} project Project resource name.
+ * @return {string} The project's displayName.
+ * @throws {Error} if the Project resource name is invalid.
+ */
+export function extractProjectDisplayName(project) {
+  const matches = project.match(PROJECT_NAME_REGEX);
+  if (!matches) {
+    throw new ResourceNameError();
+  }
+  return matches[1];
+}
+
+/**
+ * Gets the displayName of the Project referenced in a ProjectMember
+ * resource name.
+ * @param {ProjectMemberName} projectMember ProjectMember resource name.
+ * @return {string} A display name for a project.
+ */
+export function extractProjectFromProjectMember(projectMember) {
+  const matches = projectMember.match(PROJECT_MEMBER_NAME_REGEX);
+  if (!matches) {
+    throw new ResourceNameError();
+  }
+  return matches[1];
+}
+
+/**
+ * Creates a ProjectStar resource name based on a UserName nad a ProjectName.
+ * @param {ProjectName} project Resource name of the referenced project.
+ * @param {UserName} user Resource name of the referenced user.
+ * @return {ProjectStarName}
+ * @throws {Error} If the project or user resource name is invalid.
+ */
+export function projectAndUserToStarName(project, user) {
+  if (!project || !user) return undefined;
+  const userId = extractUserId(user);
+  const projectName = extractProjectDisplayName(project);
+  return `users/${userId}/projectStars/${projectName}`;
+}
+
+/**
+ * Converts a given ProjectMemberName to just the ProjectName segment present.
+ * @param {ProjectMemberName} projectMember Resource name of a ProjectMember.
+ * @return {ProjectName} Resource name of the referenced project.
+ */
+export function projectMemberToProjectName(projectMember) {
+  const project = extractProjectFromProjectMember(projectMember);
+  return `projects/${project}`;
+}
diff --git a/static_src/shared/converters.test.js b/static_src/shared/converters.test.js
new file mode 100644
index 0000000..428a74d
--- /dev/null
+++ b/static_src/shared/converters.test.js
@@ -0,0 +1,112 @@
+// Copyright 2020 The Chromium Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+import {assert} from 'chai';
+import {ResourceNameError, pathsToFieldMask, extractUserId,
+  extractProjectDisplayName, extractProjectFromProjectMember,
+  projectAndUserToStarName, projectMemberToProjectName} from './converters.js';
+
+describe('pathsToFieldMask', () => {
+  it('converts an array of strings to a FieldMask', () => {
+    assert.equal(pathsToFieldMask(['foo', 'barQux', 'qaz']), 'foo,barQux,qaz');
+  });
+});
+
+describe('extractUserId', () => {
+  it('throws error on improperly formatted resource name', () => {
+    assert.throws(() => extractUserId('projects/1234'),
+        ResourceNameError);
+    assert.throws(() => extractUserId('users/notAnId'),
+        ResourceNameError);
+    assert.throws(() => extractUserId('user/1234'),
+        ResourceNameError);
+  });
+
+  it('extracts user ID', () => {
+    assert.equal(extractUserId('users/1234'), '1234');
+  });
+});
+
+describe('extractProjectDisplayName', () => {
+  it('throws error on improperly formatted resource name', () => {
+    assert.throws(() => extractProjectDisplayName('users/1234'),
+        ResourceNameError);
+    assert.throws(() => extractProjectDisplayName('projects/(what)'),
+        ResourceNameError);
+    assert.throws(() => extractProjectDisplayName('project/test'),
+        ResourceNameError);
+    assert.throws(() => extractProjectDisplayName('projects/-test-'),
+        ResourceNameError);
+  });
+
+  it('extracts project display name', () => {
+    assert.equal(extractProjectDisplayName('projects/1234'), '1234');
+    assert.equal(extractProjectDisplayName('projects/monorail'), 'monorail');
+    assert.equal(extractProjectDisplayName('projects/test-project'),
+        'test-project');
+    assert.equal(extractProjectDisplayName('projects/what-is-love2'),
+        'what-is-love2');
+  });
+});
+
+describe('extractProjectFromProjectMember', () => {
+  it('throws error on improperly formatted resource name', () => {
+    assert.throws(
+        () => extractProjectFromProjectMember(
+            'projects/monorail/members/fakeName'),
+        ResourceNameError);
+    assert.throws(
+        () => extractProjectFromProjectMember(
+            'projects/-invalid-project-/members/1234'),
+        ResourceNameError);
+    assert.throws(
+        () => extractProjectFromProjectMember(
+            'projects/monorail/member/1234'),
+        ResourceNameError);
+  });
+
+  it('extracts project display name', () => {
+    assert.equal(extractProjectFromProjectMember(
+        'projects/1234/members/1234'), '1234');
+    assert.equal(extractProjectFromProjectMember(
+        'projects/monorail/members/1234'), 'monorail');
+    assert.equal(extractProjectFromProjectMember(
+        'projects/test-project/members/1234'), 'test-project');
+    assert.equal(extractProjectFromProjectMember(
+        'projects/what-is-love2/members/1234'), 'what-is-love2');
+  });
+});
+
+describe('projectAndUserToStarName', () => {
+  it('throws error on improperly formatted resource name', () => {
+    assert.throws(
+        () => projectAndUserToStarName('users/1234', 'projects/monorail'),
+        ResourceNameError);
+  });
+
+  it('generates project star resource name', () => {
+    assert.equal(projectAndUserToStarName('projects/monorail', 'users/1234'),
+        'users/1234/projectStars/monorail');
+  });
+});
+
+describe('projectMemberToProjectName', () => {
+  it('throws error on improperly formatted resource name', () => {
+    assert.throws(
+        () => projectMemberToProjectName(
+            'projects/monorail/members/fakeName'),
+        ResourceNameError);
+  });
+
+  it('creates project resource name', () => {
+    assert.equal(projectMemberToProjectName(
+        'projects/1234/members/1234'), 'projects/1234');
+    assert.equal(projectMemberToProjectName(
+        'projects/monorail/members/1234'), 'projects/monorail');
+    assert.equal(projectMemberToProjectName(
+        'projects/test-project/members/1234'), 'projects/test-project');
+    assert.equal(projectMemberToProjectName(
+        'projects/what-is-love2/members/1234'), 'projects/what-is-love2');
+  });
+});
diff --git a/static_src/shared/convertersV0.js b/static_src/shared/convertersV0.js
new file mode 100644
index 0000000..ffb8a36
--- /dev/null
+++ b/static_src/shared/convertersV0.js
@@ -0,0 +1,610 @@
+// Copyright 2019 The Chromium Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+/**
+ * @fileoverview This file collects helpers for managing various canonical
+ * formats used within Monorail's frontend. When converting between common
+ * Objects, for example, it's recommended to use the helpers in this file
+ * to ensure consistency across conversions.
+ *
+ * Converters between v0 and v3 object types are included in this file
+ * as well.
+ */
+
+import qs from 'qs';
+
+import {equalsIgnoreCase, capitalizeFirst} from './helpers.js';
+import {fromShortlink} from 'shared/federated.js';
+import {UserInputError} from 'shared/errors.js';
+import './typedef.js';
+
+/**
+ * Common restriction labels to do things users frequently want to do
+ * with restrictions.
+ * This code is a frontend replication of old Python server code that
+ * hardcoded specific restriction labels.
+ * @type {Array<LabelDef>}
+ */
+const FREQUENT_ISSUE_RESTRICTIONS = Object.freeze([
+  {
+    label: 'Restrict-View-EditIssue',
+    docstring: 'Only users who can edit the issue may access it',
+  },
+  {
+    label: 'Restrict-AddIssueComment-EditIssue',
+    docstring: 'Only users who can edit the issue may add comments',
+  },
+]);
+
+/**
+ * The set of actions that permissions on an issue can be applied to.
+ * For example, in the Restrict-View-Google label, "View" is an action.
+ * @type {Array<string>}
+ */
+const STANDARD_ISSUE_ACTIONS = [
+  'View', 'EditIssue', 'AddIssueComment', 'DeleteIssue', 'FlagSpam'];
+
+// A Regex defining the canonical String format used in Monorail for allowing
+// users to input structured localId and projectName values in free text inputs.
+// Match: projectName:localId format where projectName is optional.
+// ie: "monorail:1234" or "1234".
+const ISSUE_ID_REGEX = /(?:([a-z0-9-]+):)?(\d+)/i;
+
+// RFC 2821-compliant email address regex used by the server when validating
+// email addresses.
+// eslint-disable-next-line max-len
+const 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})$/;
+
+/**
+ * Converts a displayName into a canonical UserRef Object format.
+ *
+ * @param {string} displayName The user's email address, used as a display name.
+ * @return {UserRef} UserRef formatted object that contains a
+ *   user's displayName.
+ */
+export function displayNameToUserRef(displayName) {
+  if (displayName && !RFC_2821_EMAIL_REGEX.test(displayName)) {
+    throw new UserInputError(`Invalid email address: ${displayName}`);
+  }
+  return {displayName};
+}
+
+/**
+ * Converts a displayName into a canonical UserRef Object format.
+ *
+ * @param {string} user The user's email address, used as a display name,
+ *   or their numeric user ID.
+ * @return {UserRef} UserRef formatted object that contains a
+ *   user's displayName or userId.
+ */
+export function userIdOrDisplayNameToUserRef(user) {
+  if (RFC_2821_EMAIL_REGEX.test(user)) {
+    return {displayName: user};
+  }
+  const userId = Number.parseInt(user);
+  if (Number.isNaN(userId)) {
+    throw new UserInputError(`Invalid email address or user ID: ${user}`);
+  }
+  return {userId};
+}
+
+/**
+ * Converts an Object into a standard UserRef Object with only a displayName
+ * and userId. Used for cases when we need to use only the data required to
+ * identify a unique user, such as when requesting information related to a user
+ * through the API.
+ *
+ * @param {UserV0} user An Object representing a user, in the JSON format
+ *   returned by the pRPC API.
+ * @return {UserRef} UserRef style Object.
+ */
+export function userToUserRef(user) {
+  if (!user) return {};
+  const {userId, displayName} = user;
+  return {userId, displayName};
+}
+
+/**
+ * Converts a User resource name to a numeric user ID.
+ * @param {string} name
+ * @return {number}
+ */
+export function userNameToId(name) {
+  return Number.parseInt(name.split('/')[1]);
+}
+
+/**
+ * Converts a v3 API User object to a v0 API UserRef.
+ * @param {User} user
+ * @return {UserRef}
+ */
+export function userV3ToRef(user) {
+  return {userId: userNameToId(user.name), displayName: user.displayName};
+}
+
+/**
+ * Convert a UserRef style Object to a userId string.
+ *
+ * @param {UserRef} userRef Object expected to contain a userId key.
+ * @return {number} the unique ID of the user.
+ */
+export function userRefToId(userRef) {
+  return userRef && userRef.userId;
+}
+
+/**
+ * Extracts the displayName property from a UserRef Object.
+ *
+ * @param {UserRef} userRef UserRef Object uniquely identifying a user.
+ * @return {string} The user's display name (email address).
+ */
+export function userRefToDisplayName(userRef) {
+  return userRef && userRef.displayName;
+}
+
+/**
+ * Converts an Array of UserRefs to an Array of display name Strings.
+ *
+ * @param {Array<UserRef>} userRefs Array of UserRefs.
+ * @return {Array<string>} Array of display names.
+ */
+export function userRefsToDisplayNames(userRefs) {
+  if (!userRefs) return [];
+  return userRefs.map(userRefToDisplayName);
+}
+
+/**
+ * Takes an Array of UserRefs and keeps only UserRefs where ID
+ * is known.
+ *
+ * @param {Array<UserRef>} userRefs Array of UserRefs.
+ * @return {Array<UserRef>} Filtered Array IDs guaranteed.
+ */
+export function userRefsWithIds(userRefs) {
+  if (!userRefs) return [];
+  return userRefs.filter((u) => u.userId);
+}
+
+/**
+ * Takes an Array of UserRefs and returns displayNames for
+ * only those refs with IDs specified.
+ *
+ * @param {Array<UserRef>} userRefs Array of UserRefs.
+ * @return {Array<string>} Array of user displayNames.
+ */
+export function filteredUserDisplayNames(userRefs) {
+  if (!userRefs) return [];
+  return userRefsToDisplayNames(userRefsWithIds(userRefs));
+}
+
+/**
+ * Takes in the name of a label and turns it into a LabelRef Object.
+ *
+ * @param {string} label The name of a label.
+ * @return {LabelRef}
+ */
+export function labelStringToRef(label) {
+  return {label};
+}
+
+/**
+ * Takes in the name of a label and turns it into a LabelRef Object.
+ *
+ * @param {LabelRef} labelRef
+ * @return {string} The name of the label.
+ */
+export function labelRefToString(labelRef) {
+  if (!labelRef) return;
+  return labelRef.label;
+}
+
+/**
+ * Converts an Array of LabelRef Objects to label name Strings.
+ *
+ * @param {Array<LabelRef>} labelRefs Array of LabelRef Objects.
+ * @return {Array<string>} Array of label names.
+ */
+export function labelRefsToStrings(labelRefs) {
+  if (!labelRefs) return [];
+  return labelRefs.map(labelRefToString);
+}
+
+/**
+ * Filters a list of labels into a list of only labels with one word.
+ *
+ * @param {Array<LabelRef>} labelRefs
+ * @return {Array<LabelRef>} only the LabelRefs that do not have multiple words.
+ */
+export function labelRefsToOneWordLabels(labelRefs) {
+  if (!labelRefs) return [];
+  return labelRefs.filter(({label}) => {
+    return isOneWordLabel(label);
+  });
+}
+
+/**
+ * Checks whether a particular label is one word.
+ *
+ * @param {string} label the name of the label being checked.
+ * @return {boolean} Whether the label is one word or not.
+ */
+export function isOneWordLabel(label = '') {
+  const words = label.split('-');
+  return words.length === 1;
+}
+
+/**
+ * Creates a LabelDef Object for a restriction label given an action
+ * and a permission.
+ * @param {string} action What action a restriction is applied to.
+ *   eg. "View", "EditIssue", "AddIssueComment".
+ * @param {string} permission The permission group that has access to
+ *   the restricted behavior. eg. "Google".
+ * @return {LabelDef}
+ */
+export function _makeRestrictionLabel(action, permission) {
+  const perm = capitalizeFirst(permission);
+  return {
+    label: `Restrict-${action}-${perm}`,
+    docstring: `Permission ${perm} needed to use ${action}`,
+  };
+}
+
+/**
+ * Given a list of custom permissions defined for a project, this function
+ * generates simulated LabelDef objects for those permissions + default
+ * restriction labels that all projects should have.
+ * @param {Array<string>=} customPermissions
+ * @param {Array<string>=} actions
+ * @param {Array<LabelDef>=} defaultRestrictionLabels Configurable default
+ *   restriction labels to include regardless of custom permissions.
+ * @return {Array<LabelDef>}
+ */
+export function restrictionLabelsForPermissions(customPermissions = [],
+    actions = STANDARD_ISSUE_ACTIONS,
+    defaultRestrictionLabels = FREQUENT_ISSUE_RESTRICTIONS) {
+  const labels = [];
+  actions.forEach((action) => {
+    customPermissions.forEach((permission) => {
+      labels.push(_makeRestrictionLabel(action, permission));
+    });
+  });
+  return [...labels, ...defaultRestrictionLabels];
+}
+
+/**
+ * Converts a custom field name in to the prefix format used in
+ * enum type field values. Monorail defines the enum options for
+ * a custom field as labels.
+ *
+ * @param {string} fieldName Name of a custom field.
+ * @return {string} The label prefixes for enum choices
+ *   associated with the field.
+ */
+export function fieldNameToLabelPrefix(fieldName) {
+  return `${fieldName.toLowerCase()}-`;
+}
+
+/**
+ * Finds all prefixes in a label's name, delimited by '-'. A given label
+ * can have multiple possible prefixes, one for each instance of '-'.
+ * Labels that share the same prefix are implicitly treated like
+ * enum fields in certain parts of Monorail's UI.
+ *
+ * @param {string} label The name of the label.
+ * @return {Array<string>} All prefixes in the label.
+ */
+export function labelNameToLabelPrefixes(label) {
+  if (!label) return;
+  const prefixes = [];
+  for (let i = 0; i < label.length; i++) {
+    if (label[i] === '-') {
+      prefixes.push(label.substring(0, i));
+    }
+  }
+  return prefixes;
+}
+
+/**
+ * Truncates a label to include only the label's value, delimited
+ * by '-'.
+ *
+ * @param {string} label The name of the label.
+ * @param {string} fieldName The field name that the label is having a
+ *   value extracted for.
+ * @return {string} The label's value.
+ */
+export function labelNameToLabelValue(label, fieldName) {
+  if (!label || !fieldName || isOneWordLabel(label)) return null;
+  const prefix = fieldName.toLowerCase() + '-';
+  if (!label.toLowerCase().startsWith(prefix)) return null;
+
+  return label.substring(prefix.length);
+}
+
+/**
+ * Converts a FieldDef to a v3 FieldDef resource name.
+ * @param {string} projectName The name of the project.
+ * @param {FieldDef} fieldDef A FieldDef Object from the pRPC API proto objects.
+ * @return {string} The v3 FieldDef name, e.g. 'projects/proj/fieldDefs/fieldId'
+ */
+export function fieldDefToName(projectName, fieldDef) {
+  return `projects/${projectName}/fieldDefs/${fieldDef.fieldRef.fieldId}`;
+}
+
+/**
+ * Extracts just the name of the status from a StatusRef Object.
+ *
+ * @param {StatusRef} statusRef
+ * @return {string} The name of the status.
+ */
+export function statusRefToString(statusRef) {
+  return statusRef.status;
+}
+
+/**
+ * Extracts the name of multiple statuses from multiple StatusRef Objects.
+ *
+ * @param {Array<StatusRef>} statusRefs
+ * @return {Array<string>} The names of the statuses inputted.
+ */
+export function statusRefsToStrings(statusRefs) {
+  return statusRefs.map(statusRefToString);
+}
+
+/**
+ * Takes the name of a component and converts it into a ComponentRef
+ * Object.
+ *
+ * @param {string} path Name of the component.
+ * @return {ComponentRef}
+ */
+export function componentStringToRef(path) {
+  return {path};
+}
+
+/**
+ * Extracts just the name of a component from a ComponentRef.
+ *
+ * @param {ComponentRef} componentRef
+ * @return {string} The name of the component.
+ */
+export function componentRefToString(componentRef) {
+  return componentRef && componentRef.path;
+}
+
+/**
+ * Extracts the names of multiple components from multiple refs.
+ *
+ * @param {Array<ComponentRef>} componentRefs
+ * @return {Array<string>} Array of component names.
+ */
+export function componentRefsToStrings(componentRefs) {
+  if (!componentRefs) return [];
+  return componentRefs.map(componentRefToString);
+}
+
+/**
+ * Takes a String with a project name and issue ID in Monorail's canonical
+ * IssueRef format and converts it into an IssueRef Object.
+ *
+ * @param {IssueRefString} idStr A String of the format projectName:1234, a
+ *   standard issue ID input format used across Monorail.
+ * @param {string=} defaultProjectName The implied projectName if none is
+ *   specified.
+ * @return {IssueRef}
+ * @throws {UserInputError} If the IssueRef string is invalidly formatted.
+ */
+export function issueStringToRef(idStr, defaultProjectName) {
+  if (!idStr) return {};
+
+  // If the string includes a slash, it's an external tracker ref.
+  if (idStr.includes('/')) {
+    return {extIdentifier: idStr};
+  }
+
+  const matches = idStr.match(ISSUE_ID_REGEX);
+  if (!matches) {
+    throw new UserInputError(
+        `Invalid issue ref: ${idStr}. Expected [projectName:]issueId.`);
+  }
+  const projectName = matches[1] ? matches[1] : defaultProjectName;
+
+  if (!projectName) {
+    throw new UserInputError(
+        `Issue ref must include a project name or specify a default project.`);
+  }
+
+  const localId = Number.parseInt(matches[2]);
+  return {localId, projectName};
+}
+
+/**
+ * Takes an IssueRefString and converts it into an IssueRef Object, checking
+ * that it's not the same as another specified issueRef. ie: validates that an
+ * inputted blocking issue is not the same as the issue being blocked.
+ *
+ * @param {IssueRef} issueRef The issue that the IssueRefString is being
+ *   compared to.
+ * @param {IssueRefString} idStr A String of the format projectName:1234, a
+ *   standard issue ID input format used across Monorail.
+ * @return {IssueRef}
+ * @throws {UserInputError} If the IssueRef string is invalidly formatted
+ *   or if the issue is equivalent to the linked issue.
+ */
+export function issueStringToBlockingRef(issueRef, idStr) {
+  // TODO(zhangtiff): Consider simplifying this helper function to only validate
+  // that an issue does not block itself rather than also doing string parsing.
+  const result = issueStringToRef(idStr, issueRef.projectName);
+  if (result.projectName === issueRef.projectName &&
+      result.localId === issueRef.localId) {
+    throw new UserInputError(
+        `Invalid issue ref: ${idStr
+        }. Cannot merge or block an issue on itself.`);
+  }
+  return result;
+}
+
+/**
+ * Converts an IssueRef into a canonical String format. ie: "project:1234"
+ *
+ * @param {IssueRef} ref
+ * @param {string=} projectName The current project context. The
+ *   generated String excludes the projectName if it matches the
+ *   project the user is currently viewing, to create simpler
+ *   issue ID links.
+ * @return {IssueRefString} A String representing the pieces of an IssueRef.
+ */
+export function issueRefToString(ref, projectName = undefined) {
+  if (!ref) return '';
+
+  if (ref.hasOwnProperty('extIdentifier')) {
+    return ref.extIdentifier;
+  }
+
+  if (projectName && projectName.length &&
+      equalsIgnoreCase(ref.projectName, projectName)) {
+    return `${ref.localId}`;
+  }
+  return `${ref.projectName}:${ref.localId}`;
+}
+
+/**
+ * Converts a full Issue Object into only the pieces of its data needed
+ * to define an IssueRef. Useful for cases when we don't want to send excess
+ * information to ifentify an Issue.
+ *
+ * @param {Issue} issue A full Issue Object.
+ * @return {IssueRef} Just the ID part of the Issue Object.
+ */
+export function issueToIssueRef(issue) {
+  if (!issue) return {};
+
+  return {localId: issue.localId,
+    projectName: issue.projectName};
+}
+
+/**
+ * Converts a full Issue Object into an IssueRefString
+ *
+ * @param {Issue} issue A full Issue Object.
+ * @param {string=} defaultProjectName The default project the String should
+ *   assume.
+ * @return {IssueRefString} A String with all the data needed to
+ *   construct an IssueRef.
+ */
+export function issueToIssueRefString(issue, defaultProjectName = undefined) {
+  if (!issue) return '';
+
+  const ref = issueToIssueRef(issue);
+  return issueRefToString(ref, defaultProjectName);
+}
+
+/**
+ * Creates a link to a particular issue specified in an IssueRef.
+ *
+ * @param {IssueRef} ref The issue that the generated URL will point to.
+ * @param {Object} queryParams The URL params for the URL.
+ * @return {string} The URL for the issue's page as a relative path.
+ */
+export function issueRefToUrl(ref, queryParams = {}) {
+  const queryParamsCopy = {...queryParams};
+
+  if (!ref) return '';
+
+  if (ref.extIdentifier) {
+    const extRef = fromShortlink(ref.extIdentifier);
+    if (!extRef) {
+      console.error(`No tracker found for reference: ${ref.extIdentifier}`);
+      return '';
+    }
+    return extRef.toURL();
+  }
+
+  let paramString = '';
+  if (Object.keys(queryParamsCopy).length) {
+    delete queryParamsCopy.id;
+
+    paramString = `&${qs.stringify(queryParamsCopy)}`;
+  }
+
+  return `/p/${ref.projectName}/issues/detail?id=${ref.localId}${paramString}`;
+}
+
+/**
+ * Converts multiple IssueRef Objects into Strings in the canonical IssueRef
+ * String form expeced by Monorail.
+ *
+ * @param {Array<IssueRef>} arr Array of IssueRefs to convert to Strings.
+ * @param {string} projectName The default project name.
+ * @return {Array<IssueRefString>} Array of Strings where each entry is
+ *   represents one IssueRef.
+ */
+export function issueRefsToStrings(arr, projectName) {
+  if (!arr || !arr.length) return [];
+  return arr.map((ref) => issueRefToString(ref, projectName));
+}
+
+/**
+ * Converts an issue name in the v3 API to an IssueRef in the v0 API.
+ * @param {string} name The v3 Issue name, e.g. 'projects/proj-name/issues/123'
+ * @return {IssueRef} An IssueRef.
+ */
+export function issueNameToRef(name) {
+  const nameParts = name.split('/');
+  return {
+    projectName: nameParts[1],
+    localId: parseInt(nameParts[3]),
+  };
+}
+
+/**
+ * Converts an issue name in the v3 API to an IssueRefString in the v0 API.
+ * @param {string} name The v3 Issue name, e.g. 'projects/proj-name/issues/123'
+ * @return {IssueRefString} A String with all the data needed to
+ *   construct an IssueRef.
+ */
+export function issueNameToRefString(name) {
+  const nameParts = name.split('/');
+  return `${nameParts[1]}:${nameParts[3]}`;
+}
+
+/**
+ * Converts an v0 Issue to a v3 Issue name.
+ * @param {Issue} issue An Issue Object from the pRPC API issue_objects.proto.
+ * @return {string} The v3 Issue name, e.g. 'projects/proj-name/issues/123'
+ */
+export function issueToName(issue) {
+  return `projects/${issue.projectName}/issues/${issue.localId}`;
+}
+
+/**
+ * Since Monorail stores issue descriptions and description updates as comments,
+ * this function exists to filter a list of comments to get only those comments
+ * that are marked as descriptions.
+ *
+ * @param {Array<IssueComment>} comments List of many comments, usually all
+ *   comments associated with an issue.
+ * @return {Array<IssueComment>} List of only the comments that are
+ *   descriptions.
+ */
+export function commentListToDescriptionList(comments) {
+  if (!comments) return [];
+  // First comment is always a description, even if it doesn't have a
+  // descriptionNum.
+  return comments.filter((c, i) => !i || c.descriptionNum);
+}
+
+/**
+ * Wraps a String value for a field and a FieldRef into a FieldValue
+ * Object.
+ *
+ * @param {FieldRef} fieldRef A reference to the custom field that this
+ *   value is tied to.
+ * @param {string} value The value associated with the FieldRef.
+ * @return {FieldValue}
+ */
+export function valueToFieldValue(fieldRef, value) {
+  return {fieldRef, value};
+}
diff --git a/static_src/shared/convertersV0.test.js b/static_src/shared/convertersV0.test.js
new file mode 100644
index 0000000..2e34622
--- /dev/null
+++ b/static_src/shared/convertersV0.test.js
@@ -0,0 +1,427 @@
+// Copyright 2019 The Chromium Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+import {assert} from 'chai';
+import {UserInputError} from 'shared/errors.js';
+import * as exampleUsers from 'shared/test/constants-users.js';
+import {displayNameToUserRef, userIdOrDisplayNameToUserRef,
+  userNameToId, userV3ToRef, labelStringToRef,
+  labelRefToString, labelRefsToStrings, labelRefsToOneWordLabels,
+  isOneWordLabel, _makeRestrictionLabel, restrictionLabelsForPermissions,
+  fieldDefToName, statusRefToString, statusRefsToStrings,
+  componentStringToRef, componentRefToString, componentRefsToStrings,
+  issueStringToRef, issueStringToBlockingRef, issueRefToString,
+  issueRefToUrl, fieldNameToLabelPrefix, labelNameToLabelPrefixes,
+  labelNameToLabelValue, commentListToDescriptionList, valueToFieldValue,
+  issueToIssueRef, issueNameToRef, issueNameToRefString, issueToName,
+} from './convertersV0.js';
+
+describe('displayNameToUserRef', () => {
+  it('converts displayName', () => {
+    assert.deepEqual(
+        displayNameToUserRef('foo@bar.com'),
+        {displayName: 'foo@bar.com'});
+  });
+
+  it('throws on invalid email', () => {
+    assert.throws(() => displayNameToUserRef('foo'), UserInputError);
+  });
+});
+
+describe('userIdOrDisplayNameToUserRef', () => {
+  it('converts userId', () => {
+    assert.throws(() => displayNameToUserRef('foo'));
+    assert.deepEqual(
+        userIdOrDisplayNameToUserRef('12345678'),
+        {userId: 12345678});
+  });
+
+  it('converts displayName', () => {
+    assert.deepEqual(
+        userIdOrDisplayNameToUserRef('foo@bar.com'),
+        {displayName: 'foo@bar.com'});
+  });
+
+  it('throws if not an email or numeric id', () => {
+    assert.throws(() => userIdOrDisplayNameToUserRef('foo'), UserInputError);
+  });
+});
+
+it('userNameToId', () => {
+  assert.deepEqual(userNameToId(exampleUsers.NAME), exampleUsers.ID);
+});
+
+it('userV3ToRef', () => {
+  assert.deepEqual(userV3ToRef(exampleUsers.USER), exampleUsers.USER_REF);
+});
+
+describe('labelStringToRef', () => {
+  it('converts label', () => {
+    assert.deepEqual(labelStringToRef('foo'), {label: 'foo'});
+  });
+});
+
+describe('labelRefToString', () => {
+  it('converts labelRef', () => {
+    assert.deepEqual(labelRefToString({label: 'foo'}), 'foo');
+  });
+});
+
+describe('labelRefsToStrings', () => {
+  it('converts labelRefs', () => {
+    assert.deepEqual(labelRefsToStrings([{label: 'foo'}, {label: 'test'}]),
+        ['foo', 'test']);
+  });
+});
+
+describe('labelRefsToOneWordLabels', () => {
+  it('empty', () => {
+    assert.deepEqual(labelRefsToOneWordLabels(), []);
+    assert.deepEqual(labelRefsToOneWordLabels([]), []);
+  });
+
+  it('filters multi-word labels', () => {
+    assert.deepEqual(labelRefsToOneWordLabels([
+      {label: 'hello'},
+      {label: 'filter-me'},
+      {label: 'hello-world'},
+      {label: 'world'},
+      {label: 'this-label-has-so-many-words'},
+    ]), [
+      {label: 'hello'},
+      {label: 'world'},
+    ]);
+  });
+});
+
+describe('isOneWordLabel', () => {
+  it('true only for one word labels', () => {
+    assert.isTrue(isOneWordLabel('test'));
+    assert.isTrue(isOneWordLabel('LABEL'));
+    assert.isTrue(isOneWordLabel('Security'));
+
+    assert.isFalse(isOneWordLabel('Restrict-View-EditIssue'));
+    assert.isFalse(isOneWordLabel('Type-Feature'));
+  });
+});
+
+describe('_makeRestrictionLabel', () => {
+  it('creates label', () => {
+    assert.deepEqual(_makeRestrictionLabel('View', 'Google'), {
+      label: `Restrict-View-Google`,
+      docstring: `Permission Google needed to use View`,
+    });
+  });
+
+  it('capitalizes permission name', () => {
+    assert.deepEqual(_makeRestrictionLabel('EditIssue', 'security'), {
+      label: `Restrict-EditIssue-Security`,
+      docstring: `Permission Security needed to use EditIssue`,
+    });
+  });
+});
+
+describe('restrictionLabelsForPermissions', () => {
+  it('creates labels for permissions and actions', () => {
+    assert.deepEqual(restrictionLabelsForPermissions(['google', 'security'],
+        ['View', 'EditIssue'], []), [
+      {
+        label: 'Restrict-View-Google',
+        docstring: 'Permission Google needed to use View',
+      }, {
+        label: 'Restrict-View-Security',
+        docstring: 'Permission Security needed to use View',
+      }, {
+        label: 'Restrict-EditIssue-Google',
+        docstring: 'Permission Google needed to use EditIssue',
+      }, {
+        label: 'Restrict-EditIssue-Security',
+        docstring: 'Permission Security needed to use EditIssue',
+      },
+    ]);
+  });
+
+  it('appends default labels when specified', () => {
+    assert.deepEqual(restrictionLabelsForPermissions(['Google'], ['View'], [
+      {label: 'Restrict-Hello-World', docstring: 'description of label'},
+    ]), [
+      {
+        label: 'Restrict-View-Google',
+        docstring: 'Permission Google needed to use View',
+      },
+      {label: 'Restrict-Hello-World', docstring: 'description of label'},
+    ]);
+  });
+});
+
+describe('fieldNameToLabelPrefix', () => {
+  it('converts fieldName', () => {
+    assert.deepEqual(fieldNameToLabelPrefix('test'), 'test-');
+    assert.deepEqual(fieldNameToLabelPrefix('test-hello'), 'test-hello-');
+    assert.deepEqual(fieldNameToLabelPrefix('WHATEVER'), 'whatever-');
+  });
+});
+
+describe('labelNameToLabelPrefixes', () => {
+  it('converts labelName', () => {
+    assert.deepEqual(labelNameToLabelPrefixes('test'), []);
+    assert.deepEqual(labelNameToLabelPrefixes('test-hello'), ['test']);
+    assert.deepEqual(labelNameToLabelPrefixes('WHATEVER-this-label-is'),
+        ['WHATEVER', 'WHATEVER-this', 'WHATEVER-this-label']);
+  });
+});
+
+describe('labelNameToLabelValue', () => {
+  it('returns null when no matching value found in label', () => {
+    assert.isNull(labelNameToLabelValue('test-hello', ''));
+    assert.isNull(labelNameToLabelValue('', 'test'));
+    assert.isNull(labelNameToLabelValue('test-hello', 'hello'));
+    assert.isNull(labelNameToLabelValue('test-hello', 'tes'));
+    assert.isNull(labelNameToLabelValue('test', 'test'));
+  });
+
+  it('converts labelName', () => {
+    assert.deepEqual(labelNameToLabelValue('test-hello', 'test'), 'hello');
+    assert.deepEqual(labelNameToLabelValue('WHATEVER-this-label-is',
+        'WHATEVER'), 'this-label-is');
+    assert.deepEqual(labelNameToLabelValue('WHATEVER-this-label-is',
+        'WHATEVER-this'), 'label-is');
+    assert.deepEqual(labelNameToLabelValue('WHATEVER-this-label-is',
+        'WHATEVER-this-label'), 'is');
+  });
+
+  it('fieldName is case insenstitive', () => {
+    assert.deepEqual(labelNameToLabelValue('test-hello', 'TEST'), 'hello');
+    assert.deepEqual(labelNameToLabelValue('test-hello', 'tEsT'), 'hello');
+    assert.deepEqual(labelNameToLabelValue('TEST-hello', 'test'), 'hello');
+  });
+});
+
+describe('fieldDefToName', () => {
+  it('converts fieldDef', () => {
+    const fieldDef = {fieldRef: {fieldId: '1'}};
+    const actual = fieldDefToName('project-name', fieldDef);
+    assert.equal(actual, 'projects/project-name/fieldDefs/1');
+  });
+});
+
+describe('statusRefToString', () => {
+  it('converts statusRef', () => {
+    assert.deepEqual(statusRefToString({status: 'foo'}), 'foo');
+  });
+});
+
+describe('statusRefsToStrings', () => {
+  it('converts statusRefs', () => {
+    assert.deepEqual(statusRefsToStrings(
+        [{status: 'hello'}, {status: 'world'}]), ['hello', 'world']);
+  });
+});
+
+describe('componentStringToRef', () => {
+  it('converts component', () => {
+    assert.deepEqual(componentStringToRef('foo'), {path: 'foo'});
+  });
+});
+
+describe('componentRefToString', () => {
+  it('converts componentRef', () => {
+    assert.deepEqual(componentRefToString({path: 'Hello>World'}),
+        'Hello>World');
+  });
+});
+
+describe('componentRefsToStrings', () => {
+  it('converts componentRefs', () => {
+    assert.deepEqual(componentRefsToStrings(
+        [{path: 'Hello>World'}, {path: 'Test'}]), ['Hello>World', 'Test']);
+  });
+});
+
+describe('issueStringToRef', () => {
+  it('converts issue default project', () => {
+    assert.deepEqual(
+        issueStringToRef('1234', 'proj'),
+        {projectName: 'proj', localId: 1234});
+  });
+
+  it('converts issue with project', () => {
+    assert.deepEqual(
+        issueStringToRef('foo:1234', 'proj'),
+        {projectName: 'foo', localId: 1234});
+  });
+
+  it('converts external issue references', () => {
+    assert.deepEqual(
+        issueStringToRef('b/123456', 'proj'),
+        {extIdentifier: 'b/123456'});
+  });
+
+  it('throws on invalid input', () => {
+    assert.throws(() => issueStringToRef('foo', 'proj'));
+  });
+});
+
+describe('issueStringToBlockingRef', () => {
+  it('converts issue default project', () => {
+    assert.deepEqual(
+        issueStringToBlockingRef({projectName: 'proj', localId: 1}, '1234'),
+        {projectName: 'proj', localId: 1234});
+  });
+
+  it('converts issue with project', () => {
+    assert.deepEqual(
+        issueStringToBlockingRef({projectName: 'proj', localId: 1}, 'foo:1234'),
+        {projectName: 'foo', localId: 1234});
+  });
+
+  it('throws on invalid input', () => {
+    assert.throws(() => issueStringToBlockingRef(
+        {projectName: 'proj', localId: 1}, 'foo'));
+  });
+
+  it('throws when blocking an issue on itself', () => {
+    assert.throws(() => issueStringToBlockingRef(
+        {projectName: 'proj', localId: 123}, 'proj:123'));
+    assert.throws(() => issueStringToBlockingRef(
+        {projectName: 'proj', localId: 123}, '123'));
+  });
+});
+
+describe('issueRefToString', () => {
+  it('no ref', () => {
+    assert.equal(issueRefToString(), '');
+  });
+
+  it('ref with no project name', () => {
+    assert.equal(
+        'other:1234',
+        issueRefToString({projectName: 'other', localId: 1234}),
+    );
+  });
+
+  it('ref with different project name', () => {
+    assert.equal(
+        'other:1234',
+        issueRefToString({projectName: 'other', localId: 1234}, 'proj'),
+    );
+  });
+
+  it('ref with same project name', () => {
+    assert.equal(
+        '1234',
+        issueRefToString({projectName: 'proj', localId: 1234}, 'proj'),
+    );
+  });
+
+  it('external ref', () => {
+    assert.equal(
+        'b/123456',
+        issueRefToString({extIdentifier: 'b/123456'}, 'proj'),
+    );
+  });
+});
+
+describe('issueToIssueRef', () => {
+  it('creates ref', () => {
+    const issue = {'localId': 1, 'projectName': 'proj', 'starCount': 1};
+    const expectedRef = {'localId': 1,
+      'projectName': 'proj'};
+    assert.deepEqual(issueToIssueRef(issue), expectedRef);
+  });
+});
+
+describe('issueRefToUrl', () => {
+  it('no ref', () => {
+    assert.equal(issueRefToUrl(), '');
+  });
+
+  it('issue ref', () => {
+    assert.equal(issueRefToUrl({
+      projectName: 'test',
+      localId: 11,
+    }), '/p/test/issues/detail?id=11');
+  });
+
+  it('issue ref with params', () => {
+    assert.equal(issueRefToUrl({
+      projectName: 'test',
+      localId: 11,
+    }, {
+      q: 'owner:me',
+      id: 44,
+    }), '/p/test/issues/detail?id=11&q=owner%3Ame');
+  });
+
+  it('federated issue ref', () => {
+    assert.equal(issueRefToUrl({
+      extIdentifier: 'b/5678',
+    }), 'https://issuetracker.google.com/issues/5678');
+  });
+
+  it('does not mutate input queryParams', () => {
+    const queryParams = {q: 'owner:me', id: 44};
+    const EXPECTED = JSON.stringify(queryParams);
+    const ref = {projectName: 'test', localId: 11};
+    issueRefToUrl(ref, queryParams);
+    assert.equal(EXPECTED, JSON.stringify(queryParams));
+  });
+});
+
+it('issueNameToRef', () => {
+  const actual = issueNameToRef('projects/project-name/issues/2');
+  assert.deepEqual(actual, {projectName: 'project-name', localId: 2});
+});
+
+it('issueNameToRefString', () => {
+  const actual = issueNameToRefString('projects/project-name/issues/2');
+  assert.equal(actual, 'project-name:2');
+});
+
+it('issueToName', () => {
+  const actual = issueToName({projectName: 'project-name', localId: 2});
+  assert.equal(actual, 'projects/project-name/issues/2');
+});
+
+describe('commentListToDescriptionList', () => {
+  it('empty list', () => {
+    assert.deepEqual(commentListToDescriptionList(), []);
+    assert.deepEqual(commentListToDescriptionList([]), []);
+  });
+
+  it('first comment is description', () => {
+    assert.deepEqual(commentListToDescriptionList([
+      {content: 'test'},
+      {content: 'hello'},
+      {content: 'world'},
+    ]), [{content: 'test'}]);
+  });
+
+  it('some descriptions', () => {
+    assert.deepEqual(commentListToDescriptionList([
+      {content: 'test'},
+      {content: 'hello', descriptionNum: 1},
+      {content: 'world'},
+      {content: 'this'},
+      {content: 'is a'},
+      {content: 'description', descriptionNum: 2},
+    ]), [
+      {content: 'test'},
+      {content: 'hello', descriptionNum: 1},
+      {content: 'description', descriptionNum: 2},
+    ]);
+  });
+});
+
+describe('valueToFieldValue', () => {
+  it('converts field ref and value', () => {
+    assert.deepEqual(valueToFieldValue(
+        {fieldName: 'name', fieldId: 'id'},
+        'value',
+    ), {
+      fieldRef: {fieldName: 'name', fieldId: 'id'},
+      value: 'value',
+    });
+  });
+});
diff --git a/static_src/shared/cron.js b/static_src/shared/cron.js
new file mode 100644
index 0000000..bd67507
--- /dev/null
+++ b/static_src/shared/cron.js
@@ -0,0 +1,35 @@
+// Copyright 2019 The Chromium Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+import {store} from 'reducers/base.js';
+import * as sitewide from 'reducers/sitewide.js';
+
+// How long should we wait until asking the server status again.
+const SERVER_STATUS_DELAY_MS = 20 * 60 * 1000; // 20 minutes
+
+// CronTask is a class that supports periodically execution of tasks.
+export class CronTask {
+  constructor(task, delay) {
+    this.task = task;
+    this.delay = delay;
+    this.started = false;
+  }
+
+  start() {
+    if (this.started) return;
+    this.started = true;
+    this._execute();
+  }
+
+  _execute() {
+    this.task();
+    setTimeout(this._execute.bind(this), this.delay);
+  }
+}
+
+// getServerStatusCron requests status information from the server every 20
+// minutes.
+export const getServerStatusCron = new CronTask(
+    () => store.dispatch(sitewide.getServerStatus()),
+    SERVER_STATUS_DELAY_MS);
diff --git a/static_src/shared/cron.test.js b/static_src/shared/cron.test.js
new file mode 100644
index 0000000..e2f9a8e
--- /dev/null
+++ b/static_src/shared/cron.test.js
@@ -0,0 +1,36 @@
+// Copyright 2019 The Chromium Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+import sinon from 'sinon';
+import {assert} from 'chai';
+import {CronTask} from './cron.js';
+
+let clock;
+
+describe('cron', () => {
+  beforeEach(() => {
+    clock = sinon.useFakeTimers();
+  });
+
+  afterEach(() => {
+    clock.restore();
+  });
+
+  it('calls task periodically', () => {
+    const task = sinon.spy();
+    const cronTask = new CronTask(task, 1000);
+
+    // Make sure task is not called until the cron task has been started.
+    assert.isFalse(task.called);
+
+    cronTask.start();
+    assert.isTrue(task.calledOnce);
+
+    clock.tick(1000);
+    assert.isTrue(task.calledTwice);
+
+    clock.tick(1000);
+    assert.isTrue(task.calledThrice);
+  });
+});
diff --git a/static_src/shared/dom-helpers.js b/static_src/shared/dom-helpers.js
new file mode 100644
index 0000000..81dec80
--- /dev/null
+++ b/static_src/shared/dom-helpers.js
@@ -0,0 +1,60 @@
+// Copyright 2019 The Chromium Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+
+// Prevent triggering input change handlers on key events that don't
+// edit forms.
+export const NON_EDITING_KEY_EVENTS = new Set(['Enter', 'Tab', 'Escape',
+  'ArrowUp', 'ArrowLeft', 'ArrowRight', 'ArrowDown']);
+const INPUT_TYPES_WITHOUT_TEXT_INPUT = [
+  'checkbox',
+  'radio',
+  'file',
+  'submit',
+  'button',
+  'image',
+];
+
+// TODO: Add a method to watch for property changes in one of a subset of
+// element properties.
+// Via: https://crrev.com/c/infra/infra/+/1762911/7/appengine/monorail/static_src/elements/help/mr-cue/mr-cue.js
+
+/**
+ * Checks if a keyboard event should be disabled when the user is typing.
+ *
+ * @param {HTMLElement} element is a dom node to run checks against.
+ * @return {boolean} Whether the dom node is an element that accepts key input.
+ */
+export function isTextInput(element) {
+  const tagName = element.tagName && element.tagName.toUpperCase();
+  if (tagName === 'INPUT') {
+    const type = element.type.toLowerCase();
+    if (INPUT_TYPES_WITHOUT_TEXT_INPUT.includes(type)) {
+      return false;
+    }
+    return true;
+  }
+  return tagName === 'SELECT' || tagName === 'TEXTAREA' ||
+    element.isContentEditable;
+}
+
+/**
+ * Helper to find the EventTarget that an Event originated from, even if that
+ * EventTarget is buried until multiple layers of ShadowDOM.
+ *
+ * @param {Event} event
+ * @return {EventTarget} The DOM node that the event came from. For example,
+ *   if the input was a keypress, this might be the input element the user was
+ *   typing into.
+ */
+export function findDeepEventTarget(event) {
+  /**
+   * Event.target finds the element the event came from, but only
+   * finds events that come from the highest ShadowDOM level. For
+   * example, an Event listener attached to "window" will have all
+   * Events originating from the SPA set to a target of <mr-app>.
+   */
+  const path = event.composedPath();
+  return path ? path[0] : event.target;
+}
diff --git a/static_src/shared/dom-helpers.test.js b/static_src/shared/dom-helpers.test.js
new file mode 100644
index 0000000..78d535a
--- /dev/null
+++ b/static_src/shared/dom-helpers.test.js
@@ -0,0 +1,100 @@
+// Copyright 2019 The Chromium Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+import {assert} from 'chai';
+import {isTextInput, findDeepEventTarget} from './dom-helpers.js';
+
+describe('isTextInput', () => {
+  it('returns true for select', () => {
+    const element = document.createElement('select');
+    assert.isTrue(isTextInput(element));
+  });
+
+  it('returns true for input tags that take text input', () => {
+    const element = document.createElement('input');
+    assert.isTrue(isTextInput(element));
+
+    element.type = 'text';
+    assert.isTrue(isTextInput(element));
+
+    element.type = 'password';
+    assert.isTrue(isTextInput(element));
+
+    element.type = 'number';
+    assert.isTrue(isTextInput(element));
+
+    element.type = 'date';
+    assert.isTrue(isTextInput(element));
+  });
+
+  it('returns false for input tags without text input', () => {
+    const element = document.createElement('input');
+
+    element.type = 'button';
+    assert.isFalse(isTextInput(element));
+
+    element.type = 'submit';
+    assert.isFalse(isTextInput(element));
+
+    element.type = 'checkbox';
+    assert.isFalse(isTextInput(element));
+
+    element.type = 'radio';
+    assert.isFalse(isTextInput(element));
+  });
+
+  it('returns true for textarea', () => {
+    const element = document.createElement('textarea');
+    assert.isTrue(isTextInput(element));
+  });
+
+  it('returns true for contenteditable', () => {
+    const element = document.createElement('div');
+    element.contentEditable = 'true';
+    assert.isTrue(isTextInput(element));
+
+    element.contentEditable = 'false';
+    assert.isFalse(isTextInput(element));
+  });
+
+  it('returns false for non-input', () => {
+    assert.isFalse(isTextInput(document.createElement('div')));
+    assert.isFalse(isTextInput(document.createElement('table')));
+    assert.isFalse(isTextInput(document.createElement('tr')));
+    assert.isFalse(isTextInput(document.createElement('td')));
+    assert.isFalse(isTextInput(document.createElement('href')));
+    assert.isFalse(isTextInput(document.createElement('random-elment')));
+    assert.isFalse(isTextInput(document.createElement('p')));
+  });
+});
+
+describe('findDeepEventTarget', () => {
+  it('returns empty for event without target', () => {
+    const event = new Event('whatsup');
+    assert.isUndefined(findDeepEventTarget(event));
+  });
+
+  it('returns target for event with target', (done) => {
+    const element = document.createElement('div');
+    element.addEventListener('hello', (e) => {
+      assert.deepEqual(findDeepEventTarget(e), element);
+      done();
+    });
+    element.dispatchEvent(new Event('hello'));
+  });
+
+  it('returns target for event coming from shadowRoot', (done) => {
+    const target = document.createElement('button');
+    const parent = document.createElement('div');
+    parent.appendChild(target);
+    parent.attachShadow({mode: 'open'});
+
+    parent.addEventListener('shadow-root', (e) => {
+      assert.deepEqual(findDeepEventTarget(e), target);
+      done();
+    });
+
+    target.dispatchEvent(new Event('shadow-root', {bubbles: true}));
+  });
+});
diff --git a/static_src/shared/errors.js b/static_src/shared/errors.js
new file mode 100644
index 0000000..81c0035
--- /dev/null
+++ b/static_src/shared/errors.js
@@ -0,0 +1,9 @@
+// Copyright 2019 The Chromium Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+export class UserInputError extends Error {
+  get name() {
+    return 'UserInputError';
+  }
+}
diff --git a/static_src/shared/experiments.js b/static_src/shared/experiments.js
new file mode 100644
index 0000000..1528a00
--- /dev/null
+++ b/static_src/shared/experiments.js
@@ -0,0 +1,91 @@
+// Copyright 2020 The Chromium Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+/**
+ * @fileoverview Manages the current user's participation in experiments (e.g.
+ * phased rollouts).
+ *
+ * This file is an early prototype serving the needs of go/monorail-slo-v0.
+ *
+ * The more mature design is under discussion:
+ * http://doc/1rtYXq68WSlTNCzVJiSttLWF14CiK5sOlEef2JWAgheg
+ */
+
+/**
+ * An Enum representing known expreriments.
+ *
+ * @typedef {string} Experiment
+ */
+
+/**
+ * @type {Experiment}
+ */
+export const SLO_EXPERIMENT = 'slo';
+
+const EXPERIMENT_QUERY_PARAM = 'e';
+
+const DISABLED_STR = '-';
+
+const _SLO_EXPERIMENT_USER_DISPLAY_NAMES = new Set([
+  'jessan@google.com',
+]);
+
+/**
+ * Checks whether the current user is in given experiment.
+ *
+ * @param {Experiment} experiment The experiment to check.
+ * @param {UserV0=} user The current user. Although any user can currently
+ *     be passed in, we only intend to support checking if the current user is
+ *     in the experiment. In the future the user parameter may be removed.
+ * @param {Object} queryParams The current query parameters, parsed by qs.
+ *     We support a string like 'e=-exp1,-exp2...' for disabling experiments.
+ *
+ *     We allow disabling so that a user in the fishfood group can work around
+ *     any bugs or undesired behaviors the experiment may introduce for them.
+ *
+ *     As of now, we don't allow enabling experiments by override params.
+ *     We may not want access shared beyond the fishfood group (e.g. if it is a
+ *     feature we are likely to change dramatically or take away).
+ * @return {boolean} Whether the experiment is enabled for the current user.
+ */
+export const isExperimentEnabled = (experiment, user, queryParams) => {
+  const experimentOverrides = parseExperimentParam(
+      queryParams[EXPERIMENT_QUERY_PARAM]);
+  if (experimentOverrides[experiment] === false) {
+    return false;
+  }
+  switch (experiment) {
+    case SLO_EXPERIMENT:
+      return !!user &&
+        _SLO_EXPERIMENT_USER_DISPLAY_NAMES.has(user.displayName);
+    default:
+      throw Error('Unknown experiment provided');
+  }
+};
+
+/**
+ * Parses a comma separated list of experiments from the query string.
+ * Experiment strings preceded by DISABLED_STR are overrode to be disabled,
+ * otherwise they are to be enabled.
+ *
+ * Does not do any validation of the experiment string provided.
+ *
+ * @param {string?} experimentParam comma separated experiements.
+ * @return {Object} Maps experiment name to whether enabled or
+ *    disabled boolean. May include invalid experiment names.
+ */
+const parseExperimentParam = (experimentParam) => {
+  const experimentOverrides = {};
+  if (experimentParam) {
+    for (const experimentOverride of experimentParam.split(',')) {
+      if (experimentOverride.startsWith(DISABLED_STR)) {
+        const experiment = experimentOverride.substr(DISABLED_STR.length);
+        experimentOverrides[experiment] = false;
+      } else {
+        experimentOverrides[experimentOverride] = true;
+      }
+    }
+  }
+  return experimentOverrides;
+};
diff --git a/static_src/shared/experiments.test.js b/static_src/shared/experiments.test.js
new file mode 100644
index 0000000..d5f96b7
--- /dev/null
+++ b/static_src/shared/experiments.test.js
@@ -0,0 +1,62 @@
+// Copyright 2020 The Chromium Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+import {assert} from 'chai';
+import {isExperimentEnabled, SLO_EXPERIMENT} from './experiments.js';
+
+
+describe('isExperimentEnabled', () => {
+  it('throws error for unknown experiment', () => {
+    assert.throws(() =>
+      isExperimentEnabled('unknown-exp', {displayName: 'jessan@google.com'}));
+  });
+
+  it('returns false if user not in experiment', () => {
+    const ineligibleUser = {displayName: 'example@example.com'};
+    assert.isFalse(isExperimentEnabled(SLO_EXPERIMENT, ineligibleUser, {}));
+  });
+
+  it('returns false if no user provided', () => {
+    assert.isFalse(isExperimentEnabled(SLO_EXPERIMENT, undefined, {}));
+  });
+
+  it('returns true if user in experiment', () => {
+    const eligibleUser = {displayName: 'jessan@google.com'};
+    assert.isTrue(isExperimentEnabled(SLO_EXPERIMENT, eligibleUser, {}));
+  });
+
+  it('is false if user in experiment has disabled it with URL', () => {
+    const eligibleUser = {displayName: 'jessan@google.com'};
+    assert.isFalse(isExperimentEnabled(
+        SLO_EXPERIMENT, eligibleUser, {'e': '-slo'}));
+  });
+
+  it('ignores enabling experiments with URL', () => {
+    const ineligibleUser = {displayName: 'example@example.com'};
+    assert.isFalse(isExperimentEnabled(
+        SLO_EXPERIMENT, ineligibleUser, {'e': 'slo'}));
+  });
+
+  it('ignores ineligible users disabling experiment with URL', () => {
+    const ineligibleUser = {displayName: 'example@example.com'};
+    assert.isFalse(isExperimentEnabled(
+        SLO_EXPERIMENT, ineligibleUser, {'e': '-slo'}));
+  });
+
+  it('ignores invalid experiments in URL', () => {
+    const eligibleUser = {displayName: 'jessan@google.com'};
+    // Leading comma, unknown experiment str, empty experiment str in
+    // middle, disable_str with no experiment, trailing comma
+    assert.isFalse(isExperimentEnabled(
+        SLO_EXPERIMENT, eligibleUser, {'e': ',unknown,-slo,,-,'}));
+  });
+
+  it('respects last instance when experiment repeated in URL', () => {
+    const eligibleUser = {displayName: 'jessan@google.com'};
+    assert.isFalse(isExperimentEnabled(
+        SLO_EXPERIMENT, eligibleUser, {'e': 'slo,-slo'}));
+    assert.isTrue(isExperimentEnabled(
+        SLO_EXPERIMENT, eligibleUser, {'e': '-slo,slo'}));
+  });
+});
diff --git a/static_src/shared/federated.js b/static_src/shared/federated.js
new file mode 100644
index 0000000..e5b7567
--- /dev/null
+++ b/static_src/shared/federated.js
@@ -0,0 +1,194 @@
+// Copyright 2019 The Chromium Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+/**
+ * Logic for dealing with federated issue references.
+ */
+
+import loadGapi, {fetchGapiEmail} from './gapi-loader.js';
+
+const GOOGLE_ISSUE_TRACKER_REGEX = /^b\/\d+$/;
+
+const GOOGLE_ISSUE_TRACKER_API_ROOT = 'https://issuetracker.corp.googleapis.com';
+const GOOGLE_ISSUE_TRACKER_DISCOVERY_PATH = '/$discovery/rest';
+const GOOGLE_ISSUE_TRACKER_API_VERSION = 'v3';
+
+// Returns if shortlink is valid for any federated tracker.
+export function isShortlinkValid(shortlink) {
+  return FEDERATED_TRACKERS.some((TrackerClass) => {
+    try {
+      return new TrackerClass(shortlink);
+    } catch (e) {
+      if (e instanceof FederatedIssueError) {
+        return false;
+      } else {
+        throw e;
+      }
+    }
+  });
+}
+
+// Returns a issue instance for the first matching tracker.
+export function fromShortlink(shortlink) {
+  for (const key in FEDERATED_TRACKERS) {
+    if (FEDERATED_TRACKERS.hasOwnProperty(key)) {
+      const TrackerClass = FEDERATED_TRACKERS[key];
+      try {
+        return new TrackerClass(shortlink);
+      } catch (e) {
+        if (e instanceof FederatedIssueError) {
+          continue;
+        } else {
+          throw e;
+        }
+      }
+    }
+  }
+  return null;
+}
+
+// FederatedIssue is an abstract class for representing one federated issue.
+// Each supported tracker should subclass this class.
+class FederatedIssue {
+  constructor(shortlink) {
+    if (!this.isShortlinkValid(shortlink)) {
+      throw new FederatedIssueError(`Invalid tracker shortlink: ${shortlink}`);
+    }
+    this.shortlink = shortlink;
+  }
+
+  // isShortlinkValid returns whether a given shortlink is valid.
+  isShortlinkValid(shortlink) {
+    if (!(typeof shortlink === 'string')) {
+      throw new FederatedIssueError('shortlink argument must be a string.');
+    }
+    return Boolean(shortlink.match(this.shortlinkRe()));
+  }
+
+  // shortlinkRe returns the regex used to validate shortlinks.
+  shortlinkRe() {
+    throw new Error('Not implemented.');
+  }
+
+  // toURL returns the URL to this issue.
+  toURL() {
+    throw new Error('Not implemented.');
+  }
+
+  // toIssueRef converts the FedRef's information into an object having the
+  // IssueRef format everywhere on the front-end expects.
+  toIssueRef() {
+    return {
+      extIdentifier: this.shortlink,
+    };
+  }
+
+  // trackerName should return the name of the bug tracker.
+  get trackerName() {
+    throw new Error('Not implemented.');
+  }
+
+  // isOpen returns a Promise that resolves either true or false.
+  async isOpen() {
+    throw new Error('Not implemented.');
+  }
+}
+
+// Class for Google Issue Tracker (Buganizer) logic.
+export class GoogleIssueTrackerIssue extends FederatedIssue {
+  constructor(shortlink) {
+    super(shortlink);
+    this.issueID = Number(shortlink.substr(2));
+    this._federatedDetails = null;
+  }
+
+  shortlinkRe() {
+    return GOOGLE_ISSUE_TRACKER_REGEX;
+  }
+
+  toURL() {
+    return `https://issuetracker.google.com/issues/${this.issueID}`;
+  }
+
+  get trackerName() {
+    return 'Buganizer';
+  }
+
+  async getFederatedDetails() {
+    // Prevent fetching details more than once.
+    if (this._federatedDetails) {
+      return this._federatedDetails;
+    }
+
+    await loadGapi();
+    const email = await fetchGapiEmail();
+    if (!email) {
+      // Fail open.
+      return true;
+    }
+    const res = await this._loadGoogleIssueTrackerIssue(this.issueID);
+    if (!res || !res.result) {
+      // Fail open.
+      return null;
+    }
+
+    this._federatedDetails = res.result;
+    return this._federatedDetails;
+  }
+
+  // isOpen assumes getFederatedDetails has already been called, otherwise
+  // it will fail open (returning that the issue is open).
+  get isOpen() {
+    if (!this._federatedDetails) {
+      // Fail open.
+      return true;
+    }
+
+    // Open issues will not have a `resolvedTime`.
+    return !Boolean(this._federatedDetails.resolvedTime);
+  }
+
+  // summary assumes getFederatedDetails has already been called.
+  get summary() {
+    if (this._federatedDetails &&
+        this._federatedDetails.issueState &&
+        this._federatedDetails.issueState.title) {
+      return this._federatedDetails.issueState.title;
+    }
+    return null;
+  }
+
+  toIssueRef() {
+    return {
+      extIdentifier: this.shortlink,
+      summary: this.summary,
+      statusRef: {meansOpen: this.isOpen},
+    };
+  }
+
+  get _APIURL() {
+    return GOOGLE_ISSUE_TRACKER_API_ROOT + GOOGLE_ISSUE_TRACKER_DISCOVERY_PATH;
+  }
+
+  _loadGoogleIssueTrackerIssue(bugID) {
+    return new Promise((resolve, reject) => {
+      const version = GOOGLE_ISSUE_TRACKER_API_VERSION;
+      gapi.client.load(this._APIURL, version, () => {
+        const request = gapi.client.corp_issuetracker.issues.get({
+          'issueId': bugID,
+        });
+        request.execute((response) => {
+          resolve(response);
+        });
+      });
+    });
+  }
+}
+
+class FederatedIssueError extends Error {}
+
+// A list of supported tracker classes.
+const FEDERATED_TRACKERS = [
+  GoogleIssueTrackerIssue,
+];
diff --git a/static_src/shared/federated.test.js b/static_src/shared/federated.test.js
new file mode 100644
index 0000000..011b924
--- /dev/null
+++ b/static_src/shared/federated.test.js
@@ -0,0 +1,136 @@
+// Copyright 2019 The Chromium Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+import {assert} from 'chai';
+import {
+  isShortlinkValid,
+  fromShortlink,
+  GoogleIssueTrackerIssue,
+} from './federated.js';
+import {getSigninInstance} from 'shared/gapi-loader.js';
+
+describe('isShortlinkValid', () => {
+  it('Returns true for valid links', () => {
+    assert.isTrue(isShortlinkValid('b/1'));
+    assert.isTrue(isShortlinkValid('b/12345678'));
+  });
+
+  it('Returns false for invalid links', () => {
+    assert.isFalse(isShortlinkValid('b'));
+    assert.isFalse(isShortlinkValid('b/'));
+    assert.isFalse(isShortlinkValid('b//123456'));
+    assert.isFalse(isShortlinkValid('b/123/123'));
+    assert.isFalse(isShortlinkValid('b123/123'));
+    assert.isFalse(isShortlinkValid('b/123a456'));
+  });
+});
+
+describe('fromShortlink', () => {
+  it('Returns an issue class for valid links', () => {
+    assert.instanceOf(fromShortlink('b/1'), GoogleIssueTrackerIssue);
+    assert.instanceOf(fromShortlink('b/12345678'), GoogleIssueTrackerIssue);
+  });
+
+  it('Returns null for invalid links', () => {
+    assert.isNull(fromShortlink('b'));
+    assert.isNull(fromShortlink('b/'));
+    assert.isNull(fromShortlink('b//123456'));
+    assert.isNull(fromShortlink('b/123/123'));
+    assert.isNull(fromShortlink('b123/123'));
+    assert.isNull(fromShortlink('b/123a456'));
+  });
+});
+
+describe('GoogleIssueTrackerIssue', () => {
+  describe('constructor', () => {
+    it('Sets this.shortlink and this.issueID', () => {
+      const shortlink = 'b/1234';
+      const issue = new GoogleIssueTrackerIssue(shortlink);
+      assert.equal(issue.shortlink, shortlink);
+      assert.equal(issue.issueID, 1234);
+    });
+
+    it('Throws when given an invalid shortlink.', () => {
+      assert.throws(() => {
+        new GoogleIssueTrackerIssue('b/123/123');
+      });
+    });
+  });
+
+  describe('toURL', () => {
+    it('Returns a valid URL.', () => {
+      const issue = new GoogleIssueTrackerIssue('b/1234');
+      assert.equal(issue.toURL(), 'https://issuetracker.google.com/issues/1234');
+    });
+  });
+
+  describe('federated details', () => {
+    let signinImpl;
+    beforeEach(() => {
+      window.CS_env = {gapi_client_id: 'rutabaga'};
+      signinImpl = {
+        init: sinon.stub(),
+        getUserProfileAsync: () => (
+          Promise.resolve({
+            getEmail: sinon.stub().returns('rutabaga@google.com'),
+          })
+        ),
+      };
+      // Preload signinImpl with a fake for testing.
+      getSigninInstance(signinImpl, true);
+      delete window.__gapiLoadPromise;
+    });
+
+    afterEach(() => {
+      delete window.CS_env;
+    });
+
+    describe('isOpen', () => {
+      it('Fails open', () => {
+        const issue = new GoogleIssueTrackerIssue('b/1234');
+        assert.isTrue(issue.isOpen);
+      });
+
+      it('Is based on details.resolvedTime', () => {
+        const issue = new GoogleIssueTrackerIssue('b/1234');
+        issue._federatedDetails = {resolvedTime: 12345};
+        assert.isFalse(issue.isOpen);
+
+        issue._federatedDetails = {};
+        assert.isTrue(issue.isOpen);
+      });
+    });
+
+    describe('summary', () => {
+      it('Returns null if not available', () => {
+        const issue = new GoogleIssueTrackerIssue('b/1234');
+        assert.isNull(issue.summary);
+      });
+
+      it('Returns the summary if available', () => {
+        const issue = new GoogleIssueTrackerIssue('b/1234');
+        issue._federatedDetails = {issueState: {title: 'Rutabaga title'}};
+        assert.equal(issue.summary, 'Rutabaga title');
+      });
+    });
+
+    describe('toIssueRef', () => {
+      it('Returns an issue ref object', () => {
+        const issue = new GoogleIssueTrackerIssue('b/1234');
+        issue._federatedDetails = {
+          resolvedTime: 12345,
+          issueState: {
+            title: 'A fedref issue title',
+          },
+        };
+
+        assert.deepEqual(issue.toIssueRef(), {
+          extIdentifier: 'b/1234',
+          summary: 'A fedref issue title',
+          statusRef: {meansOpen: false},
+        });
+      });
+    });
+  });
+});
diff --git a/static_src/shared/ga-helpers.js b/static_src/shared/ga-helpers.js
new file mode 100644
index 0000000..52d1176
--- /dev/null
+++ b/static_src/shared/ga-helpers.js
@@ -0,0 +1,31 @@
+// Copyright 2019 The Chromium Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+const TITLE = 'title';
+const LOCATION = 'location';
+const DIMENSION1 = 'dimension1';
+const SET = 'set';
+
+/**
+ * Track page-to-page navigation via google analytics. Global window.ga
+ * is set in server rendered HTML.
+ *
+ * @param {string} page
+ * @param {string} userDisplayName
+ */
+export const trackPageChange = (page = '', userDisplayName = '') => {
+  ga(SET, TITLE, `Issue ${page}`);
+  if (page.startsWith('user')) {
+    ga(SET, TITLE, 'A user page');
+    ga(SET, LOCATION, 'A user page URL');
+  }
+
+  if (userDisplayName) {
+    ga(SET, DIMENSION1, 'Logged in');
+  } else {
+    ga(SET, DIMENSION1, 'Not logged in');
+  }
+
+  ga('send', 'pageview');
+};
diff --git a/static_src/shared/ga-helpers.test.js b/static_src/shared/ga-helpers.test.js
new file mode 100644
index 0000000..1876a27
--- /dev/null
+++ b/static_src/shared/ga-helpers.test.js
@@ -0,0 +1,45 @@
+// Copyright 2019 The Chromium Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+import {trackPageChange} from './ga-helpers.js';
+
+describe('trackPageChange', () => {
+  beforeEach(() => {
+    global.ga = sinon.spy();
+  });
+
+  afterEach(() => {
+    global.ga.resetHistory();
+  });
+
+  it('sets page title', () => {
+    trackPageChange('list');
+    sinon.assert.calledWith(global.ga, 'set', 'title', 'Issue list');
+  });
+
+  it('sets user page title', () => {
+    trackPageChange('user-anything');
+    sinon.assert.calledWith(global.ga, 'set', 'title', 'A user page');
+  });
+
+  it('sets user location', () => {
+    trackPageChange('user-anything');
+    sinon.assert.calledWith(global.ga, 'set', 'location', 'A user page URL');
+  });
+
+  it('defaults dimension1', () => {
+    trackPageChange('list');
+    sinon.assert.calledWith(global.ga, 'set', 'dimension1', 'Not logged in');
+  });
+
+  it('sets dimension1 based on userDisplayName', () => {
+    trackPageChange('list', 'somebody');
+    sinon.assert.calledWith(global.ga, 'set', 'dimension1', 'Logged in');
+  });
+
+  it('sends pageview', () => {
+    trackPageChange('list');
+    sinon.assert.calledWith(global.ga, 'send', 'pageview');
+  });
+});
diff --git a/static_src/shared/gapi-loader.js b/static_src/shared/gapi-loader.js
new file mode 100644
index 0000000..5249d68
--- /dev/null
+++ b/static_src/shared/gapi-loader.js
@@ -0,0 +1,66 @@
+// Copyright 2019 The Chromium Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+/**
+ * gapi-loader.js provides a method for loading gapi.js asynchronously.
+ *
+ * gapi.js docs:
+ * https://developers.google.com/identity/sign-in/web/reference
+ * (we load gapi.js via the chops-signin module)
+ */
+
+import * as signin from '@chopsui/chops-signin';
+
+const BUGANIZER_SCOPE = 'https://www.googleapis.com/auth/buganizer';
+// Only allow google.com profiles through currently.
+const RESTRICT_TO_DOMAIN = '@google.com';
+
+// loadGapi loads window.gapi and returns a logged in user object or null.
+// Allows overriding signinImpl for testing.
+export default function loadGapi() {
+  const signinImpl = getSigninInstance();
+  // Validate client_id exists.
+  if (!CS_env.gapi_client_id) {
+    throw new Error('Cannot find gapi.js client id');
+  }
+
+  // Prevent gapi.js from being loaded multiple times.
+  if (window.__gapiLoadPromise) {
+    return window.__gapiLoadPromise;
+  }
+
+  window.__gapiLoadPromise = new Promise(async (resolve) => {
+    signinImpl.init(CS_env.gapi_client_id, ['client'], [BUGANIZER_SCOPE]);
+    resolve(await fetchGapiEmail(signinImpl));
+  });
+
+  return window.__gapiLoadPromise;
+}
+
+// For fetching current email. May have changed since load.
+export function fetchGapiEmail() {
+  const signinImpl = getSigninInstance();
+  return new Promise((resolve) => {
+    signinImpl.getUserProfileAsync().then((profile) => {
+      resolve(
+          (
+            profile &&
+            profile.getEmail instanceof Function &&
+            profile.getEmail().endsWith(RESTRICT_TO_DOMAIN) &&
+            profile.getEmail()
+          ) || null,
+      );
+    });
+  });
+}
+
+// Provide a singleton chops-signin instance to make testing easier.
+let signinInstance;
+export function getSigninInstance(signinImpl=signin, overwrite=false) {
+  // Assign on first run.
+  if (overwrite || !signinInstance) {
+    signinInstance = signinImpl;
+  }
+  return signinInstance;
+}
diff --git a/static_src/shared/gapi-loader.test.js b/static_src/shared/gapi-loader.test.js
new file mode 100644
index 0000000..fb98fed
--- /dev/null
+++ b/static_src/shared/gapi-loader.test.js
@@ -0,0 +1,73 @@
+// Copyright 2019 The Chromium Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+import {assert} from 'chai';
+import sinon from 'sinon';
+import loadGapi, {fetchGapiEmail, getSigninInstance} from './gapi-loader.js';
+
+describe('gapi-loader', () => {
+  let signinImpl;
+  beforeEach(() => {
+    window.CS_env = {gapi_client_id: 'rutabaga'};
+    signinImpl = {
+      init: sinon.stub(),
+      getUserProfileAsync: () => (
+        Promise.resolve({
+          getEmail: sinon.stub().returns('rutabaga@google.com'),
+        })
+      ),
+    };
+    // Preload signinImpl with a fake for testing.
+    getSigninInstance(signinImpl, true);
+    delete window.__gapiLoadPromise;
+  });
+
+  afterEach(() => {
+    delete window.CS_env;
+  });
+
+  describe('loadGapi()', () => {
+    it('errors out if no client_id', () => {
+      window.CS_env.gapi_client_id = undefined;
+      assert.throws(() => loadGapi());
+    });
+
+    it('returns the same promise when called multiple times', () => {
+      const callOne = loadGapi();
+      const callTwo = loadGapi();
+
+      assert.strictEqual(callOne, callTwo);
+      assert.strictEqual(callOne, window.__gapiLoadPromise);
+      assert.strictEqual(callTwo, window.__gapiLoadPromise);
+      assert.instanceOf(callOne, Promise);
+    });
+
+    it('calls init and returns the current email if any', async () => {
+      const response = await loadGapi();
+      sinon.assert.calledWith(signinImpl.init, window.CS_env.gapi_client_id,
+          ['client'], ['https://www.googleapis.com/auth/buganizer']);
+      assert.equal(response, 'rutabaga@google.com');
+    });
+  });
+
+  describe('fetchGapiEmail()', () => {
+    it('returns a profile for allowed domains', async () => {
+      getSigninInstance({
+        getUserProfileAsync: () => Promise.resolve({
+          getEmail: sinon.stub().returns('rutabaga@google.com'),
+        }),
+      }, true);
+      assert.deepEqual(await fetchGapiEmail(), 'rutabaga@google.com');
+    });
+
+    it('returns nothing for non-allowed domains', async () => {
+      getSigninInstance({
+        getUserProfileAsync: () => Promise.resolve({
+          getEmail: sinon.stub().returns('rutabaga@rutabaga.com'),
+        }),
+      }, true);
+      assert.deepEqual(await fetchGapiEmail(), null);
+    });
+  });
+});
diff --git a/static_src/shared/helpers.js b/static_src/shared/helpers.js
new file mode 100644
index 0000000..362b4ec
--- /dev/null
+++ b/static_src/shared/helpers.js
@@ -0,0 +1,213 @@
+// Copyright 2019 The Chromium Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+import qs from 'qs';
+
+
+/**
+ * With lists a and b, get the elements that are in a but not in b.
+ * result = a - b
+ * @param {Array} listA
+ * @param {Array} listB
+ * @param {function?} equals
+ * @return {Array}
+ */
+export function arrayDifference(listA, listB, equals = undefined) {
+  if (!equals) {
+    equals = (a, b) => (a === b);
+  }
+  listA = listA || [];
+  listB = listB || [];
+  return listA.filter((a) => {
+    return !listB.find((b) => (equals(a, b)));
+  });
+}
+
+/**
+ * Check to see if a Set contains any of a list of values.
+ *
+ * @param {Set} set the Set to check for values in.
+ * @param {Iterable} values checks if any of these values are included.
+ * @return {boolean} whether the Set has any of the values or not.
+ */
+export function setHasAny(set, values) {
+  for (const value of values) {
+    if (set.has(value)) {
+      return true;
+    }
+  }
+  return false;
+}
+
+/**
+ * Capitalize the first letter of a given string.
+ * @param {string} str
+ * @return {string}
+ */
+export function capitalizeFirst(str) {
+  return `${str.charAt(0).toUpperCase()}${str.substring(1)}`;
+}
+
+/**
+ * Check if a string has a prefix, ignoring case.
+ * @param {string} str
+ * @param {string} prefix
+ * @return {boolean}
+ */
+export function hasPrefix(str, prefix) {
+  return str.toLowerCase().startsWith(prefix.toLowerCase());
+}
+
+/**
+ * Returns a string without specified prefix
+ * @param {string} str
+ * @param {string} prefix
+ * @return {string}
+ */
+export function removePrefix(str, prefix) {
+  return str.substr(prefix.length);
+}
+
+// TODO(zhangtiff): Make this more grammatically correct for
+// more than two items.
+export function arrayToEnglish(arr) {
+  if (!arr) return '';
+  return arr.join(' and ');
+}
+
+export function pluralize(count, singular, pluralArg) {
+  const plural = pluralArg || singular + 's';
+  return count === 1 ? singular : plural;
+}
+
+export function objectToMap(obj = {}) {
+  const map = new Map();
+  Object.keys(obj).forEach((key) => {
+    map.set(key, obj[key]);
+  });
+  return map;
+}
+
+/**
+ * Given an Object, extract a list of values from it, based on some
+ * specified keys.
+ *
+ * @param {Object} obj the Object to read values from.
+ * @param {Array} keys the Object keys to fetch values for.
+ * @return {Array} Object values matching the given keys.
+ */
+export function objectValuesForKeys(obj, keys = []) {
+  return keys.map((key) => ((key in obj) ? obj[key] : undefined));
+}
+
+/**
+ * Checks to see if object has no keys
+ * @param {Object} obj
+ * @return {boolean}
+ */
+export function isEmptyObject(obj) {
+  return Object.keys(obj).length === 0;
+}
+
+/**
+ * Checks if two strings are equal, case-insensitive
+ * @param {string} a
+ * @param {string} b
+ * @return {boolean}
+ */
+export function equalsIgnoreCase(a, b) {
+  if (a == b) return true;
+  if (!a || !b) return false;
+  return a.toLowerCase() === b.toLowerCase();
+}
+
+export function immutableSplice(arr, index, count, ...addedItems) {
+  if (!arr) return '';
+
+  return [...arr.slice(0, index), ...addedItems, ...arr.slice(index + count)];
+}
+
+/**
+ * Computes a new URL for a page based on an exiting path and set of query
+ * params.
+ *
+ * @param {string} baseUrl the base URL without query params.
+ * @param {Object} oldParams original query params before changes.
+ * @param {Object} newParams query parameters to override existing ones.
+ * @param {Array} deletedParams list of keys to be cleared.
+ * @return {string} the new URL with the updated params.
+ */
+export function urlWithNewParams(baseUrl = '',
+    oldParams = {}, newParams = {}, deletedParams = []) {
+  const params = {...oldParams, ...newParams};
+  deletedParams.forEach((name) => {
+    delete params[name];
+  });
+
+  const queryString = qs.stringify(params);
+
+  return `${baseUrl}${queryString ? '?' : ''}${queryString}`;
+}
+
+/**
+ * Finds out whether a user is a member of a given project based on
+ * project membership info.
+ *
+ * @param {Object} userRef reference to a given user. Expects an id.
+ * @param {string} projectName name of the project being searched for.
+ * @param {Map} usersProjects all known user project memberships where
+ *  keys are userId and values are Objects with expected values
+ *  for {ownerOf, memberOf, contributorTo}.
+ * @return {boolean} whether the user is a member of the project or not.
+ */
+export function userIsMember(userRef, projectName, usersProjects = new Map()) {
+  // TODO(crbug.com/monorail/5968): Find a better place to place this function
+  if (!userRef || !userRef.userId || !projectName) return false;
+  const userProjects = usersProjects.get(userRef.userId);
+  if (!userProjects) return false;
+  const {ownerOf = [], memberOf = [], contributorTo = []} = userProjects;
+  return ownerOf.includes(projectName) ||
+    memberOf.includes(projectName) ||
+    contributorTo.includes(projectName);
+}
+
+/**
+ * Creates a function that checks two objects are not equal
+ * based on a set of property keys
+ *
+ * @param {Set<string>} props
+ * @return {function(): boolean}
+ */
+export function createObjectComparisonFunc(props) {
+  /**
+   * Computes whether set of properties have changed
+   * @param {Object<string, string>} newVal
+   * @param {Object<string, string>} oldVal
+   * @return {boolean}
+   */
+  return function(newVal, oldVal) {
+    if (oldVal === undefined && newVal === undefined) {
+      return false;
+    } else if (oldVal === undefined || newVal === undefined) {
+      return true;
+    } else if (oldVal === null && newVal === null) {
+      return false;
+    } else if (oldVal === null || newVal === null) {
+      return true;
+    }
+
+    return Array.from(props)
+        .some((propName) => newVal[propName] !== oldVal[propName]);
+  };
+}
+
+/**
+ * Calculates whether to wait for memberDefaultQuery to exist prior
+ * to fetching IssueList. Logged in users may use a default query.
+ * @param {Object} queryParams
+ * @return {boolean}
+ */
+export const shouldWaitForDefaultQuery = (queryParams) => {
+  return !queryParams.hasOwnProperty('q');
+};
diff --git a/static_src/shared/helpers.test.js b/static_src/shared/helpers.test.js
new file mode 100644
index 0000000..7c40ed5
--- /dev/null
+++ b/static_src/shared/helpers.test.js
@@ -0,0 +1,361 @@
+// Copyright 2019 The Chromium Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+import {assert} from 'chai';
+import {arrayDifference, setHasAny, capitalizeFirst, hasPrefix, objectToMap,
+  objectValuesForKeys, equalsIgnoreCase, immutableSplice, userIsMember,
+  urlWithNewParams, createObjectComparisonFunc} from './helpers.js';
+
+
+describe('arrayDifference', () => {
+  it('empty array stays empty', () => {
+    assert.deepEqual(arrayDifference([], []), []);
+    assert.deepEqual(arrayDifference([], undefined), []);
+    assert.deepEqual(arrayDifference([], ['a']), []);
+  });
+
+  it('subtracting empty array does nothing', () => {
+    assert.deepEqual(arrayDifference(['a'], []), ['a']);
+    assert.deepEqual(arrayDifference([1, 2, 3], []), [1, 2, 3]);
+    assert.deepEqual(arrayDifference([1, 2, 'test'], []), [1, 2, 'test']);
+    assert.deepEqual(arrayDifference([1, 2, 'test'], undefined),
+        [1, 2, 'test']);
+  });
+
+  it('subtracts elements from array', () => {
+    assert.deepEqual(arrayDifference(['a', 'b', 'c'], ['b', 'c']), ['a']);
+    assert.deepEqual(arrayDifference(['a', 'b', 'c'], ['a']), ['b', 'c']);
+    assert.deepEqual(arrayDifference(['a', 'b', 'c'], ['b']), ['a', 'c']);
+    assert.deepEqual(arrayDifference([1, 2, 3], [2]), [1, 3]);
+  });
+
+  it('does not subtract missing elements from array', () => {
+    assert.deepEqual(arrayDifference(['a', 'b', 'c'], ['d']), ['a', 'b', 'c']);
+    assert.deepEqual(arrayDifference([1, 2, 3], [5]), [1, 2, 3]);
+    assert.deepEqual(arrayDifference([1, 2, 3], [-1, 2]), [1, 3]);
+  });
+
+  it('custom equals function', () => {
+    assert.deepEqual(arrayDifference(['a', 'b'], ['A']), ['a', 'b']);
+    assert.deepEqual(arrayDifference(['a', 'b'], ['A'], equalsIgnoreCase),
+        ['b']);
+  });
+});
+
+describe('setHasAny', () => {
+  it('empty set never has any values', () => {
+    assert.isFalse(setHasAny(new Set(), []));
+    assert.isFalse(setHasAny(new Set(), ['test']));
+    assert.isFalse(setHasAny(new Set(), ['nope', 'yup', 'no']));
+  });
+
+  it('false when no values found', () => {
+    assert.isFalse(setHasAny(new Set(['hello', 'world']), []));
+    assert.isFalse(setHasAny(new Set(['hello', 'world']), ['wor']));
+    assert.isFalse(setHasAny(new Set(['test']), ['other', 'values']));
+    assert.isFalse(setHasAny(new Set([1, 2, 3]), [4, 5, 6]));
+  });
+
+  it('true when values found', () => {
+    assert.isTrue(setHasAny(new Set(['hello', 'world']), ['world']));
+    assert.isTrue(setHasAny(new Set([1, 2, 3]), [3, 4, 5]));
+    assert.isTrue(setHasAny(new Set([1, 2, 3]), [1, 3]));
+  });
+});
+
+describe('capitalizeFirst', () => {
+  it('empty string', () => {
+    assert.equal(capitalizeFirst(''), '');
+  });
+
+  it('ignores non-letters', () => {
+    assert.equal(capitalizeFirst('8fcsdf'), '8fcsdf');
+  });
+
+  it('preserves existing caps', () => {
+    assert.equal(capitalizeFirst('HELLO world'), 'HELLO world');
+  });
+
+  it('capitalizes lowercase', () => {
+    assert.equal(capitalizeFirst('hello world'), 'Hello world');
+  });
+});
+
+describe('hasPrefix', () => {
+  it('only true when has prefix', () => {
+    assert.isFalse(hasPrefix('teststring', 'test-'));
+    assert.isFalse(hasPrefix('stringtest-', 'test-'));
+    assert.isFalse(hasPrefix('^test-$', 'test-'));
+    assert.isTrue(hasPrefix('test-', 'test-'));
+    assert.isTrue(hasPrefix('test-fsdfsdf', 'test-'));
+  });
+
+  it('ignores case when checking prefix', () => {
+    assert.isTrue(hasPrefix('TEST-string', 'test-'));
+    assert.isTrue(hasPrefix('test-string', 'test-'));
+    assert.isTrue(hasPrefix('tEsT-string', 'test-'));
+  });
+});
+
+describe('objectToMap', () => {
+  it('converts Object to Map with the same keys', () => {
+    assert.deepEqual(objectToMap({}), new Map());
+    assert.deepEqual(objectToMap({test: 'value'}),
+        new Map([['test', 'value']]));
+    assert.deepEqual(objectToMap({['weird:key']: 'value',
+      ['what is this key']: 'v2'}), new Map([['weird:key', 'value'],
+      ['what is this key', 'v2']]));
+  });
+});
+
+describe('objectValuesForKeys', () => {
+  it('no values when no matching keys', () => {
+    assert.deepEqual(objectValuesForKeys({}, []), []);
+    assert.deepEqual(objectValuesForKeys({}, []), []);
+    assert.deepEqual(objectValuesForKeys({key: 'value'}, []), []);
+  });
+
+  it('returns values when keys match', () => {
+    assert.deepEqual(objectValuesForKeys({a: 1, b: 2, c: 3}, ['a', 'b']),
+        [1, 2]);
+    assert.deepEqual(objectValuesForKeys({a: 1, b: 2, c: 3}, ['b', 'c']),
+        [2, 3]);
+    assert.deepEqual(objectValuesForKeys({['weird:key']: {nested: 'obj'}},
+        ['weird:key']), [{nested: 'obj'}]);
+  });
+
+  it('sets non-matching keys to undefined', () => {
+    assert.deepEqual(objectValuesForKeys({a: 1, b: 2, c: 3}, ['c', 'd', 'e']),
+        [3, undefined, undefined]);
+    assert.deepEqual(objectValuesForKeys({a: 1, b: 2, c: 3}, [1, 2, 3]),
+        [undefined, undefined, undefined]);
+  });
+});
+
+describe('equalsIgnoreCase', () => {
+  it('matches same case strings', () => {
+    assert.isTrue(equalsIgnoreCase('', ''));
+    assert.isTrue(equalsIgnoreCase('HelloWorld', 'HelloWorld'));
+    assert.isTrue(equalsIgnoreCase('hmm', 'hmm'));
+    assert.isTrue(equalsIgnoreCase('TEST', 'TEST'));
+  });
+
+  it('matches different case strings', () => {
+    assert.isTrue(equalsIgnoreCase('a', 'A'));
+    assert.isTrue(equalsIgnoreCase('HelloWorld', 'helloworld'));
+    assert.isTrue(equalsIgnoreCase('hmm', 'HMM'));
+    assert.isTrue(equalsIgnoreCase('TEST', 'teSt'));
+  });
+
+  it('does not match different strings', () => {
+    assert.isFalse(equalsIgnoreCase('hello', 'hello '));
+    assert.isFalse(equalsIgnoreCase('superstring', 'string'));
+    assert.isFalse(equalsIgnoreCase('aaa', 'aa'));
+  });
+});
+
+describe('immutableSplice', () => {
+  it('does not edit original array', () => {
+    const arr = ['apples', 'pears', 'oranges'];
+
+    assert.deepEqual(immutableSplice(arr, 1, 1),
+        ['apples', 'oranges']);
+
+    assert.deepEqual(arr, ['apples', 'pears', 'oranges']);
+  });
+
+  it('removes multiple items', () => {
+    const arr = [1, 2, 3, 4, 5, 6];
+
+    assert.deepEqual(immutableSplice(arr, 1, 0), [1, 2, 3, 4, 5, 6]);
+    assert.deepEqual(immutableSplice(arr, 1, 4), [1, 6]);
+    assert.deepEqual(immutableSplice(arr, 0, 6), []);
+  });
+
+  it('adds items', () => {
+    const arr = [1, 2, 3];
+
+    assert.deepEqual(immutableSplice(arr, 1, 1, 4, 5, 6), [1, 4, 5, 6, 3]);
+    assert.deepEqual(immutableSplice(arr, 2, 1, 4, 5, 6), [1, 2, 4, 5, 6]);
+    assert.deepEqual(immutableSplice(arr, 0, 0, -3, -2, -1, 0),
+        [-3, -2, -1, 0, 1, 2, 3]);
+  });
+});
+
+describe('urlWithNewParams', () => {
+  it('empty', () => {
+    assert.equal(urlWithNewParams(), '');
+    assert.equal(urlWithNewParams(''), '');
+    assert.equal(urlWithNewParams('', {}), '');
+    assert.equal(urlWithNewParams('', {}, {}), '');
+    assert.equal(urlWithNewParams('', {}, {}, []), '');
+  });
+
+  it('preserves existing URL without changes', () => {
+    assert.equal(urlWithNewParams('/p/chromium/issues/list'),
+        '/p/chromium/issues/list');
+    assert.equal(urlWithNewParams('/p/chromium/issues/list', {q: 'owner:me'}),
+        '/p/chromium/issues/list?q=owner%3Ame');
+    assert.equal(
+        urlWithNewParams('/p/chromium/issues/list', {q: 'owner:me', can: '1'}),
+        '/p/chromium/issues/list?q=owner%3Ame&can=1');
+  });
+
+  it('adds new params', () => {
+    assert.equal(
+        urlWithNewParams('/p/chromium/issues/list', {}, {q: 'owner:me'}),
+        '/p/chromium/issues/list?q=owner%3Ame');
+    assert.equal(
+        urlWithNewParams('/p/chromium/issues/list',
+            {can: '1'}, {q: 'owner:me'}),
+        '/p/chromium/issues/list?can=1&q=owner%3Ame');
+
+    // Override existing params.
+    assert.equal(
+        urlWithNewParams('/p/chromium/issues/list',
+            {can: '1', q: 'owner:me'}, {q: 'test'}),
+        '/p/chromium/issues/list?can=1&q=test');
+  });
+
+  it('clears existing params', () => {
+    assert.equal(
+        urlWithNewParams('/p/chromium/issues/list', {q: 'owner:me'}, {}, ['q']),
+        '/p/chromium/issues/list');
+    assert.equal(
+        urlWithNewParams('/p/chromium/issues/list',
+            {can: '1'}, {q: 'owner:me'}, ['can']),
+        '/p/chromium/issues/list?q=owner%3Ame');
+    assert.equal(
+        urlWithNewParams('/p/chromium/issues/list', {q: 'owner:me'}, {can: '2'},
+            ['q', 'can', 'fakeparam']),
+        '/p/chromium/issues/list');
+  });
+});
+
+describe('userIsMember', () => {
+  it('false when no user', () => {
+    assert.isFalse(userIsMember(undefined));
+    assert.isFalse(userIsMember({}));
+    assert.isFalse(userIsMember({}, 'chromium',
+        new Map([['123', {ownerOf: ['chromium']}]])));
+  });
+
+  it('true when user is member of project', () => {
+    assert.isTrue(userIsMember({userId: '123'}, 'chromium',
+        new Map([['123', {contributorTo: ['chromium']}]])));
+
+    assert.isTrue(userIsMember({userId: '123'}, 'chromium',
+        new Map([['123', {ownerOf: ['chromium']}]])));
+
+    assert.isTrue(userIsMember({userId: '123'}, 'chromium',
+        new Map([['123', {memberOf: ['chromium']}]])));
+  });
+
+  it('true when user is member of multiple projects', () => {
+    assert.isTrue(userIsMember({userId: '123'}, 'chromium', new Map([
+      ['123', {contributorTo: ['test', 'chromium', 'fakeproject']}],
+    ])));
+
+    assert.isTrue(userIsMember({userId: '123'}, 'chromium', new Map([
+      ['123', {ownerOf: ['test', 'chromium', 'fakeproject']}],
+    ])));
+
+    assert.isTrue(userIsMember({userId: '123'}, 'chromium', new Map([
+      ['123', {memberOf: ['test', 'chromium', 'fakeproject']}],
+    ])));
+  });
+
+  it('false when user is member of different project', () => {
+    assert.isFalse(userIsMember({userId: '123'}, 'chromium', new Map([
+      ['123', {contributorTo: ['test', 'fakeproject']}],
+    ])));
+
+    assert.isFalse(userIsMember({userId: '123'}, 'chromium', new Map([
+      ['123', {ownerOf: ['test', 'fakeproject']}],
+    ])));
+
+    assert.isFalse(userIsMember({userId: '123'}, 'chromium', new Map([
+      ['123', {memberOf: ['test', 'fakeproject']}],
+    ])));
+  });
+
+  it('false when no project data for user', () => {
+    assert.isFalse(userIsMember({userId: '123'}, 'chromium'));
+    assert.isFalse(userIsMember({userId: '123'}, 'chromium', new Map()));
+    assert.isFalse(userIsMember({userId: '123'}, 'chromium', new Map([
+      ['543', {ownerOf: ['chromium']}],
+    ])));
+  });
+});
+
+describe('createObjectComparisonFunc', () => {
+  it('returns a function', () => {
+    const result = createObjectComparisonFunc(new Set());
+    assert.instanceOf(result, Function);
+  });
+
+  describe('returned function', () => {
+    it('returns false if both inputs are undefined', () => {
+      const comparableProps = new Set(['a', 'b', 'c']);
+      const func = createObjectComparisonFunc(comparableProps);
+      const result = func(undefined, undefined);
+
+      assert.isFalse(result);
+    });
+
+    it('returns true if only one inputs is undefined', () => {
+      const comparableProps = new Set(['a', 'b', 'c']);
+      const func = createObjectComparisonFunc(comparableProps);
+      const result = func({}, undefined);
+
+      assert.isTrue(result);
+    });
+
+    it('returns false if both inputs are null', () => {
+      const comparableProps = new Set(['a', 'b', 'c']);
+      const func = createObjectComparisonFunc(comparableProps);
+      const result = func(null, null);
+
+      assert.isFalse(result);
+    });
+
+    it('returns true if only one inputs is null', () => {
+      const comparableProps = new Set(['a', 'b', 'c']);
+      const func = createObjectComparisonFunc(comparableProps);
+      const result = func({}, null);
+
+      assert.isTrue(result);
+    });
+
+    it('returns true if any comparable property is different', () => {
+      const comparableProps = new Set(['a', 'b', 'c']);
+      const func = createObjectComparisonFunc(comparableProps);
+      const a = {a: 1, b: 2, c: 3};
+      const b = {a: 1, b: 2, c: '3'};
+      const result = func(a, b);
+
+      assert.isTrue(result);
+    });
+
+    it('returns false if all comparable properties are the same', () => {
+      const comparableProps = new Set(['a', 'b', 'c']);
+      const func = createObjectComparisonFunc(comparableProps);
+      const a = {a: 1, b: 2, c: 3};
+      const b = {a: 1, b: 2, c: 3};
+      const result = func(a, b);
+
+      assert.isFalse(result);
+    });
+
+    it('ignores non-comparable properties', () => {
+      const comparableProps = new Set(['a', 'b', 'c']);
+      const func = createObjectComparisonFunc(comparableProps);
+      const a = {a: 1, b: 2, c: 3, d: 4};
+      const b = {a: 1, b: 2, c: 3, d: 'not four', e: 'exists'};
+      const result = func(a, b);
+
+      assert.isFalse(result);
+    });
+  });
+});
diff --git a/static_src/shared/issue-fields.js b/static_src/shared/issue-fields.js
new file mode 100644
index 0000000..09ac7d3
--- /dev/null
+++ b/static_src/shared/issue-fields.js
@@ -0,0 +1,424 @@
+// Copyright 2019 The Chromium Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+import {relativeTime} from
+  'elements/chops/chops-timestamp/chops-timestamp-helpers.js';
+import {labelRefsToStrings, issueRefsToStrings, componentRefsToStrings,
+  userRefsToDisplayNames, statusRefsToStrings, labelNameToLabelPrefixes,
+} from './convertersV0.js';
+import {removePrefix} from './helpers.js';
+import {STATUS_ENUM_TO_TEXT} from 'shared/consts/approval.js';
+import {fieldValueMapKey} from 'shared/metadata-helpers.js';
+
+// TODO(zhangtiff): Merge this file with metadata-helpers.js.
+
+
+/** @enum {string} */
+export const fieldTypes = Object.freeze({
+  APPROVAL_TYPE: 'APPROVAL_TYPE',
+  DATE_TYPE: 'DATE_TYPE',
+  ENUM_TYPE: 'ENUM_TYPE',
+  INT_TYPE: 'INT_TYPE',
+  STR_TYPE: 'STR_TYPE',
+  USER_TYPE: 'USER_TYPE',
+  URL_TYPE: 'URL_TYPE',
+
+  // Frontend types used to handle built in fields like BlockedOn.
+  // Although these are not configurable custom field types on the
+  // backend, hard-coding these fields types on the frontend allows
+  // us to inter-op custom and baked in fields more seamlessly on
+  // the frontend.
+  ISSUE_TYPE: 'ISSUE_TYPE',
+  TIME_TYPE: 'TIME_TYPE',
+  COMPONENT_TYPE: 'COMPONENT_TYPE',
+  STATUS_TYPE: 'STATUS_TYPE',
+  LABEL_TYPE: 'LABEL_TYPE',
+  PROJECT_TYPE: 'PROJECT_TYPE',
+});
+
+const GROUPABLE_FIELD_TYPES = new Set([
+  fieldTypes.DATE_TYPE,
+  fieldTypes.ENUM_TYPE,
+  fieldTypes.USER_TYPE,
+  fieldTypes.INT_TYPE,
+]);
+
+const SPEC_DELIMITER_REGEX = /[\s\+]+/;
+export const SITEWIDE_DEFAULT_COLUMNS = ['ID', 'Type', 'Status',
+  'Priority', 'Milestone', 'Owner', 'Summary'];
+
+// When no default can is configured, projects use "Open issues".
+export const SITEWIDE_DEFAULT_CAN = '2';
+
+export const PHASE_FIELD_COL_DELIMITER_REGEX = /\./;
+
+export const EMPTY_FIELD_VALUE = '----';
+
+export const APPROVER_COL_SUFFIX_REGEX = /\-approver$/i;
+
+/**
+ * Parses colspec or groupbyspec values from user input such as form fields
+ * or the URL.
+ *
+ * @param {string} spec a delimited string with spec values to parse.
+ * @return {Array} list of spec values represented by the string.
+ */
+export function parseColSpec(spec = '') {
+  return spec.split(SPEC_DELIMITER_REGEX).filter(Boolean);
+}
+
+/**
+ * Finds the type for an issue based on the issue's custom fields
+ * and labels. If there is a custom field named "Type", that field
+ * is used, otherwise labels are used.
+ * @param {!Array<FieldValue>} fieldValues
+ * @param {!Array<LabelRef>} labelRefs
+ * @return {string}
+ */
+export function extractTypeForIssue(fieldValues, labelRefs) {
+  if (fieldValues) {
+    // If there is a custom field for "Type", use that for type.
+    const typeFieldValue = fieldValues.find(
+        (f) => (f.fieldRef && f.fieldRef.fieldName.toLowerCase() === 'type'),
+    );
+    if (typeFieldValue) {
+      return typeFieldValue.value;
+    }
+  }
+
+  // Otherwise, search through labels for a "Type" label.
+  if (labelRefs) {
+    const typeLabel = labelRefs.find(
+        (l) => l.label.toLowerCase().startsWith('type-'));
+    if (typeLabel) {
+    // Strip length of prefix.
+      return typeLabel.label.substr(5);
+    }
+  }
+  return;
+}
+
+// TODO(jojwang): monorail:6397, Refactor these specific map producers into
+// selectors.
+/**
+ * Converts issue.fieldValues into a map where values can be looked up given
+ * a field value key.
+ *
+ * @param {Array} fieldValues List of values with a fieldRef attached.
+ * @return {Map} keys are a string constructed using fieldValueMapKey() and
+ *   values are an Array of value strings.
+ */
+export function fieldValuesToMap(fieldValues) {
+  if (!fieldValues) return new Map();
+  const acc = new Map();
+  for (const v of fieldValues) {
+    if (!v || !v.fieldRef || !v.fieldRef.fieldName || !v.value) continue;
+    const key = fieldValueMapKey(v.fieldRef.fieldName,
+        v.phaseRef && v.phaseRef.phaseName);
+    if (acc.has(key)) {
+      acc.get(key).push(v.value);
+    } else {
+      acc.set(key, [v.value]);
+    }
+  }
+  return acc;
+}
+
+/**
+ * Converts issue.approvalValues into a map where values can be looked up given
+  * a field value key.
+  *
+  * @param {Array} approvalValues list of approvals with a fieldRef attached.
+  * @return {Map} keys are a string constructed using approvalValueFieldMapKey()
+  *   and values are an Array of value strings.
+  */
+export function approvalValuesToMap(approvalValues) {
+  if (!approvalValues) return new Map();
+  const approvalKeysToValues = new Map();
+  for (const av of approvalValues) {
+    if (!av || !av.fieldRef || !av.fieldRef.fieldName) continue;
+    const key = fieldValueMapKey(av.fieldRef.fieldName);
+    // If there is not status for this approval, the value should show NOT_SET.
+    approvalKeysToValues.set(key, [STATUS_ENUM_TO_TEXT[av.status || '']]);
+  }
+  return approvalKeysToValues;
+}
+
+/**
+ * Converts issue.approvalValues into a map where the approvers can be looked
+ * up given a field value key.
+ *
+ * @param {Array} approvalValues list of approvals with a fieldRef attached.
+ * @return {Map} keys are a string constructed using fieldValueMapKey() and
+ *   values are an Array of
+ */
+export function approvalApproversToMap(approvalValues) {
+  if (!approvalValues) return new Map();
+  const approvalKeysToApprovers = new Map();
+  for (const av of approvalValues) {
+    if (!av || !av.fieldRef || !av.fieldRef.fieldName ||
+        !av.approverRefs) continue;
+    const key = fieldValueMapKey(av.fieldRef.fieldName);
+    const approvers = av.approverRefs.map((ref) => ref.displayName);
+    approvalKeysToApprovers.set(key, approvers);
+  }
+  return approvalKeysToApprovers;
+}
+
+
+// Helper function used for fields with only one value that can be unset.
+const wrapValueIfExists = (value) => value ? [value] : [];
+
+
+/**
+ * @typedef DefaultIssueField
+ * @property {string} fieldName
+ * @property {fieldTypes} type
+ * @property {function(*): Array<string>} extractor
+*/
+// TODO(zhangtiff): Merge this functionality with extract-grid-data.js
+// TODO(zhangtiff): Combine this functionality with mr-metadata and
+// mr-edit-metadata to allow more expressive representation of built in fields.
+/**
+ * @const {Array<DefaultIssueField>}
+ */
+const defaultIssueFields = Object.freeze([
+  {
+    fieldName: 'ID',
+    type: fieldTypes.ISSUE_TYPE,
+    extractor: ({localId, projectName}) => [{localId, projectName}],
+  }, {
+    fieldName: 'Project',
+    type: fieldTypes.PROJECT_TYPE,
+    extractor: (issue) => [issue.projectName],
+  }, {
+    fieldName: 'Attachments',
+    type: fieldTypes.INT_TYPE,
+    extractor: (issue) => [issue.attachmentCount || 0],
+  }, {
+    fieldName: 'AllLabels',
+    type: fieldTypes.LABEL_TYPE,
+    extractor: (issue) => issue.labelRefs || [],
+  }, {
+    fieldName: 'Blocked',
+    type: fieldTypes.STR_TYPE,
+    extractor: (issue) => {
+      if (issue.blockedOnIssueRefs && issue.blockedOnIssueRefs.length) {
+        return ['Yes'];
+      }
+      return ['No'];
+    },
+  }, {
+    fieldName: 'BlockedOn',
+    type: fieldTypes.ISSUE_TYPE,
+    extractor: (issue) => issue.blockedOnIssueRefs || [],
+  }, {
+    fieldName: 'Blocking',
+    type: fieldTypes.ISSUE_TYPE,
+    extractor: (issue) => issue.blockingIssueRefs || [],
+  }, {
+    fieldName: 'CC',
+    type: fieldTypes.USER_TYPE,
+    extractor: (issue) => issue.ccRefs || [],
+  }, {
+    fieldName: 'Closed',
+    type: fieldTypes.TIME_TYPE,
+    extractor: (issue) => wrapValueIfExists(issue.closedTimestamp),
+  }, {
+    fieldName: 'Component',
+    type: fieldTypes.COMPONENT_TYPE,
+    extractor: (issue) => issue.componentRefs || [],
+  }, {
+    fieldName: 'ComponentModified',
+    type: fieldTypes.TIME_TYPE,
+    extractor: (issue) => [issue.componentModifiedTimestamp],
+  }, {
+    fieldName: 'MergedInto',
+    type: fieldTypes.ISSUE_TYPE,
+    extractor: (issue) => wrapValueIfExists(issue.mergedIntoIssueRef),
+  }, {
+    fieldName: 'Modified',
+    type: fieldTypes.TIME_TYPE,
+    extractor: (issue) => wrapValueIfExists(issue.modifiedTimestamp),
+  }, {
+    fieldName: 'Reporter',
+    type: fieldTypes.USER_TYPE,
+    extractor: (issue) => [issue.reporterRef],
+  }, {
+    fieldName: 'Stars',
+    type: fieldTypes.INT_TYPE,
+    extractor: (issue) => [issue.starCount || 0],
+  }, {
+    fieldName: 'Status',
+    type: fieldTypes.STATUS_TYPE,
+    extractor: (issue) => wrapValueIfExists(issue.statusRef),
+  }, {
+    fieldName: 'StatusModified',
+    type: fieldTypes.TIME_TYPE,
+    extractor: (issue) => [issue.statusModifiedTimestamp],
+  }, {
+    fieldName: 'Summary',
+    type: fieldTypes.STR_TYPE,
+    extractor: (issue) => [issue.summary],
+  }, {
+    fieldName: 'Type',
+    type: fieldTypes.ENUM_TYPE,
+    extractor: (issue) => wrapValueIfExists(extractTypeForIssue(
+        issue.fieldValues, issue.labelRefs)),
+  }, {
+    fieldName: 'Owner',
+    type: fieldTypes.USER_TYPE,
+    extractor: (issue) => wrapValueIfExists(issue.ownerRef),
+  }, {
+    fieldName: 'OwnerModified',
+    type: fieldTypes.TIME_TYPE,
+    extractor: (issue) => [issue.ownerModifiedTimestamp],
+  }, {
+    fieldName: 'Opened',
+    type: fieldTypes.TIME_TYPE,
+    extractor: (issue) => [issue.openedTimestamp],
+  },
+]);
+
+/**
+ * Lowercase field name -> field object. This uses an Object instead of a Map
+ * so that it can be frozen.
+ * @type {Object<string, DefaultIssueField>}
+ */
+export const defaultIssueFieldMap = Object.freeze(
+    defaultIssueFields.reduce((acc, field) => {
+      acc[field.fieldName.toLowerCase()] = field;
+      return acc;
+    }, {}),
+);
+
+export const DEFAULT_ISSUE_FIELD_LIST = defaultIssueFields.map(
+    (field) => field.fieldName);
+
+/**
+ * Wrapper that extracts potentially composite field values from issue
+ * @param {Issue} issue
+ * @param {string} fieldName
+ * @param {string} projectName
+ * @return {Array<string>}
+ */
+export const stringValuesForIssueField = (issue, fieldName, projectName) => {
+  // Split composite fields into each segment
+  return fieldName.split('/').flatMap((fieldKey) => stringValuesExtractor(
+      issue, fieldKey, projectName));
+};
+
+/**
+ * Extract string values of an issue's field
+ * @param {Issue} issue
+ * @param {string} fieldName
+ * @param {string} projectName
+ * @return {Array<string>}
+ */
+const stringValuesExtractor = (issue, fieldName, projectName) => {
+  const fieldKey = fieldName.toLowerCase();
+
+  // Look at whether the field is a built in field first.
+  if (defaultIssueFieldMap.hasOwnProperty(fieldKey)) {
+    const bakedFieldDef = defaultIssueFieldMap[fieldKey];
+    const values = bakedFieldDef.extractor(issue);
+    switch (bakedFieldDef.type) {
+      case fieldTypes.ISSUE_TYPE:
+        return issueRefsToStrings(values, projectName);
+      case fieldTypes.COMPONENT_TYPE:
+        return componentRefsToStrings(values);
+      case fieldTypes.LABEL_TYPE:
+        return labelRefsToStrings(values);
+      case fieldTypes.USER_TYPE:
+        return userRefsToDisplayNames(values);
+      case fieldTypes.STATUS_TYPE:
+        return statusRefsToStrings(values);
+      case fieldTypes.TIME_TYPE:
+        // TODO(zhangtiff): Find a way to dynamically update displayed
+        // time without page reloads.
+        return values.map((time) => relativeTime(new Date(time * 1000)));
+    }
+    return values.map((value) => `${value}`);
+  }
+
+  // Handle custom approval field approver columns.
+  const found = fieldKey.match(APPROVER_COL_SUFFIX_REGEX);
+  if (found) {
+    const approvalName = fieldKey.slice(0, -found[0].length);
+    const approvalFieldKey = fieldValueMapKey(approvalName);
+    const approvalApproversMap = approvalApproversToMap(issue.approvalValues);
+    if (approvalApproversMap.has(approvalFieldKey)) {
+      return approvalApproversMap.get(approvalFieldKey);
+    }
+  }
+
+  // Handle custom approval field columns.
+  const approvalValuesMap = approvalValuesToMap(issue.approvalValues);
+  if (approvalValuesMap.has(fieldKey)) {
+    return approvalValuesMap.get(fieldKey);
+  }
+
+  // Handle custom fields.
+  let fieldValueKey = fieldKey;
+  let fieldNameKey = fieldKey;
+  if (fieldKey.match(PHASE_FIELD_COL_DELIMITER_REGEX)) {
+    let phaseName;
+    [phaseName, fieldNameKey] = fieldKey.split(
+        PHASE_FIELD_COL_DELIMITER_REGEX);
+    // key for fieldValues Map contain the phaseName, if any.
+    fieldValueKey = fieldValueMapKey(fieldNameKey, phaseName);
+  }
+  const fieldValuesMap = fieldValuesToMap(issue.fieldValues);
+  if (fieldValuesMap.has(fieldValueKey)) {
+    return fieldValuesMap.get(fieldValueKey);
+  }
+
+  // Handle custom labels and ad hoc labels last.
+  const matchingLabels = (issue.labelRefs || []).filter((labelRef) => {
+    const labelPrefixes = labelNameToLabelPrefixes(
+        labelRef.label).map((prefix) => prefix.toLowerCase());
+    return labelPrefixes.includes(fieldKey);
+  });
+  const labelPrefix = fieldKey + '-';
+  return matchingLabels.map(
+      (labelRef) => removePrefix(labelRef.label, labelPrefix));
+};
+
+/**
+ * Computes all custom fields set in a given Issue, including custom
+ * fields derived from label prefixes and approval values.
+ * @param {Issue} issue An Issue object.
+ * @param {boolean=} exclHighCardinality Whether to exclude fields with a high
+ *   cardinality, like string custom fields for example. This is useful for
+ *   features where issues are grouped by different values because grouping
+ *   by high cardinality fields is not meaningful.
+ * @return {Array<string>}
+ */
+export function fieldsForIssue(issue, exclHighCardinality = false) {
+  const approvalValues = issue.approvalValues || [];
+  let fieldValues = issue.fieldValues || [];
+  const labelRefs = issue.labelRefs || [];
+  const labelPrefixes = [];
+  labelRefs.forEach((labelRef) => {
+    labelPrefixes.push(...labelNameToLabelPrefixes(labelRef.label));
+  });
+  if (exclHighCardinality) {
+    fieldValues = fieldValues.filter(({fieldRef}) =>
+      GROUPABLE_FIELD_TYPES.has(fieldRef.type));
+  }
+  return [
+    ...approvalValues.map((approval) => approval.fieldRef.fieldName),
+    ...approvalValues.map(
+        (approval) => approval.fieldRef.fieldName + '-Approver'),
+    ...fieldValues.map((fieldValue) => {
+      if (fieldValue.phaseRef) {
+        return fieldValue.phaseRef.phaseName + '.' +
+            fieldValue.fieldRef.fieldName;
+      } else {
+        return fieldValue.fieldRef.fieldName;
+      }
+    }),
+    ...labelPrefixes,
+  ];
+}
diff --git a/static_src/shared/issue-fields.test.js b/static_src/shared/issue-fields.test.js
new file mode 100644
index 0000000..c37faa9
--- /dev/null
+++ b/static_src/shared/issue-fields.test.js
@@ -0,0 +1,491 @@
+// Copyright 2019 The Chromium Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+import {assert} from 'chai';
+import {parseColSpec, fieldsForIssue,
+  stringValuesForIssueField} from './issue-fields.js';
+import sinon from 'sinon';
+
+let issue;
+let clock;
+
+describe('parseColSpec', () => {
+  it('empty spec produces empty list', () => {
+    assert.deepEqual(parseColSpec(),
+        []);
+    assert.deepEqual(parseColSpec(''),
+        []);
+    assert.deepEqual(parseColSpec(' + + + '),
+        []);
+    assert.deepEqual(parseColSpec('          '),
+        []);
+    assert.deepEqual(parseColSpec('+++++'),
+        []);
+  });
+
+  it('parses spec correctly', () => {
+    assert.deepEqual(parseColSpec('ID+Summary+AllLabels+Priority'),
+        ['ID', 'Summary', 'AllLabels', 'Priority']);
+  });
+
+  it('parses spaces correctly', () => {
+    assert.deepEqual(parseColSpec('ID Summary AllLabels Priority'),
+        ['ID', 'Summary', 'AllLabels', 'Priority']);
+    assert.deepEqual(parseColSpec('ID + Summary + AllLabels + Priority'),
+        ['ID', 'Summary', 'AllLabels', 'Priority']);
+    assert.deepEqual(parseColSpec('ID   Summary AllLabels     Priority'),
+        ['ID', 'Summary', 'AllLabels', 'Priority']);
+  });
+
+  it('spec parsing preserves dashed parameters', () => {
+    assert.deepEqual(parseColSpec('ID+Summary+Test-Label+Another-Label'),
+        ['ID', 'Summary', 'Test-Label', 'Another-Label']);
+  });
+});
+
+describe('fieldsForIssue', () => {
+  const issue = {
+    projectName: 'proj',
+    localId: 1,
+  };
+
+  const issueWithLabels = {
+    projectName: 'proj',
+    localId: 1,
+    labelRefs: [
+      {label: 'test'},
+      {label: 'hello-world'},
+      {label: 'multi-label-field'},
+    ],
+  };
+
+  const issueWithFieldValues = {
+    projectName: 'proj',
+    localId: 1,
+    fieldValues: [
+      {fieldRef: {fieldName: 'number', type: 'INT_TYPE'}},
+      {fieldRef: {fieldName: 'string', type: 'STR_TYPE'}},
+    ],
+  };
+
+  const issueWithPhases = {
+    projectName: 'proj',
+    localId: 1,
+    fieldValues: [
+      {fieldRef: {fieldName: 'phase-number', type: 'INT_TYPE'},
+        phaseRef: {phaseName: 'phase1'}},
+      {fieldRef: {fieldName: 'phase-string', type: 'STR_TYPE'},
+        phaseRef: {phaseName: 'phase2'}},
+    ],
+  };
+
+  const issueWithApprovals = {
+    projectName: 'proj',
+    localId: 1,
+    approvalValues: [
+      {fieldRef: {fieldName: 'approval', type: 'APPROVAL_TYPE'}},
+    ],
+  };
+
+  it('empty issue issue produces no field names', () => {
+    assert.deepEqual(fieldsForIssue(issue), []);
+    assert.deepEqual(fieldsForIssue(issue, true), []);
+  });
+
+  it('includes label prefixes', () => {
+    assert.deepEqual(fieldsForIssue(issueWithLabels), [
+      'hello',
+      'multi',
+      'multi-label',
+    ]);
+  });
+
+  it('includes field values', () => {
+    assert.deepEqual(fieldsForIssue(issueWithFieldValues), [
+      'number',
+      'string',
+    ]);
+  });
+
+  it('excludes high cardinality field values', () => {
+    assert.deepEqual(fieldsForIssue(issueWithFieldValues, true), [
+      'number',
+    ]);
+  });
+
+  it('includes phase fields', () => {
+    assert.deepEqual(fieldsForIssue(issueWithPhases), [
+      'phase1.phase-number',
+      'phase2.phase-string',
+    ]);
+  });
+
+  it('excludes high cardinality phase fields', () => {
+    assert.deepEqual(fieldsForIssue(issueWithPhases, true), [
+      'phase1.phase-number',
+    ]);
+  });
+
+  it('includes approval values', () => {
+    assert.deepEqual(fieldsForIssue(issueWithApprovals), [
+      'approval',
+      'approval-Approver',
+    ]);
+  });
+});
+
+describe('stringValuesForIssueField', () => {
+  describe('built-in fields', () => {
+    beforeEach(() => {
+      // Set clock to some specified date for relative time.
+      const initialTime = 365 * 24 * 60 * 60;
+
+      clock = sinon.useFakeTimers({
+        now: new Date(initialTime * 1000),
+        shouldAdvanceTime: false,
+      });
+
+      issue = {
+        localId: 33,
+        projectName: 'chromium',
+        summary: 'Test summary',
+        attachmentCount: 22,
+        starCount: 2,
+        componentRefs: [{path: 'Infra'}, {path: 'Monorail>UI'}],
+        blockedOnIssueRefs: [{localId: 30, projectName: 'chromium'}],
+        blockingIssueRefs: [{localId: 60, projectName: 'chromium'}],
+        labelRefs: [{label: 'Restrict-View-Google'}, {label: 'Type-Defect'}],
+        reporterRef: {displayName: 'test@example.com'},
+        ccRefs: [{displayName: 'test@example.com'}],
+        ownerRef: {displayName: 'owner@example.com'},
+        closedTimestamp: initialTime - 120, // 2 minutes ago
+        modifiedTimestamp: initialTime - 60, // a minute ago
+        openedTimestamp: initialTime - 24 * 60 * 60, // a day ago
+        componentModifiedTimestamp: initialTime - 60, // a minute ago
+        statusModifiedTimestamp: initialTime - 60, // a minute ago
+        ownerModifiedTimestamp: initialTime - 60, // a minute ago
+        statusRef: {status: 'Duplicate'},
+        mergedIntoIssueRef: {localId: 31, projectName: 'chromium'},
+      };
+    });
+
+    afterEach(() => {
+      clock.restore();
+    });
+
+    it('computes strings for ID', () => {
+      const fieldName = 'ID';
+
+      assert.deepEqual(stringValuesForIssueField(issue, fieldName),
+          ['chromium:33']);
+    });
+
+    it('computes strings for Project', () => {
+      const fieldName = 'Project';
+
+      assert.deepEqual(stringValuesForIssueField(issue, fieldName),
+          ['chromium']);
+    });
+
+    it('computes strings for Attachments', () => {
+      const fieldName = 'Attachments';
+
+      assert.deepEqual(stringValuesForIssueField(issue, fieldName),
+          ['22']);
+    });
+
+    it('computes strings for AllLabels', () => {
+      const fieldName = 'AllLabels';
+
+      assert.deepEqual(stringValuesForIssueField(issue, fieldName),
+          ['Restrict-View-Google', 'Type-Defect']);
+    });
+
+    it('computes strings for Blocked when issue is blocked', () => {
+      const fieldName = 'Blocked';
+
+      assert.deepEqual(stringValuesForIssueField(issue, fieldName),
+          ['Yes']);
+    });
+
+    it('computes strings for Blocked when issue is not blocked', () => {
+      const fieldName = 'Blocked';
+      issue.blockedOnIssueRefs = [];
+
+      assert.deepEqual(stringValuesForIssueField(issue, fieldName),
+          ['No']);
+    });
+
+    it('computes strings for BlockedOn', () => {
+      const fieldName = 'BlockedOn';
+
+      assert.deepEqual(stringValuesForIssueField(issue, fieldName),
+          ['chromium:30']);
+    });
+
+    it('computes strings for Blocking', () => {
+      const fieldName = 'Blocking';
+
+      assert.deepEqual(stringValuesForIssueField(issue, fieldName),
+          ['chromium:60']);
+    });
+
+    it('computes strings for CC', () => {
+      const fieldName = 'CC';
+
+      assert.deepEqual(stringValuesForIssueField(issue, fieldName),
+          ['test@example.com']);
+    });
+
+    it('computes strings for Closed', () => {
+      const fieldName = 'Closed';
+
+      assert.deepEqual(stringValuesForIssueField(issue, fieldName),
+          ['2 minutes ago']);
+    });
+
+    it('computes strings for Component', () => {
+      const fieldName = 'Component';
+
+      assert.deepEqual(stringValuesForIssueField(issue, fieldName),
+          ['Infra', 'Monorail>UI']);
+    });
+
+    it('computes strings for ComponentModified', () => {
+      const fieldName = 'ComponentModified';
+
+      assert.deepEqual(stringValuesForIssueField(issue, fieldName),
+          ['a minute ago']);
+    });
+
+    it('computes strings for MergedInto', () => {
+      const fieldName = 'MergedInto';
+
+      assert.deepEqual(stringValuesForIssueField(issue, fieldName),
+          ['chromium:31']);
+    });
+
+    it('computes strings for Modified', () => {
+      const fieldName = 'Modified';
+
+      assert.deepEqual(stringValuesForIssueField(issue, fieldName),
+          ['a minute ago']);
+    });
+
+    it('computes strings for Reporter', () => {
+      const fieldName = 'Reporter';
+
+      assert.deepEqual(stringValuesForIssueField(issue, fieldName),
+          ['test@example.com']);
+    });
+
+    it('computes strings for Stars', () => {
+      const fieldName = 'Stars';
+
+      assert.deepEqual(stringValuesForIssueField(issue, fieldName),
+          ['2']);
+    });
+
+    it('computes strings for Status', () => {
+      const fieldName = 'Status';
+
+      assert.deepEqual(stringValuesForIssueField(issue, fieldName),
+          ['Duplicate']);
+    });
+
+    it('computes strings for StatusModified', () => {
+      const fieldName = 'StatusModified';
+
+      assert.deepEqual(stringValuesForIssueField(issue, fieldName),
+          ['a minute ago']);
+    });
+
+    it('computes strings for Summary', () => {
+      const fieldName = 'Summary';
+
+      assert.deepEqual(stringValuesForIssueField(issue, fieldName),
+          ['Test summary']);
+    });
+
+    it('computes strings for Type', () => {
+      const fieldName = 'Type';
+
+      assert.deepEqual(stringValuesForIssueField(issue, fieldName),
+          ['Defect']);
+    });
+
+    it('computes strings for Owner', () => {
+      const fieldName = 'Owner';
+
+      assert.deepEqual(stringValuesForIssueField(issue, fieldName),
+          ['owner@example.com']);
+    });
+
+    it('computes strings for OwnerModified', () => {
+      const fieldName = 'OwnerModified';
+
+      assert.deepEqual(stringValuesForIssueField(issue, fieldName),
+          ['a minute ago']);
+    });
+
+    it('computes strings for Opened', () => {
+      const fieldName = 'Opened';
+
+      assert.deepEqual(stringValuesForIssueField(issue, fieldName),
+          ['a day ago']);
+    });
+  });
+
+  describe('custom approval fields', () => {
+    beforeEach(() => {
+      issue = {
+        localId: 33,
+        projectName: 'bird',
+        approvalValues: [
+          {fieldRef: {type: 'APPROVAL_TYPE', fieldName: 'Goose-Approval'},
+            approverRefs: []},
+          {fieldRef: {type: 'APPROVAL_TYPE', fieldName: 'Chicken-Approval'},
+            status: 'APPROVED'},
+          {fieldRef: {type: 'APPROVAL_TYPE', fieldName: 'Dodo-Approval'},
+            status: 'NEED_INFO', approverRefs: [{displayName: 'kiwi@bird.test'},
+              {displayName: 'mini-dino@bird.test'}],
+          },
+        ],
+      };
+    });
+
+    it('handles approval approver columns', () => {
+      const projectName = 'bird';
+      assert.deepEqual(stringValuesForIssueField(
+          issue, 'goose-approval-approver',
+          projectName), []);
+      assert.deepEqual(stringValuesForIssueField(
+          issue, 'chicken-approval-approver',
+          projectName), []);
+      assert.deepEqual(stringValuesForIssueField(
+          issue, 'dodo-approval-approver',
+          projectName), ['kiwi@bird.test', 'mini-dino@bird.test']);
+    });
+
+    it('handles approval value columns', () => {
+      const projectName = 'bird';
+      assert.deepEqual(stringValuesForIssueField(issue, 'goose-approval',
+          projectName), ['NotSet']);
+      assert.deepEqual(stringValuesForIssueField(issue, 'chicken-approval',
+          projectName), ['Approved']);
+      assert.deepEqual(stringValuesForIssueField(issue, 'dodo-approval',
+          projectName), ['NeedInfo']);
+    });
+  });
+
+  describe('custom fields', () => {
+    beforeEach(() => {
+      issue = {
+        localId: 33,
+        projectName: 'chromium',
+        fieldValues: [
+          {fieldRef: {type: 'STR_TYPE', fieldName: 'aString'}, value: 'test'},
+          {fieldRef: {type: 'STR_TYPE', fieldName: 'aString'}, value: 'test2'},
+          {fieldRef: {type: 'ENUM_TYPE', fieldName: 'ENUM'}, value: 'a-value'},
+          {fieldRef: {type: 'INT_TYPE', fieldId: '6', fieldName: 'Cow-Number'},
+            phaseRef: {phaseName: 'Cow-Phase'},
+            value: '55'},
+          {fieldRef: {type: 'INT_TYPE', fieldId: '6', fieldName: 'Cow-Number'},
+            phaseRef: {phaseName: 'Cow-Phase'},
+            value: '54'},
+          {fieldRef: {type: 'INT_TYPE', fieldId: '6', fieldName: 'Cow-Number'},
+            phaseRef: {phaseName: 'MilkCow-Phase'},
+            value: '56'},
+        ],
+      };
+    });
+
+    it('gets values for custom fields', () => {
+      const projectName = 'chromium';
+      assert.deepEqual(stringValuesForIssueField(issue, 'aString',
+          projectName), ['test', 'test2']);
+      assert.deepEqual(stringValuesForIssueField(issue, 'enum',
+          projectName), ['a-value']);
+      assert.deepEqual(stringValuesForIssueField(issue, 'cow-phase.cow-number',
+          projectName), ['55', '54']);
+      assert.deepEqual(stringValuesForIssueField(issue,
+          'milkcow-phase.cow-number', projectName), ['56']);
+    });
+
+    it('custom fields get precedence over label fields', () => {
+      const projectName = 'chromium';
+      issue.labelRefs = [{label: 'aString-ignore'}];
+      assert.deepEqual(stringValuesForIssueField(issue, 'aString',
+          projectName), ['test', 'test2']);
+    });
+  });
+
+  describe('label prefix fields', () => {
+    beforeEach(() => {
+      issue = {
+        localId: 33,
+        projectName: 'chromium',
+        labelRefs: [
+          {label: 'test-label'},
+          {label: 'test-label-2'},
+          {label: 'ignore-me'},
+          {label: 'Milestone-UI'},
+          {label: 'Milestone-Goodies'},
+        ],
+      };
+    });
+
+    it('gets values for label prefixes', () => {
+      const projectName = 'chromium';
+      assert.deepEqual(stringValuesForIssueField(issue, 'test',
+          projectName), ['label', 'label-2']);
+      assert.deepEqual(stringValuesForIssueField(issue, 'Milestone',
+          projectName), ['UI', 'Goodies']);
+      assert.deepEqual(stringValuesForIssueField(issue, 'ignore',
+          projectName), ['me']);
+    });
+  });
+
+  describe('composite fields', () => {
+    beforeEach(() => {
+      // Set clock to some specified date for relative time.
+      const initialTime = 365 * 24 * 60 * 60;
+
+      clock = sinon.useFakeTimers({
+        now: new Date(initialTime * 1000),
+        shouldAdvanceTime: false,
+      });
+
+      issue = {
+        localId: 33,
+        projectName: 'chromium',
+        summary: 'Test summary',
+        closedTimestamp: initialTime - 120, // 2 minutes ago
+        modifiedTimestamp: initialTime - 60, // a minute ago
+        openedTimestamp: initialTime - 24 * 60 * 60, // a day ago
+        statusModifiedTimestamp: initialTime - 60, // a minute ago
+        statusRef: {status: 'Duplicate'},
+      };
+    });
+
+    afterEach(() => {
+      clock.restore();
+    });
+
+    it('computes strings for Status/Closed', () => {
+      const fieldName = 'Status/Closed';
+
+      assert.deepEqual(stringValuesForIssueField(issue, fieldName),
+          ['Duplicate', '2 minutes ago']);
+    });
+
+    it('ignores nonexistant fields', () => {
+      const fieldName = 'Owner/Status';
+
+      assert.isFalse(issue.hasOwnProperty('ownerRef'));
+      assert.deepEqual(stringValuesForIssueField(issue, fieldName),
+          ['Duplicate']);
+    });
+  });
+});
diff --git a/static_src/shared/math.js b/static_src/shared/math.js
new file mode 100644
index 0000000..36e2d75
--- /dev/null
+++ b/static_src/shared/math.js
@@ -0,0 +1,26 @@
+// Copyright 2019 The Chromium Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+// Get parameter of generated line using linear regression formula,
+// using last n data points of values.
+export function linearRegression(values, n) {
+  let sumValues = 0;
+  let indices = 0;
+  let sqIndices = 0;
+  let multiply = 0;
+  let temp;
+  for (let i = 0; i < n; i++) {
+    temp = values[values.length-n+i];
+    sumValues += temp;
+    indices += i;
+    sqIndices += i * i;
+    multiply += i * temp;
+  }
+  // Calculate linear regression formula for values.
+  const slope = (n * multiply - sumValues * indices) /
+    (n * sqIndices - indices * indices);
+  const intercept = (sumValues * sqIndices - indices * multiply) /
+    (n * sqIndices - indices * indices);
+  return [slope, intercept];
+}
diff --git a/static_src/shared/math.test.js b/static_src/shared/math.test.js
new file mode 100644
index 0000000..4b4c153
--- /dev/null
+++ b/static_src/shared/math.test.js
@@ -0,0 +1,22 @@
+// Copyright 2019 The Chromium Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+import {assert} from 'chai';
+import {linearRegression} from './math.js';
+
+describe('linearRegression', () => {
+  it('calculate slope and intercept using formula', () => {
+    const values = [0, 1, 2, 3, 4, 5, 6];
+    const [slope, intercept] = linearRegression(values, 7);
+    assert.equal(slope, 1);
+    assert.equal(intercept, 0);
+  });
+
+  it('calculate slope and intercept using last n data points', () => {
+    const values = [0, 1, 0, 3, 5, 7, 9];
+    const [slope, intercept] = linearRegression(values, 4);
+    assert.equal(slope, 2);
+    assert.equal(intercept, 3);
+  });
+});
diff --git a/static_src/shared/md-helper.js b/static_src/shared/md-helper.js
new file mode 100644
index 0000000..da5ac3c
--- /dev/null
+++ b/static_src/shared/md-helper.js
@@ -0,0 +1,143 @@
+import marked from 'marked';
+import DOMPurify from 'dompurify';
+
+/** @type {Set} Projects that default Markdown rendering to true. */
+export const DEFAULT_MD_PROJECTS = new Set(['monkeyrail', 'monorail', 'fuchsia']);
+
+/** @type {Set} Projects that allow users to opt into Markdown rendering. */
+export const AVAILABLE_MD_PROJECTS = new Set([...DEFAULT_MD_PROJECTS]);
+
+/** @type {Set} Authors whose comments will not be rendered as Markdown. */
+const BLOCKLIST = new Set(['sheriffbot@sheriffbot-1182.iam.gserviceaccount.com',
+                          'sheriff-o-matic@appspot.gserviceaccount.com',
+                          'sheriff-o-matic-staging@appspot.gserviceaccount.com',
+                          'bugdroid1@chromium.org',
+                          'bugdroid@chops-service-accounts.iam.gserviceaccount.com',
+                          'gitwatcher-staging.google.com@appspot.gserviceaccount.com',
+                          'gitwatcher.google.com@appspot.gserviceaccount.com']);
+
+/**
+ * Determines whether content should be rendered as Markdown.
+ * @param {string} options.project Project this content belongs to.
+ * @param {number} options.author User who authored this content.
+ * @param {boolean} options.enabled Per-issue override to force Markdown.
+ * @param {Array<string>} options.availableProjects List of opted in projects.
+ * @return {boolean} Whether this content should be rendered as Markdown.
+ */
+export const shouldRenderMarkdown = ({
+  project, author, enabled = true, availableProjects = AVAILABLE_MD_PROJECTS
+} = {}) => {
+  if (author in BLOCKLIST) {
+    return false;
+  } else if (!enabled) {
+    return false;
+  } else if (availableProjects.has(project)) {
+    return true;
+  }
+  return false;
+};
+
+/** @const {Object} Options for DOMPurify sanitizer */
+const SANITIZE_OPTIONS = Object.freeze({
+  RETURN_TRUSTED_TYPE: true,
+  FORBID_TAGS: ['style'],
+  FORBID_ATTR: ['style', 'autoplay'],
+});
+
+/**
+ * Replaces bold HTML tags in comment with Markdown equivalent.
+ * @param {string} raw Comment string as stored in database.
+ * @return {string} Comment string after b tags are placed by Markdown bolding.
+ */
+const replaceBoldTag = (raw) => {
+  return raw.replace(/<b>|<\/b>/g, '**');
+};
+
+/** @const {Object} Basic HTML character escape mapping */
+const HTML_ESCAPE_MAP = Object.freeze({
+  '&': '&amp;',
+  '<': '&lt;',
+  '>': '&gt;',
+  '"': '&quot;',
+  '\'': '&#39;',
+  '/': '&#x2F;',
+  '`': '&#x60;',
+  '=': '&#x3D;',
+});
+
+/**
+ * Escapes HTML characters, used to render HTML blocks in Markdown. This
+ * alleviates security flaws but is not the primary security barrier, that is
+ * handled by DOMPurify.
+ * @param {string} text Content that looks to Marked parser to contain HTML.
+ * @return {string} Same text content after escaping HTML characters.
+ */
+const escapeHtml = (text) => {
+  return text.replace(/[&<>"'`=\/]/g, (s) => {
+    return HTML_ESCAPE_MAP[s];
+  });
+};
+
+/**
+* Checks to see if input string is a valid HTTP link.
+ * @param {string} string
+ * @return {boolean} Whether input string is a valid HTTP(s) link.
+ */
+const isValidHttpUrl = (string) => {
+  let url;
+
+  try {
+    url = new URL(string);
+  } catch (_exception) {
+    return false;
+  }
+
+  return url.protocol === 'http:' || url.protocol === 'https:';
+};
+
+/**
+ * Renderer option for Marked.
+ * See https://marked.js.org/using_pro#renderer on how to use renderer.
+ * @type {Object}
+ */
+const renderer = {
+  html(text) {
+    // Do not render HTML, instead escape HTML and render as plaintext.
+    return escapeHtml(text);
+  },
+  link(href, title, text) {
+    // Overrides default link rendering by adding icon and destination on hover.
+    // TODO(crbug.com/monorail/9316): Add shared-styles/MD_STYLES to all
+    // components that consume the markdown renderer.
+    let linkIcon;
+    let tooltipText;
+    if (isValidHttpUrl(href)) {
+      linkIcon = `<span class="material-icons link">link</span>`;
+      tooltipText = `Link destination: ${href}`;
+    } else {
+      linkIcon = `<span class="material-icons link_off">link_off</span>`;
+      tooltipText = `Link may be malformed: ${href}`;
+    }
+    const tooltip = `<span class="tooltip">${tooltipText}</span>`;
+    return `<span class="annotated-link"><a href=${href} ` +
+        `title=${title ? title : ''}>${linkIcon}${text}</a>${tooltip}</span>`;
+  },
+};
+
+marked.use({renderer, headerIds: false});
+
+/**
+ * Renders Markdown content into HTML.
+ * @param {string} raw Content to be intepretted as Markdown.
+ * @return {string} Rendered content in HTML format.
+ */
+export const renderMarkdown = (raw) => {
+  // TODO(crbug.com/monorail/9310): Add commentReferences, projectName,
+  // and revisionUrlFormat to use in conjunction with Marked's lexer for
+  // autolinking.
+  // TODO(crbug.com/monorail/9310): Integrate autolink
+  const preprocessed = replaceBoldTag(raw);
+  const converted = marked(preprocessed);
+  const sanitized = DOMPurify.sanitize(converted, SANITIZE_OPTIONS);
+  return sanitized.toString();
+};
diff --git a/static_src/shared/md-helper.test.js b/static_src/shared/md-helper.test.js
new file mode 100644
index 0000000..9f7dba1
--- /dev/null
+++ b/static_src/shared/md-helper.test.js
@@ -0,0 +1,90 @@
+import {assert} from 'chai';
+import {renderMarkdown, shouldRenderMarkdown} from './md-helper.js';
+
+describe('shouldRenderMarkdown', () => {
+  it('defaults to false', () => {
+    const actual = shouldRenderMarkdown();
+    assert.isFalse(actual);
+  });
+
+  it('returns true for enabled projects', () => {
+    const actual = shouldRenderMarkdown({project:'astor',
+      availableProjects: new Set(['astor'])});
+    assert.isTrue(actual);
+  });
+
+  it('returns false for disabled projects', () => {
+    const actual = shouldRenderMarkdown({project:'hazelnut',
+      availableProjects: new Set(['astor'])});
+    assert.isFalse(actual);
+  });
+
+  it('user pref can disable markdown', () => {
+    const actual = shouldRenderMarkdown({project:'astor',
+      enabledProjects: new Set(['astor']), enabled: false});
+    assert.isFalse(actual);
+  });
+});
+
+describe('renderMarkdown', () => {
+  it('can render empty string', () => {
+    const actual = renderMarkdown('');
+    assert.equal(actual, '');
+  });
+
+  it('can render basic string', () => {
+    const actual = renderMarkdown('hello world');
+    assert.equal(actual, '<p>hello world</p>\n');
+  });
+
+  it('can render lists', () => {
+    const input = '* First item\n* Second item\n* Third item\n* Fourth item';
+    const actual = renderMarkdown(input);
+    const expected = '<ul>\n<li>First item</li>\n<li>Second item</li>\n' +
+        '<li>Third item</li>\n<li>Fourth item</li>\n</ul>\n';
+    assert.equal(actual, expected);
+  });
+
+  it('can render headings', () => {
+    const actual = renderMarkdown('# Heading level 1\n\n## Heading level 2');
+    assert.equal(actual,
+        '<h1>Heading level 1</h1>\n<h2>Heading level 2</h2>\n');
+  });
+
+  describe('can render links', () => {
+    it('for simple links', () => {
+      const actual = renderMarkdown('[clickme](http://google.com)');
+      const expected = `<p><span class="annotated-link"><a title="" ` +
+          `href="http://google.com"><span class="material-icons link">` +
+          `link</span>clickme</a><span class="tooltip">Link destination: ` +
+          `http://google.com</span></span></p>\n`;
+      assert.equal(actual, expected);
+    });
+
+    it('and indicates malformed link', () => {
+      const actual = renderMarkdown('[clickme](google.com)');
+      const expected = `<p><span class="annotated-link"><a title="" ` +
+          `href="google.com"><span class="material-icons link_off">link_off` +
+          `</span>clickme</a><span class="tooltip">Link may be malformed: ` +
+          `google.com</span></span></p>\n`;
+      assert.equal(actual, expected);
+    });
+  });
+
+  it('preserves bolding from description templates', () => {
+    const input = `<b>What's the problem?</b>\n<b>1.</b> A\n<b>2.</b> B`;
+    const actual = renderMarkdown(input);
+    const expected = `<p><strong>What's the problem?</strong>\n<strong>1.` +
+        `</strong> A\n<strong>2.</strong> B</p>\n`;
+    assert.equal(actual, expected);
+  });
+
+  it('escapes HTML content', () => {
+    let actual = renderMarkdown('<input></input>');
+    assert.equal(actual, '<p>&lt;input&gt;&lt;/input&gt;</p>\n');
+
+    actual = renderMarkdown('<a href="https://google.com">clickme</a>');
+    assert.equal(actual,
+        '<p>&lt;a href="https://google.com"&gt;clickme&lt;/a&gt;</p>\n');
+  });
+});
diff --git a/static_src/shared/metadata-helpers.js b/static_src/shared/metadata-helpers.js
new file mode 100644
index 0000000..5735557
--- /dev/null
+++ b/static_src/shared/metadata-helpers.js
@@ -0,0 +1,114 @@
+// Copyright 2019 The Chromium Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+// TODO(crbug.com/monorail/4549): Remove this hardcoded data once backend custom
+// field grouping is implemented.
+export const HARDCODED_FIELD_GROUPS = [
+  {
+    groupName: 'Feature Team',
+    fieldNames: ['PM', 'Tech Lead', 'Tech-Lead', 'TechLead', 'TL',
+      'Team', 'UX', 'TE'],
+    applicableType: 'FLT-Launch',
+  },
+  {
+    groupName: 'Docs',
+    fieldNames: ['PRD', 'DD', 'Design Doc', 'Design-Doc',
+      'DesignDoc', 'Mocks', 'Test Plan', 'Test-Plan', 'TestPlan',
+      'Metrics'],
+    applicableType: 'FLT-Launch',
+  },
+];
+
+export const fieldGroupMap = (fieldGroupsArg, issueType) => {
+  const fieldGroups = groupsForType(fieldGroupsArg, issueType);
+  return fieldGroups.reduce((acc, group) => {
+    return group.fieldNames.reduce((acc, fieldName) => {
+      acc[fieldName] = group.groupName;
+      return acc;
+    }, acc);
+  }, {});
+};
+
+/**
+ * Get all values for a field, given an issue's fieldValueMap.
+ * @param {Map.<string, Array<string>>} fieldValueMap Map where keys are
+ *   lowercase fieldNames and values are fieldValue strings.
+ * @param {string} fieldName The name of the field to look up.
+ * @param {string=} phaseName Name of the phase the field is attached to,
+ *   if applicable.
+ * @return {Array<string>} The values of the field.
+ */
+export const valuesForField = (fieldValueMap, fieldName, phaseName) => {
+  if (!fieldValueMap) return [];
+  return fieldValueMap.get(
+      fieldValueMapKey(fieldName, phaseName)) || [];
+};
+
+/**
+ * Get just one value for a field. Convenient in some cases for
+ * fields that are not multi-valued.
+ * @param {Map.<string, Array<string>>} fieldValueMap Map where keys are
+ *   lowercase fieldNames and values are fieldValue strings.
+ * @param {string} fieldName The name of the field to look up.
+ * @param {string=} phaseName Name of the phase the field is attached to,
+ *   if applicable.
+ * @return {string} The value of the field.
+ */
+export function valueForField(fieldValueMap, fieldName, phaseName) {
+  const values = valuesForField(fieldValueMap, fieldName, phaseName);
+  return values.length ? values[0] : undefined;
+}
+
+/**
+ * Helper to generate Map keys for FieldValueMaps in a standard format.
+ * @param {string} fieldName Name of the field the value is tied to.
+ * @param {string=} phaseName Name of the phase the value is tied to.
+ * @return {string}
+ */
+export const fieldValueMapKey = (fieldName, phaseName) => {
+  const key = [fieldName];
+  if (phaseName) {
+    key.push(phaseName);
+  }
+  return key.join(' ').toLowerCase();
+};
+
+export const groupsForType = (fieldGroups, issueType) => {
+  return fieldGroups.filter((group) => {
+    if (!group.applicableType) return true;
+    return issueType && group.applicableType.toLowerCase() ===
+      issueType.toLowerCase();
+  });
+};
+
+export const fieldDefsWithGroup = (fieldDefs, fieldGroupsArg, issueType) => {
+  const fieldGroups = groupsForType(fieldGroupsArg, issueType);
+  if (!fieldDefs) return [];
+  const groups = [];
+  fieldGroups.forEach((group) => {
+    const groupFields = [];
+    group.fieldNames.forEach((name) => {
+      const fd = fieldDefs.find(
+          (fd) => (fd.fieldRef.fieldName == name));
+      if (fd) {
+        groupFields.push(fd);
+      }
+    });
+    if (groupFields.length > 0) {
+      groups.push({
+        groupName: group.groupName,
+        fieldDefs: groupFields,
+      });
+    }
+  });
+  return groups;
+};
+
+export const fieldDefsWithoutGroup = (fieldDefs, fieldGroups, issueType) => {
+  if (!fieldDefs) return [];
+  const map = fieldGroupMap(fieldGroups, issueType);
+  return fieldDefs.filter((fd) => {
+    return !(fd.fieldRef.fieldName in map);
+  });
+};
diff --git a/static_src/shared/metadata-helpers.test.js b/static_src/shared/metadata-helpers.test.js
new file mode 100644
index 0000000..fd04806
--- /dev/null
+++ b/static_src/shared/metadata-helpers.test.js
@@ -0,0 +1,93 @@
+// Copyright 2019 The Chromium Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+import {assert} from 'chai';
+import {valuesForField, valueForField, fieldDefsWithGroup, fieldValueMapKey,
+  fieldDefsWithoutGroup, HARDCODED_FIELD_GROUPS} from './metadata-helpers.js';
+
+const fieldDefs = [
+  {
+    fieldRef: {
+      fieldName: 'Ignore',
+      fieldId: 1,
+    },
+  },
+  {
+    fieldRef: {
+      fieldName: 'DesignDoc',
+      fieldId: 2,
+    },
+  },
+];
+const fieldGroups = HARDCODED_FIELD_GROUPS;
+
+const fieldValueMap = new Map([
+  ['field', ['one', 'two', 'three']],
+  ['field-two phase', ['four']],
+  ['field-three', ['five']],
+]);
+
+describe('metadata-helpers', () => {
+  it('valuesForField', () => {
+    assert.deepEqual(valuesForField(fieldValueMap, 'Field-None'), []);
+    assert.deepEqual(valuesForField(fieldValueMap, 'Field'),
+        ['one', 'two', 'three']);
+    assert.deepEqual(valuesForField(fieldValueMap, 'Field-Two', 'Phase'),
+        ['four']);
+    assert.deepEqual(valuesForField(fieldValueMap, 'Field-Three'), ['five']);
+  });
+
+  it('valueForField', () => {
+    assert.equal(valueForField(fieldValueMap, 'Field-None'),
+        undefined);
+    assert.equal(valueForField(fieldValueMap, 'Field-Two', 'Phase'), 'four');
+    assert.equal(valueForField(fieldValueMap, 'Field-Three'), 'five');
+  });
+
+  it('fieldValueMapKey', () => {
+    assert.equal(fieldValueMapKey('test', 'two'), 'test two');
+
+    assert.equal(fieldValueMapKey('noPhase'), 'nophase');
+  });
+
+  it('fieldDefsWithoutGroup ignores non applicable types', () => {
+    assert.deepEqual(fieldDefsWithoutGroup(
+        fieldDefs, fieldGroups, 'ungrouped-type'), fieldDefs);
+  });
+
+  it('fieldDefsWithoutGroup filters grouped fields', () => {
+    assert.deepEqual(fieldDefsWithoutGroup(
+        fieldDefs, fieldGroups, 'flt-launch'), [
+      {
+        fieldRef: {
+          fieldName: 'Ignore',
+          fieldId: 1,
+        },
+      },
+    ]);
+  });
+
+  it('fieldDefsWithGroup filters by type', () => {
+    const filteredGroupsList = [{
+      groupName: 'Docs',
+      fieldDefs: [
+        {
+          fieldRef: {
+            fieldName: 'DesignDoc',
+            fieldId: 2,
+          },
+        },
+      ],
+    }];
+
+    assert.deepEqual(
+        fieldDefsWithGroup(fieldDefs, fieldGroups, 'Not-FLT-Launch'), []);
+
+    assert.deepEqual(fieldDefsWithGroup(fieldDefs, fieldGroups, 'flt-launch'),
+        filteredGroupsList);
+
+    assert.deepEqual(fieldDefsWithGroup(fieldDefs, fieldGroups, 'FLT-LAUNCH'),
+        filteredGroupsList);
+  });
+});
diff --git a/static_src/shared/settings.js b/static_src/shared/settings.js
new file mode 100644
index 0000000..0b5fc3c
--- /dev/null
+++ b/static_src/shared/settings.js
@@ -0,0 +1,23 @@
+// Copyright 2019 The Chromium Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+// List of content type prefixes that the user will not be warned about when
+// downloading an attachment.
+export const ALLOWED_CONTENT_TYPE_PREFIXES = [
+  'audio/', 'font/', 'image/', 'text/plain', 'video/',
+];
+
+// List of file extensions that the user will not be warned about when
+// downloading an attachment.
+export const ALLOWED_ATTACHMENT_EXTENSIONS = [
+  '.avi', '.avif', '.bmp', '.csv', '.doc', '.docx', '.email', '.eml', '.gif',
+  '.ico', '.jpeg', '.jpg', '.log', '.m4p', '.m4v', '.mkv', '.mov', '.mp2',
+  '.mp4', '.mpeg', '.mpg', '.mpv', '.odt', '.ogg', '.pdf', '.png', '.sql',
+  '.svg', '.tif', '.tiff', '.txt', '.wav', '.webm', '.wmv',
+];
+
+// The message to show the user when they attempt to download an unrecognized
+// file type.
+export const FILE_DOWNLOAD_WARNING = 'This file type is not recognized. Are' +
+  ' you sure you want to download this attachment?';
diff --git a/static_src/shared/shared-styles.js b/static_src/shared/shared-styles.js
new file mode 100644
index 0000000..c00f639
--- /dev/null
+++ b/static_src/shared/shared-styles.js
@@ -0,0 +1,203 @@
+// Copyright 2019 The Chromium Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+import {css} from 'lit-element';
+
+export const SHARED_STYLES = css`
+  :host {
+    --mr-edit-field-padding: 0.125em 4px;
+    --mr-edit-field-width: 90%;
+    --mr-input-grid-gap: 6px;
+    font-family: var(--chops-font-family);
+    color: var(--chops-primary-font-color);
+    font-size: var(--chops-main-font-size);
+  }
+  /** Converts a <button> to look like an <a> tag. */
+  .linkify {
+    display: inline;
+    padding: 0;
+    margin: 0;
+    border: 0;
+    background: 0;
+    cursor: pointer;
+  }
+  h1, h2, h3, h4 {
+    background: none;
+  }
+  a, chops-button, a.button, .button, .linkify {
+    color: var(--chops-link-color);
+    text-decoration: none;
+    font-weight: var(--chops-link-font-weight);
+    font-family: var(--chops-font-family);
+  }
+  a:hover, .linkify:hover {
+    text-decoration: underline;
+  }
+  a.button, .button {
+    /* Links that look like buttons. */
+    display: inline-flex;
+    align-items: center;
+    justify-content: center;
+    text-decoration: none;
+    transition: filter 0.3s ease-in-out, box-shadow 0.3s ease-in-out;
+  }
+  a.button:hover, .button:hover {
+    filter: brightness(95%);
+  }
+  chops-button, a.button, .button {
+    box-sizing: border-box;
+    font-size: var(--chops-main-font-size);
+    background: var(--chops-white);
+    border-radius: 6px;
+    --chops-button-padding: 0.25em 8px;
+    margin: 0;
+    margin-left: auto;
+  }
+  a.button, .button {
+    padding: var(--chops-button-padding);
+  }
+  chops-button i.material-icons, a.button i.material-icons, .button i.material-icons {
+    display: block;
+    margin-right: 4px;
+  }
+  chops-button.emphasized, a.button.emphasized, .button.emphasized {
+    background: var(--chops-primary-button-bg);
+    color: var(--chops-primary-button-color);
+    text-shadow: 1px 1px 3px hsla(0, 0%, 0%, 0.25);
+  }
+  textarea, select, input {
+    box-sizing: border-box;
+    font-size: var(--chops-main-font-size);
+  }
+  /* Note: decoupling heading levels from styles is useful for
+  * accessibility because styles will not always line up with semantically
+  * appropriate heading levels.
+  */
+  .medium-heading {
+    font-size: var(--chops-large-font-size);
+    font-weight: normal;
+    line-height: 1;
+    padding: 0.25em 0;
+    color: var(--chops-link-color);
+    margin: 0;
+    margin-top: 0.25em;
+    border-bottom: var(--chops-normal-border);
+  }
+  .medium-heading chops-button {
+    line-height: 1.6;
+  }
+  .input-grid {
+    padding: 0.5em 0;
+    display: grid;
+    max-width: 100%;
+    grid-gap: var(--mr-input-grid-gap);
+    grid-template-columns: minmax(120px, max-content) 1fr;
+    align-items: flex-start;
+  }
+  .input-grid label {
+    font-weight: bold;
+    text-align: right;
+    word-wrap: break-word;
+  }
+  @media (max-width: 600px) {
+    .input-grid label {
+      margin-top: var(--mr-input-grid-gap);
+      text-align: left;
+    }
+    .input-grid {
+      grid-gap: var(--mr-input-grid-gap);
+      grid-template-columns: 100%;
+    }
+  }
+`;
+
+/**
+ * Markdown specific styling:
+ * * render link destination on hover as a tooltip
+ * @type {CSSResult}
+ */
+export const MD_STYLES = css`
+  .markdown .annotated-link {
+    position: relative;
+  }
+  .markdown .annotated-link:hover .tooltip {
+    display: block
+  }
+  .markdown .tooltip {
+    display: none;
+    position: absolute;
+    width: auto;
+    white-space: nowrap;
+    box-shadow: rgb(170 170 170) 1px 1px 5px;
+    box-shadow: 0 4px 8px 3px rgb(0 0 0 / 10%);
+    border-radius: 8px;
+    background-color: rgb(255, 255, 255);
+    top: -32px;
+    left: 0px;
+    border: 1px solid #dadce0;
+    padding: 6px 10px;
+  }
+  .markdown .material-icons {
+    font-size: 18px;
+    vertical-align: middle;
+  }
+  .markdown .material-icons.link {
+    color: var(--chops-link-color);
+  }
+  .markdown .material-icons.link_off {
+    color: var(--chops-field-error-color);
+  }
+  .markdown table {
+    -webkit-font-smoothing: antialiased;
+    box-sizing: inherit;
+    border-collapse: collapse;
+    margin: 8px 0 8px 0;
+    box-shadow: 0 2px 2px 0 hsla(315, 3%, 26%, 0.30);
+    border: 1px solid var(--chops-gray-300);
+    line-height: 1.4;
+  }
+  .markdown th {
+      border-bottom: 1px solid var(--chops-gray-300);
+      border-right: 1px solid var(--chops-gray-300);
+      padding: 1px;
+      text-align: left;
+      font-weight: 500;
+      color: var(--chops-gray-900);
+      background-color: var(--chops-gray-50);
+  }
+  .markdown td {
+      border-bottom: 1px solid var(--chops-gray-300);
+      border-right: 1px solid var(--chops-gray-300);
+      padding: 1px;
+  }
+  .markdown pre {
+    -webkit-font-smoothing: antialiased;
+    line-height: 1.6;
+    box-sizing: inherit;
+    background-color: hsla(0, 0%, 0%, 0.05);
+    border: 2px solid hsla(0, 0%, 0%, 0.10);
+    border-radius: 2px;
+    overflow-x: auto;
+    padding: 4px;
+  }
+`;
+
+export const MD_PREVIEW_STYLES = css`
+  ${MD_STYLES}
+  .markdown-preview {
+    padding: 0.25em 1em;
+    color: var(--chops-gray-800);
+    background-color: var(--chops-gray-200);
+    border-radius: 10px;
+    margin: 0px 0px 10px;
+    overflow: auto;
+  }
+  .preview-height-description {
+    max-height: 40vh;
+  }
+  .preview-height-comment {
+    min-height: 5vh;
+    max-height: 15vh;
+  }
+`;
\ No newline at end of file
diff --git a/static_src/shared/test/constants-hotlists.js b/static_src/shared/test/constants-hotlists.js
new file mode 100644
index 0000000..a496905
--- /dev/null
+++ b/static_src/shared/test/constants-hotlists.js
@@ -0,0 +1,76 @@
+// Copyright 2019 The Chromium Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+import * as issueV0 from './constants-issueV0.js';
+import * as users from './constants-users.js';
+import 'shared/typedef.js';
+
+/** @type {string} */
+export const NAME = 'hotlists/1234';
+
+/** @type {Hotlist} */
+export const HOTLIST = Object.freeze({
+  name: NAME,
+  displayName: 'Hotlist-Name',
+  owner: users.NAME,
+  editors: [users.NAME_2],
+  summary: 'Summary',
+  description: 'Description',
+  defaultColumns: [{column: 'Rank'}, {column: 'ID'}, {column: 'Summary'}],
+  hotlistPrivacy: 'PUBLIC',
+});
+
+export const HOTLIST_ITEM_NAME = NAME + '/items/56';
+
+/** @type {HotlistItem} */
+export const HOTLIST_ITEM = Object.freeze({
+  name: HOTLIST_ITEM_NAME,
+  issue: issueV0.NAME,
+  // rank: The API excludes the rank field if it's 0.
+  adder: users.NAME,
+  createTime: '2020-01-01T12:00:00Z',
+});
+
+/** @type {HotlistIssue} */
+export const HOTLIST_ISSUE = Object.freeze({
+  ...issueV0.ISSUE, ...HOTLIST_ITEM, adder: users.USER,
+});
+
+/** @type {HotlistItem} */
+export const HOTLIST_ITEM_OTHER_PROJECT = Object.freeze({
+  name: NAME + '/items/78',
+  issue: issueV0.NAME_OTHER_PROJECT,
+  rank: 1,
+  adder: users.NAME,
+  createTime: '2020-01-01T12:00:00Z',
+});
+
+/** @type {HotlistIssue} */
+export const HOTLIST_ISSUE_OTHER_PROJECT = Object.freeze({
+  ...issueV0.ISSUE_OTHER_PROJECT,
+  ...HOTLIST_ITEM_OTHER_PROJECT,
+  adder: users.USER,
+});
+
+/** @type {HotlistItem} */
+export const HOTLIST_ITEM_CLOSED = Object.freeze({
+  name: NAME + '/items/90',
+  issue: issueV0.NAME_CLOSED,
+  rank: 2,
+  adder: users.NAME,
+  createTime: '2020-01-01T12:00:00Z',
+});
+
+/** @type {HotlistIssue} */
+export const HOTLIST_ISSUE_CLOSED = Object.freeze({
+  ...issueV0.ISSUE_CLOSED, ...HOTLIST_ITEM_CLOSED, adder: users.USER,
+});
+
+/** @type {Object<string, Hotlist>} */
+export const BY_NAME = Object.freeze({[NAME]: HOTLIST});
+
+/** @type {Object<string, Array<HotlistItem>>} */
+export const HOTLIST_ITEMS = Object.freeze({
+  [NAME]: [HOTLIST_ITEM],
+});
diff --git a/static_src/shared/test/constants-issueV0.js b/static_src/shared/test/constants-issueV0.js
new file mode 100644
index 0000000..4f52aef
--- /dev/null
+++ b/static_src/shared/test/constants-issueV0.js
@@ -0,0 +1,42 @@
+// Copyright 2019 The Chromium Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+import 'shared/typedef.js';
+
+export const NAME = 'projects/project-name/issues/1234';
+
+export const ISSUE_REF_STRING = 'project-name:1234';
+
+/** @type {IssueRef} */
+export const ISSUE_REF = Object.freeze({
+  projectName: 'project-name',
+  localId: 1234,
+});
+
+/** @type {Issue} */
+export const ISSUE = Object.freeze({
+  projectName: 'project-name',
+  localId: 1234,
+  statusRef: {status: 'Available', meansOpen: true},
+});
+
+export const NAME_OTHER_PROJECT = 'projects/other-project-name/issues/1234';
+
+export const ISSUE_OTHER_PROJECT_REF_STRING = 'other-project-name:1234';
+
+/** @type {Issue} */
+export const ISSUE_OTHER_PROJECT = Object.freeze({
+  projectName: 'other-project-name',
+  localId: 1234,
+  statusRef: {status: 'Available', meansOpen: true},
+});
+
+export const NAME_CLOSED = 'projects/project-name/issues/5678';
+
+/** @type {Issue} */
+export const ISSUE_CLOSED = Object.freeze({
+  projectName: 'project-name',
+  localId: 5678,
+  statusRef: {status: 'Fixed', meansOpen: false},
+});
diff --git a/static_src/shared/test/constants-permissions.js b/static_src/shared/test/constants-permissions.js
new file mode 100644
index 0000000..f4b09c0
--- /dev/null
+++ b/static_src/shared/test/constants-permissions.js
@@ -0,0 +1,23 @@
+// Copyright 2020 The Chromium Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+import * as issue from './constants-issueV0.js';
+import 'shared/typedef.js';
+
+/** @type {Permission} */
+export const PERMISSION_ISSUE_EDIT = 'ISSUE_EDIT';
+
+/** @type {PermissionSet} */
+export const PERMISSION_SET_ISSUE = {
+  resource: issue.NAME,
+  permissions: [PERMISSION_ISSUE_EDIT],
+};
+
+/** @type {Object<string, PermissionSet>} */
+export const BY_NAME = {
+  [issue.NAME]: PERMISSION_SET_ISSUE,
+};
+
+/** @type {Array<Permission>} */
+export const PERMISSION_HOTLIST_EDIT = ['HOTLIST_EDIT'];
diff --git a/static_src/shared/test/constants-projectV0.js b/static_src/shared/test/constants-projectV0.js
new file mode 100644
index 0000000..4a46af8
--- /dev/null
+++ b/static_src/shared/test/constants-projectV0.js
@@ -0,0 +1,80 @@
+// Copyright 2019 The Chromium Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+import {fieldTypes} from 'shared/issue-fields.js';
+import {USER_REF} from './constants-users.js';
+import 'shared/typedef.js';
+
+/** @type {string} */
+export const PROJECT_NAME = 'project-name';
+
+/** @type {FieldDef} */
+export const FIELD_DEF_INT = Object.freeze({
+  fieldRef: Object.freeze({
+    fieldId: 123,
+    fieldName: 'field-name',
+    type: fieldTypes.INT_TYPE,
+  }),
+});
+
+/** @type {FieldDef} */
+export const FIELD_DEF_ENUM = Object.freeze({
+  fieldRef: Object.freeze({
+    fieldId: 456,
+    fieldName: 'enum',
+    type: fieldTypes.ENUM_TYPE,
+  }),
+});
+
+/** @type {Array<FieldDef>} */
+export const FIELD_DEFS = [
+  FIELD_DEF_INT,
+  FIELD_DEF_ENUM,
+];
+
+/** @type {Config} */
+export const CONFIG = Object.freeze({
+  projectName: PROJECT_NAME,
+  fieldDefs: FIELD_DEFS,
+  labelDefs: [
+    {label: 'One'},
+    {label: 'EnUm'},
+    {label: 'eNuM-Options'},
+    {label: 'hello-world', docstring: 'hmmm'},
+    {label: 'hello-me', docstring: 'hmmm'},
+  ],
+});
+
+/** @type {string} */
+export const DEFAULT_QUERY = 'owner:me';
+
+/** @type {PresentationConfig} */
+export const PRESENTATION_CONFIG = Object.freeze({
+  projectThumbnailUrl: 'test.png',
+  defaultColSpec: 'ID+Summary+AllLabels',
+  defaultQuery: DEFAULT_QUERY,
+});
+
+/** @type {Array<string>} */
+export const CUSTOM_PERMISSIONS = ['google', 'security'];
+
+/** @type {{userRefs: Array<UserRef>, groupRefs: Array<UserRef>}} */
+export const VISIBLE_MEMBERS = Object.freeze({
+  userRefs: [USER_REF],
+  groupRefs: [],
+});
+
+/** @type {TemplateDef} */
+export const TEMPLATE_DEF = Object.freeze({
+  templateName: 'Template Name',
+});
+
+export const STATE = Object.freeze({projectV0: {
+  name: PROJECT_NAME,
+  configs: {[PROJECT_NAME]: CONFIG},
+  presentationConfigs: {[PROJECT_NAME]: PRESENTATION_CONFIG},
+  customPermissions: {[PROJECT_NAME]: CUSTOM_PERMISSIONS},
+  visibleMembers: {[PROJECT_NAME]: VISIBLE_MEMBERS},
+  templates: {[PROJECT_NAME]: TEMPLATE_DEF},
+}});
diff --git a/static_src/shared/test/constants-projects.js b/static_src/shared/test/constants-projects.js
new file mode 100644
index 0000000..c25f46b
--- /dev/null
+++ b/static_src/shared/test/constants-projects.js
@@ -0,0 +1,27 @@
+// Copyright 2019 The Chromium Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+import 'shared/typedef.js';
+
+/** @type {ProjectName} */
+export const NAME = 'projects/chromium';
+
+/** @type {Project} */
+export const PROJECT = Object.freeze({
+  name: NAME,
+  displayName: 'Chromium',
+  summary: 'A great open source project.',
+  thumbnailUrl: 'chromium.png',
+});
+
+/** @type {ProjectName} */
+export const NAME_2 = 'projects/monorail';
+
+/** @type {Project} */
+export const PROJECT_2 = Object.freeze({
+  name: NAME_2,
+  displayName: 'mOnOrAiL',
+  summary: 'Best issue tracker.',
+  thumbnailUrl: 'dogtrain.gif',
+});
diff --git a/static_src/shared/test/constants-stars.js b/static_src/shared/test/constants-stars.js
new file mode 100644
index 0000000..42e7012
--- /dev/null
+++ b/static_src/shared/test/constants-stars.js
@@ -0,0 +1,18 @@
+// Copyright 2019 The Chromium Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+import 'shared/typedef.js';
+
+export const PROJECT_STAR_NAME = 'users/1234/projectStars/monorail';
+export const PROJECT_STAR_NAME_2 = 'users/1234/projectStars/chromium';
+
+/** @type {ProjectStar} */
+export const PROJECT_STAR = Object.freeze({
+  name: PROJECT_STAR_NAME,
+});
+
+/** @type {ProjectStar} */
+export const PROJECT_STAR_2 = Object.freeze({
+  name: PROJECT_STAR_NAME_2,
+});
diff --git a/static_src/shared/test/constants-users.js b/static_src/shared/test/constants-users.js
new file mode 100644
index 0000000..0a9bbf8
--- /dev/null
+++ b/static_src/shared/test/constants-users.js
@@ -0,0 +1,43 @@
+// Copyright 2019 The Chromium Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+import 'shared/typedef.js';
+
+export const NAME = 'users/1234';
+
+export const DISPLAY_NAME = 'example@example.com';
+
+export const ID = 1234;
+
+/** @type {UserRef} */
+export const USER_REF = Object.freeze({
+  userId: ID,
+  displayName: DISPLAY_NAME,
+});
+
+/** @type {User} */
+export const USER = Object.freeze({
+  name: NAME,
+  displayName: DISPLAY_NAME,
+});
+
+export const NAME_2 = 'users/5678';
+
+export const DISPLAY_NAME_2 = 'other_user@example.com';
+
+/** @type {User} */
+export const USER_2 = Object.freeze({
+  name: NAME_2,
+  displayName: DISPLAY_NAME_2,
+});
+
+/** @type {Object<string, User>} */
+export const BY_NAME = Object.freeze({[NAME]: USER, [NAME_2]: USER_2});
+
+/** @type {ProjectMember} */
+export const PROJECT_MEMBER = Object.freeze({
+  name: 'projects/proj/members/1234',
+  role: 'CONTRIBUTOR',
+});
+
diff --git a/static_src/shared/test/fakes.js b/static_src/shared/test/fakes.js
new file mode 100644
index 0000000..d506f6a
--- /dev/null
+++ b/static_src/shared/test/fakes.js
@@ -0,0 +1,12 @@
+// Copyright 2019 The Chromium Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+import sinon from 'sinon';
+
+export const clientLoggerFake = () => ({
+  logStart: sinon.stub(),
+  logEnd: sinon.stub(),
+  logPause: sinon.stub(),
+  started: sinon.stub().returns(true),
+});
diff --git a/static_src/shared/test/helpers.js b/static_src/shared/test/helpers.js
new file mode 100644
index 0000000..63a1e12
--- /dev/null
+++ b/static_src/shared/test/helpers.js
@@ -0,0 +1,57 @@
+// Copyright 2019 The Chromium Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+import axe from 'axe-core';
+import userEvent from '@testing-library/user-event';
+import {fireEvent} from '@testing-library/react';
+
+// TODO(seanmccullough): Move this into crdx/chopsui-npm if we decide this
+// is worth using in other projects.
+
+/**
+ * @param {HTMLElement} element The element to audit accessibility for.
+ */
+export async function auditA11y(element) {
+  // Performance tip: try restricting the analysis using
+  // https://github.com/dequelabs/axe-core/blob/develop/doc/API.md#use-resulttypes
+  const options = {};
+
+  // Adjust this set to make tests more/less permissible.
+  const reportImpact = new Set(['critical', 'serious', 'moderate', 'minor']);
+  const results = await axe.run(element, options);
+
+  if (results.violations.length == 0) {
+    return;
+  }
+
+  const msgs = ['Accessibility violations:'];
+  results.violations.forEach((result) => {
+    if (reportImpact.has(result.impact)) {
+      msgs.push(`\n[${result.impact}] ${result.help}`);
+      for (const node of result.nodes) {
+        if (node.failureSummary) {
+          msgs.push(node.failureSummary);
+        }
+        msgs.push(node.html);
+      }
+      msgs.push('---');
+    }
+  });
+
+  throw new Error(msgs.join('\n'));
+}
+
+/**
+ * Types text into an input field and presses Enter.
+ * @param {HTMLInputElement} input The input field to enter text in.
+ * @param {string} value The text to enter in the input field.
+ */
+export function enterInput(input, value) {
+  userEvent.clear(input);
+
+  userEvent.type(input, value);
+
+  fireEvent.keyDown(input, {key: 'Enter', code: 'Enter'});
+}
+
diff --git a/static_src/shared/typedef.js b/static_src/shared/typedef.js
new file mode 100644
index 0000000..923e1db
--- /dev/null
+++ b/static_src/shared/typedef.js
@@ -0,0 +1,646 @@
+// Copyright 2019 The Chromium Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+/**
+ * @fileoverview Shared file for specifying common types used in type
+ * annotations across Monorail.
+ */
+
+// TODO(zhangtiff): Find out if there's a way we can generate typedef's for
+// API object from .proto files.
+
+
+/**
+ * Types used in the app that don't come from any Proto files.
+ */
+
+/**
+ * A HotlistItem with the Issue flattened into the top-level,
+ * containing the intersection of the fields of HotlistItem and Issue.
+ *
+ * @typedef {Issue & HotlistItem} HotlistIssue
+ * @property {User=} adder
+ */
+
+/**
+ * A String containing the data necessary to identify an IssueRef. An IssueRef
+ * can reference either an issue in Monorail or an external issue in another
+ * tracker.
+ *
+ * Examples of valid IssueRefStrings:
+ * - monorail:1234
+ * - chromium:1
+ * - 1234
+ * - b/123456
+ *
+ * @typedef {string} IssueRefString
+ */
+
+/**
+ * An Object for specifying what to display in a single entry in the
+ * dropdown list.
+ *
+ * @typedef {Object} MenuItem
+ * @property {string=} text The text to display in the menu.
+ * @property {string=} icon A Material Design icon shown left of the text.
+ * @property {Array<MenuItem>=} items A specification for a nested submenu.
+ * @property {function=} handler An optional click handler for an item.
+ * @property {string=} url A link for the menu item to navigate to.
+ */
+
+/**
+ * An Object containing the metadata associated with tracking async requests
+ * through Redux.
+ *
+ * @typedef {Object} ReduxRequestState
+ * @property {boolean=} requesting Whether a request is in flight.
+ * @property {Error=} error An Error Object returned by the request.
+ */
+
+
+/**
+ * Resource names used in our resource-oriented API.
+ * @see https://aip.dev/122
+ */
+
+
+/**
+ * Resource name of an IssueStar.
+ *
+ * Examples of valid IssueStar resource names:
+ * - users/1234/issueStars/monorail.5556
+ * - users/1234/issueStars/test-project.4321
+ *
+ * @typedef {string} IssueStarName
+ */
+
+
+/**
+ * Resource name of a ProjectStar.
+ *
+ * Examples of valid ProjectStar resource names:
+ * - users/1234/projectStars/monorail
+ * - users/1234/projectStars/test-project
+ *
+ * @typedef {string} ProjectStarName
+ */
+
+
+/**
+ * Resource name of a Star.
+ *
+ * @typedef {ProjectStarName|IssueStarName} StarName
+ */
+
+
+/**
+ * Resource name of a Project.
+ *
+ * Examples of valid Project resource names:
+ * - projects/monorail
+ * - projects/test-project-1
+ *
+ * @typedef {string} ProjectName
+ */
+
+
+/**
+ * Resource name of a User.
+ *
+ * Examples of valid User resource names:
+ * - users/test@example.com
+ * - users/1234
+ *
+ * @typedef {string} UserName
+ */
+
+/**
+ * Resource name of a ProjectMember.
+ *
+ * Examples of valid ProjectMember resource names:
+ * - projects/monorail/members/1234
+ * - projects/test-xyz/members/5678
+ *
+ * @typedef {string} ProjectMemberName
+ */
+
+
+/**
+ * Types defined in common.proto.
+ */
+
+
+/**
+ * A ComponentRef Object returned by the pRPC API common.proto.
+ *
+ * @typedef {Object} ComponentRef
+ * @property {string} path
+ * @property {boolean=} isDerived
+ */
+
+/**
+ * An Enum representing the type that a custom field uses.
+ *
+ * @typedef {string} FieldType
+ */
+
+/**
+ * A FieldRef Object returned by the pRPC API common.proto.
+ *
+ * @typedef {Object} FieldRef
+ * @property {number} fieldId
+ * @property {string} fieldName
+ * @property {FieldType} type
+ * @property {string=} approvalName
+ */
+
+/**
+ * A LabelRef Object returned by the pRPC API common.proto.
+ *
+ * @typedef {Object} LabelRef
+ * @property {string} label
+ * @property {boolean=} isDerived
+ */
+
+/**
+ * A StatusRef Object returned by the pRPC API common.proto.
+ *
+ * @typedef {Object} StatusRef
+ * @property {string} status
+ * @property {boolean=} meansOpen
+ * @property {boolean=} isDerived
+ */
+
+/**
+ * An IssueRef Object returned by the pRPC API common.proto.
+ *
+ * @typedef {Object} IssueRef
+ * @property {string=} projectName
+ * @property {number=} localId
+ * @property {string=} extIdentifier
+ */
+
+/**
+ * A UserRef Object returned by the pRPC API common.proto.
+ *
+ * @typedef {Object} UserRef
+ * @property {string=} displayName
+ * @property {number=} userId
+ */
+
+/**
+ * A HotlistRef Object returned by the pRPC API common.proto.
+ *
+ * @typedef {Object} HotlistRef
+ * @property {string=} name
+ * @property {UserRef=} owner
+ */
+
+/**
+ * A SavedQuery Object returned by the pRPC API common.proto.
+ *
+ * @typedef {Object} SavedQuery
+ * @property {number} queryId
+ * @property {string} name
+ * @property {string} query
+ * @property {Array<string>} projectNames
+ */
+
+
+/**
+ * Types defined in issue_objects.proto.
+ */
+
+/**
+ * An Approval Object returned by the pRPC API issue_objects.proto.
+ *
+ * @typedef {Object} Approval
+ * @property {FieldRef} fieldRef
+ * @property {Array<UserRef>} approverRefs
+ * @property {ApprovalStatus} status
+ * @property {number} setOn
+ * @property {UserRef} setterRef
+ * @property {PhaseRef} phaseRef
+ */
+
+/**
+ * An Enum representing the status of an Approval.
+ *
+ * @typedef {string} ApprovalStatus
+ */
+
+/**
+ * An Amendment Object returned by the pRPC API issue_objects.proto.
+ *
+ * @typedef {Object} Amendment
+ * @property {string} fieldName
+ * @property {string} newOrDeltaValue
+ * @property {string} oldValue
+ */
+
+/**
+ * An Attachment Object returned by the pRPC API issue_objects.proto.
+ *
+ * @typedef {Object} Attachment
+* @property {number} attachmentId
+* @property {string} filename
+* @property {number} size
+* @property {string} contentType
+* @property {boolean} isDeleted
+* @property {string} thumbnailUrl
+* @property {string} viewUrl
+* @property {string} downloadUrl
+*/
+
+/**
+ * A Comment Object returned by the pRPC API issue_objects.proto.
+ *
+ * Note: This Object is called "Comment" in the backend but is named
+ * "IssueComment" here to avoid a collision with an internal JSDoc Intellisense
+ * type.
+ *
+ * @typedef {Object} IssueComment
+ * @property {string} projectName
+ * @property {number} localId
+ * @property {number=} sequenceNum
+ * @property {boolean=} isDeleted
+ * @property {UserRef=} commenter
+ * @property {number=} timestamp
+ * @property {string=} content
+ * @property {string=} inboundMessage
+ * @property {Array<Amendment>=} amendments
+ * @property {Array<Attachment>=} attachments
+ * @property {FieldRef=} approvalRef
+ * @property {number=} descriptionNum
+ * @property {boolean=} isSpam
+ * @property {boolean=} canDelete
+ * @property {boolean=} canFlag
+ */
+
+/**
+ * A FieldValue Object returned by the pRPC API issue_objects.proto.
+ *
+ * @typedef {Object} FieldValue
+ * @property {FieldRef} fieldRef
+ * @property {string} value
+ * @property {boolean=} isDerived
+ * @property {PhaseRef=} phaseRef
+ */
+
+/**
+ * An Issue Object returned by the pRPC API issue_objects.proto.
+ *
+ * @typedef {Object} Issue
+ * @property {string} projectName
+ * @property {number} localId
+ * @property {string=} summary
+ * @property {StatusRef=} statusRef
+ * @property {UserRef=} ownerRef
+ * @property {Array<UserRef>=} ccRefs
+ * @property {Array<LabelRef>=} labelRefs
+ * @property {Array<ComponentRef>=} componentRefs
+ * @property {Array<IssueRef>=} blockedOnIssueRefs
+ * @property {Array<IssueRef>=} blockingIssueRefs
+ * @property {Array<IssueRef>=} danglingBlockedOnRefs
+ * @property {Array<IssueRef>=} danglingBlockingRefs
+ * @property {IssueRef=} mergedIntoIssueRef
+ * @property {Array<FieldValue>=} fieldValues
+ * @property {boolean=} isDeleted
+ * @property {UserRef=} reporterRef
+ * @property {number=} openedTimestamp
+ * @property {number=} closedTimestamp
+ * @property {number=} modifiedTimestamp
+ * @property {number=} componentModifiedTimestamp
+ * @property {number=} statusModifiedTimestamp
+ * @property {number=} ownerModifiedTimestamp
+ * @property {number=} starCount
+ * @property {boolean=} isSpam
+ * @property {number=} attachmentCount
+ * @property {Array<Approval>=} approvalValues
+ * @property {Array<PhaseDef>=} phases
+ */
+
+/**
+ * A IssueDelta Object returned by the pRPC API issue_objects.proto.
+ *
+ * @typedef {Object} IssueDelta
+ * @property {string=} status
+ * @property {UserRef=} ownerRef
+ * @property {Array<UserRef>=} ccRefsAdd
+ * @property {Array<UserRef>=} ccRefsRemove
+ * @property {Array<ComponentRef>=} compRefsAdd
+ * @property {Array<ComponentRef>=} compRefsRemove
+ * @property {Array<LabelRef>=} labelRefsAdd
+ * @property {Array<LabelRef>=} labelRefsRemove
+ * @property {Array<FieldValue>=} fieldValsAdd
+ * @property {Array<FieldValue>=} fieldValsRemove
+ * @property {Array<FieldRef>=} fieldsClear
+ * @property {Array<IssueRef>=} blockedOnRefsAdd
+ * @property {Array<IssueRef>=} blockedOnRefsRemove
+ * @property {Array<IssueRef>=} blockingRefsAdd
+ * @property {Array<IssueRef>=} blockingRefsRemove
+ * @property {IssueRef=} mergedIntoRef
+ * @property {string=} summary
+ */
+
+/**
+ * An PhaseDef Object returned by the pRPC API issue_objects.proto.
+ *
+ * @typedef {Object} PhaseDef
+ * @property {PhaseRef} phaseRef
+ * @property {number} rank
+ */
+
+/**
+ * An PhaseRef Object returned by the pRPC API issue_objects.proto.
+ *
+ * @typedef {Object} PhaseRef
+ * @property {string} phaseName
+ */
+
+/**
+ * An Object returned by the pRPC v3 API from feature_objects.proto.
+ *
+ * @typedef {Object} IssuesListColumn
+ * @property {string} column
+ */
+
+
+/**
+ * Types defined in permission_objects.proto.
+ */
+
+/**
+ * A Permission string returned by the pRPC API permission_objects.proto.
+ *
+ * @typedef {string} Permission
+ */
+
+/**
+ * A PermissionSet Object returned by the pRPC API permission_objects.proto.
+ *
+ * @typedef {Object} PermissionSet
+ * @property {string} resource
+ * @property {Array<Permission>} permissions
+ */
+
+
+/**
+ * Types defined in project_objects.proto.
+ */
+
+/**
+ * An Enum representing the role a ProjectMember has.
+ *
+ * @typedef {string} ProjectRole
+ */
+
+/**
+ * An Enum representing how a ProjectMember shows up in autocomplete.
+ *
+ * @typedef {string} AutocompleteVisibility
+ */
+
+/**
+ * A ProjectMember Object returned by the pRPC API project_objects.proto.
+ *
+ * @typedef {Object} ProjectMember
+ * @property {ProjectMemberName} name
+ * @property {ProjectRole} role
+ * @property {Array<Permission>=} standardPerms
+ * @property {Array<string>=} customPerms
+ * @property {string=} notes
+ * @property {AutocompleteVisibility=} includeInAutocomplete
+ */
+
+/**
+ * A Project Object returned by the pRPC API project_objects.proto.
+ *
+ * @typedef {Object} Project
+ * @property {string} name
+ * @property {string} summary
+ * @property {string=} description
+ */
+
+/**
+ * A Project Object returned by the v0 pRPC API project_objects.proto.
+ *
+ * @typedef {Object} ProjectV0
+ * @property {string} name
+ * @property {string} summary
+ * @property {string=} description
+ */
+
+/**
+ * A StatusDef Object returned by the pRPC API project_objects.proto.
+ *
+ * @typedef {Object} StatusDef
+ * @property {string} status
+ * @property {boolean} meansOpen
+ * @property {number} rank
+ * @property {string} docstring
+ * @property {boolean} deprecated
+ */
+
+/**
+ * A LabelDef Object returned by the pRPC API project_objects.proto.
+ *
+ * @typedef {Object} LabelDef
+ * @property {string} label
+ * @property {string=} docstring
+ * @property {boolean=} deprecated
+ */
+
+/**
+ * A ComponentDef Object returned by the pRPC API project_objects.proto.
+ *
+ * @typedef {Object} ComponentDef
+ * @property {string} path
+ * @property {string} docstring
+ * @property {Array<UserRef>} adminRefs
+ * @property {Array<UserRef>} ccRefs
+ * @property {boolean} deprecated
+ * @property {number} created
+ * @property {UserRef} creatorRef
+ * @property {number} modified
+ * @property {UserRef} modifierRef
+ * @property {Array<LabelRef>} labelRefs
+ */
+
+/**
+ * A FieldDef Object returned by the pRPC API project_objects.proto.
+ *
+ * @typedef {Object} FieldDef
+ * @property {FieldRef} fieldRef
+ * @property {string=} applicableType
+ * @property {boolean=} isRequired
+ * @property {boolean=} isNiche
+ * @property {boolean=} isMultivalued
+ * @property {string=} docstring
+ * @property {Array<UserRef>=} adminRefs
+ * @property {boolean=} isPhaseField
+ * @property {Array<UserRef>=} userChoices
+ * @property {Array<LabelDef>=} enumChoices
+ */
+
+/**
+ * A ApprovalDef Object returned by the pRPC API project_objects.proto.
+ *
+ * @typedef {Object} ApprovalDef
+ * @property {FieldRef} fieldRef
+ * @property {Array<UserRef>} approverRefs
+ * @property {string} survey
+ */
+
+/**
+ * A Config Object returned by the pRPC API project_objects.proto.
+ *
+ * @typedef {Object} Config
+ * @property {string} projectName
+ * @property {Array<StatusDef>=} statusDefs
+ * @property {Array<StatusRef>=} statusesOfferMerge
+ * @property {Array<LabelDef>=} labelDefs
+ * @property {Array<string>=} exclusiveLabelPrefixes
+ * @property {Array<ComponentDef>=} componentDefs
+ * @property {Array<FieldDef>=} fieldDefs
+ * @property {Array<ApprovalDef>=} approvalDefs
+ * @property {boolean=} restrictToKnown
+ */
+
+
+/**
+ * A PresentationConfig Object returned by the pRPC API project_objects.proto.
+ *
+ * @typedef {Object} PresentationConfig
+ * @property {string=} projectThumbnailUrl
+ * @property {string=} projectSummary
+ * @property {string=} customIssueEntryUrl
+ * @property {string=} defaultQuery
+ * @property {Array<SavedQuery>=} savedQueries
+ * @property {string=} revisionUrlFormat
+ * @property {string=} defaultColSpec
+ * @property {string=} defaultSortSpec
+ * @property {string=} defaultXAttr
+ * @property {string=} defaultYAttr
+ */
+
+/**
+ * A TemplateDef Object returned by the pRPC API project_objects.proto.
+ *
+ * @typedef {Object} TemplateDef
+ * @property {string} templateName
+ * @property {string=} content
+ * @property {string=} summary
+ * @property {boolean=} summaryMustBeEdited
+ * @property {UserRef=} ownerRef
+ * @property {StatusRef=} statusRef
+ * @property {Array<LabelRef>=} labelRefs
+ * @property {boolean=} membersOnly
+ * @property {boolean=} ownerDefaultsToMember
+ * @property {Array<UserRef>=} adminRefs
+ * @property {Array<FieldValue>=} fieldValues
+ * @property {Array<ComponentRef>=} componentRefs
+ * @property {boolean=} componentRequired
+ * @property {Array<Approval>=} approvalValues
+ * @property {Array<PhaseDef>=} phases
+ */
+
+
+/**
+ * Types defined in features_objects.proto.
+ */
+
+/**
+ * A Hotlist Object returned by the pRPC API features_objects.proto.
+ *
+ * @typedef {Object} HotlistV0
+ * @property {UserRef=} ownerRef
+ * @property {string=} name
+ * @property {string=} summary
+ * @property {string=} description
+ * @property {string=} defaultColSpec
+ * @property {boolean=} isPrivate
+ */
+
+/**
+ * A Hotlist Object returned by the pRPC v3 API from feature_objects.proto.
+ *
+ * @typedef {Object} Hotlist
+ * @property {string} name
+ * @property {string=} displayName
+ * @property {string=} owner
+ * @property {Array<string>=} editors
+ * @property {string=} summary
+ * @property {string=} description
+ * @property {Array<IssuesListColumn>=} defaultColumns
+ * @property {string=} hotlistPrivacy
+ */
+
+/**
+ * A HotlistItem Object returned by the pRPC API features_objects.proto.
+ *
+ * @typedef {Object} HotlistItemV0
+ * @property {Issue=} issue
+ * @property {number=} rank
+ * @property {UserRef=} adderRef
+ * @property {number=} addedTimestamp
+ * @property {string=} note
+ */
+
+/**
+ * A HotlistItem Object returned by the pRPC v3 API from feature_objects.proto.
+ *
+ * @typedef {Object} HotlistItem
+ * @property {string=} name
+ * @property {string=} issue
+ * @property {number=} rank
+ * @property {string=} adder
+ * @property {string=} createTime
+ * @property {string=} note
+ */
+
+/**
+ * Types defined in user_objects.proto.
+ */
+
+/**
+ * A User Object returned by the pRPC API user_objects.proto.
+ *
+ * @typedef {Object} UserV0
+ * @property {string=} displayName
+ * @property {number=} userId
+ * @property {boolean=} isSiteAdmin
+ * @property {string=} availability
+ * @property {UserRef=} linkedParentRef
+ * @property {Array<UserRef>=} linkedChildRefs
+ */
+
+/**
+ * A User Object returned by the pRPC v3 API from user_objects.proto.
+ *
+ * @typedef {Object} User
+ * @property {string=} name
+ * @property {string=} displayName
+ * @property {string=} availabilityMessage
+ */
+
+/**
+ * A ProjectStar Object returned by the pRPC v3 API from user_objects.proto.
+ *
+ * @typedef {Object} ProjectStar
+ * @property {string=} name
+ */
+
+/**
+ * A IssueStar Object returned by the pRPC v3 API from user_objects.proto.
+ *
+ * @typedef {Object} IssueStar
+ * @property {string=} name
+ */
+
+/**
+ * Type alias for any Star object.
+ *
+ * @typedef {ProjectStar|IssueStar} Star
+ */