Project import generated by Copybara.

GitOrigin-RevId: d9e9e3fb4e31372ec1fb43b178994ca78fa8fe70
diff --git a/static_src/reducers/base.js b/static_src/reducers/base.js
new file mode 100644
index 0000000..f4603b7
--- /dev/null
+++ b/static_src/reducers/base.js
@@ -0,0 +1,109 @@
+// 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 {connect} from 'pwa-helpers/connect-mixin.js';
+import {applyMiddleware, combineReducers, compose, createStore} from 'redux';
+import thunk from 'redux-thunk';
+import {hotlists} from './hotlists.js';
+import * as issueV0 from './issueV0.js';
+import * as permissions from './permissions.js';
+import * as projects from './projects.js';
+import * as projectV0 from './projectV0.js';
+import * as sitewide from './sitewide.js';
+import {stars} from './stars.js';
+import * as users from './users.js';
+import * as userV0 from './userV0.js';
+import * as ui from './ui.js';
+
+/** @typedef {import('redux').AnyAction} AnyAction */
+
+// Actions
+const RESET_STATE = 'RESET_STATE';
+
+/* State Shape
+{
+  hotlists: Object,
+  permissions: Object,
+  projects: Object,
+  sitewide: Object,
+  users: Object,
+
+  ui: Object,
+
+  // To be deprecated
+  issue: Object,
+  projectV0: Object,
+  userV0: Object,
+}
+*/
+
+// Reducers
+const reducer = combineReducers({
+  hotlists: hotlists.reducer,
+  issue: issueV0.reducer,
+  permissions: permissions.reducer,
+  projects: projects.reducer,
+  projectV0: projectV0.reducer,
+  users: users.reducer,
+  userV0: userV0.reducer,
+  sitewide: sitewide.reducer,
+  stars: stars.reducer,
+
+  ui: ui.reducer,
+});
+
+/**
+ * The top level reducer function that all actions pass through.
+ * @param {any} state
+ * @param {AnyAction} action
+ * @return {any}
+ */
+export function rootReducer(state, action) {
+  if (action.type === RESET_STATE) {
+    state = undefined;
+  }
+  return reducer(state, action);
+}
+
+// Selectors
+
+// Action Creators
+
+/**
+ * Changes Redux state back to its default initial state. Primarily
+ * used in testing.
+ * @return {AnyAction} An action to reset Redux state to default.
+ */
+export const resetState = () => ({type: RESET_STATE});
+
+// Store
+
+// For debugging with the Redux Devtools extension:
+// https://chrome.google.com/webstore/detail/redux-devtools/lmhkpmbekcpmknklioeibfkpmmfibljd/
+const composeEnhancers = window.__REDUX_DEVTOOLS_EXTENSION_COMPOSE__ || compose;
+export const store = createStore(rootReducer, composeEnhancers(
+    applyMiddleware(thunk),
+));
+
+/**
+ * Class mixin function that connects a given HTMLElement class to our
+ * store instance.
+ * @link https://pwa-starter-kit.polymer-project.org/redux-and-state-management#connecting-an-element-to-the-store
+ * @param {typeof HTMLElement} class
+ * @return {function} New class type with connected features.
+ */
+export const connectStore = connect(store);
+
+/**
+ * Promise to allow waiting for a state update. Useful in testing.
+ * @example
+ * store.dispatch(updateState());
+ * await stateUpdated;
+ * doThingWithUpdatedState();
+ *
+ * @type {Promise}
+ */
+export const stateUpdated = new Promise((resolve) => {
+  store.subscribe(resolve);
+});
diff --git a/static_src/reducers/hotlists.js b/static_src/reducers/hotlists.js
new file mode 100644
index 0000000..95989cc
--- /dev/null
+++ b/static_src/reducers/hotlists.js
@@ -0,0 +1,517 @@
+// 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 Hotlist actions, selectors, and reducers organized into
+ * a single Redux "Duck" that manages updating and retrieving hotlist state
+ * on the frontend.
+ *
+ * The Hotlist data is stored in a normalized format.
+ * `name` is a reference to the currently viewed Hotlist.
+ * `hotlists` stores all Hotlist data indexed by Hotlist name.
+ * `hotlistItems` stores all Hotlist items indexed by Hotlist name.
+ * `hotlist` is a selector that gets the currently viewed Hotlist data.
+ *
+ * Reference: https://github.com/erikras/ducks-modular-redux
+ */
+
+import {combineReducers} from 'redux';
+import {createSelector} from 'reselect';
+import {createReducer, createRequestReducer} from './redux-helpers.js';
+
+import {prpcClient} from 'prpc-client-instance.js';
+import {userIdOrDisplayNameToUserRef, issueNameToRef}
+  from 'shared/convertersV0.js';
+import {pathsToFieldMask} from 'shared/converters.js';
+
+import * as issueV0 from './issueV0.js';
+import * as permissions from './permissions.js';
+import * as sitewide from './sitewide.js';
+import * as ui from './ui.js';
+import * as users from './users.js';
+
+import 'shared/typedef.js';
+/** @typedef {import('redux').AnyAction} AnyAction */
+
+/** @type {Array<string>} */
+export const DEFAULT_COLUMNS = [
+  'Rank', 'ID', 'Status', 'Owner', 'Summary', 'Modified',
+];
+
+// Permissions
+// TODO(crbug.com/monorail/7879): Move these to a permissions constants file.
+export const EDIT = 'HOTLIST_EDIT';
+export const ADMINISTER = 'HOTLIST_ADMINISTER';
+
+// Actions
+export const SELECT = 'hotlist/SELECT';
+export const RECEIVE_HOTLIST = 'hotlist/RECEIVE_HOTLIST';
+
+export const DELETE_START = 'hotlist/DELETE_START';
+export const DELETE_SUCCESS = 'hotlist/DELETE_SUCCESS';
+export const DELETE_FAILURE = 'hotlist/DELETE_FAILURE';
+
+export const FETCH_START = 'hotlist/FETCH_START';
+export const FETCH_SUCCESS = 'hotlist/FETCH_SUCCESS';
+export const FETCH_FAILURE = 'hotlist/FETCH_FAILURE';
+
+export const FETCH_ITEMS_START = 'hotlist/FETCH_ITEMS_START';
+export const FETCH_ITEMS_SUCCESS = 'hotlist/FETCH_ITEMS_SUCCESS';
+export const FETCH_ITEMS_FAILURE = 'hotlist/FETCH_ITEMS_FAILURE';
+
+export const REMOVE_EDITORS_START = 'hotlist/REMOVE_EDITORS_START';
+export const REMOVE_EDITORS_SUCCESS = 'hotlist/REMOVE_EDITORS_SUCCESS';
+export const REMOVE_EDITORS_FAILURE = 'hotlist/REMOVE_EDITORS_FAILURE';
+
+export const REMOVE_ITEMS_START = 'hotlist/REMOVE_ITEMS_START';
+export const REMOVE_ITEMS_SUCCESS = 'hotlist/REMOVE_ITEMS_SUCCESS';
+export const REMOVE_ITEMS_FAILURE = 'hotlist/REMOVE_ITEMS_FAILURE';
+
+export const RERANK_ITEMS_START = 'hotlist/RERANK_ITEMS_START';
+export const RERANK_ITEMS_SUCCESS = 'hotlist/RERANK_ITEMS_SUCCESS';
+export const RERANK_ITEMS_FAILURE = 'hotlist/RERANK_ITEMS_FAILURE';
+
+export const UPDATE_START = 'hotlist/UPDATE_START';
+export const UPDATE_SUCCESS = 'hotlist/UPDATE_SUCCESS';
+export const UPDATE_FAILURE = 'hotlist/UPDATE_FAILURE';
+
+/* State Shape
+{
+  name: string,
+
+  byName: Object<string, Hotlist>,
+  hotlistItems: Object<string, Array<HotlistItem>>,
+
+  requests: {
+    fetch: ReduxRequestState,
+    fetchItems: ReduxRequestState,
+    update: ReduxRequestState,
+  },
+}
+*/
+
+// Reducers
+
+/**
+ * A reference to the currently viewed Hotlist.
+ * @param {?string} state The existing Hotlist resource name.
+ * @param {AnyAction} action
+ * @return {?string}
+ */
+export const nameReducer = createReducer(null, {
+  [SELECT]: (_state, {name}) => name,
+});
+
+/**
+ * All Hotlist data indexed by Hotlist resource name.
+ * @param {Object<string, Hotlist>} state The existing Hotlist data.
+ * @param {AnyAction} action
+ * @param {Hotlist} action.hotlist The Hotlist that was fetched.
+ * @return {Object<string, Hotlist>}
+ */
+export const byNameReducer = createReducer({}, {
+  [RECEIVE_HOTLIST]: (state, {hotlist}) => {
+    if (!hotlist.defaultColumns) hotlist.defaultColumns = [];
+    if (!hotlist.editors) hotlist.editors = [];
+    return {...state, [hotlist.name]: hotlist};
+  },
+});
+
+/**
+ * All Hotlist items indexed by Hotlist resource name.
+ * @param {Object<string, Array<HotlistItem>>} state The existing items.
+ * @param {AnyAction} action
+ * @param {name} action.name The Hotlist resource name.
+ * @param {Array<HotlistItem>} action.items The Hotlist items fetched.
+ * @return {Object<string, Array<HotlistItem>>}
+ */
+export const hotlistItemsReducer = createReducer({}, {
+  [FETCH_ITEMS_SUCCESS]: (state, {name, items}) => ({...state, [name]: items}),
+});
+
+export const requestsReducer = combineReducers({
+  deleteHotlist: createRequestReducer(
+      DELETE_START, DELETE_SUCCESS, DELETE_FAILURE),
+  fetch: createRequestReducer(
+      FETCH_START, FETCH_SUCCESS, FETCH_FAILURE),
+  fetchItems: createRequestReducer(
+      FETCH_ITEMS_START, FETCH_ITEMS_SUCCESS, FETCH_ITEMS_FAILURE),
+  removeEditors: createRequestReducer(
+      REMOVE_EDITORS_START, REMOVE_EDITORS_SUCCESS, REMOVE_EDITORS_FAILURE),
+  removeItems: createRequestReducer(
+      REMOVE_ITEMS_START, REMOVE_ITEMS_SUCCESS, REMOVE_ITEMS_FAILURE),
+  rerankItems: createRequestReducer(
+      RERANK_ITEMS_START, RERANK_ITEMS_SUCCESS, RERANK_ITEMS_FAILURE),
+  update: createRequestReducer(
+      UPDATE_START, UPDATE_SUCCESS, UPDATE_FAILURE),
+});
+
+export const reducer = combineReducers({
+  name: nameReducer,
+
+  byName: byNameReducer,
+  hotlistItems: hotlistItemsReducer,
+
+  requests: requestsReducer,
+});
+
+// Selectors
+
+/**
+ * Returns the currently viewed Hotlist resource name, or null if there is none.
+ * @param {any} state
+ * @return {?string}
+ */
+export const name = (state) => state.hotlists.name;
+
+/**
+ * Returns all the Hotlist data in the store as a mapping from name to Hotlist.
+ * @param {any} state
+ * @return {Object<string, Hotlist>}
+ */
+export const byName = (state) => state.hotlists.byName;
+
+/**
+ * Returns all the Hotlist items in the store as a mapping from a
+ * Hotlist resource name to its respective array of HotlistItems.
+ * @param {any} state
+ * @return {Object<string, Array<HotlistItem>>}
+ */
+export const hotlistItems = (state) => state.hotlists.hotlistItems;
+
+/**
+ * Returns the currently viewed Hotlist, or null if there is none.
+ * @param {any} state
+ * @return {?Hotlist}
+ */
+export const viewedHotlist = createSelector(
+    [byName, name],
+    (byName, name) => name && byName[name] || null);
+
+/**
+ * Returns the owner of the currently viewed Hotlist, or null if there is none.
+ * @param {any} state
+ * @return {?User}
+ */
+export const viewedHotlistOwner = createSelector(
+    [viewedHotlist, users.byName],
+    (hotlist, usersByName) => {
+      return hotlist && usersByName[hotlist.owner] || null;
+    });
+
+/**
+ * Returns the editors of the currently viewed Hotlist. Returns null if there
+ * is no hotlist data. Includes a null in the array for each editor whose User
+ * data is not in the store.
+ * @param {any} state
+ * @return {Array<User>}
+ */
+export const viewedHotlistEditors = createSelector(
+    [viewedHotlist, users.byName],
+    (hotlist, usersByName) => {
+      if (!hotlist) return null;
+      return hotlist.editors.map((editor) => usersByName[editor] || null);
+    });
+
+/**
+ * Returns an Array containing the items in the currently viewed Hotlist,
+ * or [] if there is no current Hotlist or no Hotlist data.
+ * @param {any} state
+ * @return {Array<HotlistItem>}
+ */
+export const viewedHotlistItems = createSelector(
+    [hotlistItems, name],
+    (hotlistItems, name) => name && hotlistItems[name] || []);
+
+/**
+ * Returns an Array containing the HotlistIssues in the currently viewed
+ * Hotlist, or [] if there is no current Hotlist or no Hotlist data.
+ * A HotlistIssue merges the HotlistItem and Issue into one flat object.
+ * @param {any} state
+ * @return {Array<HotlistIssue>}
+ */
+export const viewedHotlistIssues = createSelector(
+    [viewedHotlistItems, issueV0.issue, users.byName],
+    (items, getIssue, usersByName) => {
+      // Filter out issues that haven't been fetched yet or failed to fetch.
+      // Example: if the user doesn't have permissions to view the issue.
+      // <mr-issue-list> assumes that every Issue is populated.
+      const itemsWithData = items.filter((item) => getIssue(item.issue));
+      return itemsWithData.map((item) => ({
+        ...getIssue(item.issue),
+        ...item,
+        adder: usersByName[item.adder],
+      }));
+    });
+
+/**
+ * Returns the currently viewed Hotlist columns.
+ * @param {any} state
+ * @return {Array<string>}
+ */
+export const viewedHotlistColumns = createSelector(
+    [viewedHotlist, sitewide.currentColumns],
+    (hotlist, sitewideCurrentColumns) => {
+      if (sitewideCurrentColumns) return sitewideCurrentColumns;
+      if (!hotlist) return DEFAULT_COLUMNS;
+      if (!hotlist.defaultColumns.length) return DEFAULT_COLUMNS;
+      return hotlist.defaultColumns.map((col) => col.column);
+    });
+
+/**
+ * Returns the currently viewed Hotlist permissions, or [] if there is none.
+ * @param {any} state
+ * @return {Array<Permission>}
+ */
+export const viewedHotlistPermissions = createSelector(
+    [viewedHotlist, permissions.byName],
+    (hotlist, permissionsByName) => {
+      if (!hotlist) return [];
+      const permissionSet = permissionsByName[hotlist.name];
+      if (!permissionSet) return [];
+      return permissionSet.permissions;
+    });
+
+/**
+ * Returns the Hotlist requests.
+ * @param {any} state
+ * @return {Object<string, ReduxRequestState>}
+ */
+export const requests = (state) => state.hotlists.requests;
+
+// Action Creators
+
+/**
+ * Action creator to delete the Hotlist. We would have liked to have named this
+ * `delete` but it's a reserved word in JS.
+ * @param {string} name The resource name of the Hotlist to delete.
+ * @return {function(function): Promise<void>}
+ */
+export const deleteHotlist = (name) => async (dispatch) => {
+  dispatch({type: DELETE_START});
+
+  try {
+    const args = {name};
+    await prpcClient.call('monorail.v3.Hotlists', 'DeleteHotlist', args);
+
+    dispatch({type: DELETE_SUCCESS});
+  } catch (error) {
+    dispatch({type: DELETE_FAILURE, error});
+  };
+};
+
+/**
+ * Action creator to fetch a Hotlist object.
+ * @param {string} name The resource name of the Hotlist to fetch.
+ * @return {function(function): Promise<void>}
+ */
+export const fetch = (name) => async (dispatch) => {
+  dispatch({type: FETCH_START});
+
+  try {
+    /** @type {Hotlist} */
+    const hotlist = await prpcClient.call(
+        'monorail.v3.Hotlists', 'GetHotlist', {name});
+    dispatch({type: FETCH_SUCCESS});
+    dispatch({type: RECEIVE_HOTLIST, hotlist});
+
+    const editors = hotlist.editors.map((editor) => editor);
+    editors.push(hotlist.owner);
+    await dispatch(users.batchGet(editors));
+  } catch (error) {
+    dispatch({type: FETCH_FAILURE, error});
+  };
+};
+
+/**
+ * Action creator to fetch the items in a Hotlist.
+ * @param {string} name The resource name of the Hotlist to fetch.
+ * @return {function(function): Promise<Array<HotlistItem>>}
+ */
+export const fetchItems = (name) => async (dispatch) => {
+  dispatch({type: FETCH_ITEMS_START});
+
+  try {
+    const args = {parent: name, orderBy: 'rank'};
+    /** @type {{items: Array<HotlistItem>}} */
+    const {items} = await prpcClient.call(
+        'monorail.v3.Hotlists', 'ListHotlistItems', args);
+    if (!items) {
+      dispatch({type: FETCH_ITEMS_SUCCESS, name, items: []});
+    }
+    const itemsWithRank =
+        items.map((item) => item.rank ? item : {...item, rank: 0});
+
+    const issueRefs = items.map((item) => issueNameToRef(item.issue));
+    await dispatch(issueV0.fetchIssues(issueRefs));
+
+    const adderNames = [...new Set(items.map((item) => item.adder))];
+    await dispatch(users.batchGet(adderNames));
+
+    dispatch({type: FETCH_ITEMS_SUCCESS, name, items: itemsWithRank});
+    return itemsWithRank;
+  } catch (error) {
+    dispatch({type: FETCH_ITEMS_FAILURE, error});
+  };
+};
+
+/**
+ * Action creator to remove editors from a Hotlist.
+ * @param {string} name The resource name of the Hotlist.
+ * @param {Array<string>} editors The resource names of the Users to remove.
+ * @return {function(function): Promise<void>}
+ */
+export const removeEditors = (name, editors) => async (dispatch) => {
+  dispatch({type: REMOVE_EDITORS_START});
+
+  try {
+    const args = {name, editors};
+    await prpcClient.call('monorail.v3.Hotlists', 'RemoveHotlistEditors', args);
+
+    dispatch({type: REMOVE_EDITORS_SUCCESS});
+
+    await dispatch(fetch(name));
+  } catch (error) {
+    dispatch({type: REMOVE_EDITORS_FAILURE, error});
+  };
+};
+
+/**
+ * Action creator to remove items from a Hotlist.
+ * @param {string} name The resource name of the Hotlist.
+ * @param {Array<string>} issues The resource names of the Issues to remove.
+ * @return {function(function): Promise<void>}
+ */
+export const removeItems = (name, issues) => async (dispatch) => {
+  dispatch({type: REMOVE_ITEMS_START});
+
+  try {
+    const args = {parent: name, issues};
+    await prpcClient.call('monorail.v3.Hotlists', 'RemoveHotlistItems', args);
+
+    dispatch({type: REMOVE_ITEMS_SUCCESS});
+
+    await dispatch(fetchItems(name));
+  } catch (error) {
+    dispatch({type: REMOVE_ITEMS_FAILURE, error});
+  };
+};
+
+/**
+ * Action creator to rerank the items in a Hotlist.
+ * @param {string} name The resource name of the Hotlist.
+ * @param {Array<string>} items The resource names of the HotlistItems to move.
+ * @param {number} index The index to insert the moved items.
+ * @return {function(function): Promise<void>}
+ */
+export const rerankItems = (name, items, index) => async (dispatch) => {
+  dispatch({type: RERANK_ITEMS_START});
+
+  try {
+    const args = {name, hotlistItems: items, targetPosition: index};
+    await prpcClient.call('monorail.v3.Hotlists', 'RerankHotlistItems', args);
+
+    dispatch({type: RERANK_ITEMS_SUCCESS});
+
+    await dispatch(fetchItems(name));
+  } catch (error) {
+    dispatch({type: RERANK_ITEMS_FAILURE, error});
+  };
+};
+
+/**
+ * Action creator to set the currently viewed Hotlist.
+ * @param {string} name The resource name of the Hotlist to select.
+ * @return {AnyAction}
+ */
+export const select = (name) => ({type: SELECT, name});
+
+/**
+ * Action creator to update the Hotlist metadata.
+ * @param {string} name The resource name of the Hotlist to delete.
+ * @param {Hotlist} hotlist This represents the updated version of the Hotlist
+ *    with only the fields that need to be updated.
+ * @return {function(function): Promise<void>}
+ */
+export const update = (name, hotlist) => async (dispatch) => {
+  dispatch({type: UPDATE_START});
+  try {
+    const paths = pathsToFieldMask(Object.keys(hotlist));
+    const hotlistArg = {...hotlist, name};
+    const args = {hotlist: hotlistArg, updateMask: paths};
+
+    /** @type {Hotlist} */
+    const updatedHotlist = await prpcClient.call(
+        'monorail.v3.Hotlists', 'UpdateHotlist', args);
+    dispatch({type: UPDATE_SUCCESS});
+    dispatch({type: RECEIVE_HOTLIST, hotlist: updatedHotlist});
+
+    const editors = updatedHotlist.editors.map((editor) => editor);
+    editors.push(updatedHotlist.owner);
+    await dispatch(users.batchGet(editors));
+  } catch (error) {
+    dispatch({type: UPDATE_FAILURE, error});
+    dispatch(ui.showSnackbar(UPDATE_FAILURE, error.description));
+    throw error;
+  }
+};
+
+// Helpers
+
+/**
+ * Helper to fetch a Hotlist ID given its owner and display name.
+ * @param {string} owner The Hotlist owner's user id or display name.
+ * @param {string} hotlist The Hotlist's id or display name.
+ * @return {Promise<?string>}
+ */
+export const getHotlistName = async (owner, hotlist) => {
+  const hotlistRef = {
+    owner: userIdOrDisplayNameToUserRef(owner),
+    name: hotlist,
+  };
+
+  try {
+    /** @type {{hotlistId: number}} */
+    const {hotlistId} = await prpcClient.call(
+        'monorail.Features', 'GetHotlistID', {hotlistRef});
+    return 'hotlists/' + hotlistId;
+  } catch (error) {
+    return null;
+  };
+};
+
+export const hotlists = {
+  // Permissions
+  EDIT,
+  ADMINISTER,
+
+  // Reducer
+  reducer,
+
+  // Selectors
+  name,
+  byName,
+  hotlistItems,
+  viewedHotlist,
+  viewedHotlistOwner,
+  viewedHotlistEditors,
+  viewedHotlistItems,
+  viewedHotlistIssues,
+  viewedHotlistColumns,
+  viewedHotlistPermissions,
+  requests,
+
+  // Action creators
+  deleteHotlist,
+  fetch,
+  fetchItems,
+  removeEditors,
+  removeItems,
+  rerankItems,
+  select,
+  update,
+
+  // Helpers
+  getHotlistName,
+};
diff --git a/static_src/reducers/hotlists.test.js b/static_src/reducers/hotlists.test.js
new file mode 100644
index 0000000..4aa42a2
--- /dev/null
+++ b/static_src/reducers/hotlists.test.js
@@ -0,0 +1,568 @@
+// 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 * as hotlists from './hotlists.js';
+import * as example from 'shared/test/constants-hotlists.js';
+import * as exampleIssues from 'shared/test/constants-issueV0.js';
+import * as exampleUsers from 'shared/test/constants-users.js';
+
+import {prpcClient} from 'prpc-client-instance.js';
+
+let dispatch;
+
+describe('hotlist reducers', () => {
+  it('root reducer initial state', () => {
+    const actual = hotlists.reducer(undefined, {type: null});
+    const expected = {
+      name: null,
+      byName: {},
+      hotlistItems: {},
+      requests: {
+        deleteHotlist: {error: null, requesting: false},
+        fetch: {error: null, requesting: false},
+        fetchItems: {error: null, requesting: false},
+        removeEditors: {error: null, requesting: false},
+        removeItems: {error: null, requesting: false},
+        rerankItems: {error: null, requesting: false},
+        update: {error: null, requesting: false},
+      },
+    };
+    assert.deepEqual(actual, expected);
+  });
+
+  it('name updates on SELECT', () => {
+    const action = {type: hotlists.SELECT, name: example.NAME};
+    const actual = hotlists.nameReducer(null, action);
+    assert.deepEqual(actual, example.NAME);
+  });
+
+  it('byName updates on RECEIVE_HOTLIST', () => {
+    const action = {type: hotlists.RECEIVE_HOTLIST, hotlist: example.HOTLIST};
+    const actual = hotlists.byNameReducer({}, action);
+    assert.deepEqual(actual, example.BY_NAME);
+  });
+
+  it('byName fills in missing fields on RECEIVE_HOTLIST', () => {
+    const action = {
+      type: hotlists.RECEIVE_HOTLIST,
+      hotlist: {name: example.NAME},
+    };
+    const actual = hotlists.byNameReducer({}, action);
+
+    const hotlist = {name: example.NAME, defaultColumns: [], editors: []};
+    assert.deepEqual(actual, {[example.NAME]: hotlist});
+  });
+
+  it('hotlistItems updates on FETCH_ITEMS_SUCCESS', () => {
+    const action = {
+      type: hotlists.FETCH_ITEMS_SUCCESS,
+      name: example.NAME,
+      items: [example.HOTLIST_ITEM],
+    };
+    const actual = hotlists.hotlistItemsReducer({}, action);
+    assert.deepEqual(actual, example.HOTLIST_ITEMS);
+  });
+});
+
+describe('hotlist selectors', () => {
+  it('name', () => {
+    const state = {hotlists: {name: example.NAME}};
+    assert.deepEqual(hotlists.name(state), example.NAME);
+  });
+
+  it('byName', () => {
+    const state = {hotlists: {byName: example.BY_NAME}};
+    assert.deepEqual(hotlists.byName(state), example.BY_NAME);
+  });
+
+  it('hotlistItems', () => {
+    const state = {hotlists: {hotlistItems: example.HOTLIST_ITEMS}};
+    assert.deepEqual(hotlists.hotlistItems(state), example.HOTLIST_ITEMS);
+  });
+
+  describe('viewedHotlist', () => {
+    it('normal case', () => {
+      const state = {hotlists: {name: example.NAME, byName: example.BY_NAME}};
+      assert.deepEqual(hotlists.viewedHotlist(state), example.HOTLIST);
+    });
+
+    it('no name', () => {
+      const state = {hotlists: {name: null, byName: example.BY_NAME}};
+      assert.deepEqual(hotlists.viewedHotlist(state), null);
+    });
+
+    it('hotlist not found', () => {
+      const state = {hotlists: {name: example.NAME, byName: {}}};
+      assert.deepEqual(hotlists.viewedHotlist(state), null);
+    });
+  });
+
+  describe('viewedHotlistOwner', () => {
+    it('normal case', () => {
+      const state = {
+        hotlists: {name: example.NAME, byName: example.BY_NAME},
+        users: {byName: exampleUsers.BY_NAME},
+      };
+      assert.deepEqual(hotlists.viewedHotlistOwner(state), exampleUsers.USER);
+    });
+
+    it('no hotlist', () => {
+      const state = {hotlists: {}, users: {}};
+      assert.deepEqual(hotlists.viewedHotlistOwner(state), null);
+    });
+  });
+
+  describe('viewedHotlistEditors', () => {
+    it('normal case', () => {
+      const state = {
+        hotlists: {
+          name: example.NAME,
+          byName: {[example.NAME]: {
+            ...example.HOTLIST,
+            editors: [exampleUsers.NAME, exampleUsers.NAME_2],
+          }},
+        },
+        users: {byName: exampleUsers.BY_NAME},
+      };
+
+      const editors = [exampleUsers.USER, exampleUsers.USER_2];
+      assert.deepEqual(hotlists.viewedHotlistEditors(state), editors);
+    });
+
+    it('no user data', () => {
+      const editors = [exampleUsers.NAME, exampleUsers.NAME_2];
+      const state = {
+        hotlists: {
+          name: example.NAME,
+          byName: {[example.NAME]: {...example.HOTLIST, editors}},
+        },
+        users: {byName: {}},
+      };
+      assert.deepEqual(hotlists.viewedHotlistEditors(state), [null, null]);
+    });
+
+    it('no hotlist', () => {
+      const state = {hotlists: {}, users: {}};
+      assert.deepEqual(hotlists.viewedHotlistEditors(state), null);
+    });
+  });
+
+  describe('viewedHotlistItems', () => {
+    it('normal case', () => {
+      const state = {hotlists: {
+        name: example.NAME,
+        hotlistItems: example.HOTLIST_ITEMS,
+      }};
+      const actual = hotlists.viewedHotlistItems(state);
+      assert.deepEqual(actual, [example.HOTLIST_ITEM]);
+    });
+
+    it('no name', () => {
+      const state = {hotlists: {
+        name: null,
+        hotlistItems: example.HOTLIST_ITEMS,
+      }};
+      assert.deepEqual(hotlists.viewedHotlistItems(state), []);
+    });
+
+    it('hotlist not found', () => {
+      const state = {hotlists: {name: example.NAME, hotlistItems: {}}};
+      assert.deepEqual(hotlists.viewedHotlistItems(state), []);
+    });
+  });
+
+  describe('viewedHotlistIssues', () => {
+    it('normal case', () => {
+      const state = {
+        hotlists: {
+          name: example.NAME,
+          hotlistItems: example.HOTLIST_ITEMS,
+        },
+        issue: {
+          issuesByRefString: {
+            [exampleIssues.ISSUE_REF_STRING]: exampleIssues.ISSUE,
+          },
+        },
+        users: {byName: {[exampleUsers.NAME]: exampleUsers.USER}},
+      };
+      const actual = hotlists.viewedHotlistIssues(state);
+      assert.deepEqual(actual, [example.HOTLIST_ISSUE]);
+    });
+
+    it('no issue', () => {
+      const state = {
+        hotlists: {
+          name: example.NAME,
+          hotlistItems: example.HOTLIST_ITEMS,
+        },
+        issue: {
+          issuesByRefString: {
+            [exampleIssues.ISSUE_OTHER_PROJECT_REF_STRING]: exampleIssues.ISSUE,
+          },
+        },
+        users: {byName: {}},
+      };
+      assert.deepEqual(hotlists.viewedHotlistIssues(state), []);
+    });
+  });
+
+  describe('viewedHotlistColumns', () => {
+    it('sitewide currentColumns overrides hotlist defaultColumns', () => {
+      const state = {
+        sitewide: {queryParams: {colspec: 'Summary+ColumnName'}},
+        hotlists: {},
+      };
+      const actual = hotlists.viewedHotlistColumns(state);
+      assert.deepEqual(actual, ['Summary', 'ColumnName']);
+    });
+
+    it('uses DEFAULT_COLUMNS when no hotlist', () => {
+      const actual = hotlists.viewedHotlistColumns({hotlists: {}});
+      assert.deepEqual(actual, hotlists.DEFAULT_COLUMNS);
+    });
+
+    it('uses DEFAULT_COLUMNS when hotlist has empty defaultColumns', () => {
+      const state = {hotlists: {
+        name: example.HOTLIST.name,
+        byName: {
+          [example.HOTLIST.name]: {...example.HOTLIST, defaultColumns: []},
+        },
+      }};
+      const actual = hotlists.viewedHotlistColumns(state);
+      assert.deepEqual(actual, hotlists.DEFAULT_COLUMNS);
+    });
+
+    it('uses hotlist defaultColumns', () => {
+      const state = {hotlists: {
+        name: example.HOTLIST.name,
+        byName: {[example.HOTLIST.name]: {
+          ...example.HOTLIST,
+          defaultColumns: [{column: 'ID'}, {column: 'ColumnName'}],
+        }},
+      }};
+      const actual = hotlists.viewedHotlistColumns(state);
+      assert.deepEqual(actual, ['ID', 'ColumnName']);
+    });
+  });
+
+  describe('viewedHotlistPermissions', () => {
+    it('normal case', () => {
+      const permissions = [hotlists.ADMINISTER, hotlists.EDIT];
+      const state = {
+        hotlists: {name: example.NAME, byName: example.BY_NAME},
+        permissions: {byName: {[example.NAME]: {permissions}}},
+      };
+      assert.deepEqual(hotlists.viewedHotlistPermissions(state), permissions);
+    });
+
+    it('no issue', () => {
+      const state = {hotlists: {}, permissions: {}};
+      assert.deepEqual(hotlists.viewedHotlistPermissions(state), []);
+    });
+  });
+});
+
+describe('hotlist action creators', () => {
+  beforeEach(() => {
+    sinon.stub(prpcClient, 'call');
+    dispatch = sinon.stub();
+  });
+
+  afterEach(() => {
+    prpcClient.call.restore();
+  });
+
+  it('select', () => {
+    const actual = hotlists.select(example.NAME);
+    const expected = {type: hotlists.SELECT, name: example.NAME};
+    assert.deepEqual(actual, expected);
+  });
+
+  describe('deleteHotlist', () => {
+    it('success', async () => {
+      prpcClient.call.returns(Promise.resolve({}));
+
+      await hotlists.deleteHotlist(example.NAME)(dispatch);
+
+      sinon.assert.calledWith(dispatch, {type: hotlists.DELETE_START});
+
+      const args = {name: example.NAME};
+      sinon.assert.calledWith(
+          prpcClient.call, 'monorail.v3.Hotlists', 'DeleteHotlist', args);
+
+      sinon.assert.calledWith(dispatch, {type: hotlists.DELETE_SUCCESS});
+    });
+
+    it('failure', async () => {
+      prpcClient.call.throws();
+
+      await hotlists.deleteHotlist(example.NAME)(dispatch);
+
+      const action = {
+        type: hotlists.DELETE_FAILURE,
+        error: sinon.match.any,
+      };
+      sinon.assert.calledWith(dispatch, action);
+    });
+  });
+
+  describe('fetch', () => {
+    it('success', async () => {
+      const hotlist = example.HOTLIST;
+      prpcClient.call.returns(Promise.resolve(hotlist));
+
+      await hotlists.fetch(example.NAME)(dispatch);
+
+      const args = {name: example.NAME};
+      sinon.assert.calledWith(
+          prpcClient.call, 'monorail.v3.Hotlists', 'GetHotlist', args);
+
+      sinon.assert.calledWith(dispatch, {type: hotlists.FETCH_START});
+      sinon.assert.calledWith(dispatch, {type: hotlists.FETCH_SUCCESS});
+      sinon.assert.calledWith(
+          dispatch, {type: hotlists.RECEIVE_HOTLIST, hotlist});
+    });
+
+    it('failure', async () => {
+      prpcClient.call.throws();
+
+      await hotlists.fetch(example.NAME)(dispatch);
+
+      const action = {type: hotlists.FETCH_FAILURE, error: sinon.match.any};
+      sinon.assert.calledWith(dispatch, action);
+    });
+  });
+
+  describe('fetchItems', () => {
+    it('success', async () => {
+      const response = {items: [example.HOTLIST_ITEM]};
+      prpcClient.call.returns(Promise.resolve(response));
+
+      const returnValue = await hotlists.fetchItems(example.NAME)(dispatch);
+      assert.deepEqual(returnValue, [{...example.HOTLIST_ITEM, rank: 0}]);
+
+      const args = {parent: example.NAME, orderBy: 'rank'};
+      sinon.assert.calledWith(
+          prpcClient.call, 'monorail.v3.Hotlists', 'ListHotlistItems', args);
+
+      sinon.assert.calledWith(dispatch, {type: hotlists.FETCH_ITEMS_START});
+      const action = {
+        type: hotlists.FETCH_ITEMS_SUCCESS,
+        name: example.NAME,
+        items: [{...example.HOTLIST_ITEM, rank: 0}],
+      };
+      sinon.assert.calledWith(dispatch, action);
+    });
+
+    it('failure', async () => {
+      prpcClient.call.throws();
+
+      await hotlists.fetchItems(example.NAME)(dispatch);
+
+      const action = {
+        type: hotlists.FETCH_ITEMS_FAILURE,
+        error: sinon.match.any,
+      };
+      sinon.assert.calledWith(dispatch, action);
+    });
+
+    it('success with empty hotlist', async () => {
+      const response = {items: []};
+      prpcClient.call.returns(Promise.resolve(response));
+
+      const returnValue = await hotlists.fetchItems(example.NAME)(dispatch);
+      assert.deepEqual(returnValue, []);
+
+      sinon.assert.calledWith(dispatch, {type: hotlists.FETCH_ITEMS_START});
+
+      const args = {parent: example.NAME, orderBy: 'rank'};
+      sinon.assert.calledWith(
+          prpcClient.call, 'monorail.v3.Hotlists', 'ListHotlistItems', args);
+
+      const action = {
+        type: hotlists.FETCH_ITEMS_SUCCESS,
+        name: example.NAME,
+        items: [],
+      };
+      sinon.assert.calledWith(dispatch, action);
+    });
+  });
+
+  describe('removeEditors', () => {
+    it('success', async () => {
+      prpcClient.call.returns(Promise.resolve({}));
+
+      const editors = [exampleUsers.NAME];
+      await hotlists.removeEditors(example.NAME, editors)(dispatch);
+
+      const args = {name: example.NAME, editors};
+      sinon.assert.calledWith(
+          prpcClient.call, 'monorail.v3.Hotlists',
+          'RemoveHotlistEditors', args);
+
+      sinon.assert.calledWith(dispatch, {type: hotlists.REMOVE_EDITORS_START});
+      const action = {type: hotlists.REMOVE_EDITORS_SUCCESS};
+      sinon.assert.calledWith(dispatch, action);
+    });
+
+    it('failure', async () => {
+      prpcClient.call.throws();
+
+      await hotlists.removeEditors(example.NAME, [])(dispatch);
+
+      const action = {
+        type: hotlists.REMOVE_EDITORS_FAILURE,
+        error: sinon.match.any,
+      };
+      sinon.assert.calledWith(dispatch, action);
+    });
+  });
+
+  describe('removeItems', () => {
+    it('success', async () => {
+      prpcClient.call.returns(Promise.resolve({}));
+
+      const issues = [exampleIssues.NAME];
+      await hotlists.removeItems(example.NAME, issues)(dispatch);
+
+      const args = {parent: example.NAME, issues};
+      sinon.assert.calledWith(
+          prpcClient.call, 'monorail.v3.Hotlists',
+          'RemoveHotlistItems', args);
+
+      sinon.assert.calledWith(dispatch, {type: hotlists.REMOVE_ITEMS_START});
+      sinon.assert.calledWith(dispatch, {type: hotlists.REMOVE_ITEMS_SUCCESS});
+    });
+
+    it('failure', async () => {
+      prpcClient.call.throws();
+
+      await hotlists.removeItems(example.NAME, [])(dispatch);
+
+      const action = {
+        type: hotlists.REMOVE_ITEMS_FAILURE,
+        error: sinon.match.any,
+      };
+      sinon.assert.calledWith(dispatch, action);
+    });
+  });
+
+  describe('rerankItems', () => {
+    it('success', async () => {
+      prpcClient.call.returns(Promise.resolve({}));
+
+      const items = [example.HOTLIST_ITEM_NAME];
+      await hotlists.rerankItems(example.NAME, items, 0)(dispatch);
+
+      const args = {
+        name: example.NAME,
+        hotlistItems: items,
+        targetPosition: 0,
+      };
+      sinon.assert.calledWith(
+          prpcClient.call, 'monorail.v3.Hotlists',
+          'RerankHotlistItems', args);
+
+      sinon.assert.calledWith(dispatch, {type: hotlists.RERANK_ITEMS_START});
+      sinon.assert.calledWith(dispatch, {type: hotlists.RERANK_ITEMS_SUCCESS});
+    });
+
+    it('failure', async () => {
+      prpcClient.call.throws();
+
+      await hotlists.rerankItems(example.NAME, [], 0)(dispatch);
+
+      const action = {
+        type: hotlists.RERANK_ITEMS_FAILURE,
+        error: sinon.match.any,
+      };
+      sinon.assert.calledWith(dispatch, action);
+    });
+  });
+
+  describe('update', () => {
+    it('success', async () => {
+      const hotlistOnlyWithUpdates = {
+        displayName: example.HOTLIST.displayName + 'foo',
+        summary: example.HOTLIST.summary + 'abc',
+      };
+      const hotlist = {...example.HOTLIST, ...hotlistOnlyWithUpdates};
+      prpcClient.call.returns(Promise.resolve(hotlist));
+
+      await hotlists.update(
+          example.HOTLIST.name, hotlistOnlyWithUpdates)(dispatch);
+
+      const hotlistArg = {
+        ...hotlistOnlyWithUpdates,
+        name: example.HOTLIST.name,
+      };
+      const fieldMask = 'displayName,summary';
+      const args = {hotlist: hotlistArg, updateMask: fieldMask};
+      sinon.assert.calledWith(
+          prpcClient.call, 'monorail.v3.Hotlists', 'UpdateHotlist', args);
+
+      sinon.assert.calledWith(dispatch, {type: hotlists.UPDATE_START});
+      sinon.assert.calledWith(dispatch, {type: hotlists.UPDATE_SUCCESS});
+      sinon.assert.calledWith(
+          dispatch, {type: hotlists.RECEIVE_HOTLIST, hotlist});
+    });
+
+    it('failure', async () => {
+      prpcClient.call.throws();
+      const hotlistOnlyWithUpdates = {
+        displayName: example.HOTLIST.displayName + 'foo',
+        summary: example.HOTLIST.summary + 'abc',
+      };
+      try {
+        // TODO(crbug.com/monorail/7883): Use Chai Promises plugin
+        // to assert promise rejected.
+        await hotlists.update(
+            example.HOTLIST.name, hotlistOnlyWithUpdates)(dispatch);
+      } catch (e) {}
+
+      const action = {
+        type: hotlists.UPDATE_FAILURE,
+        error: sinon.match.any,
+      };
+      sinon.assert.calledWith(dispatch, action);
+    });
+  });
+});
+
+describe('helpers', () => {
+  beforeEach(() => {
+    sinon.stub(prpcClient, 'call');
+    dispatch = sinon.stub();
+  });
+
+  afterEach(() => {
+    prpcClient.call.restore();
+  });
+
+  describe('getHotlistName', () => {
+    it('success', async () => {
+      const response = {hotlistId: '1234'};
+      prpcClient.call.returns(Promise.resolve(response));
+
+      const name = await hotlists.getHotlistName('foo@bar.com', 'hotlist');
+      assert.deepEqual(name, 'hotlists/1234');
+
+      const args = {hotlistRef: {
+        owner: {displayName: 'foo@bar.com'},
+        name: 'hotlist',
+      }};
+      sinon.assert.calledWith(
+          prpcClient.call, 'monorail.Features', 'GetHotlistID', args);
+    });
+
+    it('failure', async () => {
+      prpcClient.call.throws();
+
+      assert.isNull(await hotlists.getHotlistName('foo@bar.com', 'hotlist'));
+    });
+  });
+});
diff --git a/static_src/reducers/issueV0.js b/static_src/reducers/issueV0.js
new file mode 100644
index 0000000..36c446d
--- /dev/null
+++ b/static_src/reducers/issueV0.js
@@ -0,0 +1,1411 @@
+// 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 Issue actions, selectors, and reducers organized into
+ * a single Redux "Duck" that manages updating and retrieving issue state
+ * on the frontend.
+ *
+ * Reference: https://github.com/erikras/ducks-modular-redux
+ */
+
+import {combineReducers} from 'redux';
+import {createSelector} from 'reselect';
+import {autolink} from 'autolink.js';
+import {fieldTypes, extractTypeForIssue,
+  fieldValuesToMap} from 'shared/issue-fields.js';
+import {removePrefix, objectToMap} from 'shared/helpers.js';
+import {issueRefToString, issueToIssueRefString,
+  issueStringToRef, issueNameToRefString} from 'shared/convertersV0.js';
+import {fromShortlink} from 'shared/federated.js';
+import {createReducer, createRequestReducer,
+  createKeyedRequestReducer} from './redux-helpers.js';
+import * as projectV0 from './projectV0.js';
+import * as userV0 from './userV0.js';
+import {fieldValueMapKey} from 'shared/metadata-helpers.js';
+import {prpcClient} from 'prpc-client-instance.js';
+import loadGapi, {fetchGapiEmail} from 'shared/gapi-loader.js';
+import 'shared/typedef.js';
+
+/** @typedef {import('redux').AnyAction} AnyAction */
+
+// Actions
+export const VIEW_ISSUE = 'VIEW_ISSUE';
+
+export const FETCH_START = 'issueV0/FETCH_START';
+export const FETCH_SUCCESS = 'issueV0/FETCH_SUCCESS';
+export const FETCH_FAILURE = 'issueV0/FETCH_FAILURE';
+
+export const FETCH_ISSUES_START = 'issueV0/FETCH_ISSUES_START';
+export const FETCH_ISSUES_SUCCESS = 'issueV0/FETCH_ISSUES_SUCCESS';
+export const FETCH_ISSUES_FAILURE = 'issueV0/FETCH_ISSUES_FAILURE';
+
+const FETCH_HOTLISTS_START = 'FETCH_HOTLISTS_START';
+export const FETCH_HOTLISTS_SUCCESS = 'FETCH_HOTLISTS_SUCCESS';
+const FETCH_HOTLISTS_FAILURE = 'FETCH_HOTLISTS_FAILURE';
+
+const FETCH_ISSUE_LIST_START = 'FETCH_ISSUE_LIST_START';
+export const FETCH_ISSUE_LIST_UPDATE = 'FETCH_ISSUE_LIST_UPDATE';
+const FETCH_ISSUE_LIST_SUCCESS = 'FETCH_ISSUE_LIST_SUCCESS';
+const FETCH_ISSUE_LIST_FAILURE = 'FETCH_ISSUE_LIST_FAILURE';
+
+const FETCH_PERMISSIONS_START = 'FETCH_PERMISSIONS_START';
+const FETCH_PERMISSIONS_SUCCESS = 'FETCH_PERMISSIONS_SUCCESS';
+const FETCH_PERMISSIONS_FAILURE = 'FETCH_PERMISSIONS_FAILURE';
+
+export const STAR_START = 'STAR_START';
+export const STAR_SUCCESS = 'STAR_SUCCESS';
+const STAR_FAILURE = 'STAR_FAILURE';
+
+const PRESUBMIT_START = 'PRESUBMIT_START';
+const PRESUBMIT_SUCCESS = 'PRESUBMIT_SUCCESS';
+const PRESUBMIT_FAILURE = 'PRESUBMIT_FAILURE';
+
+export const FETCH_IS_STARRED_START = 'FETCH_IS_STARRED_START';
+export const FETCH_IS_STARRED_SUCCESS = 'FETCH_IS_STARRED_SUCCESS';
+const FETCH_IS_STARRED_FAILURE = 'FETCH_IS_STARRED_FAILURE';
+
+const FETCH_ISSUES_STARRED_START = 'FETCH_ISSUES_STARRED_START';
+export const FETCH_ISSUES_STARRED_SUCCESS = 'FETCH_ISSUES_STARRED_SUCCESS';
+const FETCH_ISSUES_STARRED_FAILURE = 'FETCH_ISSUES_STARRED_FAILURE';
+
+const FETCH_COMMENTS_START = 'FETCH_COMMENTS_START';
+export const FETCH_COMMENTS_SUCCESS = 'FETCH_COMMENTS_SUCCESS';
+const FETCH_COMMENTS_FAILURE = 'FETCH_COMMENTS_FAILURE';
+
+const FETCH_COMMENT_REFERENCES_START = 'FETCH_COMMENT_REFERENCES_START';
+const FETCH_COMMENT_REFERENCES_SUCCESS = 'FETCH_COMMENT_REFERENCES_SUCCESS';
+const FETCH_COMMENT_REFERENCES_FAILURE = 'FETCH_COMMENT_REFERENCES_FAILURE';
+
+const FETCH_REFERENCED_USERS_START = 'FETCH_REFERENCED_USERS_START';
+const FETCH_REFERENCED_USERS_SUCCESS = 'FETCH_REFERENCED_USERS_SUCCESS';
+const FETCH_REFERENCED_USERS_FAILURE = 'FETCH_REFERENCED_USERS_FAILURE';
+
+const FETCH_RELATED_ISSUES_START = 'FETCH_RELATED_ISSUES_START';
+const FETCH_RELATED_ISSUES_SUCCESS = 'FETCH_RELATED_ISSUES_SUCCESS';
+const FETCH_RELATED_ISSUES_FAILURE = 'FETCH_RELATED_ISSUES_FAILURE';
+
+const FETCH_FEDERATED_REFERENCES_START = 'FETCH_FEDERATED_REFERENCES_START';
+const FETCH_FEDERATED_REFERENCES_SUCCESS = 'FETCH_FEDERATED_REFERENCES_SUCCESS';
+const FETCH_FEDERATED_REFERENCES_FAILURE = 'FETCH_FEDERATED_REFERENCES_FAILURE';
+
+const CONVERT_START = 'CONVERT_START';
+const CONVERT_SUCCESS = 'CONVERT_SUCCESS';
+const CONVERT_FAILURE = 'CONVERT_FAILURE';
+
+const UPDATE_START = 'UPDATE_START';
+const UPDATE_SUCCESS = 'UPDATE_SUCCESS';
+const UPDATE_FAILURE = 'UPDATE_FAILURE';
+
+const UPDATE_APPROVAL_START = 'UPDATE_APPROVAL_START';
+const UPDATE_APPROVAL_SUCCESS = 'UPDATE_APPROVAL_SUCCESS';
+const UPDATE_APPROVAL_FAILURE = 'UPDATE_APPROVAL_FAILURE';
+
+/* State Shape
+{
+  issuesByRefString: Object<IssueRefString, Issue>,
+
+  viewedIssueRef: IssueRefString,
+
+  hotlists: Array<HotlistV0>,
+  issueList: {
+    issueRefs: Array<IssueRefString>,
+    progress: number,
+    totalResults: number,
+  }
+  comments: Array<IssueComment>,
+  commentReferences: Object,
+  relatedIssues: Object,
+  referencedUsers: Array<UserV0>,
+  starredIssues: Object<IssueRefString, Boolean>,
+  permissions: Array<string>,
+  presubmitResponse: Object,
+
+  requests: {
+    fetch: ReduxRequestState,
+    fetchHotlists: ReduxRequestState,
+    fetchIssueList: ReduxRequestState,
+    fetchPermissions: ReduxRequestState,
+    starringIssues: Object<string, ReduxRequestState>,
+    presubmit: ReduxRequestState,
+    fetchComments: ReduxRequestState,
+    fetchCommentReferences: ReduxRequestState,
+    fetchFederatedReferences: ReduxRequestState,
+    fetchRelatedIssues: ReduxRequestState,
+    fetchStarredIssues: ReduxRequestState,
+    fetchStarredIssues: ReduxRequestState,
+    convert: ReduxRequestState,
+    update: ReduxRequestState,
+    updateApproval: ReduxRequestState,
+  },
+}
+*/
+
+// Helpers for the reducers.
+
+/**
+ * Overrides local data for single approval on an Issue object with fresh data.
+ * Note that while an Issue can have multiple approvals, this function only
+ * refreshes data for a single approval.
+ * @param {Issue} issue Issue Object being updated.
+ * @param {ApprovalDef} approval A single approval to override in the issue.
+ * @return {Issue} Issue with updated approval data.
+ */
+const updateApprovalValues = (issue, approval) => {
+  if (!issue.approvalValues) return issue;
+  const newApprovals = issue.approvalValues.map((item) => {
+    if (item.fieldRef.fieldName === approval.fieldRef.fieldName) {
+      // PhaseRef isn't populated on the response so we want to make sure
+      // it doesn't overwrite the original phaseRef with {}.
+      return {...approval, phaseRef: item.phaseRef};
+    }
+    return item;
+  });
+  return {...issue, approvalValues: newApprovals};
+};
+
+// Reducers
+
+/**
+ * Creates a new issuesByRefString Object with a single issue's data
+ * edited.
+ * @param {Object<IssueRefString, Issue>} issuesByRefString
+ * @param {Issue} issue The new issue data to add to the state.
+ * @return {Object<IssueRefString, Issue>}
+ */
+const updateSingleIssueInState = (issuesByRefString, issue) => {
+  return {
+    ...issuesByRefString,
+    [issueToIssueRefString(issue)]: issue,
+  };
+};
+
+// TODO(crbug.com/monorail/6882): Finish converting all other issue
+//   actions to use this format.
+/**
+ * Adds issues fetched by a ListIssues request to the Redux store in a
+ * normalized format.
+ * @param {Object<IssueRefString, Issue>} state Redux state.
+ * @param {AnyAction} action
+ * @param {Array<Issue>} action.issues The list of issues that was fetched.
+ * @param {Issue=} action.issue The issue being updated.
+ * @param {number=} action.starCount Number of stars the issue has. This changes
+ *   when a user stars an issue and needs to be updated.
+ * @param {ApprovalDef=} action.approval A new approval to update the issue
+ *   with.
+ * @param {IssueRef=} action.issueRef A specific IssueRef to update.
+ */
+export const issuesByRefStringReducer = createReducer({}, {
+  [FETCH_ISSUE_LIST_UPDATE]: (state, {issues}) => {
+    const newState = {...state};
+
+    issues.forEach((issue) => {
+      const refString = issueToIssueRefString(issue);
+      newState[refString] = {...newState[refString], ...issue};
+    });
+
+    return newState;
+  },
+  [FETCH_SUCCESS]: (state, {issue}) => updateSingleIssueInState(state, issue),
+  [FETCH_ISSUES_SUCCESS]: (state, {issues}) => {
+    const newState = {...state};
+
+    issues.forEach((issue) => {
+      const refString = issueToIssueRefString(issue);
+      newState[refString] = {...newState[refString], ...issue};
+    });
+
+    return newState;
+  },
+  [CONVERT_SUCCESS]: (state, {issue}) => updateSingleIssueInState(state, issue),
+  [UPDATE_SUCCESS]: (state, {issue}) => updateSingleIssueInState(state, issue),
+  [UPDATE_APPROVAL_SUCCESS]: (state, {issueRef, approval}) => {
+    const issueRefString = issueToIssueRefString(issueRef);
+    const originalIssue = state[issueRefString] || {};
+    const newIssue = updateApprovalValues(originalIssue, approval);
+    return {
+      ...state,
+      [issueRefString]: {
+        ...newIssue,
+      },
+    };
+  },
+  [STAR_SUCCESS]: (state, {issueRef, starCount}) => {
+    const issueRefString = issueToIssueRefString(issueRef);
+    const originalIssue = state[issueRefString] || {};
+    return {
+      ...state,
+      [issueRefString]: {
+        ...originalIssue,
+        starCount,
+      },
+    };
+  },
+});
+
+/**
+ * Sets a reference for the issue that the user is currently viewing.
+ * @param {IssueRefString} state Currently viewed issue.
+ * @param {AnyAction} action
+ * @param {IssueRef} action.issueRef The updated localId to view.
+ * @return {IssueRefString}
+ */
+const viewedIssueRefReducer = createReducer('', {
+  [VIEW_ISSUE]: (state, {issueRef}) => issueRefToString(issueRef) || state,
+});
+
+/**
+ * Reducer to manage updating the list of hotlists attached to an Issue.
+ * @param {Array<HotlistV0>} state List of issue hotlists.
+ * @param {AnyAction} action
+ * @param {Array<HotlistV0>} action.hotlists New list of hotlists.
+ * @return {Array<HotlistV0>}
+ */
+const hotlistsReducer = createReducer([], {
+  [FETCH_HOTLISTS_SUCCESS]: (_, {hotlists}) => hotlists,
+});
+
+/**
+ * @typedef {Object} IssueListState
+ * @property {Array<IssueRefString>} issues The list of issues being viewed,
+ *   in a normalized form.
+ * @property {number} progress The percentage of issues loaded. Used for
+ *   incremental loading of issues in the grid view.
+ * @property {number} totalResults The total number of issues matching the
+ *   query.
+ */
+
+/**
+ * Handles the state of the currently viewed issue list. This reducer
+ * stores this data in normalized form.
+ * @param {IssueListState} state
+ * @param {AnyAction} action
+ * @param {Array<Issue>} action.issues Issues that were fetched.
+ * @param {number} state.progress New percentage of issues have been loaded.
+ * @param {number} state.totalResults The total number of issues matching the
+ *   query.
+ * @return {IssueListState}
+ */
+export const issueListReducer = createReducer({}, {
+  [FETCH_ISSUE_LIST_UPDATE]: (_state, {issues, progress, totalResults}) => ({
+    issueRefs: issues.map(issueToIssueRefString), progress, totalResults,
+  }),
+});
+
+/**
+ * Updates the comments attached to the currently viewed issue.
+ * @param {Array<IssueComment>} state The list of comments in an issue.
+ * @param {AnyAction} action
+ * @param {Array<IssueComment>} action.comments Fetched comments.
+ * @return {Array<IssueComment>}
+ */
+const commentsReducer = createReducer([], {
+  [FETCH_COMMENTS_SUCCESS]: (_state, {comments}) => comments,
+});
+
+// TODO(crbug.com/monorail/5953): Come up with some way to refactor
+// autolink.js's reference code to allow avoiding duplicate lookups
+// with data already in Redux state.
+/**
+ * For autolinking, this reducer stores the dereferenced data for bits
+ * of data that were referenced in comments. For example, comments might
+ * include user emails or IDs for other issues, and this state slice would
+ * store the full Objects for that data.
+ * @param {Array<CommentReference>} state
+ * @param {AnyAction} action
+ * @param {Array<CommentReference>} action.commentReferences New references
+ *   to store.
+ * @return {Array<CommentReference>}
+ */
+const commentReferencesReducer = createReducer({}, {
+  [FETCH_COMMENTS_START]: (_state, _action) => ({}),
+  [FETCH_COMMENT_REFERENCES_SUCCESS]: (_state, {commentReferences}) => {
+    return commentReferences;
+  },
+});
+
+/**
+ * Handles state for related issues such as blocking and blocked on issues,
+ * including federated references that could reference external issues outside
+ * Monorail.
+ * @param {Object<IssueRefString, Issue>} state
+ * @param {AnyAction} action
+ * @param {Object<IssueRefString, Issue>=} action.relatedIssues New related
+ *   issues.
+ * @param {Array<IssueRef>=} action.fedRefIssueRefs List of fetched federated
+ *   issue references.
+ * @return {Object<IssueRefString, Issue>}
+ */
+export const relatedIssuesReducer = createReducer({}, {
+  [FETCH_RELATED_ISSUES_SUCCESS]: (_state, {relatedIssues}) => relatedIssues,
+  [FETCH_FEDERATED_REFERENCES_SUCCESS]: (state, {fedRefIssueRefs}) => {
+    if (!fedRefIssueRefs) {
+      return state;
+    }
+
+    const fedRefStates = {};
+    fedRefIssueRefs.forEach((ref) => {
+      fedRefStates[ref.extIdentifier] = ref;
+    });
+
+    // Return a new object, in Redux fashion.
+    return Object.assign(fedRefStates, state);
+  },
+});
+
+/**
+ * Stores data for users referenced by issue. ie: Owner, CC, etc.
+ * @param {Object<string, UserV0>} state
+ * @param {AnyAction} action
+ * @param {Object<string, UserV0>} action.referencedUsers
+ * @return {Object<string, UserV0>}
+ */
+const referencedUsersReducer = createReducer({}, {
+  [FETCH_REFERENCED_USERS_SUCCESS]: (_state, {referencedUsers}) =>
+    referencedUsers,
+});
+
+/**
+ * Handles updating state of all starred issues.
+ * @param {Object<IssueRefString, boolean>} state Set of starred issues,
+ *   stored in a serializeable Object form.
+ * @param {AnyAction} action
+ * @param {IssueRef=} action.issueRef An issue with a star state being updated.
+ * @param {boolean=} action.starred Whether the issue is starred or unstarred.
+ * @param {Array<IssueRef>=} action.starredIssueRefs A list of starred issues.
+ * @return {Object<IssueRefString, boolean>}
+ */
+export const starredIssuesReducer = createReducer({}, {
+  [STAR_SUCCESS]: (state, {issueRef, starred}) => {
+    return {...state, [issueRefToString(issueRef)]: starred};
+  },
+  [FETCH_ISSUES_STARRED_SUCCESS]: (_state, {starredIssueRefs}) => {
+    const normalizedStars = {};
+    starredIssueRefs.forEach((issueRef) => {
+      normalizedStars[issueRefToString(issueRef)] = true;
+    });
+    return normalizedStars;
+  },
+  [FETCH_IS_STARRED_SUCCESS]: (state, {issueRef, starred}) => {
+    const refString = issueRefToString(issueRef);
+    return {...state, [refString]: starred};
+  },
+});
+
+/**
+ * Adds the result of an IssuePresubmit response to the Redux store.
+ * @param {Object} state Initial Redux state.
+ * @param {AnyAction} action
+ * @param {Object} action.presubmitResponse The issue
+ *   presubmit response Object.
+ * @return {Object}
+ */
+const presubmitResponseReducer = createReducer({}, {
+  [PRESUBMIT_SUCCESS]: (_state, {presubmitResponse}) => presubmitResponse,
+});
+
+/**
+ * Stores the user's permissions for a given issue.
+ * @param {Array<string>} state Permission list. Each permission is a string
+ *   with the name of the permission.
+ * @param {AnyAction} action
+ * @param {Array<string>} action.permissions The fetched permission data.
+ * @return {Array<string>}
+ */
+const permissionsReducer = createReducer([], {
+  [FETCH_PERMISSIONS_SUCCESS]: (_state, {permissions}) => permissions,
+});
+
+const requestsReducer = combineReducers({
+  fetch: createRequestReducer(
+      FETCH_START, FETCH_SUCCESS, FETCH_FAILURE),
+  fetchIssues: createRequestReducer(
+      FETCH_ISSUES_START, FETCH_ISSUES_SUCCESS, FETCH_ISSUES_FAILURE),
+  fetchHotlists: createRequestReducer(
+      FETCH_HOTLISTS_START, FETCH_HOTLISTS_SUCCESS, FETCH_HOTLISTS_FAILURE),
+  fetchIssueList: createRequestReducer(
+      FETCH_ISSUE_LIST_START,
+      FETCH_ISSUE_LIST_SUCCESS,
+      FETCH_ISSUE_LIST_FAILURE),
+  fetchPermissions: createRequestReducer(
+      FETCH_PERMISSIONS_START,
+      FETCH_PERMISSIONS_SUCCESS,
+      FETCH_PERMISSIONS_FAILURE),
+  starringIssues: createKeyedRequestReducer(
+      STAR_START, STAR_SUCCESS, STAR_FAILURE),
+  presubmit: createRequestReducer(
+      PRESUBMIT_START, PRESUBMIT_SUCCESS, PRESUBMIT_FAILURE),
+  fetchComments: createRequestReducer(
+      FETCH_COMMENTS_START, FETCH_COMMENTS_SUCCESS, FETCH_COMMENTS_FAILURE),
+  fetchCommentReferences: createRequestReducer(
+      FETCH_COMMENT_REFERENCES_START,
+      FETCH_COMMENT_REFERENCES_SUCCESS,
+      FETCH_COMMENT_REFERENCES_FAILURE),
+  fetchFederatedReferences: createRequestReducer(
+      FETCH_FEDERATED_REFERENCES_START,
+      FETCH_FEDERATED_REFERENCES_SUCCESS,
+      FETCH_FEDERATED_REFERENCES_FAILURE),
+  fetchRelatedIssues: createRequestReducer(
+      FETCH_RELATED_ISSUES_START,
+      FETCH_RELATED_ISSUES_SUCCESS,
+      FETCH_RELATED_ISSUES_FAILURE),
+  fetchReferencedUsers: createRequestReducer(
+      FETCH_REFERENCED_USERS_START,
+      FETCH_REFERENCED_USERS_SUCCESS,
+      FETCH_REFERENCED_USERS_FAILURE),
+  fetchIsStarred: createRequestReducer(
+      FETCH_IS_STARRED_START, FETCH_IS_STARRED_SUCCESS,
+      FETCH_IS_STARRED_FAILURE),
+  fetchStarredIssues: createRequestReducer(
+      FETCH_ISSUES_STARRED_START, FETCH_ISSUES_STARRED_SUCCESS,
+      FETCH_ISSUES_STARRED_FAILURE,
+  ),
+  convert: createRequestReducer(
+      CONVERT_START, CONVERT_SUCCESS, CONVERT_FAILURE),
+  update: createRequestReducer(
+      UPDATE_START, UPDATE_SUCCESS, UPDATE_FAILURE),
+  // TODO(zhangtiff): Update this to use createKeyedRequestReducer() instead, so
+  // users can update multiple approvals at once.
+  updateApproval: createRequestReducer(
+      UPDATE_APPROVAL_START, UPDATE_APPROVAL_SUCCESS, UPDATE_APPROVAL_FAILURE),
+});
+
+export const reducer = combineReducers({
+  viewedIssueRef: viewedIssueRefReducer,
+
+  issuesByRefString: issuesByRefStringReducer,
+
+  hotlists: hotlistsReducer,
+  issueList: issueListReducer,
+  comments: commentsReducer,
+  commentReferences: commentReferencesReducer,
+  relatedIssues: relatedIssuesReducer,
+  referencedUsers: referencedUsersReducer,
+  starredIssues: starredIssuesReducer,
+  permissions: permissionsReducer,
+  presubmitResponse: presubmitResponseReducer,
+
+  requests: requestsReducer,
+});
+
+// Selectors
+const RESTRICT_VIEW_PREFIX = 'restrict-view-';
+const RESTRICT_EDIT_PREFIX = 'restrict-editissue-';
+const RESTRICT_COMMENT_PREFIX = 'restrict-addissuecomment-';
+
+/**
+ * Selector to retrieve all normalized Issue data in the Redux store,
+ * keyed by IssueRefString.
+ * @param {any} state
+ * @return {Object<IssueRefString, Issue>}
+ */
+const issuesByRefString = (state) => state.issue.issuesByRefString;
+
+/**
+ * Selector to return a function to retrieve an Issue from the Redux store.
+ * @param {any} state
+ * @return {function(string): ?Issue}
+ */
+export const issue = createSelector(issuesByRefString, (issuesByRefString) =>
+  (name) => issuesByRefString[issueNameToRefString(name)]);
+
+/**
+ * Selector to return a function to retrieve a given Issue Object from
+ * the Redux store.
+ * @param {any} state
+ * @return {function(IssueRefString, string): Issue}
+ */
+export const issueForRefString = createSelector(issuesByRefString,
+    (issuesByRefString) => (issueRefString, projectName = undefined) => {
+      // In some contexts, an issue ref string will omit a project name,
+      // assuming the default project to be the project name. We never
+      // omit the project name in strings used as keys, so we have to
+      // make sure issue ref strings contain the project name.
+      const ref = issueStringToRef(issueRefString, projectName);
+      const refString = issueRefToString(ref);
+      if (issuesByRefString.hasOwnProperty(refString)) {
+        return issuesByRefString[refString];
+      }
+      return issueStringToRef(refString, projectName);
+    });
+
+/**
+ * Selector to get a reference to the currently viewed issue, in string form.
+ * @param {any} state
+ * @return {IssueRefString}
+ */
+const viewedIssueRefString = (state) => state.issue.viewedIssueRef;
+
+/**
+ * Selector to get a reference to the currently viewed issue.
+ * @param {any} state
+ * @return {IssueRef}
+ */
+export const viewedIssueRef = createSelector(viewedIssueRefString,
+    (viewedIssueRefString) => issueStringToRef(viewedIssueRefString));
+
+/**
+ * Selector to get the full Issue data for the currently viewed issue.
+ * @param {any} state
+ * @return {Issue}
+ */
+export const viewedIssue = createSelector(issuesByRefString,
+    viewedIssueRefString,
+    (issuesByRefString, viewedIssueRefString) =>
+      issuesByRefString[viewedIssueRefString] || {});
+
+export const comments = (state) => state.issue.comments;
+export const commentsLoaded = (state) => state.issue.commentsLoaded;
+
+const _commentReferences = (state) => state.issue.commentReferences;
+export const commentReferences = createSelector(_commentReferences,
+    (commentReferences) => objectToMap(commentReferences));
+
+export const hotlists = (state) => state.issue.hotlists;
+
+const stateIssueList = (state) => state.issue.issueList;
+export const issueList = createSelector(
+    issuesByRefString,
+    stateIssueList,
+    (issuesByRefString, stateIssueList) => {
+      return (stateIssueList.issueRefs || []).map((issueRef) => {
+        return issuesByRefString[issueRef];
+      });
+    },
+);
+export const totalIssues = (state) => state.issue.issueList.totalResults;
+export const issueListProgress = (state) => state.issue.issueList.progress;
+export const issueListPhaseNames = createSelector(issueList, (issueList) => {
+  const phaseNamesSet = new Set();
+  if (issueList) {
+    issueList.forEach(({phases}) => {
+      if (phases) {
+        phases.forEach(({phaseRef: {phaseName}}) => {
+          phaseNamesSet.add(phaseName.toLowerCase());
+        });
+      }
+    });
+  }
+  return Array.from(phaseNamesSet);
+});
+
+/**
+ * @param {any} state
+ * @return {boolean} Whether the currently viewed issue list
+ *   has loaded.
+ */
+export const issueListLoaded = createSelector(
+    stateIssueList,
+    (stateIssueList) => stateIssueList.issueRefs !== undefined);
+
+export const permissions = (state) => state.issue.permissions;
+export const presubmitResponse = (state) => state.issue.presubmitResponse;
+
+const _relatedIssues = (state) => state.issue.relatedIssues || {};
+export const relatedIssues = createSelector(_relatedIssues,
+    (relatedIssues) => objectToMap(relatedIssues));
+
+const _referencedUsers = (state) => state.issue.referencedUsers || {};
+export const referencedUsers = createSelector(_referencedUsers,
+    (referencedUsers) => objectToMap(referencedUsers));
+
+export const isStarred = (state) => state.issue.isStarred;
+export const _starredIssues = (state) => state.issue.starredIssues;
+
+export const requests = (state) => state.issue.requests;
+
+// Returns a Map of in flight StarIssues requests, keyed by issueRef.
+export const starringIssues = createSelector(requests, (requests) =>
+  objectToMap(requests.starringIssues));
+
+export const starredIssues = createSelector(
+    _starredIssues,
+    (starredIssues) => {
+      const stars = new Set();
+      for (const [ref, starred] of Object.entries(starredIssues)) {
+        if (starred) stars.add(ref);
+      }
+      return stars;
+    },
+);
+
+// TODO(zhangtiff): Split up either comments or approvals into their own "duck".
+export const commentsByApprovalName = createSelector(
+    comments,
+    (comments) => {
+      const map = new Map();
+      comments.forEach((comment) => {
+        const key = (comment.approvalRef && comment.approvalRef.fieldName) ||
+          '';
+        if (map.has(key)) {
+          map.get(key).push(comment);
+        } else {
+          map.set(key, [comment]);
+        }
+      });
+      return map;
+    },
+);
+
+export const fieldValues = createSelector(
+    viewedIssue,
+    (issue) => issue && issue.fieldValues,
+);
+
+export const labelRefs = createSelector(
+    viewedIssue,
+    (issue) => issue && issue.labelRefs,
+);
+
+export const type = createSelector(
+    fieldValues,
+    labelRefs,
+    (fieldValues, labelRefs) => extractTypeForIssue(fieldValues, labelRefs),
+);
+
+export const restrictions = createSelector(
+    labelRefs,
+    (labelRefs) => {
+      if (!labelRefs) return {};
+
+      const restrictions = {};
+
+      labelRefs.forEach((labelRef) => {
+        const label = labelRef.label;
+        const lowerCaseLabel = label.toLowerCase();
+
+        if (lowerCaseLabel.startsWith(RESTRICT_VIEW_PREFIX)) {
+          const permissionType = removePrefix(label, RESTRICT_VIEW_PREFIX);
+          if (!('view' in restrictions)) {
+            restrictions['view'] = [permissionType];
+          } else {
+            restrictions['view'].push(permissionType);
+          }
+        } else if (lowerCaseLabel.startsWith(RESTRICT_EDIT_PREFIX)) {
+          const permissionType = removePrefix(label, RESTRICT_EDIT_PREFIX);
+          if (!('edit' in restrictions)) {
+            restrictions['edit'] = [permissionType];
+          } else {
+            restrictions['edit'].push(permissionType);
+          }
+        } else if (lowerCaseLabel.startsWith(RESTRICT_COMMENT_PREFIX)) {
+          const permissionType = removePrefix(label, RESTRICT_COMMENT_PREFIX);
+          if (!('comment' in restrictions)) {
+            restrictions['comment'] = [permissionType];
+          } else {
+            restrictions['comment'].push(permissionType);
+          }
+        }
+      });
+
+      return restrictions;
+    },
+);
+
+export const isOpen = createSelector(
+    viewedIssue,
+    (issue) => issue && issue.statusRef && issue.statusRef.meansOpen || false);
+
+// Returns a function that, given an issue and its related issues,
+// returns a combined list of issue ref strings including related issues,
+// blocking or blocked on issues, and federated references.
+const mapRefsWithRelated = (blocking) => {
+  return (issue, relatedIssues) => {
+    let refs = [];
+    if (blocking) {
+      if (issue.blockingIssueRefs) {
+        refs = refs.concat(issue.blockingIssueRefs);
+      }
+      if (issue.danglingBlockingRefs) {
+        refs = refs.concat(issue.danglingBlockingRefs);
+      }
+    } else {
+      if (issue.blockedOnIssueRefs) {
+        refs = refs.concat(issue.blockedOnIssueRefs);
+      }
+      if (issue.danglingBlockedOnRefs) {
+        refs = refs.concat(issue.danglingBlockedOnRefs);
+      }
+    }
+
+    // Note: relatedIssues is a Redux generated key for issues, not part of the
+    // pRPC Issue object.
+    if (issue.relatedIssues) {
+      refs = refs.concat(issue.relatedIssues);
+    }
+    return refs.map((ref) => {
+      const key = issueRefToString(ref);
+      if (relatedIssues.has(key)) {
+        return relatedIssues.get(key);
+      }
+      return ref;
+    });
+  };
+};
+
+export const blockingIssues = createSelector(
+    viewedIssue, relatedIssues,
+    mapRefsWithRelated(true),
+);
+
+export const blockedOnIssues = createSelector(
+    viewedIssue, relatedIssues,
+    mapRefsWithRelated(false),
+);
+
+export const mergedInto = createSelector(
+    viewedIssue, relatedIssues,
+    (issue, relatedIssues) => {
+      if (!issue || !issue.mergedIntoIssueRef) return {};
+      const key = issueRefToString(issue.mergedIntoIssueRef);
+      if (relatedIssues && relatedIssues.has(key)) {
+        return relatedIssues.get(key);
+      }
+      return issue.mergedIntoIssueRef;
+    },
+);
+
+export const sortedBlockedOn = createSelector(
+    blockedOnIssues,
+    (blockedOn) => blockedOn.sort((a, b) => {
+      const aIsOpen = a.statusRef && a.statusRef.meansOpen ? 1 : 0;
+      const bIsOpen = b.statusRef && b.statusRef.meansOpen ? 1 : 0;
+      return bIsOpen - aIsOpen;
+    }),
+);
+
+// values (from issue.fieldValues) is an array with one entry per value.
+// We want to turn this into a map of fieldNames -> values.
+export const fieldValueMap = createSelector(
+    fieldValues,
+    (fieldValues) => fieldValuesToMap(fieldValues),
+);
+
+// Get the list of full componentDefs for the viewed issue.
+export const components = createSelector(
+    viewedIssue,
+    projectV0.componentsMap,
+    (issue, components) => {
+      if (!issue || !issue.componentRefs) return [];
+      return issue.componentRefs.map(
+          (comp) => components.get(comp.path) || comp);
+    },
+);
+
+// Get custom fields that apply to a specific issue.
+export const fieldDefs = createSelector(
+    projectV0.fieldDefs,
+    type,
+    fieldValueMap,
+    (fieldDefs, type, fieldValues) => {
+      if (!fieldDefs) return [];
+      type = type || '';
+      return fieldDefs.filter((f) => {
+        const fieldValueKey = fieldValueMapKey(f.fieldRef.fieldName,
+            f.phaseRef && f.phaseRef.phaseName);
+        if (fieldValues && fieldValues.has(fieldValueKey)) {
+        // Regardless of other checks, include a particular field def if the
+        // issue has a value defined.
+          return true;
+        }
+        // Skip approval type and phase fields here.
+        if (f.fieldRef.approvalName ||
+            f.fieldRef.type === fieldTypes.APPROVAL_TYPE ||
+            f.isPhaseField) {
+          return false;
+        }
+
+        // If this fieldDef belongs to only one type, filter out the field if
+        // that type isn't the specified type.
+        if (f.applicableType && type.toLowerCase() !==
+            f.applicableType.toLowerCase()) {
+          return false;
+        }
+
+        return true;
+      });
+    },
+);
+
+// Action Creators
+/**
+ * Tells Redux that the user has navigated to an issue page and is now
+ * viewing a new issue.
+ * @param {IssueRef} issueRef The issue that the user is viewing.
+ * @return {AnyAction}
+ */
+export const viewIssue = (issueRef) => ({type: VIEW_ISSUE, issueRef});
+
+export const fetchCommentReferences = (comments, projectName) => {
+  return async (dispatch) => {
+    dispatch({type: FETCH_COMMENT_REFERENCES_START});
+
+    try {
+      const refs = await autolink.getReferencedArtifacts(comments, projectName);
+      const commentRefs = {};
+      refs.forEach(({componentName, existingRefs}) => {
+        commentRefs[componentName] = existingRefs;
+      });
+      dispatch({
+        type: FETCH_COMMENT_REFERENCES_SUCCESS,
+        commentReferences: commentRefs,
+      });
+    } catch (error) {
+      dispatch({type: FETCH_COMMENT_REFERENCES_FAILURE, error});
+    }
+  };
+};
+
+export const fetchReferencedUsers = (issue) => async (dispatch) => {
+  if (!issue) return;
+  dispatch({type: FETCH_REFERENCED_USERS_START});
+
+  // TODO(zhangtiff): Make this function account for custom fields
+  // of type user.
+  const userRefs = [...(issue.ccRefs || [])];
+  if (issue.ownerRef) {
+    userRefs.push(issue.ownerRef);
+  }
+  (issue.approvalValues || []).forEach((approval) => {
+    userRefs.push(...(approval.approverRefs || []));
+    if (approval.setterRef) {
+      userRefs.push(approval.setterRef);
+    }
+  });
+
+  try {
+    const resp = await prpcClient.call(
+        'monorail.Users', 'ListReferencedUsers', {userRefs});
+
+    const referencedUsers = {};
+    (resp.users || []).forEach((user) => {
+      referencedUsers[user.displayName] = user;
+    });
+    dispatch({type: FETCH_REFERENCED_USERS_SUCCESS, referencedUsers});
+  } catch (error) {
+    dispatch({type: FETCH_REFERENCED_USERS_FAILURE, error});
+  }
+};
+
+export const fetchFederatedReferences = (issue) => async (dispatch) => {
+  dispatch({type: FETCH_FEDERATED_REFERENCES_START});
+
+  // Concat all potential fedrefs together, convert from shortlink to classes,
+  // then fire off a request to fetch the status of each.
+  const fedRefs = []
+      .concat(issue.danglingBlockingRefs || [])
+      .concat(issue.danglingBlockedOnRefs || [])
+      .concat(issue.mergedIntoIssueRef ? [issue.mergedIntoIssueRef] : [])
+      .filter((ref) => ref && ref.extIdentifier)
+      .map((ref) => fromShortlink(ref.extIdentifier))
+      .filter((fedRef) => fedRef);
+
+  // If no FedRefs, return empty Map.
+  if (fedRefs.length === 0) {
+    return;
+  }
+
+  try {
+    // Load email separately since it might have changed.
+    await loadGapi();
+    const email = await fetchGapiEmail();
+
+    // If already logged in, dispatch login success event.
+    dispatch({
+      type: userV0.GAPI_LOGIN_SUCCESS,
+      email: email,
+    });
+
+    await Promise.all(fedRefs.map((fedRef) => fedRef.getFederatedDetails()));
+    const fedRefIssueRefs = fedRefs.map((fedRef) => fedRef.toIssueRef());
+
+    dispatch({
+      type: FETCH_FEDERATED_REFERENCES_SUCCESS,
+      fedRefIssueRefs: fedRefIssueRefs,
+    });
+  } catch (error) {
+    dispatch({type: FETCH_FEDERATED_REFERENCES_FAILURE, error});
+  }
+};
+
+// TODO(zhangtiff): Figure out if we can reduce request/response sizes by
+// diffing issues to fetch against issues we already know about to avoid
+// fetching duplicate info.
+export const fetchRelatedIssues = (issue) => async (dispatch) => {
+  if (!issue) return;
+  dispatch({type: FETCH_RELATED_ISSUES_START});
+
+  const refsToFetch = (issue.blockedOnIssueRefs || []).concat(
+      issue.blockingIssueRefs || []);
+  // Add mergedinto ref, exclude FedRefs which are fetched separately.
+  if (issue.mergedIntoIssueRef && !issue.mergedIntoIssueRef.extIdentifier) {
+    refsToFetch.push(issue.mergedIntoIssueRef);
+  }
+
+  const message = {
+    issueRefs: refsToFetch,
+  };
+  try {
+    // Fire off call to fetch FedRefs. Since it might take longer it is
+    // handled by a separate reducer.
+    dispatch(fetchFederatedReferences(issue));
+
+    const resp = await prpcClient.call(
+        'monorail.Issues', 'ListReferencedIssues', message);
+
+    const relatedIssues = {};
+
+    const openIssues = resp.openRefs || [];
+    const closedIssues = resp.closedRefs || [];
+    openIssues.forEach((issue) => {
+      issue.statusRef.meansOpen = true;
+      relatedIssues[issueRefToString(issue)] = issue;
+    });
+    closedIssues.forEach((issue) => {
+      issue.statusRef.meansOpen = false;
+      relatedIssues[issueRefToString(issue)] = issue;
+    });
+    dispatch({
+      type: FETCH_RELATED_ISSUES_SUCCESS,
+      relatedIssues: relatedIssues,
+    });
+  } catch (error) {
+    dispatch({type: FETCH_RELATED_ISSUES_FAILURE, error});
+  };
+};
+
+/**
+ * Fetches issue data needed to display a detailed view of a single
+ * issue. This function dispatches many actions to handle the fetching
+ * of issue comments, permissions, star state, and more.
+ * @param {IssueRef} issueRef The issue that the user is viewing.
+ * @return {function(function): Promise<void>}
+ */
+export const fetchIssuePageData = (issueRef) => async (dispatch) => {
+  dispatch(fetchComments(issueRef));
+  dispatch(fetch(issueRef));
+  dispatch(fetchPermissions(issueRef));
+  dispatch(fetchIsStarred(issueRef));
+};
+
+/**
+ * @param {IssueRef} issueRef Which issue to fetch.
+ * @return {function(function): Promise<void>}
+ */
+export const fetch = (issueRef) => async (dispatch) => {
+  dispatch({type: FETCH_START});
+
+  try {
+    const resp = await prpcClient.call(
+        'monorail.Issues', 'GetIssue', {issueRef},
+    );
+
+    const movedToRef = resp.movedToRef;
+
+    // The API can return deleted issue objects that don't have issueRef data
+    // specified. For this case, we want to make sure a projectName and localId
+    // are still provided to the frontend to ensure that keying issues still
+    // works.
+    const issue = {...issueRef, ...resp.issue};
+    if (movedToRef) {
+      issue.movedToRef = movedToRef;
+    }
+
+    dispatch({type: FETCH_SUCCESS, issue});
+
+    if (!issue.isDeleted && !movedToRef) {
+      dispatch(fetchRelatedIssues(issue));
+      dispatch(fetchHotlists(issueRef));
+      dispatch(fetchReferencedUsers(issue));
+      dispatch(userV0.fetchProjects([issue.reporterRef]));
+    }
+  } catch (error) {
+    dispatch({type: FETCH_FAILURE, error});
+  }
+};
+
+/**
+ * Action creator to fetch multiple Issues.
+ * @param {Array<IssueRef>} issueRefs An Array of Issue references to fetch.
+ * @return {function(function): Promise<void>}
+ */
+export const fetchIssues = (issueRefs) => async (dispatch) => {
+  dispatch({type: FETCH_ISSUES_START});
+
+  try {
+    const {openRefs, closedRefs} = await prpcClient.call(
+        'monorail.Issues', 'ListReferencedIssues', {issueRefs});
+    const issues = [...openRefs || [], ...closedRefs || []];
+
+    dispatch({type: FETCH_ISSUES_SUCCESS, issues});
+  } catch (error) {
+    dispatch({type: FETCH_ISSUES_FAILURE, error});
+  }
+};
+
+/**
+ * Gets the hotlists that a given issue is in.
+ * @param {IssueRef} issueRef
+ * @return {function(function): Promise<void>}
+ */
+export const fetchHotlists = (issueRef) => async (dispatch) => {
+  dispatch({type: FETCH_HOTLISTS_START});
+
+  try {
+    const resp = await prpcClient.call(
+        'monorail.Features', 'ListHotlistsByIssue', {issue: issueRef});
+
+    const hotlists = (resp.hotlists || []);
+    hotlists.sort((hotlistA, hotlistB) => {
+      return hotlistA.name.localeCompare(hotlistB.name);
+    });
+    dispatch({type: FETCH_HOTLISTS_SUCCESS, hotlists});
+  } catch (error) {
+    dispatch({type: FETCH_HOTLISTS_FAILURE, error});
+  };
+};
+
+/**
+ * Async action creator to fetch issues in the issue list and grid pages. This
+ * action creator supports batching multiple async requests to support the grid
+ * view's ability to load up to 6,000 issues in one page load.
+ *
+ * @param {string} projectName The project to fetch issues from.
+ * @param {Object} params Options for which issues to fetch.
+ * @param {string=} params.q The query string for the search.
+ * @param {string=} params.can The ID of the canned query for the search.
+ * @param {string=} params.groupby The spec of which fields to group by.
+ * @param {string=} params.sort The spec of which fields to sort by.
+ * @param {number=} params.start What cursor index to start at.
+ * @param {number=} params.maxItems How many items to fetch per page.
+ * @param {number=} params.maxCalls The maximum number of API calls to make.
+ *   Combined with pagination.maxItems, this defines the maximum number of
+ *   issues this method can fetch.
+ * @return {function(function): Promise<void>}
+ */
+export const fetchIssueList =
+  (projectName, {q = undefined, can = undefined, groupby = undefined,
+    sort = undefined, start = undefined, maxItems = undefined,
+    maxCalls = 1,
+  }) => async (dispatch) => {
+    let updateData = {};
+    const promises = [];
+    const issuesByRequest = [];
+    let issueLimit;
+    let totalIssues;
+    let totalCalls;
+    const itemsPerCall = maxItems || 1000;
+
+    const cannedQuery = Number.parseInt(can) || undefined;
+
+    const pagination = {
+      ...(start && {start}),
+      ...(maxItems && {maxItems}),
+    };
+
+    const message = {
+      projectNames: [projectName],
+      query: q,
+      cannedQuery,
+      groupBySpec: groupby,
+      sortSpec: sort,
+      pagination,
+    };
+
+    dispatch({type: FETCH_ISSUE_LIST_START});
+
+    // initial api call made to determine total number of issues matching
+    // the query.
+    try {
+      // TODO(zhangtiff): Refactor this action creator when adding issue
+      // list pagination.
+      const resp = await prpcClient.call(
+          'monorail.Issues', 'ListIssues', message);
+
+      // prpcClient is not actually a protobuf client and therefore not
+      // hydrating default values. See crbug.com/monorail/6641
+      // Until that is fixed, we have to explicitly define it.
+      const defaultFetchListResponse = {totalResults: 0, issues: []};
+
+      updateData =
+        Object.entries(resp).length === 0 ?
+          defaultFetchListResponse :
+          resp;
+      issuesByRequest[0] = updateData.issues;
+      issueLimit = updateData.totalResults;
+
+      // determine correct issues to load and number of calls to be made.
+      if (issueLimit > (itemsPerCall * maxCalls)) {
+        totalIssues = itemsPerCall * maxCalls;
+        totalCalls = maxCalls - 1;
+      } else {
+        totalIssues = issueLimit;
+        totalCalls = Math.ceil(issueLimit / itemsPerCall) - 1;
+      }
+
+      if (totalIssues) {
+        updateData.progress = updateData.issues.length / totalIssues;
+      } else {
+        updateData.progress = 1;
+      }
+
+      dispatch({type: FETCH_ISSUE_LIST_UPDATE, ...updateData});
+
+      // remaining api calls are made.
+      for (let i = 1; i <= totalCalls; i++) {
+        promises[i - 1] = (async () => {
+          const resp = await prpcClient.call(
+              'monorail.Issues', 'ListIssues', {
+                ...message,
+                pagination: {start: i * itemsPerCall, maxItems: itemsPerCall},
+              });
+          issuesByRequest[i] = (resp.issues || []);
+          // sort the issues in the correct order.
+          updateData.issues = [];
+          issuesByRequest.forEach((issue) => {
+            updateData.issues = updateData.issues.concat(issue);
+          });
+          updateData.progress = updateData.issues.length / totalIssues;
+          dispatch({type: FETCH_ISSUE_LIST_UPDATE, ...updateData});
+        })();
+      }
+
+      await Promise.all(promises);
+
+      // TODO(zhangtiff): Try to delete FETCH_ISSUE_LIST_SUCCESS in favor of
+      // just FETCH_ISSUE_LIST_UPDATE.
+      dispatch({type: FETCH_ISSUE_LIST_SUCCESS});
+    } catch (error) {
+      dispatch({type: FETCH_ISSUE_LIST_FAILURE, error});
+    };
+  };
+
+/**
+ * Fetches the currently logged in user's permissions for a given issue.
+ * @param {Issue} issueRef
+ * @return {function(function): Promise<void>}
+ */
+export const fetchPermissions = (issueRef) => async (dispatch) => {
+  dispatch({type: FETCH_PERMISSIONS_START});
+
+  try {
+    const resp = await prpcClient.call(
+        'monorail.Issues', 'ListIssuePermissions', {issueRef},
+    );
+
+    dispatch({type: FETCH_PERMISSIONS_SUCCESS, permissions: resp.permissions});
+  } catch (error) {
+    dispatch({type: FETCH_PERMISSIONS_FAILURE, error});
+  };
+};
+
+/**
+ * Fetches comments for an issue. Note that issue descriptions are also
+ * comments.
+ * @param {IssueRef} issueRef
+ * @return {function(function): Promise<void>}
+ */
+export const fetchComments = (issueRef) => async (dispatch) => {
+  dispatch({type: FETCH_COMMENTS_START});
+
+  try {
+    const resp = await prpcClient.call(
+        'monorail.Issues', 'ListComments', {issueRef});
+
+    dispatch({type: FETCH_COMMENTS_SUCCESS, comments: resp.comments});
+    dispatch(fetchCommentReferences(
+        resp.comments, issueRef.projectName));
+
+    const commenterRefs = (resp.comments || []).map(
+        (comment) => comment.commenter);
+    dispatch(userV0.fetchProjects(commenterRefs));
+  } catch (error) {
+    dispatch({type: FETCH_COMMENTS_FAILURE, error});
+  };
+};
+
+/**
+ * Gets whether the logged in user has starred a given issue.
+ * @param {IssueRef} issueRef
+ * @return {function(function): Promise<void>}
+ */
+export const fetchIsStarred = (issueRef) => async (dispatch) => {
+  dispatch({type: FETCH_IS_STARRED_START});
+  try {
+    const resp = await prpcClient.call(
+        'monorail.Issues', 'IsIssueStarred', {issueRef},
+    );
+
+    dispatch({
+      type: FETCH_IS_STARRED_SUCCESS,
+      starred: resp.isStarred,
+      issueRef: issueRef,
+    });
+  } catch (error) {
+    dispatch({type: FETCH_IS_STARRED_FAILURE, error});
+  };
+};
+
+/**
+ * Fetch all of a logged in user's starred issues.
+ * @return {function(function): Promise<void>}
+ */
+export const fetchStarredIssues = () => async (dispatch) => {
+  dispatch({type: FETCH_ISSUES_STARRED_START});
+
+  try {
+    const resp = await prpcClient.call(
+        'monorail.Issues', 'ListStarredIssues', {},
+    );
+    dispatch({type: FETCH_ISSUES_STARRED_SUCCESS,
+      starredIssueRefs: resp.starredIssueRefs});
+  } catch (error) {
+    dispatch({type: FETCH_ISSUES_STARRED_FAILURE, error});
+  };
+};
+
+/**
+ * Stars or unstars an issue.
+ * @param {IssueRef} issueRef The issue to star.
+ * @param {boolean} starred Whether to star or unstar.
+ * @return {function(function): Promise<void>}
+ */
+export const star = (issueRef, starred) => async (dispatch) => {
+  const requestKey = issueRefToString(issueRef);
+
+  dispatch({type: STAR_START, requestKey});
+  const message = {issueRef, starred};
+
+  try {
+    const resp = await prpcClient.call(
+        'monorail.Issues', 'StarIssue', message,
+    );
+
+    dispatch({
+      type: STAR_SUCCESS,
+      starCount: resp.starCount,
+      issueRef,
+      starred,
+      requestKey,
+    });
+  } catch (error) {
+    dispatch({type: STAR_FAILURE, error, requestKey});
+  }
+};
+
+/**
+ * Runs a presubmit request to find warnings to show the user before an issue
+ * edit is saved.
+ * @param {IssueRef} issueRef The issue being edited.
+ * @param {IssueDelta} issueDelta The user's in flight changes to the issue.
+ * @return {function(function): Promise<void>}
+ */
+export const presubmit = (issueRef, issueDelta) => async (dispatch) => {
+  dispatch({type: PRESUBMIT_START});
+
+  try {
+    const resp = await prpcClient.call(
+        'monorail.Issues', 'PresubmitIssue', {issueRef, issueDelta});
+
+    dispatch({type: PRESUBMIT_SUCCESS, presubmitResponse: resp});
+  } catch (error) {
+    dispatch({type: PRESUBMIT_FAILURE, error: error});
+  }
+};
+
+/**
+ * Async action creator to update an issue's approval.
+ *
+ * @param {Object} params Options for the approval update.
+ * @param {IssueRef} params.issueRef
+ * @param {FieldRef} params.fieldRef
+ * @param {ApprovalDelta=} params.approvalDelta
+ * @param {string=} params.commentContent
+ * @param {boolean=} params.sendEmail
+ * @param {boolean=} params.isDescription
+ * @param {AttachmentUpload=} params.uploads
+ * @return {function(function): Promise<void>}
+ */
+export const updateApproval = ({issueRef, fieldRef, approvalDelta,
+  commentContent, sendEmail, isDescription, uploads}) => async (dispatch) => {
+  dispatch({type: UPDATE_APPROVAL_START});
+  try {
+    const {approval} = await prpcClient.call(
+        'monorail.Issues', 'UpdateApproval', {
+          ...(issueRef && {issueRef}),
+          ...(fieldRef && {fieldRef}),
+          ...(approvalDelta && {approvalDelta}),
+          ...(commentContent && {commentContent}),
+          ...(sendEmail && {sendEmail}),
+          ...(isDescription && {isDescription}),
+          ...(uploads && {uploads}),
+        });
+
+    dispatch({type: UPDATE_APPROVAL_SUCCESS, approval, issueRef});
+    dispatch(fetch(issueRef));
+    dispatch(fetchComments(issueRef));
+  } catch (error) {
+    dispatch({type: UPDATE_APPROVAL_FAILURE, error: error});
+  };
+};
+
+/**
+ * Async action creator to update an issue.
+ *
+ * @param {Object} params Options for the issue update.
+ * @param {IssueRef} params.issueRef
+ * @param {IssueDelta=} params.delta
+ * @param {string=} params.commentContent
+ * @param {boolean=} params.sendEmail
+ * @param {boolean=} params.isDescription
+ * @param {AttachmentUpload=} params.uploads
+ * @param {Array<number>=} params.keptAttachments
+ * @return {function(function): Promise<void>}
+ */
+export const update = ({issueRef, delta, commentContent, sendEmail,
+  isDescription, uploads, keptAttachments}) => async (dispatch) => {
+  dispatch({type: UPDATE_START});
+
+  try {
+    const {issue} = await prpcClient.call(
+        'monorail.Issues', 'UpdateIssue', {issueRef, delta,
+          commentContent, sendEmail, isDescription, uploads,
+          keptAttachments});
+
+    dispatch({type: UPDATE_SUCCESS, issue});
+    dispatch(fetchComments(issueRef));
+    dispatch(fetchRelatedIssues(issue));
+    dispatch(fetchReferencedUsers(issue));
+  } catch (error) {
+    dispatch({type: UPDATE_FAILURE, error: error});
+  };
+};
+
+/**
+ * Converts an issue from one template to another. This is used for changing
+ * launch issues.
+ * @param {IssueRef} issueRef
+ * @param {Object} options
+ * @param {string=} options.templateName
+ * @param {string=} options.commentContent
+ * @param {boolean=} options.sendEmail
+ * @return {function(function): Promise<void>}
+ */
+export const convert = (issueRef, {templateName = '',
+  commentContent = '', sendEmail = true},
+) => async (dispatch) => {
+  dispatch({type: CONVERT_START});
+
+  try {
+    const resp = await prpcClient.call(
+        'monorail.Issues', 'ConvertIssueApprovalsTemplate',
+        {issueRef, templateName, commentContent, sendEmail});
+
+    dispatch({type: CONVERT_SUCCESS, issue: resp.issue});
+    const fetchCommentsMessage = {issueRef};
+    dispatch(fetchComments(fetchCommentsMessage));
+  } catch (error) {
+    dispatch({type: CONVERT_FAILURE, error: error});
+  };
+};
diff --git a/static_src/reducers/issueV0.test.js b/static_src/reducers/issueV0.test.js
new file mode 100644
index 0000000..0c7a0f5
--- /dev/null
+++ b/static_src/reducers/issueV0.test.js
@@ -0,0 +1,1409 @@
+// 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 {createSelector} from 'reselect';
+import {store, resetState} from './base.js';
+import * as issueV0 from './issueV0.js';
+import * as example from 'shared/test/constants-issueV0.js';
+import {fieldTypes} from 'shared/issue-fields.js';
+import {issueToIssueRef, issueRefToString} from 'shared/convertersV0.js';
+import {prpcClient} from 'prpc-client-instance.js';
+import {getSigninInstance} from 'shared/gapi-loader.js';
+
+let prpcCall;
+let dispatch;
+
+describe('issue', () => {
+  beforeEach(() => {
+    store.dispatch(resetState());
+  });
+
+  describe('reducers', () => {
+    describe('issueByRefReducer', () => {
+      it('no-op on unmatching action', () => {
+        const action = {
+          type: 'FAKE_ACTION',
+          issues: [example.ISSUE_OTHER_PROJECT],
+        };
+        assert.deepEqual(issueV0.issuesByRefStringReducer({}, action), {});
+
+        const state = {[example.ISSUE_REF_STRING]: example.ISSUE};
+        assert.deepEqual(issueV0.issuesByRefStringReducer(state, action),
+            state);
+      });
+
+      it('handles FETCH_ISSUE_LIST_UPDATE', () => {
+        const newState = issueV0.issuesByRefStringReducer({}, {
+          type: issueV0.FETCH_ISSUE_LIST_UPDATE,
+          issues: [example.ISSUE, example.ISSUE_OTHER_PROJECT],
+          totalResults: 2,
+          progress: 1,
+        });
+        assert.deepEqual(newState, {
+          [example.ISSUE_REF_STRING]: example.ISSUE,
+          [example.ISSUE_OTHER_PROJECT_REF_STRING]: example.ISSUE_OTHER_PROJECT,
+        });
+      });
+
+      it('handles FETCH_ISSUES_SUCCESS', () => {
+        const newState = issueV0.issuesByRefStringReducer({}, {
+          type: issueV0.FETCH_ISSUES_SUCCESS,
+          issues: [example.ISSUE, example.ISSUE_OTHER_PROJECT],
+        });
+        assert.deepEqual(newState, {
+          [example.ISSUE_REF_STRING]: example.ISSUE,
+          [example.ISSUE_OTHER_PROJECT_REF_STRING]: example.ISSUE_OTHER_PROJECT,
+        });
+      });
+    });
+
+    describe('issueListReducer', () => {
+      it('no-op on unmatching action', () => {
+        const action = {
+          type: 'FETCH_ISSUE_LIST_FAKE_ACTION',
+          issues: [
+            {localId: 1, projectName: 'chromium', summary: 'hello-world'},
+          ],
+        };
+        assert.deepEqual(issueV0.issueListReducer({}, action), {});
+
+        assert.deepEqual(issueV0.issueListReducer({
+          issueRefs: ['chromium:1'],
+          totalResults: 1,
+          progress: 1,
+        }, action), {
+          issueRefs: ['chromium:1'],
+          totalResults: 1,
+          progress: 1,
+        });
+      });
+
+      it('handles FETCH_ISSUE_LIST_UPDATE', () => {
+        const newState = issueV0.issueListReducer({}, {
+          type: 'FETCH_ISSUE_LIST_UPDATE',
+          issues: [
+            {localId: 1, projectName: 'chromium', summary: 'hello-world'},
+            {localId: 2, projectName: 'monorail', summary: 'Test'},
+          ],
+          totalResults: 2,
+          progress: 1,
+        });
+        assert.deepEqual(newState, {
+          issueRefs: ['chromium:1', 'monorail:2'],
+          totalResults: 2,
+          progress: 1,
+        });
+      });
+    });
+
+    describe('relatedIssuesReducer', () => {
+      it('handles FETCH_RELATED_ISSUES_SUCCESS', () => {
+        const newState = issueV0.relatedIssuesReducer({}, {
+          type: 'FETCH_RELATED_ISSUES_SUCCESS',
+          relatedIssues: {'rutabaga:1234': {}},
+        });
+        assert.deepEqual(newState, {'rutabaga:1234': {}});
+      });
+
+      describe('FETCH_FEDERATED_REFERENCES_SUCCESS', () => {
+        it('returns early if data is missing', () => {
+          const newState = issueV0.relatedIssuesReducer({'b/123': {}}, {
+            type: 'FETCH_FEDERATED_REFERENCES_SUCCESS',
+          });
+          assert.deepEqual(newState, {'b/123': {}});
+        });
+
+        it('returns early if data is empty', () => {
+          const newState = issueV0.relatedIssuesReducer({'b/123': {}}, {
+            type: 'FETCH_FEDERATED_REFERENCES_SUCCESS',
+            fedRefIssueRefs: [],
+          });
+          assert.deepEqual(newState, {'b/123': {}});
+        });
+
+        it('assigns each FedRef to the state', () => {
+          const state = {
+            'rutabaga:123': {},
+            'rutabaga:345': {},
+          };
+          const newState = issueV0.relatedIssuesReducer(state, {
+            type: 'FETCH_FEDERATED_REFERENCES_SUCCESS',
+            fedRefIssueRefs: [
+              {
+                extIdentifier: 'b/987',
+                summary: 'What is up',
+                statusRef: {meansOpen: true},
+              },
+              {
+                extIdentifier: 'b/765',
+                summary: 'Rutabaga',
+                statusRef: {meansOpen: false},
+              },
+            ],
+          });
+          assert.deepEqual(newState, {
+            'rutabaga:123': {},
+            'rutabaga:345': {},
+            'b/987': {
+              extIdentifier: 'b/987',
+              summary: 'What is up',
+              statusRef: {meansOpen: true},
+            },
+            'b/765': {
+              extIdentifier: 'b/765',
+              summary: 'Rutabaga',
+              statusRef: {meansOpen: false},
+            },
+          });
+        });
+      });
+    });
+  });
+
+  it('viewedIssue', () => {
+    assert.deepEqual(issueV0.viewedIssue(wrapIssue()), {});
+    assert.deepEqual(
+        issueV0.viewedIssue(wrapIssue({projectName: 'proj', localId: 100})),
+        {projectName: 'proj', localId: 100},
+    );
+  });
+
+  describe('issueList', () => {
+    it('issueList', () => {
+      const stateWithEmptyIssueList = {issue: {
+        issueList: {},
+      }};
+      assert.deepEqual(issueV0.issueList(stateWithEmptyIssueList), []);
+
+      const stateWithIssueList = {issue: {
+        issuesByRefString: {
+          'chromium:1': {localId: 1, projectName: 'chromium', summary: 'test'},
+          'monorail:2': {localId: 2, projectName: 'monorail',
+            summary: 'hello world'},
+        },
+        issueList: {
+          issueRefs: ['chromium:1', 'monorail:2'],
+        }}};
+      assert.deepEqual(issueV0.issueList(stateWithIssueList),
+          [
+            {localId: 1, projectName: 'chromium', summary: 'test'},
+            {localId: 2, projectName: 'monorail', summary: 'hello world'},
+          ]);
+    });
+
+    it('is a selector', () => {
+      issueV0.issueList.constructor === createSelector;
+    });
+
+    it('memoizes results: returns same reference', () => {
+      const stateWithIssueList = {issue: {
+        issuesByRefString: {
+          'chromium:1': {localId: 1, projectName: 'chromium', summary: 'test'},
+          'monorail:2': {localId: 2, projectName: 'monorail',
+            summary: 'hello world'},
+        },
+        issueList: {
+          issueRefs: ['chromium:1', 'monorail:2'],
+        }}};
+      const reference1 = issueV0.issueList(stateWithIssueList);
+      const reference2 = issueV0.issueList(stateWithIssueList);
+
+      assert.equal(typeof reference1, 'object');
+      assert.equal(typeof reference2, 'object');
+      assert.equal(reference1, reference2);
+    });
+  });
+
+  describe('issueListLoaded', () => {
+    const stateWithEmptyIssueList = {issue: {
+      issueList: {},
+    }};
+
+    it('false when no issue list', () => {
+      assert.isFalse(issueV0.issueListLoaded(stateWithEmptyIssueList));
+    });
+
+    it('true after issues loaded, even when empty', () => {
+      const issueList = issueV0.issueListReducer({}, {
+        type: issueV0.FETCH_ISSUE_LIST_UPDATE,
+        issues: [],
+        progress: 1,
+        totalResults: 0,
+      });
+      assert.isTrue(issueV0.issueListLoaded({issue: {issueList}}));
+    });
+  });
+
+  it('fieldValues', () => {
+    assert.isUndefined(issueV0.fieldValues(wrapIssue()));
+    assert.deepEqual(issueV0.fieldValues(wrapIssue({
+      fieldValues: [{value: 'v'}],
+    })), [{value: 'v'}]);
+  });
+
+  it('type computes type from custom field', () => {
+    assert.isUndefined(issueV0.type(wrapIssue()));
+    assert.isUndefined(issueV0.type(wrapIssue({
+      fieldValues: [{value: 'v'}],
+    })));
+    assert.deepEqual(issueV0.type(wrapIssue({
+      fieldValues: [
+        {fieldRef: {fieldName: 'IgnoreMe'}, value: 'v'},
+        {fieldRef: {fieldName: 'Type'}, value: 'Defect'},
+      ],
+    })), 'Defect');
+  });
+
+  it('type computes type from label', () => {
+    assert.deepEqual(issueV0.type(wrapIssue({
+      labelRefs: [
+        {label: 'Test'},
+        {label: 'tYpE-FeatureRequest'},
+      ],
+    })), 'FeatureRequest');
+
+    assert.deepEqual(issueV0.type(wrapIssue({
+      fieldValues: [
+        {fieldRef: {fieldName: 'IgnoreMe'}, value: 'v'},
+      ],
+      labelRefs: [
+        {label: 'Test'},
+        {label: 'Type-Defect'},
+      ],
+    })), 'Defect');
+  });
+
+  it('restrictions', () => {
+    assert.deepEqual(issueV0.restrictions(wrapIssue()), {});
+    assert.deepEqual(issueV0.restrictions(wrapIssue({labelRefs: []})), {});
+
+    assert.deepEqual(issueV0.restrictions(wrapIssue({labelRefs: [
+      {label: 'IgnoreThis'},
+      {label: 'IgnoreThis2'},
+    ]})), {});
+
+    assert.deepEqual(issueV0.restrictions(wrapIssue({labelRefs: [
+      {label: 'IgnoreThis'},
+      {label: 'IgnoreThis2'},
+      {label: 'Restrict-View-Google'},
+      {label: 'Restrict-EditIssue-hello'},
+      {label: 'Restrict-EditIssue-test'},
+      {label: 'Restrict-AddIssueComment-HELLO'},
+    ]})), {
+      'view': ['Google'],
+      'edit': ['hello', 'test'],
+      'comment': ['HELLO'],
+    });
+  });
+
+  it('isOpen', () => {
+    assert.isFalse(issueV0.isOpen(wrapIssue()));
+    assert.isTrue(issueV0.isOpen(wrapIssue({statusRef: {meansOpen: true}})));
+    assert.isFalse(issueV0.isOpen(wrapIssue({statusRef: {meansOpen: false}})));
+  });
+
+  it('issueListPhaseNames', () => {
+    const stateWithEmptyIssueList = {issue: {
+      issueList: [],
+    }};
+    assert.deepEqual(issueV0.issueListPhaseNames(stateWithEmptyIssueList), []);
+    const stateWithIssueList = {issue: {
+      issuesByRefString: {
+        '1': {localId: 1, phases: [{phaseRef: {phaseName: 'chicken-phase'}}]},
+        '2': {localId: 2, phases: [
+          {phaseRef: {phaseName: 'chicken-Phase'}},
+          {phaseRef: {phaseName: 'cow-phase'}}],
+        },
+        '3': {localId: 3, phases: [
+          {phaseRef: {phaseName: 'cow-Phase'}},
+          {phaseRef: {phaseName: 'DOG-phase'}}],
+        },
+        '4': {localId: 4, phases: [
+          {phaseRef: {phaseName: 'dog-phase'}},
+        ]},
+      },
+      issueList: {
+        issueRefs: ['1', '2', '3', '4'],
+      }}};
+    assert.deepEqual(issueV0.issueListPhaseNames(stateWithIssueList),
+        ['chicken-phase', 'cow-phase', 'dog-phase']);
+  });
+
+  describe('blockingIssues', () => {
+    const relatedIssues = {
+      ['proj:1']: {
+        localId: 1,
+        projectName: 'proj',
+        labelRefs: [{label: 'label'}],
+      },
+      ['proj:3']: {
+        localId: 3,
+        projectName: 'proj',
+        labelRefs: [],
+      },
+      ['chromium:332']: {
+        localId: 332,
+        projectName: 'chromium',
+        labelRefs: [],
+      },
+    };
+
+    it('returns references when no issue data', () => {
+      const stateNoReferences = wrapIssue(
+          {
+            projectName: 'project',
+            localId: 123,
+            blockingIssueRefs: [{localId: 1, projectName: 'proj'}],
+          },
+          {relatedIssues: {}},
+      );
+      assert.deepEqual(issueV0.blockingIssues(stateNoReferences),
+          [{localId: 1, projectName: 'proj'}],
+      );
+    });
+
+    it('returns empty when no blocking issues', () => {
+      const stateNoIssues = wrapIssue(
+          {
+            projectName: 'project',
+            localId: 123,
+            blockingIssueRefs: [],
+          },
+          {relatedIssues},
+      );
+      assert.deepEqual(issueV0.blockingIssues(stateNoIssues), []);
+    });
+
+    it('returns full issues when deferenced data present', () => {
+      const stateIssuesWithReferences = wrapIssue(
+          {
+            projectName: 'project',
+            localId: 123,
+            blockingIssueRefs: [
+              {localId: 1, projectName: 'proj'},
+              {localId: 332, projectName: 'chromium'},
+            ],
+          },
+          {relatedIssues},
+      );
+      assert.deepEqual(issueV0.blockingIssues(stateIssuesWithReferences),
+          [
+            {localId: 1, projectName: 'proj', labelRefs: [{label: 'label'}]},
+            {localId: 332, projectName: 'chromium', labelRefs: []},
+          ]);
+    });
+
+    it('returns federated references', () => {
+      const stateIssuesWithFederatedReferences = wrapIssue(
+          {
+            projectName: 'project',
+            localId: 123,
+            blockingIssueRefs: [
+              {localId: 1, projectName: 'proj'},
+              {extIdentifier: 'b/1234'},
+            ],
+          },
+          {relatedIssues},
+      );
+      assert.deepEqual(
+          issueV0.blockingIssues(stateIssuesWithFederatedReferences), [
+            {localId: 1, projectName: 'proj', labelRefs: [{label: 'label'}]},
+            {extIdentifier: 'b/1234'},
+          ]);
+    });
+  });
+
+  describe('blockedOnIssues', () => {
+    const relatedIssues = {
+      ['proj:1']: {
+        localId: 1,
+        projectName: 'proj',
+        labelRefs: [{label: 'label'}],
+      },
+      ['proj:3']: {
+        localId: 3,
+        projectName: 'proj',
+        labelRefs: [],
+      },
+      ['chromium:332']: {
+        localId: 332,
+        projectName: 'chromium',
+        labelRefs: [],
+      },
+    };
+
+    it('returns references when no issue data', () => {
+      const stateNoReferences = wrapIssue(
+          {
+            projectName: 'project',
+            localId: 123,
+            blockedOnIssueRefs: [{localId: 1, projectName: 'proj'}],
+          },
+          {relatedIssues: {}},
+      );
+      assert.deepEqual(issueV0.blockedOnIssues(stateNoReferences),
+          [{localId: 1, projectName: 'proj'}],
+      );
+    });
+
+    it('returns empty when no blocking issues', () => {
+      const stateNoIssues = wrapIssue(
+          {
+            projectName: 'project',
+            localId: 123,
+            blockedOnIssueRefs: [],
+          },
+          {relatedIssues},
+      );
+      assert.deepEqual(issueV0.blockedOnIssues(stateNoIssues), []);
+    });
+
+    it('returns full issues when deferenced data present', () => {
+      const stateIssuesWithReferences = wrapIssue(
+          {
+            projectName: 'project',
+            localId: 123,
+            blockedOnIssueRefs: [
+              {localId: 1, projectName: 'proj'},
+              {localId: 332, projectName: 'chromium'},
+            ],
+          },
+          {relatedIssues},
+      );
+      assert.deepEqual(issueV0.blockedOnIssues(stateIssuesWithReferences),
+          [
+            {localId: 1, projectName: 'proj', labelRefs: [{label: 'label'}]},
+            {localId: 332, projectName: 'chromium', labelRefs: []},
+          ]);
+    });
+
+    it('returns federated references', () => {
+      const stateIssuesWithFederatedReferences = wrapIssue(
+          {
+            projectName: 'project',
+            localId: 123,
+            blockedOnIssueRefs: [
+              {localId: 1, projectName: 'proj'},
+              {extIdentifier: 'b/1234'},
+            ],
+          },
+          {relatedIssues},
+      );
+      assert.deepEqual(
+          issueV0.blockedOnIssues(stateIssuesWithFederatedReferences),
+          [
+            {localId: 1, projectName: 'proj', labelRefs: [{label: 'label'}]},
+            {extIdentifier: 'b/1234'},
+          ]);
+    });
+  });
+
+  describe('sortedBlockedOn', () => {
+    const relatedIssues = {
+      ['proj:1']: {
+        localId: 1,
+        projectName: 'proj',
+        statusRef: {meansOpen: true},
+      },
+      ['proj:3']: {
+        localId: 3,
+        projectName: 'proj',
+        statusRef: {meansOpen: false},
+      },
+      ['proj:4']: {
+        localId: 4,
+        projectName: 'proj',
+        statusRef: {meansOpen: false},
+      },
+      ['proj:5']: {
+        localId: 5,
+        projectName: 'proj',
+        statusRef: {meansOpen: false},
+      },
+      ['chromium:332']: {
+        localId: 332,
+        projectName: 'chromium',
+        statusRef: {meansOpen: true},
+      },
+    };
+
+    it('does not sort references when no issue data', () => {
+      const stateNoReferences = wrapIssue(
+          {
+            projectName: 'project',
+            localId: 123,
+            blockedOnIssueRefs: [
+              {localId: 3, projectName: 'proj'},
+              {localId: 1, projectName: 'proj'},
+            ],
+          },
+          {relatedIssues: {}},
+      );
+      assert.deepEqual(issueV0.sortedBlockedOn(stateNoReferences), [
+        {localId: 3, projectName: 'proj'},
+        {localId: 1, projectName: 'proj'},
+      ]);
+    });
+
+    it('sorts open issues first when issue data available', () => {
+      const stateReferences = wrapIssue(
+          {
+            projectName: 'project',
+            localId: 123,
+            blockedOnIssueRefs: [
+              {localId: 3, projectName: 'proj'},
+              {localId: 1, projectName: 'proj'},
+            ],
+          },
+          {relatedIssues},
+      );
+      assert.deepEqual(issueV0.sortedBlockedOn(stateReferences), [
+        {localId: 1, projectName: 'proj', statusRef: {meansOpen: true}},
+        {localId: 3, projectName: 'proj', statusRef: {meansOpen: false}},
+      ]);
+    });
+
+    it('preserves original order on ties', () => {
+      const statePreservesArrayOrder = wrapIssue(
+          {
+            projectName: 'project',
+            localId: 123,
+            blockedOnIssueRefs: [
+              {localId: 5, projectName: 'proj'}, // Closed
+              {localId: 1, projectName: 'proj'}, // Open
+              {localId: 4, projectName: 'proj'}, // Closed
+              {localId: 3, projectName: 'proj'}, // Closed
+              {localId: 332, projectName: 'chromium'}, // Open
+            ],
+          },
+          {relatedIssues},
+      );
+      assert.deepEqual(issueV0.sortedBlockedOn(statePreservesArrayOrder),
+          [
+            {localId: 1, projectName: 'proj', statusRef: {meansOpen: true}},
+            {localId: 332, projectName: 'chromium',
+              statusRef: {meansOpen: true}},
+            {localId: 5, projectName: 'proj', statusRef: {meansOpen: false}},
+            {localId: 4, projectName: 'proj', statusRef: {meansOpen: false}},
+            {localId: 3, projectName: 'proj', statusRef: {meansOpen: false}},
+          ],
+      );
+    });
+  });
+
+  describe('mergedInto', () => {
+    it('empty', () => {
+      assert.deepEqual(issueV0.mergedInto(wrapIssue()), {});
+    });
+
+    it('gets mergedInto ref for viewed issue', () => {
+      const state = issueV0.mergedInto(wrapIssue({
+        projectName: 'project',
+        localId: 123,
+        mergedIntoIssueRef: {localId: 22, projectName: 'proj'},
+      }));
+      assert.deepEqual(state, {
+        localId: 22,
+        projectName: 'proj',
+      });
+    });
+
+    it('gets full mergedInto issue data when it exists in the store', () => {
+      const state = wrapIssue(
+          {
+            projectName: 'project',
+            localId: 123,
+            mergedIntoIssueRef: {localId: 22, projectName: 'proj'},
+          }, {
+            relatedIssues: {
+              ['proj:22']: {localId: 22, projectName: 'proj', summary: 'test'},
+            },
+          });
+      assert.deepEqual(issueV0.mergedInto(state), {
+        localId: 22,
+        projectName: 'proj',
+        summary: 'test',
+      });
+    });
+  });
+
+  it('fieldValueMap', () => {
+    assert.deepEqual(issueV0.fieldValueMap(wrapIssue()), new Map());
+    assert.deepEqual(issueV0.fieldValueMap(wrapIssue({
+      fieldValues: [],
+    })), new Map());
+    assert.deepEqual(issueV0.fieldValueMap(wrapIssue({
+      fieldValues: [
+        {fieldRef: {fieldName: 'hello'}, value: 'v3'},
+        {fieldRef: {fieldName: 'hello'}, value: 'v2'},
+        {fieldRef: {fieldName: 'world'}, value: 'v3'},
+      ],
+    })), new Map([
+      ['hello', ['v3', 'v2']],
+      ['world', ['v3']],
+    ]));
+  });
+
+  it('fieldDefs filters fields by applicable type', () => {
+    assert.deepEqual(issueV0.fieldDefs({
+      projectV0: {},
+      ...wrapIssue(),
+    }), []);
+
+    assert.deepEqual(issueV0.fieldDefs({
+      projectV0: {
+        name: 'chromium',
+        configs: {
+          chromium: {
+            fieldDefs: [
+              {fieldRef: {fieldName: 'intyInt', type: fieldTypes.INT_TYPE}},
+              {fieldRef: {fieldName: 'enum', type: fieldTypes.ENUM_TYPE}},
+              {
+                fieldRef:
+                  {fieldName: 'nonApplicable', type: fieldTypes.STR_TYPE},
+                applicableType: 'None',
+              },
+              {fieldRef: {fieldName: 'defectsOnly', type: fieldTypes.STR_TYPE},
+                applicableType: 'Defect'},
+            ],
+          },
+        },
+      },
+      ...wrapIssue({
+        fieldValues: [
+          {fieldRef: {fieldName: 'Type'}, value: 'Defect'},
+        ],
+      }),
+    }), [
+      {fieldRef: {fieldName: 'intyInt', type: fieldTypes.INT_TYPE}},
+      {fieldRef: {fieldName: 'enum', type: fieldTypes.ENUM_TYPE}},
+      {fieldRef: {fieldName: 'defectsOnly', type: fieldTypes.STR_TYPE},
+        applicableType: 'Defect'},
+    ]);
+  });
+
+  it('fieldDefs skips approval fields for all issues', () => {
+    assert.deepEqual(issueV0.fieldDefs({
+      projectV0: {
+        name: 'chromium',
+        configs: {
+          chromium: {
+            fieldDefs: [
+              {fieldRef: {fieldName: 'test', type: fieldTypes.INT_TYPE}},
+              {fieldRef:
+                {fieldName: 'ignoreMe', type: fieldTypes.APPROVAL_TYPE}},
+              {fieldRef:
+                {fieldName: 'LookAway', approvalName: 'ThisIsAnApproval'}},
+              {fieldRef: {fieldName: 'phaseField'}, isPhaseField: true},
+            ],
+          },
+        },
+      },
+      ...wrapIssue(),
+    }), [
+      {fieldRef: {fieldName: 'test', type: fieldTypes.INT_TYPE}},
+    ]);
+  });
+
+  it('fieldDefs includes non applicable fields when values defined', () => {
+    assert.deepEqual(issueV0.fieldDefs({
+      projectV0: {
+        name: 'chromium',
+        configs: {
+          chromium: {
+            fieldDefs: [
+              {
+                fieldRef:
+                  {fieldName: 'nonApplicable', type: fieldTypes.STR_TYPE},
+                applicableType: 'None',
+              },
+            ],
+          },
+        },
+      },
+      ...wrapIssue({
+        fieldValues: [
+          {fieldRef: {fieldName: 'nonApplicable'}, value: 'v3'},
+        ],
+      }),
+    }), [
+      {fieldRef: {fieldName: 'nonApplicable', type: fieldTypes.STR_TYPE},
+        applicableType: 'None'},
+    ]);
+  });
+
+  describe('action creators', () => {
+    beforeEach(() => {
+      prpcCall = sinon.stub(prpcClient, 'call');
+    });
+
+    afterEach(() => {
+      prpcCall.restore();
+    });
+
+    it('viewIssue creates action with issueRef', () => {
+      assert.deepEqual(
+          issueV0.viewIssue({projectName: 'proj', localId: 123}),
+          {
+            type: issueV0.VIEW_ISSUE,
+            issueRef: {projectName: 'proj', localId: 123},
+          },
+      );
+    });
+
+
+    describe('updateApproval', async () => {
+      const APPROVAL = {
+        fieldRef: {fieldName: 'Privacy', type: 'APPROVAL_TYPE'},
+        approverRefs: [{userId: 1234, displayName: 'test@example.com'}],
+        status: 'APPROVED',
+      };
+
+      it('approval update success', async () => {
+        const dispatch = sinon.stub();
+
+        prpcCall.returns({approval: APPROVAL});
+
+        const action = issueV0.updateApproval({
+          issueRef: {projectName: 'chromium', localId: 1234},
+          fieldRef: {fieldName: 'Privacy', type: 'APPROVAL_TYPE'},
+          approvalDelta: {status: 'APPROVED'},
+          sendEmail: true,
+        });
+
+        await action(dispatch);
+
+        sinon.assert.calledOnce(prpcCall);
+
+        sinon.assert.calledWith(prpcCall, 'monorail.Issues',
+            'UpdateApproval', {
+              issueRef: {projectName: 'chromium', localId: 1234},
+              fieldRef: {fieldName: 'Privacy', type: 'APPROVAL_TYPE'},
+              approvalDelta: {status: 'APPROVED'},
+              sendEmail: true,
+            });
+
+        sinon.assert.calledWith(dispatch, {type: 'UPDATE_APPROVAL_START'});
+        sinon.assert.calledWith(dispatch, {
+          type: 'UPDATE_APPROVAL_SUCCESS',
+          approval: APPROVAL,
+          issueRef: {projectName: 'chromium', localId: 1234},
+        });
+      });
+
+      it('approval survey update success', async () => {
+        const dispatch = sinon.stub();
+
+        prpcCall.returns({approval: APPROVAL});
+
+        const action = issueV0.updateApproval({
+          issueRef: {projectName: 'chromium', localId: 1234},
+          fieldRef: {fieldName: 'Privacy', type: 'APPROVAL_TYPE'},
+          commentContent: 'new survey',
+          sendEmail: false,
+          isDescription: true,
+        });
+
+        await action(dispatch);
+
+        sinon.assert.calledOnce(prpcCall);
+
+        sinon.assert.calledWith(prpcCall, 'monorail.Issues',
+            'UpdateApproval', {
+              issueRef: {projectName: 'chromium', localId: 1234},
+              fieldRef: {fieldName: 'Privacy', type: 'APPROVAL_TYPE'},
+              commentContent: 'new survey',
+              isDescription: true,
+            });
+
+        sinon.assert.calledWith(dispatch, {type: 'UPDATE_APPROVAL_START'});
+        sinon.assert.calledWith(dispatch, {
+          type: 'UPDATE_APPROVAL_SUCCESS',
+          approval: APPROVAL,
+          issueRef: {projectName: 'chromium', localId: 1234},
+        });
+      });
+
+      it('attachment upload success', async () => {
+        const dispatch = sinon.stub();
+
+        prpcCall.returns({approval: APPROVAL});
+
+        const action = issueV0.updateApproval({
+          issueRef: {projectName: 'chromium', localId: 1234},
+          fieldRef: {fieldName: 'Privacy', type: 'APPROVAL_TYPE'},
+          uploads: '78f17a020cbf39e90e344a842cd19911',
+        });
+
+        await action(dispatch);
+
+        sinon.assert.calledOnce(prpcCall);
+
+        sinon.assert.calledWith(prpcCall, 'monorail.Issues',
+            'UpdateApproval', {
+              issueRef: {projectName: 'chromium', localId: 1234},
+              fieldRef: {fieldName: 'Privacy', type: 'APPROVAL_TYPE'},
+              uploads: '78f17a020cbf39e90e344a842cd19911',
+            });
+
+        sinon.assert.calledWith(dispatch, {type: 'UPDATE_APPROVAL_START'});
+        sinon.assert.calledWith(dispatch, {
+          type: 'UPDATE_APPROVAL_SUCCESS',
+          approval: APPROVAL,
+          issueRef: {projectName: 'chromium', localId: 1234},
+        });
+      });
+    });
+
+    describe('fetchIssues', () => {
+      it('success', async () => {
+        const response = {
+          openRefs: [example.ISSUE],
+          closedRefs: [example.ISSUE_OTHER_PROJECT],
+        };
+        prpcClient.call.returns(Promise.resolve(response));
+        const dispatch = sinon.stub();
+
+        await issueV0.fetchIssues([example.ISSUE_REF])(dispatch);
+
+        sinon.assert.calledWith(dispatch, {type: issueV0.FETCH_ISSUES_START});
+
+        const args = {issueRefs: [example.ISSUE_REF]};
+        sinon.assert.calledWith(
+            prpcClient.call, 'monorail.Issues', 'ListReferencedIssues', args);
+
+        const action = {
+          type: issueV0.FETCH_ISSUES_SUCCESS,
+          issues: [example.ISSUE, example.ISSUE_OTHER_PROJECT],
+        };
+        sinon.assert.calledWith(dispatch, action);
+      });
+
+      it('failure', async () => {
+        prpcClient.call.throws();
+        const dispatch = sinon.stub();
+
+        await issueV0.fetchIssues([example.ISSUE_REF])(dispatch);
+
+        const action = {
+          type: issueV0.FETCH_ISSUES_FAILURE,
+          error: sinon.match.any,
+        };
+        sinon.assert.calledWith(dispatch, action);
+      });
+    });
+
+    it('fetchIssueList calls ListIssues', async () => {
+      prpcCall.callsFake(() => {
+        return {
+          issues: [{localId: 1}, {localId: 2}, {localId: 3}],
+          totalResults: 6,
+        };
+      });
+
+      store.dispatch(issueV0.fetchIssueList('chromium',
+          {q: 'owner:me', can: '4'}));
+
+      sinon.assert.calledWith(prpcCall, 'monorail.Issues', 'ListIssues', {
+        query: 'owner:me',
+        cannedQuery: 4,
+        projectNames: ['chromium'],
+        pagination: {},
+        groupBySpec: undefined,
+        sortSpec: undefined,
+      });
+    });
+
+    it('fetchIssueList does not set can when can is NaN', async () => {
+      prpcCall.callsFake(() => ({}));
+
+      store.dispatch(issueV0.fetchIssueList('chromium', {q: 'owner:me',
+        can: 'four-leaf-clover'}));
+
+      sinon.assert.calledWith(prpcCall, 'monorail.Issues', 'ListIssues', {
+        query: 'owner:me',
+        cannedQuery: undefined,
+        projectNames: ['chromium'],
+        pagination: {},
+        groupBySpec: undefined,
+        sortSpec: undefined,
+      });
+    });
+
+    it('fetchIssueList makes several calls to ListIssues', async () => {
+      prpcCall.callsFake(() => {
+        return {
+          issues: [{localId: 1}, {localId: 2}, {localId: 3}],
+          totalResults: 6,
+        };
+      });
+
+      const dispatch = sinon.stub();
+      const action = issueV0.fetchIssueList('chromium',
+          {maxItems: 3, maxCalls: 2});
+      await action(dispatch);
+
+      sinon.assert.calledTwice(prpcCall);
+      sinon.assert.calledWith(dispatch, {
+        type: 'FETCH_ISSUE_LIST_UPDATE',
+        issues:
+          [{localId: 1}, {localId: 2}, {localId: 3},
+            {localId: 1}, {localId: 2}, {localId: 3}],
+        progress: 1,
+        totalResults: 6,
+      });
+      sinon.assert.calledWith(dispatch, {type: 'FETCH_ISSUE_LIST_SUCCESS'});
+    });
+
+    it('fetchIssueList orders issues correctly', async () => {
+      prpcCall.onFirstCall().returns({issues: [{localId: 1}], totalResults: 6});
+      prpcCall.onSecondCall().returns({
+        issues: [{localId: 2}],
+        totalResults: 6});
+      prpcCall.onThirdCall().returns({issues: [{localId: 3}], totalResults: 6});
+
+      const dispatch = sinon.stub();
+      const action = issueV0.fetchIssueList('chromium',
+          {maxItems: 1, maxCalls: 3});
+      await action(dispatch);
+
+      sinon.assert.calledWith(dispatch, {
+        type: 'FETCH_ISSUE_LIST_UPDATE',
+        issues: [{localId: 1}, {localId: 2}, {localId: 3}],
+        progress: 1,
+        totalResults: 6,
+      });
+      sinon.assert.calledWith(dispatch, {type: 'FETCH_ISSUE_LIST_SUCCESS'});
+    });
+
+    it('returns progress of 1 when no totalIssues', async () => {
+      prpcCall.onFirstCall().returns({issues: [], totalResults: 0});
+
+      const dispatch = sinon.stub();
+      const action = issueV0.fetchIssueList('chromium',
+          {maxItems: 1, maxCalls: 1});
+      await action(dispatch);
+
+      sinon.assert.calledWith(dispatch, {
+        type: 'FETCH_ISSUE_LIST_UPDATE',
+        issues: [],
+        progress: 1,
+        totalResults: 0,
+      });
+      sinon.assert.calledWith(dispatch, {type: 'FETCH_ISSUE_LIST_SUCCESS'});
+    });
+
+    it('returns progress of 1 when totalIssues undefined', async () => {
+      prpcCall.onFirstCall().returns({issues: []});
+
+      const dispatch = sinon.stub();
+      const action = issueV0.fetchIssueList('chromium',
+          {maxItems: 1, maxCalls: 1});
+      await action(dispatch);
+
+      sinon.assert.calledWith(dispatch, {
+        type: 'FETCH_ISSUE_LIST_UPDATE',
+        issues: [],
+        progress: 1,
+      });
+      sinon.assert.calledWith(dispatch, {type: 'FETCH_ISSUE_LIST_SUCCESS'});
+    });
+
+    // TODO(kweng@) remove once crbug.com/monorail/6641 is fixed
+    it('has expected default for empty response', async () => {
+      prpcCall.onFirstCall().returns({});
+
+      const dispatch = sinon.stub();
+      const action = issueV0.fetchIssueList('chromium',
+          {maxItems: 1, maxCalls: 1});
+      await action(dispatch);
+
+      sinon.assert.calledWith(dispatch, {
+        type: 'FETCH_ISSUE_LIST_UPDATE',
+        issues: [],
+        progress: 1,
+        totalResults: 0,
+      });
+      sinon.assert.calledWith(dispatch, {type: 'FETCH_ISSUE_LIST_SUCCESS'});
+    });
+
+    describe('federated references', () => {
+      beforeEach(() => {
+        // Preload signinImpl with a fake for testing.
+        getSigninInstance({
+          init: sinon.stub(),
+          getUserProfileAsync: () => (
+            Promise.resolve({
+              getEmail: sinon.stub().returns('rutabaga@google.com'),
+            })
+          ),
+        });
+        window.CS_env = {gapi_client_id: 'rutabaga'};
+        const getStub = sinon.stub().returns({
+          execute: (cb) => cb(response),
+        });
+        const response = {
+          result: {
+            resolvedTime: 12345,
+            issueState: {
+              title: 'Rutabaga title',
+            },
+          },
+        };
+        window.gapi = {
+          client: {
+            load: (_url, _version, cb) => cb(),
+            corp_issuetracker: {issues: {get: getStub}},
+          },
+        };
+      });
+
+      afterEach(() => {
+        delete window.CS_env;
+        delete window.gapi;
+      });
+
+      describe('fetchFederatedReferences', () => {
+        it('returns an empty map if no fedrefs found', async () => {
+          const dispatch = sinon.stub();
+          const testIssue = {};
+          const action = issueV0.fetchFederatedReferences(testIssue);
+          const result = await action(dispatch);
+
+          assert.equal(dispatch.getCalls().length, 1);
+          sinon.assert.calledWith(dispatch, {
+            type: 'FETCH_FEDERATED_REFERENCES_START',
+          });
+          assert.isUndefined(result);
+        });
+
+        it('fetches from Buganizer API', async () => {
+          const dispatch = sinon.stub();
+          const testIssue = {
+            danglingBlockingRefs: [
+              {extIdentifier: 'b/123456'},
+            ],
+            danglingBlockedOnRefs: [
+              {extIdentifier: 'b/654321'},
+            ],
+            mergedIntoIssueRef: {
+              extIdentifier: 'b/987654',
+            },
+          };
+          const action = issueV0.fetchFederatedReferences(testIssue);
+          await action(dispatch);
+
+          sinon.assert.calledWith(dispatch, {
+            type: 'FETCH_FEDERATED_REFERENCES_START',
+          });
+          sinon.assert.calledWith(dispatch, {
+            type: 'GAPI_LOGIN_SUCCESS',
+            email: 'rutabaga@google.com',
+          });
+          sinon.assert.calledWith(dispatch, {
+            type: 'FETCH_FEDERATED_REFERENCES_SUCCESS',
+            fedRefIssueRefs: [
+              {
+                extIdentifier: 'b/123456',
+                statusRef: {meansOpen: false},
+                summary: 'Rutabaga title',
+              },
+              {
+                extIdentifier: 'b/654321',
+                statusRef: {meansOpen: false},
+                summary: 'Rutabaga title',
+              },
+              {
+                extIdentifier: 'b/987654',
+                statusRef: {meansOpen: false},
+                summary: 'Rutabaga title',
+              },
+            ],
+          });
+        });
+      });
+
+      describe('fetchRelatedIssues', () => {
+        it('calls fetchFederatedReferences for mergedinto', async () => {
+          const dispatch = sinon.stub();
+          prpcCall.returns(Promise.resolve({openRefs: [], closedRefs: []}));
+          const testIssue = {
+            mergedIntoIssueRef: {
+              extIdentifier: 'b/987654',
+            },
+          };
+          const action = issueV0.fetchRelatedIssues(testIssue);
+          await action(dispatch);
+
+          // Important: mergedinto fedref is not passed to ListReferencedIssues.
+          const expectedMessage = {issueRefs: []};
+          sinon.assert.calledWith(prpcClient.call, 'monorail.Issues',
+              'ListReferencedIssues', expectedMessage);
+
+          sinon.assert.calledWith(dispatch, {
+            type: 'FETCH_RELATED_ISSUES_START',
+          });
+          // No mergedInto refs returned, they're handled by
+          // fetchFederatedReferences.
+          sinon.assert.calledWith(dispatch, {
+            type: 'FETCH_RELATED_ISSUES_SUCCESS',
+            relatedIssues: {},
+          });
+        });
+      });
+    });
+  });
+
+  describe('starring issues', () => {
+    describe('reducers', () => {
+      it('FETCH_IS_STARRED_SUCCESS updates the starredIssues object', () => {
+        const state = {};
+        const newState = issueV0.starredIssuesReducer(state,
+            {
+              type: issueV0.FETCH_IS_STARRED_SUCCESS,
+              starred: false,
+              issueRef: {
+                projectName: 'proj',
+                localId: 1,
+              },
+            },
+        );
+        assert.deepEqual(newState, {'proj:1': false});
+      });
+
+      it('FETCH_ISSUES_STARRED_SUCCESS updates the starredIssues object',
+          () => {
+            const state = {};
+            const starredIssueRefs = [{projectName: 'proj', localId: 1},
+              {projectName: 'proj', localId: 2}];
+            const newState = issueV0.starredIssuesReducer(state,
+                {type: issueV0.FETCH_ISSUES_STARRED_SUCCESS, starredIssueRefs},
+            );
+            assert.deepEqual(newState, {'proj:1': true, 'proj:2': true});
+          });
+
+      it('FETCH_ISSUES_STARRED_SUCCESS does not time out with 10,000 stars',
+          () => {
+            const state = {};
+            const starredIssueRefs = [];
+            const expected = {};
+            for (let i = 1; i <= 10000; i++) {
+              starredIssueRefs.push({projectName: 'proj', localId: i});
+              expected[`proj:${i}`] = true;
+            }
+            const newState = issueV0.starredIssuesReducer(state,
+                {type: issueV0.FETCH_ISSUES_STARRED_SUCCESS, starredIssueRefs},
+            );
+            assert.deepEqual(newState, expected);
+          });
+
+      it('STAR_SUCCESS updates the starredIssues object', () => {
+        const state = {'proj:1': true, 'proj:2': false};
+        const newState = issueV0.starredIssuesReducer(state,
+            {
+              type: issueV0.STAR_SUCCESS,
+              starred: true,
+              issueRef: {projectName: 'proj', localId: 2},
+            });
+        assert.deepEqual(newState, {'proj:1': true, 'proj:2': true});
+      });
+    });
+
+    describe('selectors', () => {
+      describe('issue', () => {
+        const selector = issueV0.issue(wrapIssue(example.ISSUE));
+        assert.deepEqual(selector(example.NAME), example.ISSUE);
+      });
+
+      describe('issueForRefString', () => {
+        const noIssues = issueV0.issueForRefString(wrapIssue({}));
+        const withIssue = issueV0.issueForRefString(wrapIssue({
+          projectName: 'test',
+          localId: 1,
+          summary: 'hello world',
+        }));
+
+        it('returns issue ref when no issue data', () => {
+          assert.deepEqual(noIssues('1', 'chromium'), {
+            localId: 1,
+            projectName: 'chromium',
+          });
+
+          assert.deepEqual(noIssues('chromium:2', 'ignore'), {
+            localId: 2,
+            projectName: 'chromium',
+          });
+
+          assert.deepEqual(noIssues('other:3'), {
+            localId: 3,
+            projectName: 'other',
+          });
+
+          assert.deepEqual(withIssue('other:3'), {
+            localId: 3,
+            projectName: 'other',
+          });
+        });
+
+        it('returns full issue data when available', () => {
+          assert.deepEqual(withIssue('1', 'test'), {
+            projectName: 'test',
+            localId: 1,
+            summary: 'hello world',
+          });
+
+          assert.deepEqual(withIssue('test:1', 'other'), {
+            projectName: 'test',
+            localId: 1,
+            summary: 'hello world',
+          });
+
+          assert.deepEqual(withIssue('test:1'), {
+            projectName: 'test',
+            localId: 1,
+            summary: 'hello world',
+          });
+        });
+      });
+
+      it('starredIssues', () => {
+        const state = {issue:
+          {starredIssues: {'proj:1': true, 'proj:2': false}}};
+        assert.deepEqual(issueV0.starredIssues(state), new Set(['proj:1']));
+      });
+
+      it('starringIssues', () => {
+        const state = {issue: {
+          requests: {
+            starringIssues: {
+              'proj:1': {requesting: true},
+              'proj:2': {requestin: false, error: 'unknown error'},
+            },
+          },
+        }};
+        assert.deepEqual(issueV0.starringIssues(state), new Map([
+          ['proj:1', {requesting: true}],
+          ['proj:2', {requestin: false, error: 'unknown error'}],
+        ]));
+      });
+    });
+
+    describe('action creators', () => {
+      beforeEach(() => {
+        prpcCall = sinon.stub(prpcClient, 'call');
+
+        dispatch = sinon.stub();
+      });
+
+      afterEach(() => {
+        prpcCall.restore();
+      });
+
+      it('fetching if an issue is starred', async () => {
+        const issueRef = {projectName: 'proj', localId: 1};
+        const action = issueV0.fetchIsStarred(issueRef);
+
+        prpcCall.returns(Promise.resolve({isStarred: true}));
+
+        await action(dispatch);
+
+        sinon.assert.calledWith(dispatch,
+            {type: issueV0.FETCH_IS_STARRED_START});
+
+        sinon.assert.calledWith(
+            prpcClient.call, 'monorail.Issues',
+            'IsIssueStarred', {issueRef},
+        );
+
+        sinon.assert.calledWith(dispatch, {
+          type: issueV0.FETCH_IS_STARRED_SUCCESS,
+          starred: true,
+          issueRef,
+        });
+      });
+
+      it('fetching starred issues', async () => {
+        const returnedIssueRef = {projectName: 'proj', localId: 1};
+        const starredIssueRefs = [returnedIssueRef];
+        const action = issueV0.fetchStarredIssues();
+
+        prpcCall.returns(Promise.resolve({starredIssueRefs}));
+
+        await action(dispatch);
+
+        sinon.assert.calledWith(dispatch, {type: 'FETCH_ISSUES_STARRED_START'});
+
+        sinon.assert.calledWith(
+            prpcClient.call, 'monorail.Issues',
+            'ListStarredIssues', {},
+        );
+
+        sinon.assert.calledWith(dispatch, {
+          type: issueV0.FETCH_ISSUES_STARRED_SUCCESS,
+          starredIssueRefs,
+        });
+      });
+
+      it('star', async () => {
+        const testIssue = {projectName: 'proj', localId: 1, starCount: 1};
+        const issueRef = issueToIssueRef(testIssue);
+        const action = issueV0.star(issueRef, false);
+
+        prpcCall.returns(Promise.resolve(testIssue));
+
+        await action(dispatch);
+
+        sinon.assert.calledWith(dispatch, {
+          type: issueV0.STAR_START,
+          requestKey: 'proj:1',
+        });
+
+        sinon.assert.calledWith(
+            prpcClient.call,
+            'monorail.Issues', 'StarIssue',
+            {issueRef, starred: false},
+        );
+
+        sinon.assert.calledWith(dispatch, {
+          type: issueV0.STAR_SUCCESS,
+          starCount: 1,
+          issueRef,
+          starred: false,
+          requestKey: 'proj:1',
+        });
+      });
+    });
+  });
+});
+
+/**
+ * Return an initial Redux state with a given viewed
+ * @param {Issue=} viewedIssue The viewed issue.
+ * @param {Object=} otherValues Any other state values that need
+ *   to be initialized.
+ * @return {Object}
+ */
+function wrapIssue(viewedIssue, otherValues = {}) {
+  if (!viewedIssue) {
+    return {
+      issue: {
+        issuesByRefString: {},
+        ...otherValues,
+      },
+    };
+  }
+
+  const ref = issueRefToString(viewedIssue);
+  return {
+    issue: {
+      viewedIssueRef: ref,
+      issuesByRefString: {
+        [ref]: {...viewedIssue},
+      },
+      ...otherValues,
+    },
+  };
+}
diff --git a/static_src/reducers/permissions.js b/static_src/reducers/permissions.js
new file mode 100644
index 0000000..2f0101b
--- /dev/null
+++ b/static_src/reducers/permissions.js
@@ -0,0 +1,118 @@
+// 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 Permissions actions, selectors, and reducers organized into
+ * a single Redux "Duck" that manages updating and retrieving permissions state
+ * on the frontend.
+ *
+ * The Permissions data is stored in a normalized format.
+ * `permissions` stores all PermissionSets[] indexed by resource name.
+ *
+ * Reference: https://github.com/erikras/ducks-modular-redux
+ */
+
+import {combineReducers} from 'redux';
+import {createReducer, createRequestReducer} from './redux-helpers.js';
+
+import {prpcClient} from 'prpc-client-instance.js';
+
+import 'shared/typedef.js';
+
+/** @typedef {import('redux').AnyAction} AnyAction */
+
+// Permissions
+
+// Field Permissions
+export const FIELD_DEF_EDIT = 'FIELD_DEF_EDIT';
+export const FIELD_DEF_VALUE_EDIT = 'FIELD_DEF_VALUE_EDIT';
+
+// Actions
+export const BATCH_GET_START = 'permissions/BATCH_GET_START';
+export const BATCH_GET_SUCCESS = 'permissions/BATCH_GET_SUCCESS';
+export const BATCH_GET_FAILURE = 'permissions/BATCH_GET_FAILURE';
+
+/* State Shape
+{
+  byName: Object<string, PermissionSet>,
+
+  requests: {
+    batchGet: ReduxRequestState,
+  },
+}
+*/
+
+// Reducers
+
+/**
+ * All PermissionSets indexed by resource name.
+ * @param {Object<string, PermissionSet>} state The existing items.
+ * @param {AnyAction} action
+ * @param {Array<PermissionSet>} action.permissionSets
+ * @return {Object<string, PermissionSet>}
+ */
+export const byNameReducer = createReducer({}, {
+  [BATCH_GET_SUCCESS]: (state, {permissionSets}) => {
+    const newState = {...state};
+    for (const permissionSet of permissionSets) {
+      newState[permissionSet.resource] = permissionSet;
+    }
+    return newState;
+  },
+});
+
+const requestsReducer = combineReducers({
+  batchGet: createRequestReducer(
+      BATCH_GET_START, BATCH_GET_SUCCESS, BATCH_GET_FAILURE),
+});
+
+export const reducer = combineReducers({
+  byName: byNameReducer,
+
+  requests: requestsReducer,
+});
+
+// Selectors
+
+/**
+ * Returns all the PermissionSets in the store as a mapping.
+ * @param {any} state
+ * @return {Object<string, PermissionSet>}
+ */
+export const byName = (state) => state.permissions.byName;
+
+/**
+ * Returns the Permissions requests.
+ * @param {any} state
+ * @return {Object<string, ReduxRequestState>}
+ */
+export const requests = (state) => state.permissions.requests;
+
+// Action Creators
+
+/**
+ * Action creator to fetch PermissionSets.
+ * @param {Array<string>} names The resource names to get.
+ * @return {function(function): Promise<Array<PermissionSet>>}
+ */
+export const batchGet = (names) => async (dispatch) => {
+  dispatch({type: BATCH_GET_START});
+
+  try {
+    /** @type {{permissionSets: Array<PermissionSet>}} */
+    const {permissionSets} = await prpcClient.call(
+        'monorail.v3.Permissions', 'BatchGetPermissionSets', {names});
+
+    for (const permissionSet of permissionSets) {
+      if (!permissionSet.permissions) {
+        permissionSet.permissions = [];
+      }
+    }
+    dispatch({type: BATCH_GET_SUCCESS, permissionSets});
+
+    return permissionSets;
+  } catch (error) {
+    dispatch({type: BATCH_GET_FAILURE, error});
+  };
+};
diff --git a/static_src/reducers/permissions.test.js b/static_src/reducers/permissions.test.js
new file mode 100644
index 0000000..3c29076
--- /dev/null
+++ b/static_src/reducers/permissions.test.js
@@ -0,0 +1,105 @@
+// 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 sinon from 'sinon';
+
+import * as permissions from './permissions.js';
+import * as example from 'shared/test/constants-permissions.js';
+import * as exampleIssues from 'shared/test/constants-issueV0.js';
+
+import {prpcClient} from 'prpc-client-instance.js';
+
+let dispatch;
+
+describe('permissions reducers', () => {
+  it('root reducer initial state', () => {
+    const actual = permissions.reducer(undefined, {type: null});
+    const expected = {
+      byName: {},
+      requests: {
+        batchGet: {error: null, requesting: false},
+      },
+    };
+    assert.deepEqual(actual, expected);
+  });
+
+  it('byName updates on BATCH_GET_SUCCESS', () => {
+    const action = {
+      type: permissions.BATCH_GET_SUCCESS,
+      permissionSets: [example.PERMISSION_SET_ISSUE],
+    };
+    const actual = permissions.byNameReducer({}, action);
+    const expected = {
+      [example.PERMISSION_SET_ISSUE.resource]: example.PERMISSION_SET_ISSUE,
+    };
+    assert.deepEqual(actual, expected);
+  });
+});
+
+describe('permissions selectors', () => {
+  it('byName', () => {
+    const state = {permissions: {byName: example.BY_NAME}};
+    const actual = permissions.byName(state);
+    assert.deepEqual(actual, example.BY_NAME);
+  });
+});
+
+describe('permissions action creators', () => {
+  beforeEach(() => {
+    sinon.stub(prpcClient, 'call');
+    dispatch = sinon.stub();
+  });
+
+  afterEach(() => {
+    prpcClient.call.restore();
+  });
+
+  describe('batchGet', () => {
+    it('success', async () => {
+      const response = {permissionSets: [example.PERMISSION_SET_ISSUE]};
+      prpcClient.call.returns(Promise.resolve(response));
+
+      await permissions.batchGet([exampleIssues.NAME])(dispatch);
+
+      sinon.assert.calledWith(dispatch, {type: permissions.BATCH_GET_START});
+
+      const args = {names: [exampleIssues.NAME]};
+      sinon.assert.calledWith(
+          prpcClient.call, 'monorail.v3.Permissions',
+          'BatchGetPermissionSets', args);
+
+      const action = {
+        type: permissions.BATCH_GET_SUCCESS,
+        permissionSets: [example.PERMISSION_SET_ISSUE],
+      };
+      sinon.assert.calledWith(dispatch, action);
+    });
+
+    it('failure', async () => {
+      prpcClient.call.throws();
+
+      await permissions.batchGet([exampleIssues.NAME])(dispatch);
+
+      const action = {
+        type: permissions.BATCH_GET_FAILURE,
+        error: sinon.match.any,
+      };
+      sinon.assert.calledWith(dispatch, action);
+    });
+
+    it('fills in permissions field', async () => {
+      const response = {permissionSets: [{resource: exampleIssues.NAME}]};
+      prpcClient.call.returns(Promise.resolve(response));
+
+      await permissions.batchGet([exampleIssues.NAME])(dispatch);
+
+      const action = {
+        type: permissions.BATCH_GET_SUCCESS,
+        permissionSets: [{resource: exampleIssues.NAME, permissions: []}],
+      };
+      sinon.assert.calledWith(dispatch, action);
+    });
+  });
+});
diff --git a/static_src/reducers/projectV0.js b/static_src/reducers/projectV0.js
new file mode 100644
index 0000000..5101ff8
--- /dev/null
+++ b/static_src/reducers/projectV0.js
@@ -0,0 +1,586 @@
+// 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 {combineReducers} from 'redux';
+import {createSelector} from 'reselect';
+import {createReducer, createRequestReducer} from './redux-helpers.js';
+import * as permissions from 'reducers/permissions.js';
+import {fieldTypes, SITEWIDE_DEFAULT_COLUMNS, defaultIssueFieldMap,
+  parseColSpec, stringValuesForIssueField} from 'shared/issue-fields.js';
+import {hasPrefix, removePrefix} from 'shared/helpers.js';
+import {fieldNameToLabelPrefix,
+  labelNameToLabelPrefixes, labelNameToLabelValue, fieldDefToName,
+  restrictionLabelsForPermissions} from 'shared/convertersV0.js';
+import {prpcClient} from 'prpc-client-instance.js';
+import 'shared/typedef.js';
+
+/** @typedef {import('redux').AnyAction} AnyAction */
+
+// Actions
+export const SELECT = 'projectV0/SELECT';
+
+const FETCH_CONFIG_START = 'projectV0/FETCH_CONFIG_START';
+export const FETCH_CONFIG_SUCCESS = 'projectV0/FETCH_CONFIG_SUCCESS';
+const FETCH_CONFIG_FAILURE = 'projectV0/FETCH_CONFIG_FAILURE';
+
+export const FETCH_PRESENTATION_CONFIG_START =
+  'projectV0/FETCH_PRESENTATION_CONFIG_START';
+export const FETCH_PRESENTATION_CONFIG_SUCCESS =
+  'projectV0/FETCH_PRESENTATION_CONFIG_SUCCESS';
+export const FETCH_PRESENTATION_CONFIG_FAILURE =
+  'projectV0/FETCH_PRESENTATION_CONFIG_FAILURE';
+
+export const FETCH_CUSTOM_PERMISSIONS_START =
+  'projectV0/FETCH_CUSTOM_PERMISSIONS_START';
+export const FETCH_CUSTOM_PERMISSIONS_SUCCESS =
+  'projectV0/FETCH_CUSTOM_PERMISSIONS_SUCCESS';
+export const FETCH_CUSTOM_PERMISSIONS_FAILURE =
+  'projectV0/FETCH_CUSTOM_PERMISSIONS_FAILURE';
+
+
+export const FETCH_VISIBLE_MEMBERS_START =
+  'projectV0/FETCH_VISIBLE_MEMBERS_START';
+export const FETCH_VISIBLE_MEMBERS_SUCCESS =
+  'projectV0/FETCH_VISIBLE_MEMBERS_SUCCESS';
+export const FETCH_VISIBLE_MEMBERS_FAILURE =
+  'projectV0/FETCH_VISIBLE_MEMBERS_FAILURE';
+
+const FETCH_TEMPLATES_START = 'projectV0/FETCH_TEMPLATES_START';
+export const FETCH_TEMPLATES_SUCCESS = 'projectV0/FETCH_TEMPLATES_SUCCESS';
+const FETCH_TEMPLATES_FAILURE = 'projectV0/FETCH_TEMPLATES_FAILURE';
+
+/* State Shape
+{
+  name: string,
+
+  configs: Object<string, Config>,
+  presentationConfigs: Object<string, PresentationConfig>,
+  customPermissions: Object<string, Array<string>>,
+  visibleMembers:
+      Object<string, {userRefs: Array<UserRef>, groupRefs: Array<UserRef>}>,
+  templates: Object<string, Array<TemplateDef>>,
+  presentationConfigsLoaded: Object<string, boolean>,
+
+  requests: {
+    fetchConfig: ReduxRequestState,
+    fetchMembers: ReduxRequestState
+    fetchCustomPermissions: ReduxRequestState,
+    fetchPresentationConfig: ReduxRequestState,
+    fetchTemplates: ReduxRequestState,
+  },
+}
+*/
+
+// Reducers
+export const nameReducer = createReducer(null, {
+  [SELECT]: (_state, {projectName}) => projectName,
+});
+
+export const configsReducer = createReducer({}, {
+  [FETCH_CONFIG_SUCCESS]: (state, {projectName, config}) => ({
+    ...state,
+    [projectName]: config,
+  }),
+});
+
+export const presentationConfigsReducer = createReducer({}, {
+  [FETCH_PRESENTATION_CONFIG_SUCCESS]:
+    (state, {projectName, presentationConfig}) => ({
+      ...state,
+      [projectName]: presentationConfig,
+    }),
+});
+
+/**
+ * Adds custom permissions to Redux in a normalized state.
+ * @param {Object<string, Array<String>>} state Redux state.
+ * @param {AnyAction} Action
+ * @return {Object<string, Array<String>>}
+ */
+export const customPermissionsReducer = createReducer({}, {
+  [FETCH_CUSTOM_PERMISSIONS_SUCCESS]:
+    (state, {projectName, permissions}) => ({
+      ...state,
+      [projectName]: permissions,
+    }),
+});
+
+export const visibleMembersReducer = createReducer({}, {
+  [FETCH_VISIBLE_MEMBERS_SUCCESS]: (state, {projectName, visibleMembers}) => ({
+    ...state,
+    [projectName]: visibleMembers,
+  }),
+});
+
+export const templatesReducer = createReducer({}, {
+  [FETCH_TEMPLATES_SUCCESS]: (state, {projectName, templates}) => ({
+    ...state,
+    [projectName]: templates,
+  }),
+});
+
+const requestsReducer = combineReducers({
+  fetchConfig: createRequestReducer(
+      FETCH_CONFIG_START, FETCH_CONFIG_SUCCESS, FETCH_CONFIG_FAILURE),
+  fetchMembers: createRequestReducer(
+      FETCH_VISIBLE_MEMBERS_START,
+      FETCH_VISIBLE_MEMBERS_SUCCESS,
+      FETCH_VISIBLE_MEMBERS_FAILURE),
+  fetchCustomPermissions: createRequestReducer(
+      FETCH_CUSTOM_PERMISSIONS_START,
+      FETCH_CUSTOM_PERMISSIONS_SUCCESS,
+      FETCH_CUSTOM_PERMISSIONS_FAILURE),
+  fetchPresentationConfig: createRequestReducer(
+      FETCH_PRESENTATION_CONFIG_START,
+      FETCH_PRESENTATION_CONFIG_SUCCESS,
+      FETCH_PRESENTATION_CONFIG_FAILURE),
+  fetchTemplates: createRequestReducer(
+      FETCH_TEMPLATES_START, FETCH_TEMPLATES_SUCCESS, FETCH_TEMPLATES_FAILURE),
+});
+
+export const reducer = combineReducers({
+  name: nameReducer,
+  configs: configsReducer,
+  customPermissions: customPermissionsReducer,
+  presentationConfigs: presentationConfigsReducer,
+  visibleMembers: visibleMembersReducer,
+  templates: templatesReducer,
+  requests: requestsReducer,
+});
+
+// Selectors
+export const project = (state) => state.projectV0 || {};
+
+export const viewedProjectName =
+  createSelector(project, (project) => project.name || null);
+
+export const configs =
+  createSelector(project, (project) => project.configs || {});
+export const presentationConfigs =
+  createSelector(project, (project) => project.presentationConfigs || {});
+export const customPermissions =
+  createSelector(project, (project) => project.customPermissions || {});
+export const visibleMembers =
+  createSelector(project, (project) => project.visibleMembers || {});
+export const templates =
+  createSelector(project, (project) => project.templates || {});
+
+export const viewedConfig = createSelector(
+    [viewedProjectName, configs],
+    (projectName, configs) => configs[projectName] || {});
+export const viewedPresentationConfig = createSelector(
+    [viewedProjectName, presentationConfigs],
+    (projectName, configs) => configs[projectName] || {});
+
+// TODO(crbug.com/monorail/7080): Come up with a more clear and
+// consistent pattern for determining when data is loaded.
+export const viewedPresentationConfigLoaded = createSelector(
+    [viewedProjectName, presentationConfigs],
+    (projectName, configs) => !!configs[projectName]);
+export const viewedCustomPermissions = createSelector(
+    [viewedProjectName, customPermissions],
+    (projectName, permissions) => permissions[projectName] || []);
+export const viewedVisibleMembers = createSelector(
+    [viewedProjectName, visibleMembers],
+    (projectName, visibleMembers) => visibleMembers[projectName] || {});
+export const viewedTemplates = createSelector(
+    [viewedProjectName, templates],
+    (projectName, templates) => templates[projectName] || []);
+
+/**
+ * Get the default columns for the currently viewed project.
+ */
+export const defaultColumns = createSelector(viewedPresentationConfig,
+    ({defaultColSpec}) =>{
+      if (defaultColSpec) {
+        return parseColSpec(defaultColSpec);
+      }
+      return SITEWIDE_DEFAULT_COLUMNS;
+    });
+
+
+/**
+ * Get the default query for the currently viewed project.
+ */
+export const defaultQuery = createSelector(viewedPresentationConfig,
+    (config) => config.defaultQuery || '');
+
+// Look up components by path.
+export const componentsMap = createSelector(
+    viewedConfig,
+    (config) => {
+      if (!config || !config.componentDefs) return new Map();
+      const acc = new Map();
+      for (const v of config.componentDefs) {
+        acc.set(v.path, v);
+      }
+      return acc;
+    },
+);
+
+export const fieldDefs = createSelector(
+    viewedConfig, (config) => ((config && config.fieldDefs) || []),
+);
+
+export const fieldDefMap = createSelector(
+    fieldDefs, (fieldDefs) => {
+      const map = new Map();
+      fieldDefs.forEach((fd) => {
+        map.set(fd.fieldRef.fieldName.toLowerCase(), fd);
+      });
+      return map;
+    },
+);
+
+export const labelDefs = createSelector(
+    [viewedConfig, viewedCustomPermissions],
+    (config, permissions) => [
+      ...((config && config.labelDefs) || []),
+      ...restrictionLabelsForPermissions(permissions),
+    ],
+);
+
+// labelDefs stored in an easily findable format with label names as keys.
+export const labelDefMap = createSelector(
+    labelDefs, (labelDefs) => {
+      const map = new Map();
+      labelDefs.forEach((ld) => {
+        map.set(ld.label.toLowerCase(), ld);
+      });
+      return map;
+    },
+);
+
+/**
+ * A selector that builds a map where keys are label prefixes
+ * and values equal to sets of possible values corresponding to the prefix
+ * @param {Object} state Current Redux state.
+ * @return {Map}
+ */
+export const labelPrefixValueMap = createSelector(labelDefs, (labelDefs) => {
+  const prefixMap = new Map();
+  labelDefs.forEach((ld) => {
+    const prefixes = labelNameToLabelPrefixes(ld.label);
+
+    prefixes.forEach((prefix) => {
+      if (prefixMap.has(prefix)) {
+        prefixMap.get(prefix).add(labelNameToLabelValue(ld.label, prefix));
+      } else {
+        prefixMap.set(prefix, new Set(
+            [labelNameToLabelValue(ld.label, prefix)]));
+      }
+    });
+  });
+
+  return prefixMap;
+});
+
+/**
+ * A selector that builds an array of label prefixes, keeping casing intact
+ * Some labels are implicitly used as custom fields in the grid and list view.
+ * Only labels with more than one option are included, to reduce noise.
+ * @param {Object} state Current Redux state.
+ * @return {Array}
+ */
+export const labelPrefixFields = createSelector(
+    labelPrefixValueMap, (map) => {
+      const prefixes = [];
+
+      map.forEach((options, prefix) => {
+      // Ignore label prefixes with only one value.
+        if (options.size > 1) {
+          prefixes.push(prefix);
+        }
+      });
+
+      return prefixes;
+    },
+);
+
+/**
+ * A selector that wraps labelPrefixFields arrays as set for fast lookup.
+ * @param {Object} state Current Redux state.
+ * @return {Set}
+ */
+export const labelPrefixSet = createSelector(
+    labelPrefixFields, (fields) => new Set(fields.map(
+        (field) => field.toLowerCase())),
+);
+
+export const enumFieldDefs = createSelector(
+    fieldDefs,
+    (fieldDefs) => {
+      return fieldDefs.filter(
+          (fd) => fd.fieldRef.type === fieldTypes.ENUM_TYPE);
+    },
+);
+
+/**
+ * A selector that builds a function that's used to compute the value of
+ * a given field name on a given issue. This function abstracts the difference
+ * between custom fields, built-in fields, and implicit fields created
+ * from labels and considers these values in the context of the current
+ * project configuration.
+ * @param {Object} state Current Redux state.
+ * @return {function(Issue, string): Array<string>} A function that processes a
+ *   given issue and field name to find the string value for that field, in
+ *   the issue.
+ */
+export const extractFieldValuesFromIssue = createSelector(
+    viewedProjectName,
+    (projectName) => (issue, fieldName) =>
+      stringValuesForIssueField(issue, fieldName, projectName),
+);
+
+/**
+ * A selector that builds a function that's used to compute the type of a given
+ * field name.
+ * @param {Object} state Current Redux state.
+ * @return {function(string): string}
+ */
+export const extractTypeForFieldName = createSelector(fieldDefMap,
+    (fieldDefMap) => {
+      return (fieldName) => {
+        const key = fieldName.toLowerCase();
+
+        // If the field is a built in field. Default fields have precedence
+        // over custom fields.
+        if (defaultIssueFieldMap.hasOwnProperty(key)) {
+          return defaultIssueFieldMap[key].type;
+        }
+
+        // If the field is a custom field. Custom fields have precedence
+        // over label prefixes.
+        if (fieldDefMap.has(key)) {
+          return fieldDefMap.get(key).fieldRef.type;
+        }
+
+        // Default to STR_TYPE, including for label fields.
+        return fieldTypes.STR_TYPE;
+      };
+    },
+);
+
+export const optionsPerEnumField = createSelector(
+    enumFieldDefs,
+    labelDefs,
+    (fieldDefs, labelDefs) => {
+      const map = new Map(fieldDefs.map(
+          (fd) => [fd.fieldRef.fieldName.toLowerCase(), []]));
+      labelDefs.forEach((ld) => {
+        const labelName = ld.label;
+
+        const fd = fieldDefs.find((fd) => hasPrefix(
+            labelName, fieldNameToLabelPrefix(fd.fieldRef.fieldName)));
+        if (fd) {
+          const key = fd.fieldRef.fieldName.toLowerCase();
+          map.get(key).push({
+            ...ld,
+            optionName: removePrefix(labelName,
+                fieldNameToLabelPrefix(fd.fieldRef.fieldName)),
+          });
+        }
+      });
+      return map;
+    },
+);
+
+export const fieldDefsForPhases = createSelector(
+    fieldDefs,
+    (fieldDefs) => {
+      if (!fieldDefs) return [];
+      return fieldDefs.filter((f) => f.isPhaseField);
+    },
+);
+
+export const fieldDefsByApprovalName = createSelector(
+    fieldDefs,
+    (fieldDefs) => {
+      if (!fieldDefs) return new Map();
+      const acc = new Map();
+      for (const fd of fieldDefs) {
+        if (fd.fieldRef && fd.fieldRef.approvalName) {
+          if (acc.has(fd.fieldRef.approvalName)) {
+            acc.get(fd.fieldRef.approvalName).push(fd);
+          } else {
+            acc.set(fd.fieldRef.approvalName, [fd]);
+          }
+        }
+      }
+      return acc;
+    },
+);
+
+export const fetchingConfig = (state) => {
+  return state.projectV0.requests.fetchConfig.requesting;
+};
+
+/**
+ * Shorthand method for detecting whether we are currently
+ * fetching presentationConcifg
+ * @param {Object} state Current Redux state.
+ * @return {boolean}
+ */
+export const fetchingPresentationConfig = (state) => {
+  return state.projectV0.requests.fetchPresentationConfig.requesting;
+};
+
+// Action Creators
+/**
+ * Action creator to set the currently viewed Project.
+ * @param {string} projectName The name of the Project to select.
+ * @return {function(function): Promise<void>}
+ */
+export const select = (projectName) => {
+  return (dispatch) => dispatch({type: SELECT, projectName});
+};
+
+/**
+ * Fetches data required to view project.
+ * @param {string} projectName
+ * @return {function(function): Promise<void>}
+ */
+export const fetch = (projectName) => async (dispatch) => {
+  const configPromise = dispatch(fetchConfig(projectName));
+  const visibleMembersPromise = dispatch(fetchVisibleMembers(projectName));
+
+  dispatch(fetchPresentationConfig(projectName));
+  dispatch(fetchTemplates(projectName));
+
+  const customPermissionsPromise = dispatch(
+      fetchCustomPermissions(projectName));
+
+  // TODO(crbug.com/monorail/5828): Remove window.TKR_populateAutocomplete once
+  // the old autocomplete code is deprecated.
+  const [config, visibleMembers, customPermissions] = await Promise.all([
+    configPromise,
+    visibleMembersPromise,
+    customPermissionsPromise]);
+  config.labelDefs = [...config.labelDefs,
+    ...restrictionLabelsForPermissions(customPermissions)];
+  dispatch(fetchFieldPerms(config.projectName, config.fieldDefs));
+  // eslint-disable-next-line new-cap
+  window.TKR_populateAutocomplete(config, visibleMembers, customPermissions);
+};
+
+/**
+ * Fetches project configuration including things like the custom fields in a
+ * project, the statuses, etc.
+ * @param {string} projectName
+ * @return {function(function): Promise<Config>}
+ */
+const fetchConfig = (projectName) => async (dispatch) => {
+  dispatch({type: FETCH_CONFIG_START});
+
+  const getConfig = prpcClient.call(
+      'monorail.Projects', 'GetConfig', {projectName});
+
+  try {
+    const config = await getConfig;
+    dispatch({type: FETCH_CONFIG_SUCCESS, projectName, config});
+    return config;
+  } catch (error) {
+    dispatch({type: FETCH_CONFIG_FAILURE, error});
+  }
+};
+
+export const fetchPresentationConfig = (projectName) => async (dispatch) => {
+  dispatch({type: FETCH_PRESENTATION_CONFIG_START});
+
+  try {
+    const presentationConfig = await prpcClient.call(
+        'monorail.Projects', 'GetPresentationConfig', {projectName});
+    dispatch({
+      type: FETCH_PRESENTATION_CONFIG_SUCCESS,
+      projectName,
+      presentationConfig,
+    });
+  } catch (error) {
+    dispatch({type: FETCH_PRESENTATION_CONFIG_FAILURE, error});
+  }
+};
+
+/**
+ * Fetches custom permissions defined for a project.
+ * @param {string} projectName
+ * @return {function(function): Promise<Array<string>>}
+ */
+export const fetchCustomPermissions = (projectName) => async (dispatch) => {
+  dispatch({type: FETCH_CUSTOM_PERMISSIONS_START});
+
+  try {
+    const {permissions} = await prpcClient.call(
+        'monorail.Projects', 'GetCustomPermissions', {projectName});
+    dispatch({
+      type: FETCH_CUSTOM_PERMISSIONS_SUCCESS,
+      projectName,
+      permissions,
+    });
+    return permissions;
+  } catch (error) {
+    dispatch({type: FETCH_CUSTOM_PERMISSIONS_FAILURE, error});
+  }
+};
+
+/**
+ * Fetches the project members that the user is able to view.
+ * @param {string} projectName
+ * @return {function(function): Promise<GetVisibleMembersResponse>}
+ */
+export const fetchVisibleMembers = (projectName) => async (dispatch) => {
+  dispatch({type: FETCH_VISIBLE_MEMBERS_START});
+
+  try {
+    const visibleMembers = await prpcClient.call(
+        'monorail.Projects', 'GetVisibleMembers', {projectName});
+    dispatch({
+      type: FETCH_VISIBLE_MEMBERS_SUCCESS,
+      projectName,
+      visibleMembers,
+    });
+    return visibleMembers;
+  } catch (error) {
+    dispatch({type: FETCH_VISIBLE_MEMBERS_FAILURE, error});
+  }
+};
+
+const fetchTemplates = (projectName) => async (dispatch) => {
+  dispatch({type: FETCH_TEMPLATES_START});
+
+  const listTemplates = prpcClient.call(
+      'monorail.Projects', 'ListProjectTemplates', {projectName});
+
+  // TODO(zhangtiff): Remove (see above TODO).
+  if (!listTemplates) return;
+
+  try {
+    const resp = await listTemplates;
+    dispatch({
+      type: FETCH_TEMPLATES_SUCCESS,
+      projectName,
+      templates: resp.templates,
+    });
+  } catch (error) {
+    dispatch({type: FETCH_TEMPLATES_FAILURE, error});
+  }
+};
+
+// Helpers
+
+/**
+ * Helper to fetch field permissions.
+ * @param {string} projectName The name of the project where the fields are.
+ * @param {Array<FieldDef>} fieldDefs
+ * @return {function(function): Promise<void>}
+ */
+export const fetchFieldPerms = (projectName, fieldDefs) => async (dispatch) => {
+  const fieldDefsNames = [];
+  if (fieldDefs) {
+    fieldDefs.forEach((fd) => {
+      const fieldDefName = fieldDefToName(projectName, fd);
+      fieldDefsNames.push(fieldDefName);
+    });
+  }
+  await dispatch(permissions.batchGet(fieldDefsNames));
+};
diff --git a/static_src/reducers/projectV0.test.js b/static_src/reducers/projectV0.test.js
new file mode 100644
index 0000000..fb1f051
--- /dev/null
+++ b/static_src/reducers/projectV0.test.js
@@ -0,0 +1,944 @@
+// 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 {prpcClient} from 'prpc-client-instance.js';
+import * as projectV0 from './projectV0.js';
+import {store} from './base.js';
+import * as example from 'shared/test/constants-projectV0.js';
+import {restrictionLabelsForPermissions} from 'shared/convertersV0.js';
+import {fieldTypes, SITEWIDE_DEFAULT_COLUMNS} from 'shared/issue-fields.js';
+
+describe('project reducers', () => {
+  it('root reducer initial state', () => {
+    const actual = projectV0.reducer(undefined, {type: null});
+    const expected = {
+      name: null,
+      configs: {},
+      presentationConfigs: {},
+      customPermissions: {},
+      visibleMembers: {},
+      templates: {},
+      requests: {
+        fetchConfig: {
+          error: null,
+          requesting: false,
+        },
+        fetchCustomPermissions: {
+          error: null,
+          requesting: false,
+        },
+        fetchMembers: {
+          error: null,
+          requesting: false,
+        },
+        fetchPresentationConfig: {
+          error: null,
+          requesting: false,
+        },
+        fetchTemplates: {
+          error: null,
+          requesting: false,
+        },
+      },
+    };
+    assert.deepEqual(actual, expected);
+  });
+
+  it('name', () => {
+    const action = {type: projectV0.SELECT, projectName: example.PROJECT_NAME};
+    assert.deepEqual(projectV0.nameReducer(null, action), example.PROJECT_NAME);
+  });
+
+  it('configs updates when fetching Config', () => {
+    const action = {
+      type: projectV0.FETCH_CONFIG_SUCCESS,
+      projectName: example.PROJECT_NAME,
+      config: example.CONFIG,
+    };
+    const expected = {[example.PROJECT_NAME]: example.CONFIG};
+    assert.deepEqual(projectV0.configsReducer({}, action), expected);
+  });
+
+  it('customPermissions', () => {
+    const action = {
+      type: projectV0.FETCH_CUSTOM_PERMISSIONS_SUCCESS,
+      projectName: example.PROJECT_NAME,
+      permissions: example.CUSTOM_PERMISSIONS,
+    };
+    const expected = {[example.PROJECT_NAME]: example.CUSTOM_PERMISSIONS};
+    assert.deepEqual(projectV0.customPermissionsReducer({}, action), expected);
+  });
+
+  it('presentationConfigs', () => {
+    const action = {
+      type: projectV0.FETCH_PRESENTATION_CONFIG_SUCCESS,
+      projectName: example.PROJECT_NAME,
+      presentationConfig: example.PRESENTATION_CONFIG,
+    };
+    const expected = {[example.PROJECT_NAME]: example.PRESENTATION_CONFIG};
+    assert.deepEqual(projectV0.presentationConfigsReducer({}, action),
+      expected);
+  });
+
+  it('visibleMembers', () => {
+    const action = {
+      type: projectV0.FETCH_VISIBLE_MEMBERS_SUCCESS,
+      projectName: example.PROJECT_NAME,
+      visibleMembers: example.VISIBLE_MEMBERS,
+    };
+    const expected = {[example.PROJECT_NAME]: example.VISIBLE_MEMBERS};
+    assert.deepEqual(projectV0.visibleMembersReducer({}, action), expected);
+  });
+
+  it('templates', () => {
+    const action = {
+      type: projectV0.FETCH_TEMPLATES_SUCCESS,
+      projectName: example.PROJECT_NAME,
+      templates: [example.TEMPLATE_DEF],
+    };
+    const expected = {[example.PROJECT_NAME]: [example.TEMPLATE_DEF]};
+    assert.deepEqual(projectV0.templatesReducer({}, action), expected);
+  });
+});
+
+describe('project selectors', () => {
+  it('viewedProjectName', () => {
+    const actual = projectV0.viewedProjectName(example.STATE);
+    assert.deepEqual(actual, example.PROJECT_NAME);
+  });
+
+  it('viewedVisibleMembers', () => {
+    assert.deepEqual(projectV0.viewedVisibleMembers({}), {});
+    assert.deepEqual(projectV0.viewedVisibleMembers({projectV0: {}}), {});
+    assert.deepEqual(projectV0.viewedVisibleMembers(
+        {projectV0: {visibleMembers: {}}}), {});
+    const actual = projectV0.viewedVisibleMembers(example.STATE);
+    assert.deepEqual(actual, example.VISIBLE_MEMBERS);
+  });
+
+  it('viewedCustomPermissions', () => {
+    assert.deepEqual(projectV0.viewedCustomPermissions({}), []);
+    assert.deepEqual(projectV0.viewedCustomPermissions({projectV0: {}}), []);
+    assert.deepEqual(projectV0.viewedCustomPermissions(
+        {projectV0: {customPermissions: {}}}), []);
+    const actual = projectV0.viewedCustomPermissions(example.STATE);
+    assert.deepEqual(actual, example.CUSTOM_PERMISSIONS);
+  });
+
+  it('viewedPresentationConfig', () => {
+    assert.deepEqual(projectV0.viewedPresentationConfig({}), {});
+    assert.deepEqual(projectV0.viewedPresentationConfig({projectV0: {}}), {});
+    const actual = projectV0.viewedPresentationConfig(example.STATE);
+    assert.deepEqual(actual, example.PRESENTATION_CONFIG);
+  });
+
+  it('defaultColumns', () => {
+    assert.deepEqual(projectV0.defaultColumns({}), SITEWIDE_DEFAULT_COLUMNS);
+    assert.deepEqual(
+        projectV0.defaultColumns({projectV0: {}}), SITEWIDE_DEFAULT_COLUMNS);
+    assert.deepEqual(
+        projectV0.defaultColumns({projectV0: {presentationConfig: {}}}),
+        SITEWIDE_DEFAULT_COLUMNS);
+    const expected = ['ID', 'Summary', 'AllLabels'];
+    assert.deepEqual(projectV0.defaultColumns(example.STATE), expected);
+  });
+
+  it('defaultQuery', () => {
+    assert.deepEqual(projectV0.defaultQuery({}), '');
+    assert.deepEqual(projectV0.defaultQuery({projectV0: {}}), '');
+    const actual = projectV0.defaultQuery(example.STATE);
+    assert.deepEqual(actual, example.DEFAULT_QUERY);
+  });
+
+  it('fieldDefs', () => {
+    assert.deepEqual(projectV0.fieldDefs({projectV0: {}}), []);
+    assert.deepEqual(projectV0.fieldDefs({projectV0: {config: {}}}), []);
+    const actual = projectV0.fieldDefs(example.STATE);
+    assert.deepEqual(actual, example.FIELD_DEFS);
+  });
+
+  it('labelDefMap', () => {
+    const labelDefs = (permissions) =>
+        restrictionLabelsForPermissions(permissions).map((labelDef) =>
+            [labelDef.label.toLowerCase(), labelDef]);
+
+    assert.deepEqual(
+      projectV0.labelDefMap({projectV0: {}}), new Map(labelDefs([])));
+    assert.deepEqual(
+      projectV0.labelDefMap({projectV0: {config: {}}}), new Map(labelDefs([])));
+    const expected = new Map([
+      ['one', {label: 'One'}],
+      ['enum', {label: 'EnUm'}],
+      ['enum-options', {label: 'eNuM-Options'}],
+      ['hello-world', {label: 'hello-world', docstring: 'hmmm'}],
+      ['hello-me', {label: 'hello-me', docstring: 'hmmm'}],
+      ...labelDefs(example.CUSTOM_PERMISSIONS),
+    ]);
+    assert.deepEqual(projectV0.labelDefMap(example.STATE), expected);
+  });
+
+  it('labelPrefixValueMap', () => {
+    const builtInLabelPrefixes = [
+      ['Restrict', new Set(['View-EditIssue', 'AddIssueComment-EditIssue'])],
+      ['Restrict-View', new Set(['EditIssue'])],
+      ['Restrict-AddIssueComment', new Set(['EditIssue'])],
+    ];
+    assert.deepEqual(projectV0.labelPrefixValueMap({projectV0: {}}),
+        new Map(builtInLabelPrefixes));
+
+    assert.deepEqual(projectV0.labelPrefixValueMap(
+        {projectV0: {config: {}}}), new Map(builtInLabelPrefixes));
+
+    const expected = new Map([
+      ['Restrict', new Set(['View-Google', 'View-Security', 'EditIssue-Google',
+          'EditIssue-Security', 'AddIssueComment-Google',
+          'AddIssueComment-Security', 'DeleteIssue-Google',
+          'DeleteIssue-Security', 'FlagSpam-Google', 'FlagSpam-Security',
+          'View-EditIssue', 'AddIssueComment-EditIssue'])],
+      ['Restrict-View', new Set(['Google', 'Security', 'EditIssue'])],
+      ['Restrict-EditIssue', new Set(['Google', 'Security'])],
+      ['Restrict-AddIssueComment', new Set(['Google', 'Security', 'EditIssue'])],
+      ['Restrict-DeleteIssue', new Set(['Google', 'Security'])],
+      ['Restrict-FlagSpam', new Set(['Google', 'Security'])],
+      ['eNuM', new Set(['Options'])],
+      ['hello', new Set(['world', 'me'])],
+    ]);
+    assert.deepEqual(projectV0.labelPrefixValueMap(example.STATE), expected);
+  });
+
+  it('labelPrefixFields', () => {
+    const fields1 = projectV0.labelPrefixFields({projectV0: {}});
+    assert.deepEqual(fields1, ['Restrict']);
+    const fields2 = projectV0.labelPrefixFields({projectV0: {config: {}}});
+    assert.deepEqual(fields2, ['Restrict']);
+    const expected = [
+      'hello', 'Restrict', 'Restrict-View', 'Restrict-EditIssue',
+      'Restrict-AddIssueComment', 'Restrict-DeleteIssue', 'Restrict-FlagSpam'
+    ];
+    assert.deepEqual(projectV0.labelPrefixFields(example.STATE), expected);
+  });
+
+  it('enumFieldDefs', () => {
+    assert.deepEqual(projectV0.enumFieldDefs({projectV0: {}}), []);
+    assert.deepEqual(projectV0.enumFieldDefs({projectV0: {config: {}}}), []);
+    const expected = [example.FIELD_DEF_ENUM];
+    assert.deepEqual(projectV0.enumFieldDefs(example.STATE), expected);
+  });
+
+  it('optionsPerEnumField', () => {
+    assert.deepEqual(projectV0.optionsPerEnumField({projectV0: {}}), new Map());
+    const expected = new Map([
+      ['enum', [
+        {label: 'eNuM-Options', optionName: 'Options'},
+      ]],
+    ]);
+    assert.deepEqual(projectV0.optionsPerEnumField(example.STATE), expected);
+  });
+
+  it('viewedPresentationConfigLoaded', () => {
+    const loadConfigAction = {
+      type: projectV0.FETCH_PRESENTATION_CONFIG_SUCCESS,
+      projectName: example.PROJECT_NAME,
+      presentationConfig: example.PRESENTATION_CONFIG,
+    };
+    const selectProjectAction = {
+      type: projectV0.SELECT,
+      projectName: example.PROJECT_NAME,
+    };
+    let projectState = {};
+
+    assert.equal(false, projectV0.viewedPresentationConfigLoaded(
+        {projectV0: projectState}));
+
+    projectState = projectV0.reducer(projectState, selectProjectAction);
+    projectState = projectV0.reducer(projectState, loadConfigAction);
+
+    assert.equal(true, projectV0.viewedPresentationConfigLoaded(
+        {projectV0: projectState}));
+  });
+
+  it('fetchingPresentationConfig', () => {
+    const projectState = projectV0.reducer(undefined, {type: null});
+    assert.equal(false,
+        projectState.requests.fetchPresentationConfig.requesting);
+  });
+
+  describe('extractTypeForFieldName', () => {
+    let typeExtractor;
+
+    describe('built-in fields', () => {
+      beforeEach(() => {
+        typeExtractor = projectV0.extractTypeForFieldName({});
+      });
+
+      it('not case sensitive', () => {
+        assert.deepEqual(typeExtractor('id'), fieldTypes.ISSUE_TYPE);
+        assert.deepEqual(typeExtractor('iD'), fieldTypes.ISSUE_TYPE);
+        assert.deepEqual(typeExtractor('Id'), fieldTypes.ISSUE_TYPE);
+      });
+
+      it('gets type for ID', () => {
+        assert.deepEqual(typeExtractor('ID'), fieldTypes.ISSUE_TYPE);
+      });
+
+      it('gets type for Project', () => {
+        assert.deepEqual(typeExtractor('Project'), fieldTypes.PROJECT_TYPE);
+      });
+
+      it('gets type for Attachments', () => {
+        assert.deepEqual(typeExtractor('Attachments'), fieldTypes.INT_TYPE);
+      });
+
+      it('gets type for AllLabels', () => {
+        assert.deepEqual(typeExtractor('AllLabels'), fieldTypes.LABEL_TYPE);
+      });
+
+      it('gets type for AllLabels', () => {
+        assert.deepEqual(typeExtractor('AllLabels'), fieldTypes.LABEL_TYPE);
+      });
+
+      it('gets type for Blocked', () => {
+        assert.deepEqual(typeExtractor('Blocked'), fieldTypes.STR_TYPE);
+      });
+
+      it('gets type for BlockedOn', () => {
+        assert.deepEqual(typeExtractor('BlockedOn'), fieldTypes.ISSUE_TYPE);
+      });
+
+      it('gets type for Blocking', () => {
+        assert.deepEqual(typeExtractor('Blocking'), fieldTypes.ISSUE_TYPE);
+      });
+
+      it('gets type for CC', () => {
+        assert.deepEqual(typeExtractor('CC'), fieldTypes.USER_TYPE);
+      });
+
+      it('gets type for Closed', () => {
+        assert.deepEqual(typeExtractor('Closed'), fieldTypes.TIME_TYPE);
+      });
+
+      it('gets type for Component', () => {
+        assert.deepEqual(typeExtractor('Component'), fieldTypes.COMPONENT_TYPE);
+      });
+
+      it('gets type for ComponentModified', () => {
+        assert.deepEqual(typeExtractor('ComponentModified'),
+            fieldTypes.TIME_TYPE);
+      });
+
+      it('gets type for MergedInto', () => {
+        assert.deepEqual(typeExtractor('MergedInto'), fieldTypes.ISSUE_TYPE);
+      });
+
+      it('gets type for Modified', () => {
+        assert.deepEqual(typeExtractor('Modified'), fieldTypes.TIME_TYPE);
+      });
+
+      it('gets type for Reporter', () => {
+        assert.deepEqual(typeExtractor('Reporter'), fieldTypes.USER_TYPE);
+      });
+
+      it('gets type for Stars', () => {
+        assert.deepEqual(typeExtractor('Stars'), fieldTypes.INT_TYPE);
+      });
+
+      it('gets type for Status', () => {
+        assert.deepEqual(typeExtractor('Status'), fieldTypes.STATUS_TYPE);
+      });
+
+      it('gets type for StatusModified', () => {
+        assert.deepEqual(typeExtractor('StatusModified'), fieldTypes.TIME_TYPE);
+      });
+
+      it('gets type for Summary', () => {
+        assert.deepEqual(typeExtractor('Summary'), fieldTypes.STR_TYPE);
+      });
+
+      it('gets type for Type', () => {
+        assert.deepEqual(typeExtractor('Type'), fieldTypes.ENUM_TYPE);
+      });
+
+      it('gets type for Owner', () => {
+        assert.deepEqual(typeExtractor('Owner'), fieldTypes.USER_TYPE);
+      });
+
+      it('gets type for OwnerModified', () => {
+        assert.deepEqual(typeExtractor('OwnerModified'), fieldTypes.TIME_TYPE);
+      });
+
+      it('gets type for Opened', () => {
+        assert.deepEqual(typeExtractor('Opened'), fieldTypes.TIME_TYPE);
+      });
+    });
+
+    it('gets types for custom fields', () => {
+      typeExtractor = projectV0.extractTypeForFieldName({projectV0: {
+        name: example.PROJECT_NAME,
+        configs: {[example.PROJECT_NAME]: {fieldDefs: [
+          {fieldRef: {fieldName: 'CustomIntField', type: 'INT_TYPE'}},
+          {fieldRef: {fieldName: 'CustomStrField', type: 'STR_TYPE'}},
+          {fieldRef: {fieldName: 'CustomUserField', type: 'USER_TYPE'}},
+          {fieldRef: {fieldName: 'CustomEnumField', type: 'ENUM_TYPE'}},
+          {fieldRef: {fieldName: 'CustomApprovalField',
+            type: 'APPROVAL_TYPE'}},
+        ]}},
+      }});
+
+      assert.deepEqual(typeExtractor('CustomIntField'), fieldTypes.INT_TYPE);
+      assert.deepEqual(typeExtractor('CustomStrField'), fieldTypes.STR_TYPE);
+      assert.deepEqual(typeExtractor('CustomUserField'), fieldTypes.USER_TYPE);
+      assert.deepEqual(typeExtractor('CustomEnumField'), fieldTypes.ENUM_TYPE);
+      assert.deepEqual(typeExtractor('CustomApprovalField'),
+          fieldTypes.APPROVAL_TYPE);
+    });
+
+    it('defaults to string type for other fields', () => {
+      typeExtractor = projectV0.extractTypeForFieldName({projectV0: {
+        name: example.PROJECT_NAME,
+        configs: {[example.PROJECT_NAME]: {fieldDefs: [
+          {fieldRef: {fieldName: 'CustomIntField', type: 'INT_TYPE'}},
+          {fieldRef: {fieldName: 'CustomUserField', type: 'USER_TYPE'}},
+        ]}},
+      }});
+
+      assert.deepEqual(typeExtractor('FakeUserField'), fieldTypes.STR_TYPE);
+      assert.deepEqual(typeExtractor('NotOwner'), fieldTypes.STR_TYPE);
+    });
+  });
+
+  describe('extractFieldValuesFromIssue', () => {
+    let clock;
+    let issue;
+    let fieldExtractor;
+
+    describe('built-in fields', () => {
+      beforeEach(() => {
+        // Built-in fields will always act the same, regardless of
+        // project config.
+        fieldExtractor = projectV0.extractFieldValuesFromIssue({});
+
+        // Set clock to some specified date for relative time.
+        const initialTime = 365 * 24 * 60 * 60;
+
+        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'},
+        };
+
+        clock = sinon.useFakeTimers({
+          now: new Date(initialTime * 1000),
+          shouldAdvanceTime: false,
+        });
+      });
+
+      afterEach(() => {
+        clock.restore();
+      });
+
+      it('computes strings for ID', () => {
+        const fieldName = 'ID';
+
+        assert.deepEqual(fieldExtractor(issue, fieldName),
+            ['chromium:33']);
+      });
+
+      it('computes strings for Project', () => {
+        const fieldName = 'Project';
+
+        assert.deepEqual(fieldExtractor(issue, fieldName),
+            ['chromium']);
+      });
+
+      it('computes strings for Attachments', () => {
+        const fieldName = 'Attachments';
+
+        assert.deepEqual(fieldExtractor(issue, fieldName),
+            ['22']);
+      });
+
+      it('computes strings for AllLabels', () => {
+        const fieldName = 'AllLabels';
+
+        assert.deepEqual(fieldExtractor(issue, fieldName),
+            ['Restrict-View-Google', 'Type-Defect']);
+      });
+
+      it('computes strings for Blocked when issue is blocked', () => {
+        const fieldName = 'Blocked';
+
+        assert.deepEqual(fieldExtractor(issue, fieldName),
+            ['Yes']);
+      });
+
+      it('computes strings for Blocked when issue is not blocked', () => {
+        const fieldName = 'Blocked';
+        issue.blockedOnIssueRefs = [];
+
+        assert.deepEqual(fieldExtractor(issue, fieldName),
+            ['No']);
+      });
+
+      it('computes strings for BlockedOn', () => {
+        const fieldName = 'BlockedOn';
+
+        assert.deepEqual(fieldExtractor(issue, fieldName),
+            ['chromium:30']);
+      });
+
+      it('computes strings for Blocking', () => {
+        const fieldName = 'Blocking';
+
+        assert.deepEqual(fieldExtractor(issue, fieldName),
+            ['chromium:60']);
+      });
+
+      it('computes strings for CC', () => {
+        const fieldName = 'CC';
+
+        assert.deepEqual(fieldExtractor(issue, fieldName),
+            ['test@example.com']);
+      });
+
+      it('computes strings for Closed', () => {
+        const fieldName = 'Closed';
+
+        assert.deepEqual(fieldExtractor(issue, fieldName),
+            ['2 minutes ago']);
+      });
+
+      it('computes strings for Component', () => {
+        const fieldName = 'Component';
+
+        assert.deepEqual(fieldExtractor(issue, fieldName),
+            ['Infra', 'Monorail>UI']);
+      });
+
+      it('computes strings for ComponentModified', () => {
+        const fieldName = 'ComponentModified';
+
+        assert.deepEqual(fieldExtractor(issue, fieldName),
+            ['a minute ago']);
+      });
+
+      it('computes strings for MergedInto', () => {
+        const fieldName = 'MergedInto';
+
+        assert.deepEqual(fieldExtractor(issue, fieldName),
+            ['chromium:31']);
+      });
+
+      it('computes strings for Modified', () => {
+        const fieldName = 'Modified';
+
+        assert.deepEqual(fieldExtractor(issue, fieldName),
+            ['a minute ago']);
+      });
+
+      it('computes strings for Reporter', () => {
+        const fieldName = 'Reporter';
+
+        assert.deepEqual(fieldExtractor(issue, fieldName),
+            ['test@example.com']);
+      });
+
+      it('computes strings for Stars', () => {
+        const fieldName = 'Stars';
+
+        assert.deepEqual(fieldExtractor(issue, fieldName),
+            ['2']);
+      });
+
+      it('computes strings for Status', () => {
+        const fieldName = 'Status';
+
+        assert.deepEqual(fieldExtractor(issue, fieldName),
+            ['Duplicate']);
+      });
+
+      it('computes strings for StatusModified', () => {
+        const fieldName = 'StatusModified';
+
+        assert.deepEqual(fieldExtractor(issue, fieldName),
+            ['a minute ago']);
+      });
+
+      it('computes strings for Summary', () => {
+        const fieldName = 'Summary';
+
+        assert.deepEqual(fieldExtractor(issue, fieldName),
+            ['Test summary']);
+      });
+
+      it('computes strings for Type', () => {
+        const fieldName = 'Type';
+
+        assert.deepEqual(fieldExtractor(issue, fieldName),
+            ['Defect']);
+      });
+
+      it('computes strings for Owner', () => {
+        const fieldName = 'Owner';
+
+        assert.deepEqual(fieldExtractor(issue, fieldName),
+            ['owner@example.com']);
+      });
+
+      it('computes strings for OwnerModified', () => {
+        const fieldName = 'OwnerModified';
+
+        assert.deepEqual(fieldExtractor(issue, fieldName),
+            ['a minute ago']);
+      });
+
+      it('computes strings for Opened', () => {
+        const fieldName = 'Opened';
+
+        assert.deepEqual(fieldExtractor(issue, fieldName),
+            ['a day ago']);
+      });
+    });
+
+    describe('custom approval fields', () => {
+      beforeEach(() => {
+        const fieldDefs = [
+          {fieldRef: {type: 'APPROVAL_TYPE', fieldName: 'Goose-Approval'}},
+          {fieldRef: {type: 'APPROVAL_TYPE', fieldName: 'Chicken-Approval'}},
+          {fieldRef: {type: 'APPROVAL_TYPE', fieldName: 'Dodo-Approval'}},
+        ];
+        fieldExtractor = projectV0.extractFieldValuesFromIssue({
+          projectV0: {
+            name: example.PROJECT_NAME,
+            configs: {
+              [example.PROJECT_NAME]: {
+                projectName: 'chromium',
+                fieldDefs,
+              },
+            },
+          },
+        });
+
+        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', () => {
+        assert.deepEqual(fieldExtractor(issue, 'goose-approval-approver'), []);
+        assert.deepEqual(fieldExtractor(issue, 'chicken-approval-approver'),
+            []);
+        assert.deepEqual(fieldExtractor(issue, 'dodo-approval-approver'),
+            ['kiwi@bird.test', 'mini-dino@bird.test']);
+      });
+
+      it('handles approval value columns', () => {
+        assert.deepEqual(fieldExtractor(issue, 'goose-approval'), ['NotSet']);
+        assert.deepEqual(fieldExtractor(issue, 'chicken-approval'),
+            ['Approved']);
+        assert.deepEqual(fieldExtractor(issue, 'dodo-approval'),
+            ['NeedInfo']);
+      });
+    });
+
+    describe('custom fields', () => {
+      beforeEach(() => {
+        const fieldDefs = [
+          {fieldRef: {type: 'STR_TYPE', fieldName: 'aString'}},
+          {fieldRef: {type: 'ENUM_TYPE', fieldName: 'ENUM'}},
+          {fieldRef: {type: 'INT_TYPE', fieldName: 'Cow-Number'},
+            bool_is_phase_field: true, is_multivalued: true},
+        ];
+        // As a label prefix, aString conflicts with the custom field named
+        // "aString". In this case, Monorail gives precedence to the
+        // custom field.
+        const labelDefs = [
+          {label: 'aString-ignore'},
+          {label: 'aString-two'},
+        ];
+        fieldExtractor = projectV0.extractFieldValuesFromIssue({
+          projectV0: {
+            name: example.PROJECT_NAME,
+            configs: {
+              [example.PROJECT_NAME]: {
+                projectName: 'chromium',
+                fieldDefs,
+                labelDefs,
+              },
+            },
+          },
+        });
+
+        const 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'},
+        ];
+
+        issue = {
+          localId: 33,
+          projectName: 'chromium',
+          fieldValues,
+        };
+      });
+
+      it('gets values for custom fields', () => {
+        assert.deepEqual(fieldExtractor(issue, 'aString'), ['test', 'test2']);
+        assert.deepEqual(fieldExtractor(issue, 'enum'), ['a-value']);
+        assert.deepEqual(fieldExtractor(issue, 'cow-phase.cow-number'),
+            ['55', '54']);
+        assert.deepEqual(fieldExtractor(issue, 'milkcow-phase.cow-number'),
+            ['56']);
+      });
+
+      it('custom fields get precedence over label fields', () => {
+        issue.labelRefs = [{label: 'aString-ignore'}];
+        assert.deepEqual(fieldExtractor(issue, 'aString'),
+            ['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'},
+          ],
+        };
+
+        fieldExtractor = projectV0.extractFieldValuesFromIssue({
+          projectV0: {
+            name: example.PROJECT_NAME,
+            configs: {
+              [example.PROJECT_NAME]: {
+                projectName: 'chromium',
+                labelDefs: [
+                  {label: 'test-1'},
+                  {label: 'test-2'},
+                  {label: 'milestone-1'},
+                  {label: 'milestone-2'},
+                ],
+              },
+            },
+          },
+        });
+      });
+
+      it('gets values for label prefixes', () => {
+        assert.deepEqual(fieldExtractor(issue, 'test'), ['label', 'label-2']);
+        assert.deepEqual(fieldExtractor(issue, 'Milestone'), ['UI', 'Goodies']);
+      });
+    });
+  });
+
+  it('fieldDefsByApprovalName', () => {
+    assert.deepEqual(projectV0.fieldDefsByApprovalName({projectV0: {}}),
+        new Map());
+
+    assert.deepEqual(projectV0.fieldDefsByApprovalName({projectV0: {
+      name: example.PROJECT_NAME,
+      configs: {[example.PROJECT_NAME]: {
+        fieldDefs: [
+          {fieldRef: {fieldName: 'test', type: fieldTypes.INT_TYPE}},
+          {fieldRef: {fieldName: 'ignoreMe', type: fieldTypes.APPROVAL_TYPE}},
+          {fieldRef: {fieldName: 'yay', approvalName: 'ThisIsAnApproval'}},
+          {fieldRef: {fieldName: 'ImAField', approvalName: 'ThisIsAnApproval'}},
+          {fieldRef: {fieldName: 'TalkToALawyer', approvalName: 'Legal'}},
+        ],
+      }},
+    }}), new Map([
+      ['ThisIsAnApproval', [
+        {fieldRef: {fieldName: 'yay', approvalName: 'ThisIsAnApproval'}},
+        {fieldRef: {fieldName: 'ImAField', approvalName: 'ThisIsAnApproval'}},
+      ]],
+      ['Legal', [
+        {fieldRef: {fieldName: 'TalkToALawyer', approvalName: 'Legal'}},
+      ]],
+    ]));
+  });
+});
+
+let dispatch;
+
+describe('project action creators', () => {
+  beforeEach(() => {
+    sinon.stub(prpcClient, 'call');
+
+    dispatch = sinon.stub();
+  });
+
+  afterEach(() => {
+    prpcClient.call.restore();
+  });
+
+  it('select', () => {
+    projectV0.select('project-name')(dispatch);
+    const action = {type: projectV0.SELECT, projectName: 'project-name'};
+    sinon.assert.calledWith(dispatch, action);
+  });
+
+  it('fetchCustomPermissions', async () => {
+    const action = projectV0.fetchCustomPermissions('chromium');
+
+    prpcClient.call.returns(Promise.resolve({permissions: ['google']}));
+
+    await action(dispatch);
+
+    sinon.assert.calledWith(dispatch,
+        {type: projectV0.FETCH_CUSTOM_PERMISSIONS_START});
+
+    sinon.assert.calledWith(
+        prpcClient.call,
+        'monorail.Projects',
+        'GetCustomPermissions',
+        {projectName: 'chromium'});
+
+    sinon.assert.calledWith(dispatch, {
+      type: projectV0.FETCH_CUSTOM_PERMISSIONS_SUCCESS,
+      projectName: 'chromium',
+      permissions: ['google'],
+    });
+  });
+
+  it('fetchPresentationConfig', async () => {
+    const action = projectV0.fetchPresentationConfig('chromium');
+
+    prpcClient.call.returns(Promise.resolve({projectThumbnailUrl: 'test'}));
+
+    await action(dispatch);
+
+    sinon.assert.calledWith(dispatch,
+        {type: projectV0.FETCH_PRESENTATION_CONFIG_START});
+
+    sinon.assert.calledWith(
+        prpcClient.call,
+        'monorail.Projects',
+        'GetPresentationConfig',
+        {projectName: 'chromium'});
+
+    sinon.assert.calledWith(dispatch, {
+      type: projectV0.FETCH_PRESENTATION_CONFIG_SUCCESS,
+      projectName: 'chromium',
+      presentationConfig: {projectThumbnailUrl: 'test'},
+    });
+  });
+
+  it('fetchVisibleMembers', async () => {
+    const action = projectV0.fetchVisibleMembers('chromium');
+
+    prpcClient.call.returns(Promise.resolve({userRefs: [{userId: '123'}]}));
+
+    await action(dispatch);
+
+    sinon.assert.calledWith(dispatch,
+        {type: projectV0.FETCH_VISIBLE_MEMBERS_START});
+
+    sinon.assert.calledWith(
+        prpcClient.call,
+        'monorail.Projects',
+        'GetVisibleMembers',
+        {projectName: 'chromium'});
+
+    sinon.assert.calledWith(dispatch, {
+      type: projectV0.FETCH_VISIBLE_MEMBERS_SUCCESS,
+      projectName: 'chromium',
+      visibleMembers: {userRefs: [{userId: '123'}]},
+    });
+  });
+});
+
+describe('helpers', () => {
+  beforeEach(() => {
+    sinon.stub(prpcClient, 'call');
+  });
+
+  afterEach(() => {
+    prpcClient.call.restore();
+  });
+
+  describe('fetchFieldPerms', () => {
+    it('fetch field permissions', async () => {
+      const projectName = 'proj';
+      const fieldDefs = [
+        {
+          fieldRef: {
+            fieldName: 'testField',
+            fieldId: 1,
+            type: 'ENUM_TYPE',
+          },
+        },
+      ];
+      const response = {};
+      prpcClient.call.returns(Promise.resolve(response));
+
+      await store.dispatch(projectV0.fetchFieldPerms(projectName, fieldDefs));
+
+      const args = {names: ['projects/proj/fieldDefs/1']};
+      sinon.assert.calledWith(
+          prpcClient.call, 'monorail.v3.Permissions',
+          'BatchGetPermissionSets', args);
+    });
+
+    it('fetch with no fieldDefs', async () => {
+      const config = {projectName: 'proj'};
+      const response = {};
+      prpcClient.call.returns(Promise.resolve(response));
+
+      // fieldDefs will be undefined.
+      await store.dispatch(projectV0.fetchFieldPerms(
+          config.projectName, config.fieldDefs));
+
+      const args = {names: []};
+      sinon.assert.calledWith(
+          prpcClient.call, 'monorail.v3.Permissions',
+          'BatchGetPermissionSets', args);
+    });
+  });
+});
diff --git a/static_src/reducers/projects.js b/static_src/reducers/projects.js
new file mode 100644
index 0000000..955dfea
--- /dev/null
+++ b/static_src/reducers/projects.js
@@ -0,0 +1,129 @@
+// 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 Project actions, selectors, and reducers organized into
+ * a single Redux "Duck" that manages updating and retrieving project state
+ * on the frontend.
+ *
+ * Reference: https://github.com/erikras/ducks-modular-redux
+ */
+
+import {combineReducers} from 'redux';
+import {createSelector} from 'reselect';
+import {createReducer, createRequestReducer} from './redux-helpers.js';
+import {prpcClient} from 'prpc-client-instance.js';
+import 'shared/typedef.js';
+
+/** @typedef {import('redux').AnyAction} AnyAction */
+
+export const LIST_START = 'projects/LIST_START';
+export const LIST_SUCCESS = 'projects/LIST_SUCCESS';
+export const LIST_FAILURE = 'projects/LIST_FAILURE';
+
+/* State Shape
+{
+  name: string,
+
+  byName: Object<ProjectName, Project>,
+  allNames: Array<ProjectName>,
+
+  requests: {
+    list: ReduxRequestState,
+  },
+}
+*/
+
+/**
+ * All Project data indexed by Project name.
+ * @param {Object<ProjectName, Project>} state Existing Project data.
+ * @param {AnyAction} action
+ * @param {Array<Project>} action.projects The Projects that were fetched.
+ * @return {Object<ProjectName, Project>}
+ */
+export const byNameReducer = createReducer({}, {
+  [LIST_SUCCESS]: (state, {projects}) => {
+    const newProjects = {};
+    projects.forEach((proj) => {
+      newProjects[proj.name] = proj;
+    });
+    return {...state, ...newProjects};
+  },
+});
+
+/**
+ * Resource names for all Projects in Monorail.
+ * @param {Array<ProjectName>} _state Existing Project data.
+ * @param {AnyAction} action
+ * @param {Array<Project>} action.projects The Projects that were fetched.
+ * @return {Array<ProjectName>}
+ */
+export const allNamesReducer = createReducer([], {
+  [LIST_SUCCESS]: (_state, {projects}) => {
+    return projects.map((proj) => proj.name);
+  },
+});
+
+const requestsReducer = combineReducers({
+  list: createRequestReducer(
+      LIST_START, LIST_SUCCESS, LIST_FAILURE),
+});
+
+export const reducer = combineReducers({
+  byName: byNameReducer,
+  allNames: allNamesReducer,
+
+  requests: requestsReducer,
+});
+
+
+/**
+ * Returns normalized Project data by name.
+ * @param {any} state
+ * @return {Object<ProjectName, Project>}
+ * @private
+ */
+export const byName = (state) => state.projects.byName;
+
+/**
+ * Base selector for wrapping the allNames state key.
+ * @param {any} state
+ * @return {Array<ProjectName>}
+ * @private
+ */
+export const _allNames = (state) => state.projects.allNames;
+
+/**
+ * Returns all Projects on Monorail, in denormalized form, in
+ * the sort order returned by the API.
+ * @param {any} state
+ * @return {Array<Project>}
+ */
+export const all = createSelector([byName, _allNames],
+    (byName, allNames) => allNames.map((name) => byName[name]));
+
+
+/**
+ * Returns the Project requests.
+ * @param {any} state
+ * @return {Object<string, ReduxRequestState>}
+ */
+export const requests = (state) => state.projects.requests;
+
+/**
+ * Gets all projects hosted on Monorail.
+ * @return {function(function): Promise<void>}
+ */
+export const list = () => async (dispatch) => {
+  dispatch({type: LIST_START});
+  try {
+    /** @type {{projects: Array<Project>}} */
+    const {projects} = await prpcClient.call(
+        'monorail.v3.Projects', 'ListProjects', {});
+
+    dispatch({type: LIST_SUCCESS, projects});
+  } catch (error) {
+    dispatch({type: LIST_FAILURE, error});
+  }
+};
diff --git a/static_src/reducers/projects.test.js b/static_src/reducers/projects.test.js
new file mode 100644
index 0000000..0a9dee4
--- /dev/null
+++ b/static_src/reducers/projects.test.js
@@ -0,0 +1,174 @@
+// 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 * as projects from './projects.js';
+import * as example from 'shared/test/constants-projects.js';
+
+import {prpcClient} from 'prpc-client-instance.js';
+
+let dispatch;
+
+
+describe('project reducers', () => {
+  it('root reducer initial state', () => {
+    const actual = projects.reducer(undefined, {type: null});
+    const expected = {
+      byName: {},
+      allNames: [],
+      requests: {
+        list: {
+          error: null,
+          requesting: false,
+        },
+      },
+    };
+    assert.deepEqual(actual, expected);
+  });
+
+  describe('byNameReducer', () => {
+    it('populated on LIST_SUCCESS', () => {
+      const action = {type: projects.LIST_SUCCESS, projects:
+          [example.PROJECT, example.PROJECT_2]};
+      const actual = projects.byNameReducer({}, action);
+
+      assert.deepEqual(actual, {
+        [example.NAME]: example.PROJECT,
+        [example.NAME_2]: example.PROJECT_2,
+      });
+    });
+
+    it('keeps original state on empty LIST_SUCCESS', () => {
+      const originalState = {
+        [example.NAME]: example.PROJECT,
+        [example.NAME_2]: example.PROJECT_2,
+      };
+      const action = {type: projects.LIST_SUCCESS, projects: []};
+      const actual = projects.byNameReducer(originalState, action);
+
+      assert.deepEqual(actual, originalState);
+    });
+
+    it('appends new issues to state on LIST_SUCCESS', () => {
+      const originalState = {
+        [example.NAME]: example.PROJECT,
+      };
+      const action = {type: projects.LIST_SUCCESS,
+        projects: [example.PROJECT_2]};
+      const actual = projects.byNameReducer(originalState, action);
+
+      const expected = {
+        [example.NAME]: example.PROJECT,
+        [example.NAME_2]: example.PROJECT_2,
+      };
+      assert.deepEqual(actual, expected);
+    });
+
+    it('overrides outdated data on LIST_SUCCESS', () => {
+      const originalState = {
+        [example.NAME]: example.PROJECT,
+        [example.NAME_2]: example.PROJECT_2,
+      };
+
+      const newProject2 = {
+        name: example.NAME_2,
+        summary: 'I hacked your project!',
+      };
+      const action = {type: projects.LIST_SUCCESS,
+        projects: [newProject2]};
+      const actual = projects.byNameReducer(originalState, action);
+      const expected = {
+        [example.NAME]: example.PROJECT,
+        [example.NAME_2]: newProject2,
+      };
+      assert.deepEqual(actual, expected);
+    });
+  });
+
+  it('allNames populated on LIST_SUCCESS', () => {
+    const action = {type: projects.LIST_SUCCESS, projects:
+        [example.PROJECT, example.PROJECT_2]};
+    const actual = projects.allNamesReducer([], action);
+
+    assert.deepEqual(actual, [example.NAME, example.NAME_2]);
+  });
+});
+
+describe('project selectors', () => {
+  it('byName', () => {
+    const normalizedProjects = {
+      [example.NAME]: example.PROJECT,
+    };
+    const state = {projects: {
+      byName: normalizedProjects,
+    }};
+    assert.deepEqual(projects.byName(state), normalizedProjects);
+  });
+
+  it('all', () => {
+    const state = {projects: {
+      byName: {
+        [example.NAME]: example.PROJECT,
+      },
+      allNames: [example.NAME],
+    }};
+    assert.deepEqual(projects.all(state), [example.PROJECT]);
+  });
+
+  it('requests', () => {
+    const state = {projects: {
+      requests: {
+        list: {error: null, requesting: false},
+      },
+    }};
+    assert.deepEqual(projects.requests(state), {
+      list: {error: null, requesting: false},
+    });
+  });
+});
+
+describe('project action creators', () => {
+  beforeEach(() => {
+    sinon.stub(prpcClient, 'call');
+    dispatch = sinon.stub();
+  });
+
+  afterEach(() => {
+    prpcClient.call.restore();
+  });
+
+  describe('list', () => {
+    it('success', async () => {
+      const projectsResponse = {projects: [example.PROJECT, example.PROJECT_2]};
+      prpcClient.call.returns(Promise.resolve(projectsResponse));
+
+      await projects.list()(dispatch);
+
+      sinon.assert.calledWith(dispatch, {type: projects.LIST_START});
+
+      sinon.assert.calledWith(
+          prpcClient.call, 'monorail.v3.Projects', 'ListProjects', {});
+
+      const successAction = {
+        type: projects.LIST_SUCCESS,
+        projects: projectsResponse.projects,
+      };
+      sinon.assert.calledWith(dispatch, successAction);
+    });
+
+    it('failure', async () => {
+      prpcClient.call.throws();
+
+      await projects.list()(dispatch);
+
+      const action = {
+        type: projects.LIST_FAILURE,
+        error: sinon.match.any,
+      };
+      sinon.assert.calledWith(dispatch, action);
+    });
+  });
+});
diff --git a/static_src/reducers/redux-helpers.js b/static_src/reducers/redux-helpers.js
new file mode 100644
index 0000000..ce80d60
--- /dev/null
+++ b/static_src/reducers/redux-helpers.js
@@ -0,0 +1,64 @@
+// 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 createReducer = (initialState, handlers) => {
+  return function reducer(state = initialState, action) {
+    if (handlers.hasOwnProperty(action.type)) {
+      return handlers[action.type](state, action);
+    } else {
+      return state;
+    }
+  };
+};
+
+const DEFAULT_REQUEST_KEY = '*';
+
+export const createKeyedRequestReducer = (start, success, failure) => {
+  return createReducer({}, {
+    [start]: (state, {requestKey = DEFAULT_REQUEST_KEY}) => {
+      return {
+        ...state,
+        [requestKey]: {
+          requesting: true,
+          error: null,
+        },
+      };
+    },
+    [success]: (state, {requestKey = DEFAULT_REQUEST_KEY}) =>{
+      return {
+        ...state,
+        [requestKey]: {
+          requesting: false,
+          error: null,
+        },
+      };
+    },
+    [failure]: (state, {requestKey = DEFAULT_REQUEST_KEY, error}) => {
+      return {
+        ...state,
+        [requestKey]: {
+          requesting: false,
+          error,
+        },
+      };
+    },
+  });
+};
+
+export const createRequestReducer = (start, success, failure) => {
+  return createReducer({requesting: false, error: null}, {
+    [start]: (_state, _action) => ({
+      requesting: true,
+      error: null,
+    }),
+    [success]: (_state, _action) =>({
+      requesting: false,
+      error: null,
+    }),
+    [failure]: (_state, {error}) => ({
+      requesting: false,
+      error,
+    }),
+  });
+};
diff --git a/static_src/reducers/redux-helpers.test.js b/static_src/reducers/redux-helpers.test.js
new file mode 100644
index 0000000..93f0e0a
--- /dev/null
+++ b/static_src/reducers/redux-helpers.test.js
@@ -0,0 +1,102 @@
+// 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 {createRequestReducer,
+  createKeyedRequestReducer} from './redux-helpers.js';
+
+let keyedRequestReducer;
+let requestReducer;
+
+describe('redux-helpers', () => {
+  describe('createKeyedRequestReducer', () => {
+    beforeEach(() => {
+      keyedRequestReducer = createKeyedRequestReducer(
+          'REQUEST_START', 'REQUEST_SUCCESS', 'REQUEST_FAILURE');
+    });
+
+    it('sets requesting to true on start', () => {
+      assert.deepEqual(keyedRequestReducer({}, {type: 'REQUEST_START'}),
+          {['*']: {requesting: true, error: null}});
+    });
+
+    it('sets requesting to false on success', () => {
+      assert.deepEqual(keyedRequestReducer({}, {type: 'REQUEST_SUCCESS'}),
+          {['*']: {requesting: false, error: null}});
+    });
+
+    it('sets error message on failure', () => {
+      assert.deepEqual(keyedRequestReducer({}, {
+        type: 'REQUEST_FAILURE',
+        error: 'hello',
+      }), {['*']: {requesting: false, error: 'hello'}});
+    });
+
+    it('preserves previous request state on start', () => {
+      const initialState = {
+        ['*']: {requesting: false, error: 'hello'},
+      };
+      assert.deepEqual(keyedRequestReducer(initialState, {
+        type: 'REQUEST_START',
+        requestKey: 'chromium:11',
+      }), {
+        ['*']: {requesting: false, error: 'hello'},
+        ['chromium:11']: {requesting: true, error: null},
+      });
+    });
+
+    it('preserves previous request state on success', () => {
+      const initialState = {
+        ['*']: {requesting: false, error: 'hello'},
+        ['chromium:11']: {requesting: true, error: null},
+      };
+      assert.deepEqual(keyedRequestReducer(initialState, {
+        type: 'REQUEST_SUCCESS',
+        requestKey: 'chromium:11',
+      }), {
+        ['*']: {requesting: false, error: 'hello'},
+        ['chromium:11']: {requesting: false, error: null},
+      });
+    });
+
+    it('preserves previous request state on failure', () => {
+      const initialState = {
+        ['*']: {requesting: false, error: 'hello'},
+        ['chromium:11']: {requesting: false, error: null},
+      };
+      assert.deepEqual(keyedRequestReducer(initialState, {
+        type: 'REQUEST_FAILURE',
+        requestKey: 'chromium:11',
+        error: 'something went wrong',
+      }), {
+        ['*']: {requesting: false, error: 'hello'},
+        ['chromium:11']: {requesting: false, error: 'something went wrong'},
+      });
+    });
+  });
+
+  describe('createRequestReducer', () => {
+    beforeEach(() => {
+      requestReducer = createRequestReducer(
+          'REQUEST_START', 'REQUEST_SUCCESS', 'REQUEST_FAILURE');
+    });
+
+    it('sets requesting to true on start', () => {
+      assert.deepEqual(requestReducer({}, {type: 'REQUEST_START'}),
+          {requesting: true, error: null});
+    });
+
+    it('sets requesting to false on success', () => {
+      assert.deepEqual(requestReducer({}, {type: 'REQUEST_SUCCESS'}),
+          {requesting: false, error: null});
+    });
+
+    it('sets error message on failure', () => {
+      assert.deepEqual(requestReducer({}, {
+        type: 'REQUEST_FAILURE',
+        error: 'hello',
+      }), {requesting: false, error: 'hello'});
+    });
+  });
+});
diff --git a/static_src/reducers/sitewide.js b/static_src/reducers/sitewide.js
new file mode 100644
index 0000000..f7e20d7
--- /dev/null
+++ b/static_src/reducers/sitewide.js
@@ -0,0 +1,167 @@
+// 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 projectV0 from 'reducers/projectV0.js';
+import {combineReducers} from 'redux';
+import {createReducer, createRequestReducer} from './redux-helpers.js';
+import {createSelector} from 'reselect';
+import {prpcClient} from 'prpc-client-instance.js';
+import {SITEWIDE_DEFAULT_CAN, parseColSpec} from 'shared/issue-fields.js';
+
+// Actions
+const SET_PAGE_TITLE = 'SET_PAGE_TITLE';
+const SET_HEADER_TITLE = 'SET_HEADER_TITLE';
+export const SET_QUERY_PARAMS = 'SET_QUERY_PARAMS';
+
+// Async actions
+const GET_SERVER_STATUS_FAILURE = 'GET_SERVER_STATUS_FAILURE';
+const GET_SERVER_STATUS_START = 'GET_SERVER_STATUS_START';
+const GET_SERVER_STATUS_SUCCESS = 'GET_SERVER_STATUS_SUCCESS';
+
+/* State Shape
+{
+  bannerMessage: String,
+  bannerTime: Number,
+  pageTitle: String,
+  headerTitle: String,
+  queryParams: Object,
+  readOnly: Boolean,
+  requests: {
+    serverStatus: Object,
+  },
+}
+*/
+
+// Reducers
+const bannerMessageReducer = createReducer('', {
+  [GET_SERVER_STATUS_SUCCESS]:
+    (_state, action) => action.serverStatus.bannerMessage || '',
+});
+
+const bannerTimeReducer = createReducer(0, {
+  [GET_SERVER_STATUS_SUCCESS]:
+    (_state, action) => action.serverStatus.bannerTime || 0,
+});
+
+/**
+ * Handle state for the current document title.
+ */
+const pageTitleReducer = createReducer('', {
+  [SET_PAGE_TITLE]: (_state, {title}) => title,
+});
+
+const headerTitleReducer = createReducer('', {
+  [SET_HEADER_TITLE]: (_state, {title}) => title,
+});
+
+const queryParamsReducer = createReducer({}, {
+  [SET_QUERY_PARAMS]: (_state, {queryParams}) => queryParams || {},
+});
+
+const readOnlyReducer = createReducer(false, {
+  [GET_SERVER_STATUS_SUCCESS]:
+    (_state, action) => action.serverStatus.readOnly || false,
+});
+
+const requestsReducer = combineReducers({
+  serverStatus: createRequestReducer(
+      GET_SERVER_STATUS_START,
+      GET_SERVER_STATUS_SUCCESS,
+      GET_SERVER_STATUS_FAILURE),
+});
+
+export const reducer = combineReducers({
+  bannerMessage: bannerMessageReducer,
+  bannerTime: bannerTimeReducer,
+  readOnly: readOnlyReducer,
+  queryParams: queryParamsReducer,
+  pageTitle: pageTitleReducer,
+  headerTitle: headerTitleReducer,
+
+  requests: requestsReducer,
+});
+
+// Selectors
+export const sitewide = (state) => state.sitewide || {};
+export const bannerMessage =
+    createSelector(sitewide, (sitewide) => sitewide.bannerMessage);
+export const bannerTime =
+    createSelector(sitewide, (sitewide) => sitewide.bannerTime);
+export const queryParams =
+    createSelector(sitewide, (sitewide) => sitewide.queryParams || {});
+export const pageTitle = createSelector(
+    sitewide, projectV0.viewedConfig,
+    (sitewide, projectConfig) => {
+      const titlePieces = [];
+
+      // If a specific page specifies its own page title, add that
+      // to the beginning of the title.
+      if (sitewide.pageTitle) {
+        titlePieces.push(sitewide.pageTitle);
+      }
+
+      // If the user is viewing a project, add the project data.
+      if (projectConfig && projectConfig.projectName) {
+        titlePieces.push(projectConfig.projectName);
+      }
+
+      return titlePieces.join(' - ') || 'Monorail';
+    });
+export const headerTitle =
+    createSelector(sitewide, (sitewide) => sitewide.headerTitle);
+export const readOnly =
+    createSelector(sitewide, (sitewide) => sitewide.readOnly);
+
+/**
+ * Computes the issue list columns from the URL parameters.
+ */
+export const currentColumns = createSelector(
+    queryParams,
+    (params = {}) => params.colspec ? parseColSpec(params.colspec) : null);
+
+/**
+* Get the default canned query for the currently viewed project.
+* Note: Projects cannot configure a per-project default canned query,
+* so there is only a sitewide default.
+*/
+export const currentCan = createSelector(queryParams,
+    (params) => params.can || SITEWIDE_DEFAULT_CAN);
+
+/**
+ * Compute the current issue search query that the user has
+ * entered for a project, based on queryParams and the default
+ * project search.
+ */
+export const currentQuery = createSelector(
+    projectV0.defaultQuery,
+    queryParams,
+    (defaultQuery, params = {}) => {
+      // Make sure entering an empty search still works.
+      if (params.q === '') return params.q;
+      return params.q || defaultQuery;
+    });
+
+export const requests = createSelector(sitewide,
+    (sitewide) => sitewide.requests || {});
+
+// Action Creators
+export const setQueryParams =
+    (queryParams) => ({type: SET_QUERY_PARAMS, queryParams});
+
+export const setPageTitle = (title) => ({type: SET_PAGE_TITLE, title});
+
+export const setHeaderTitle = (title) => ({type: SET_HEADER_TITLE, title});
+
+export const getServerStatus = () => async (dispatch) => {
+  dispatch({type: GET_SERVER_STATUS_START});
+
+  try {
+    const serverStatus = await prpcClient.call(
+        'monorail.Sitewide', 'GetServerStatus', {});
+
+    dispatch({type: GET_SERVER_STATUS_SUCCESS, serverStatus});
+  } catch (error) {
+    dispatch({type: GET_SERVER_STATUS_FAILURE, error});
+  }
+};
diff --git a/static_src/reducers/sitewide.test.js b/static_src/reducers/sitewide.test.js
new file mode 100644
index 0000000..114ecaf
--- /dev/null
+++ b/static_src/reducers/sitewide.test.js
@@ -0,0 +1,235 @@
+// 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 {store, stateUpdated, resetState} from 'reducers/base.js';
+import {prpcClient} from 'prpc-client-instance.js';
+import * as sitewide from './sitewide.js';
+
+let prpcCall;
+
+describe('sitewide selectors', () => {
+  beforeEach(() => {
+    store.dispatch(resetState());
+  });
+  it('queryParams', () => {
+    assert.deepEqual(sitewide.queryParams({}), {});
+    assert.deepEqual(sitewide.queryParams({sitewide: {}}), {});
+    assert.deepEqual(sitewide.queryParams({sitewide: {queryParams:
+      {q: 'owner:me'}}}), {q: 'owner:me'});
+  });
+
+  describe('pageTitle', () => {
+    it('defaults to Monorail when no data', () => {
+      assert.equal(sitewide.pageTitle({}), 'Monorail');
+      assert.equal(sitewide.pageTitle({sitewide: {}}), 'Monorail');
+    });
+
+    it('uses local page title when one exists', () => {
+      assert.equal(sitewide.pageTitle(
+          {sitewide: {pageTitle: 'Issue Detail'}}), 'Issue Detail');
+    });
+
+    it('shows name of viewed project', () => {
+      assert.equal(sitewide.pageTitle({
+        sitewide: {pageTitle: 'Page'},
+        projectV0: {
+          name: 'chromium',
+          configs: {chromium: {projectName: 'chromium'}},
+        },
+      }), 'Page - chromium');
+    });
+  });
+
+  describe('currentColumns', () => {
+    it('returns null no configuration', () => {
+      assert.deepEqual(sitewide.currentColumns({}), null);
+      assert.deepEqual(sitewide.currentColumns({projectV0: {}}), null);
+      const state = {projectV0: {presentationConfig: {}}};
+      assert.deepEqual(sitewide.currentColumns(state), null);
+    });
+
+    it('gets columns from URL query params', () => {
+      const state = {sitewide: {
+        queryParams: {colspec: 'ID+Summary+ColumnName+Priority'},
+      }};
+      const expected = ['ID', 'Summary', 'ColumnName', 'Priority'];
+      assert.deepEqual(sitewide.currentColumns(state), expected);
+    });
+  });
+
+  describe('currentCan', () => {
+    it('uses sitewide default can by default', () => {
+      assert.deepEqual(sitewide.currentCan({}), '2');
+    });
+
+    it('URL params override default can', () => {
+      assert.deepEqual(sitewide.currentCan({
+        sitewide: {
+          queryParams: {can: '3'},
+        },
+      }), '3');
+    });
+
+    it('undefined query param does not override default can', () => {
+      assert.deepEqual(sitewide.currentCan({
+        sitewide: {
+          queryParams: {can: undefined},
+        },
+      }), '2');
+    });
+  });
+
+  describe('currentQuery', () => {
+    it('defaults to empty', () => {
+      assert.deepEqual(sitewide.currentQuery({}), '');
+      assert.deepEqual(sitewide.currentQuery({projectV0: {}}), '');
+    });
+
+    it('uses project default when no params', () => {
+      assert.deepEqual(sitewide.currentQuery({projectV0: {
+        name: 'chromium',
+        presentationConfigs: {
+          chromium: {defaultQuery: 'owner:me'},
+        },
+      }}), 'owner:me');
+    });
+
+    it('URL query params override default query', () => {
+      assert.deepEqual(sitewide.currentQuery({
+        projectV0: {
+          name: 'chromium',
+          presentationConfigs: {
+            chromium: {defaultQuery: 'owner:me'},
+          },
+        },
+        sitewide: {
+          queryParams: {q: 'component:Infra'},
+        },
+      }), 'component:Infra');
+    });
+
+    it('empty string in param overrides default project query', () => {
+      assert.deepEqual(sitewide.currentQuery({
+        projectV0: {
+          name: 'chromium',
+          presentationConfigs: {
+            chromium: {defaultQuery: 'owner:me'},
+          },
+        },
+        sitewide: {
+          queryParams: {q: ''},
+        },
+      }), '');
+    });
+
+    it('undefined query param does not override default search', () => {
+      assert.deepEqual(sitewide.currentQuery({
+        projectV0: {
+          name: 'chromium',
+          presentationConfigs: {
+            chromium: {defaultQuery: 'owner:me'},
+          },
+        },
+        sitewide: {
+          queryParams: {q: undefined},
+        },
+      }), 'owner:me');
+    });
+  });
+});
+
+
+describe('sitewide action creators', () => {
+  beforeEach(() => {
+    prpcCall = sinon.stub(prpcClient, 'call');
+  });
+
+  afterEach(() => {
+    prpcClient.call.restore();
+  });
+
+  it('setQueryParams updates queryParams', async () => {
+    store.dispatch(sitewide.setQueryParams({test: 'param'}));
+
+    await stateUpdated;
+
+    assert.deepEqual(sitewide.queryParams(store.getState()), {test: 'param'});
+  });
+
+  describe('getServerStatus', () => {
+    it('gets server status', async () => {
+      prpcCall.callsFake(() => {
+        return {
+          bannerMessage: 'Message',
+          bannerTime: 1234,
+          readOnly: true,
+        };
+      });
+
+      store.dispatch(sitewide.getServerStatus());
+
+      await stateUpdated;
+      const state = store.getState();
+
+      assert.deepEqual(sitewide.bannerMessage(state), 'Message');
+      assert.deepEqual(sitewide.bannerTime(state), 1234);
+      assert.isTrue(sitewide.readOnly(state));
+
+      assert.deepEqual(sitewide.requests(state), {
+        serverStatus: {
+          error: null,
+          requesting: false,
+        },
+      });
+    });
+
+    it('gets empty status', async () => {
+      prpcCall.callsFake(() => {
+        return {};
+      });
+
+      store.dispatch(sitewide.getServerStatus());
+
+      await stateUpdated;
+      const state = store.getState();
+
+      assert.deepEqual(sitewide.bannerMessage(state), '');
+      assert.deepEqual(sitewide.bannerTime(state), 0);
+      assert.isFalse(sitewide.readOnly(state));
+
+      assert.deepEqual(sitewide.requests(state), {
+        serverStatus: {
+          error: null,
+          requesting: false,
+        },
+      });
+    });
+
+    it('fails', async () => {
+      const error = new Error('error');
+      prpcCall.callsFake(() => {
+        throw error;
+      });
+
+      store.dispatch(sitewide.getServerStatus());
+
+      await stateUpdated;
+      const state = store.getState();
+
+      assert.deepEqual(sitewide.bannerMessage(state), '');
+      assert.deepEqual(sitewide.bannerTime(state), 0);
+      assert.isFalse(sitewide.readOnly(state));
+
+      assert.deepEqual(sitewide.requests(state), {
+        serverStatus: {
+          error: error,
+          requesting: false,
+        },
+      });
+    });
+  });
+});
diff --git a/static_src/reducers/stars.js b/static_src/reducers/stars.js
new file mode 100644
index 0000000..b67ff9d
--- /dev/null
+++ b/static_src/reducers/stars.js
@@ -0,0 +1,172 @@
+// 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 Star actions, selectors, and reducers organized into
+ * a single Redux "Duck" that manages updating and retrieving star state
+ * on the frontend.
+ *
+ * Reference: https://github.com/erikras/ducks-modular-redux
+ */
+
+import {combineReducers} from 'redux';
+import {createReducer, createRequestReducer,
+  createKeyedRequestReducer} from './redux-helpers.js';
+import {prpcClient} from 'prpc-client-instance.js';
+import {projectAndUserToStarName} from 'shared/converters.js';
+import 'shared/typedef.js';
+
+/** @typedef {import('redux').AnyAction} AnyAction */
+
+// Actions
+export const LIST_PROJECTS_START = 'stars/LIST_PROJECTS_START';
+export const LIST_PROJECTS_SUCCESS = 'stars/LIST_PROJECTS_SUCCESS';
+export const LIST_PROJECTS_FAILURE = 'stars/LIST_PROJECTS_FAILURE';
+
+export const STAR_PROJECT_START = 'stars/STAR_PROJECT_START';
+export const STAR_PROJECT_SUCCESS = 'stars/STAR_PROJECT_SUCCESS';
+export const STAR_PROJECT_FAILURE = 'stars/STAR_PROJECT_FAILURE';
+
+export const UNSTAR_PROJECT_START = 'stars/UNSTAR_PROJECT_START';
+export const UNSTAR_PROJECT_SUCCESS = 'stars/UNSTAR_PROJECT_SUCCESS';
+export const UNSTAR_PROJECT_FAILURE = 'stars/UNSTAR_PROJECT_FAILURE';
+
+/* State Shape
+{
+  byName: Object<StarName, Star>,
+
+  requests: {
+    listProjects: ReduxRequestState,
+  },
+}
+*/
+
+/**
+ * All star data indexed by resource name.
+ * @param {Object<ProjectName, Star>} state Existing Project data.
+ * @param {AnyAction} action
+ * @param {Array<Star>} action.star The Stars that were fetched.
+ * @param {ProjectStar} action.projectStar A single ProjectStar that was
+ *   created.
+ * @param {StarName} action.starName The StarName that was mutated.
+ * @return {Object<ProjectName, Star>}
+ */
+export const byNameReducer = createReducer({}, {
+  [LIST_PROJECTS_SUCCESS]: (state, {stars}) => {
+    const newStars = {};
+    stars.forEach((star) => {
+      newStars[star.name] = star;
+    });
+    return {...state, ...newStars};
+  },
+  [STAR_PROJECT_SUCCESS]: (state, {projectStar}) => {
+    return {...state, [projectStar.name]: projectStar};
+  },
+  [UNSTAR_PROJECT_SUCCESS]: (state, {starName}) => {
+    const newState = {...state};
+    delete newState[starName];
+    return newState;
+  },
+});
+
+
+const requestsReducer = combineReducers({
+  listProjects: createRequestReducer(LIST_PROJECTS_START,
+      LIST_PROJECTS_SUCCESS, LIST_PROJECTS_FAILURE),
+  starProject: createKeyedRequestReducer(STAR_PROJECT_START,
+      STAR_PROJECT_SUCCESS, STAR_PROJECT_FAILURE),
+  unstarProject: createKeyedRequestReducer(UNSTAR_PROJECT_START,
+      UNSTAR_PROJECT_SUCCESS, UNSTAR_PROJECT_FAILURE),
+});
+
+
+export const reducer = combineReducers({
+  byName: byNameReducer,
+  requests: requestsReducer,
+});
+
+
+/**
+ * Returns normalized star data by name.
+ * @param {any} state
+ * @return {Object<StarName, Star>}
+ * @private
+ */
+export const byName = (state) => state.stars.byName;
+
+/**
+ * Returns star requests.
+ * @param {any} state
+ * @return {Object<string, ReduxRequestState>}
+ */
+export const requests = (state) => state.stars.requests;
+
+/**
+ * Retrieves the starred projects for a given user.
+ * @param {UserName} user The resource name of the user to fetch
+ *   starred projects for.
+ * @return {function(function): Promise<void>}
+ */
+export const listProjects = (user) => async (dispatch) => {
+  dispatch({type: LIST_PROJECTS_START});
+
+  try {
+    const {projectStars} = await prpcClient.call(
+        'monorail.v3.Users', 'ListProjectStars', {parent: user});
+    dispatch({type: LIST_PROJECTS_SUCCESS, stars: projectStars});
+  } catch (error) {
+    dispatch({type: LIST_PROJECTS_FAILURE, error});
+  };
+};
+
+/**
+ * Stars a given project.
+ * @param {ProjectName} project The resource name of the project to star.
+ * @param {UserName} user The resource name of the user who is starring
+ *   the issue. This will always be the currently logged in user.
+ * @return {function(function): Promise<void>}
+ */
+export const starProject = (project, user) => async (dispatch) => {
+  const requestKey = projectAndUserToStarName(project, user);
+  dispatch({type: STAR_PROJECT_START, requestKey});
+  try {
+    const projectStar = await prpcClient.call(
+        'monorail.v3.Users', 'StarProject', {project});
+    dispatch({type: STAR_PROJECT_SUCCESS, requestKey, projectStar});
+  } catch (error) {
+    dispatch({type: STAR_PROJECT_FAILURE, requestKey, error});
+  };
+};
+
+/**
+ * Unstars a given project.
+ * @param {ProjectName} project The resource name of the project to unstar.
+ * @param {UserName} user The resource name of the user who is unstarring
+ *   the issue. This will always be the currently logged in user, but
+ *   passing in the user's resource name is necessary to make it possible to
+ *   generate the resource name of the removed star.
+ * @return {function(function): Promise<void>}
+ */
+export const unstarProject = (project, user) => async (dispatch) => {
+  const starName = projectAndUserToStarName(project, user);
+  const requestKey = starName;
+  dispatch({type: UNSTAR_PROJECT_START, requestKey});
+
+  try {
+    await prpcClient.call(
+        'monorail.v3.Users', 'UnStarProject', {project});
+    dispatch({type: UNSTAR_PROJECT_SUCCESS, requestKey, starName});
+  } catch (error) {
+    dispatch({type: UNSTAR_PROJECT_FAILURE, requestKey, error});
+  };
+};
+
+export const stars = {
+  reducer,
+  byName,
+  requests,
+  listProjects,
+  starProject,
+  unstarProject,
+};
diff --git a/static_src/reducers/stars.test.js b/static_src/reducers/stars.test.js
new file mode 100644
index 0000000..3437723
--- /dev/null
+++ b/static_src/reducers/stars.test.js
@@ -0,0 +1,247 @@
+// 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 * as stars from './stars.js';
+import * as example from 'shared/test/constants-stars.js';
+
+import {prpcClient} from 'prpc-client-instance.js';
+
+let dispatch;
+
+
+describe('star reducers', () => {
+  it('root reducer initial state', () => {
+    const actual = stars.reducer(undefined, {type: null});
+    const expected = {
+      byName: {},
+      requests: {
+        listProjects: {error: null, requesting: false},
+        starProject: {},
+        unstarProject: {},
+      },
+    };
+    assert.deepEqual(actual, expected);
+  });
+
+  describe('byNameReducer', () => {
+    it('populated on LIST_PROJECTS_SUCCESS', () => {
+      const action = {type: stars.LIST_PROJECTS_SUCCESS, stars:
+          [example.PROJECT_STAR, example.PROJECT_STAR_2]};
+      const actual = stars.byNameReducer({}, action);
+
+      assert.deepEqual(actual, {
+        [example.PROJECT_STAR_NAME]: example.PROJECT_STAR,
+        [example.PROJECT_STAR_NAME_2]: example.PROJECT_STAR_2,
+      });
+    });
+
+    it('keeps original state on empty LIST_PROJECTS_SUCCESS', () => {
+      const originalState = {
+        [example.PROJECT_STAR_NAME]: example.PROJECT_STAR,
+        [example.PROJECT_STAR_NAME_2]: example.PROJECT_STAR_2,
+      };
+      const action = {type: stars.LIST_PROJECTS_SUCCESS, stars: []};
+      const actual = stars.byNameReducer(originalState, action);
+
+      assert.deepEqual(actual, originalState);
+    });
+
+    it('appends new stars to state on LIST_PROJECTS_SUCCESS', () => {
+      const originalState = {
+        [example.PROJECT_STAR_NAME]: example.PROJECT_STAR,
+      };
+      const action = {type: stars.LIST_PROJECTS_SUCCESS,
+        stars: [example.PROJECT_STAR_2]};
+      const actual = stars.byNameReducer(originalState, action);
+
+      const expected = {
+        [example.PROJECT_STAR_NAME]: example.PROJECT_STAR,
+        [example.PROJECT_STAR_NAME_2]: example.PROJECT_STAR_2,
+      };
+      assert.deepEqual(actual, expected);
+    });
+
+    it('adds star on STAR_PROJECT_SUCCESS', () => {
+      const originalState = {
+        [example.PROJECT_STAR_NAME]: example.PROJECT_STAR,
+      };
+      const action = {type: stars.STAR_PROJECT_SUCCESS,
+        projectStar: example.PROJECT_STAR_2};
+      const actual = stars.byNameReducer(originalState, action);
+
+      const expected = {
+        [example.PROJECT_STAR_NAME]: example.PROJECT_STAR,
+        [example.PROJECT_STAR_NAME_2]: example.PROJECT_STAR_2,
+      };
+      assert.deepEqual(actual, expected);
+    });
+
+    it('removes star on UNSTAR_PROJECT_SUCCESS', () => {
+      const originalState = {
+        [example.PROJECT_STAR_NAME]: example.PROJECT_STAR,
+        [example.PROJECT_STAR_NAME_2]: example.PROJECT_STAR_2,
+      };
+      const action = {type: stars.UNSTAR_PROJECT_SUCCESS,
+        starName: example.PROJECT_STAR_NAME};
+      const actual = stars.byNameReducer(originalState, action);
+
+      const expected = {
+        [example.PROJECT_STAR_NAME_2]: example.PROJECT_STAR_2,
+      };
+      assert.deepEqual(actual, expected);
+    });
+  });
+});
+
+describe('project selectors', () => {
+  it('byName', () => {
+    const normalizedStars = {
+      [example.PROJECT_STAR_NAME]: example.PROJECT_STAR,
+    };
+    const state = {stars: {
+      byName: normalizedStars,
+    }};
+    assert.deepEqual(stars.byName(state), normalizedStars);
+  });
+
+  it('requests', () => {
+    const state = {stars: {
+      requests: {
+        listProjects: {error: null, requesting: false},
+        starProject: {},
+        unstarProject: {},
+      },
+    }};
+    assert.deepEqual(stars.requests(state), {
+      listProjects: {error: null, requesting: false},
+      starProject: {},
+      unstarProject: {},
+    });
+  });
+});
+
+describe('star action creators', () => {
+  beforeEach(() => {
+    sinon.stub(prpcClient, 'call');
+    dispatch = sinon.stub();
+  });
+
+  afterEach(() => {
+    prpcClient.call.restore();
+  });
+
+  describe('listProjects', () => {
+    it('success', async () => {
+      const starsResponse = {
+        projectStars: [example.PROJECT_STAR, example.PROJECT_STAR_2],
+      };
+      prpcClient.call.returns(Promise.resolve(starsResponse));
+
+      await stars.listProjects('users/1234')(dispatch);
+
+      sinon.assert.calledWith(dispatch, {type: stars.LIST_PROJECTS_START});
+
+      sinon.assert.calledWith(
+          prpcClient.call, 'monorail.v3.Users', 'ListProjectStars',
+          {parent: 'users/1234'});
+
+      const successAction = {
+        type: stars.LIST_PROJECTS_SUCCESS,
+        stars: [example.PROJECT_STAR, example.PROJECT_STAR_2],
+      };
+      sinon.assert.calledWith(dispatch, successAction);
+    });
+
+    it('failure', async () => {
+      prpcClient.call.throws();
+
+      await stars.listProjects('users/1234')(dispatch);
+
+      const action = {
+        type: stars.LIST_PROJECTS_FAILURE,
+        error: sinon.match.any,
+      };
+      sinon.assert.calledWith(dispatch, action);
+    });
+  });
+
+  describe('starProject', () => {
+    it('success', async () => {
+      const starResponse = example.PROJECT_STAR;
+      prpcClient.call.returns(Promise.resolve(starResponse));
+
+      await stars.starProject('projects/monorail', 'users/1234')(dispatch);
+
+      sinon.assert.calledWith(dispatch, {
+        type: stars.STAR_PROJECT_START,
+        requestKey: example.PROJECT_STAR_NAME,
+      });
+
+      sinon.assert.calledWith(
+          prpcClient.call, 'monorail.v3.Users', 'StarProject',
+          {project: 'projects/monorail'});
+
+      const successAction = {
+        type: stars.STAR_PROJECT_SUCCESS,
+        requestKey: example.PROJECT_STAR_NAME,
+        projectStar: example.PROJECT_STAR,
+      };
+      sinon.assert.calledWith(dispatch, successAction);
+    });
+
+    it('failure', async () => {
+      prpcClient.call.throws();
+
+      await stars.starProject('projects/monorail', 'users/1234')(dispatch);
+
+      const action = {
+        type: stars.STAR_PROJECT_FAILURE,
+        requestKey: example.PROJECT_STAR_NAME,
+        error: sinon.match.any,
+      };
+      sinon.assert.calledWith(dispatch, action);
+    });
+  });
+
+  describe('unstarProject', () => {
+    it('success', async () => {
+      const starResponse = {};
+      prpcClient.call.returns(Promise.resolve(starResponse));
+
+      await stars.unstarProject('projects/monorail', 'users/1234')(dispatch);
+
+      sinon.assert.calledWith(dispatch, {
+        type: stars.UNSTAR_PROJECT_START,
+        requestKey: example.PROJECT_STAR_NAME,
+      });
+
+      sinon.assert.calledWith(
+          prpcClient.call, 'monorail.v3.Users', 'UnStarProject',
+          {project: 'projects/monorail'});
+
+      const successAction = {
+        type: stars.UNSTAR_PROJECT_SUCCESS,
+        requestKey: example.PROJECT_STAR_NAME,
+        starName: example.PROJECT_STAR_NAME,
+      };
+      sinon.assert.calledWith(dispatch, successAction);
+    });
+
+    it('failure', async () => {
+      prpcClient.call.throws();
+
+      await stars.unstarProject('projects/monorail', 'users/1234')(dispatch);
+
+      const action = {
+        type: stars.UNSTAR_PROJECT_FAILURE,
+        requestKey: example.PROJECT_STAR_NAME,
+        error: sinon.match.any,
+      };
+      sinon.assert.calledWith(dispatch, action);
+    });
+  });
+});
diff --git a/static_src/reducers/ui.js b/static_src/reducers/ui.js
new file mode 100644
index 0000000..871cf87
--- /dev/null
+++ b/static_src/reducers/ui.js
@@ -0,0 +1,170 @@
+// 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 {combineReducers} from 'redux';
+import {createReducer} from './redux-helpers.js';
+
+/** @typedef {import('redux').AnyAction} AnyAction */
+
+const DEFAULT_SNACKBAR_TIMEOUT_MS = 10 * 1000;
+
+
+/**
+ * Object of various constant strings used to uniquely identify
+ * snackbar instances used in the app.
+ * TODO(https://crbug.com/monorail/7491): Avoid using this Object.
+ * @type {Object<string, string>}
+ */
+export const snackbarNames = Object.freeze({
+  // Issue list page snackbars.
+  FETCH_ISSUE_LIST_ERROR: 'FETCH_ISSUE_LIST_ERROR',
+  FETCH_ISSUE_LIST: 'FETCH_ISSUE_LIST',
+  UPDATE_HOTLISTS_SUCCESS: 'UPDATE_HOTLISTS_SUCCESS',
+
+  // Issue detail page snackbars.
+  ISSUE_COMMENT_ADDED: 'ISSUE_COMMENT_ADDED',
+});
+
+// Actions
+const INCREMENT_NAVIGATION_COUNT = 'INCREMENT_NAVIGATION_COUNT';
+const REPORT_DIRTY_FORM = 'REPORT_DIRTY_FORM';
+const CLEAR_DIRTY_FORMS = 'CLEAR_DIRTY_FORMS';
+const SET_FOCUS_ID = 'SET_FOCUS_ID';
+export const SHOW_SNACKBAR = 'SHOW_SNACKBAR';
+const HIDE_SNACKBAR = 'HIDE_SNACKBAR';
+
+/**
+ * @typedef {Object} Snackbar
+ * @param {string} id Unique string identifying the snackbar.
+ * @param {string} text The text to show in the snackbar.
+ */
+
+/* State Shape
+{
+  navigationCount: number,
+  dirtyForms: Array,
+  focusId: String,
+  snackbars: Array<Snackbar>,
+}
+*/
+
+// Reducers
+
+
+const navigationCountReducer = createReducer(0, {
+  [INCREMENT_NAVIGATION_COUNT]: (state) => state + 1,
+});
+
+/**
+ * Saves state on which forms have been edited, to warn the user
+ * about possible data loss when they navigate away from a page.
+ * @param {Array<string>} state Dirty form names.
+ * @param {AnyAction} action
+ * @param {string} action.name The name of the form being updated.
+ * @param {boolean} action.isDirty Whether the form is dirty or not dirty.
+ * @return {Array<string>}
+ */
+const dirtyFormsReducer = createReducer([], {
+  [REPORT_DIRTY_FORM]: (state, {name, isDirty}) => {
+    const newState = [...state];
+    const index = state.indexOf(name);
+    if (isDirty && index === -1) {
+      newState.push(name);
+    } else if (!isDirty && index !== -1) {
+      newState.splice(index, 1);
+    }
+    return newState;
+  },
+  [CLEAR_DIRTY_FORMS]: () => [],
+});
+
+const focusIdReducer = createReducer(null, {
+  [SET_FOCUS_ID]: (_state, action) => action.focusId,
+});
+
+/**
+ * Updates snackbar state.
+ * @param {Array<Snackbar>} state A snackbar-shaped slice of Redux state.
+ * @param {AnyAction} action
+ * @param {string} action.text The text to display in the snackbar.
+ * @param {string} action.id A unique global ID for the snackbar.
+ * @return {Array<Snackbar>} New snackbar state.
+ */
+export const snackbarsReducer = createReducer([], {
+  [SHOW_SNACKBAR]: (state, {text, id}) => {
+    return [...state, {text, id}];
+  },
+  [HIDE_SNACKBAR]: (state, {id}) => {
+    return state.filter((snackbar) => snackbar.id !== id);
+  },
+});
+
+export const reducer = combineReducers({
+  // Count of "page" navigations.
+  navigationCount: navigationCountReducer,
+  // Forms to be checked for user changes before leaving the page.
+  dirtyForms: dirtyFormsReducer,
+  // The ID of the element to be focused, as given by the hash part of the URL.
+  focusId: focusIdReducer,
+  // Array of snackbars to render on the page.
+  snackbars: snackbarsReducer,
+});
+
+// Selectors
+export const navigationCount = (state) => state.ui.navigationCount;
+export const dirtyForms = (state) => state.ui.dirtyForms;
+export const focusId = (state) => state.ui.focusId;
+
+/**
+ * Retrieves snackbar data from the Redux store.
+ * @param {any} state Redux state.
+ * @return {Array<Snackbar>} All the snackbars in the store.
+ */
+export const snackbars = (state) => state.ui.snackbars;
+
+// Action Creators
+export const incrementNavigationCount = () => {
+  return {type: INCREMENT_NAVIGATION_COUNT};
+};
+
+export const reportDirtyForm = (name, isDirty) => {
+  return {type: REPORT_DIRTY_FORM, name, isDirty};
+};
+
+export const clearDirtyForms = () => ({type: CLEAR_DIRTY_FORMS});
+
+export const setFocusId = (focusId) => {
+  return {type: SET_FOCUS_ID, focusId};
+};
+
+/**
+ * Displays a snackbar.
+ * @param {string} id Unique identifier for a given snackbar. We depend on
+ *   snackbar users to keep this unique.
+ * @param {string} text The text to be shown in the snackbar.
+ * @param {number} timeout An optional timeout in milliseconds for how
+ *   long to wait to dismiss a snackbar.
+ * @return {function(function): Promise<void>}
+ */
+export const showSnackbar = (id, text,
+    timeout = DEFAULT_SNACKBAR_TIMEOUT_MS) => (dispatch) => {
+  dispatch({type: SHOW_SNACKBAR, text, id});
+
+  if (timeout) {
+    window.setTimeout(() => dispatch(hideSnackbar(id)),
+        timeout);
+  }
+};
+
+/**
+ * Hides a snackbar.
+ * @param {string} id The unique name of the snackbar to be hidden.
+ * @return {any} A Redux action.
+ */
+export const hideSnackbar = (id) => {
+  return {
+    type: HIDE_SNACKBAR,
+    id,
+  };
+};
diff --git a/static_src/reducers/ui.test.js b/static_src/reducers/ui.test.js
new file mode 100644
index 0000000..587bc0c
--- /dev/null
+++ b/static_src/reducers/ui.test.js
@@ -0,0 +1,123 @@
+// 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 * as ui from './ui.js';
+
+
+describe('ui', () => {
+  describe('reducers', () => {
+    describe('snackbarsReducer', () => {
+      it('adds snackbar', () => {
+        let state = ui.snackbarsReducer([],
+            {type: 'SHOW_SNACKBAR', id: 'one', text: 'A snackbar'});
+
+        assert.deepEqual(state, [{id: 'one', text: 'A snackbar'}]);
+
+        state = ui.snackbarsReducer(state,
+            {type: 'SHOW_SNACKBAR', id: 'two', text: 'Another snack'});
+
+        assert.deepEqual(state, [
+          {id: 'one', text: 'A snackbar'},
+          {id: 'two', text: 'Another snack'},
+        ]);
+      });
+
+      it('removes snackbar', () => {
+        let state = [
+          {id: 'one', text: 'A snackbar'},
+          {id: 'two', text: 'Another snack'},
+        ];
+
+        state = ui.snackbarsReducer(state,
+            {type: 'HIDE_SNACKBAR', id: 'one'});
+
+        assert.deepEqual(state, [
+          {id: 'two', text: 'Another snack'},
+        ]);
+
+        state = ui.snackbarsReducer(state,
+            {type: 'HIDE_SNACKBAR', id: 'two'});
+
+        assert.deepEqual(state, []);
+      });
+
+      it('does not remove non-existent snackbar', () => {
+        let state = [
+          {id: 'one', text: 'A snackbar'},
+          {id: 'two', text: 'Another snack'},
+        ];
+
+        state = ui.snackbarsReducer(state,
+            {action: 'HIDE_SNACKBAR', id: 'whatever'});
+
+        assert.deepEqual(state, [
+          {id: 'one', text: 'A snackbar'},
+          {id: 'two', text: 'Another snack'},
+        ]);
+      });
+    });
+  });
+
+  describe('selectors', () => {
+    it('snackbars', () => {
+      assert.deepEqual(ui.snackbars({ui: {snackbars: []}}), []);
+      assert.deepEqual(ui.snackbars({ui: {snackbars: [
+        {text: 'Snackbar one', id: 'one'},
+        {text: 'Snackbar two', id: 'two'},
+      ]}}), [
+        {text: 'Snackbar one', id: 'one'},
+        {text: 'Snackbar two', id: 'two'},
+      ]);
+    });
+  });
+
+  describe('action creators', () => {
+    describe('showSnackbar', () => {
+      it('produces action', () => {
+        const action = ui.showSnackbar('id', 'text');
+        const dispatch = sinon.stub();
+
+        action(dispatch);
+
+        sinon.assert.calledWith(dispatch,
+            {type: 'SHOW_SNACKBAR', text: 'text', id: 'id'});
+      });
+
+      it('hides snackbar after timeout', () => {
+        const clock = sinon.useFakeTimers(0);
+
+        const action = ui.showSnackbar('id', 'text', 1000);
+        const dispatch = sinon.stub();
+
+        action(dispatch);
+
+        sinon.assert.neverCalledWith(dispatch,
+            {type: 'HIDE_SNACKBAR', id: 'id'});
+
+        clock.tick(1000);
+
+        sinon.assert.calledWith(dispatch, {type: 'HIDE_SNACKBAR', id: 'id'});
+
+        clock.restore();
+      });
+
+      it('does not setTimeout when no timeout specified', () => {
+        sinon.stub(window, 'setTimeout');
+
+        ui.showSnackbar('id', 'text', 0);
+
+        sinon.assert.notCalled(window.setTimeout);
+
+        window.setTimeout.restore();
+      });
+    });
+
+    it('hideSnackbar produces action', () => {
+      assert.deepEqual(ui.hideSnackbar('one'),
+          {type: 'HIDE_SNACKBAR', id: 'one'});
+    });
+  });
+});
diff --git a/static_src/reducers/userV0.js b/static_src/reducers/userV0.js
new file mode 100644
index 0000000..42a93fc
--- /dev/null
+++ b/static_src/reducers/userV0.js
@@ -0,0 +1,384 @@
+// 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 {combineReducers} from 'redux';
+import {createSelector} from 'reselect';
+import {createReducer, createRequestReducer} from './redux-helpers.js';
+import {prpcClient} from 'prpc-client-instance.js';
+import {objectToMap} from 'shared/helpers.js';
+import {userRefToId, userToUserRef} from 'shared/convertersV0.js';
+import loadGapi, {fetchGapiEmail} from 'shared/gapi-loader.js';
+import {DEFAULT_MD_PROJECTS} from 'shared/md-helper.js';
+import {viewedProjectName} from 'reducers/projectV0.js';
+
+// Actions
+const FETCH_START = 'userV0/FETCH_START';
+const FETCH_SUCCESS = 'userV0/FETCH_SUCCESS';
+const FETCH_FAILURE = 'userV0/FETCH_FAILURE';
+
+export const FETCH_PROJECTS_START = 'userV0/FETCH_PROJECTS_START';
+export const FETCH_PROJECTS_SUCCESS = 'userV0/FETCH_PROJECTS_SUCCESS';
+export const FETCH_PROJECTS_FAILURE = 'userV0/FETCH_PROJECTS_FAILURE';
+
+const FETCH_HOTLISTS_START = 'userV0/FETCH_HOTLISTS_START';
+const FETCH_HOTLISTS_SUCCESS = 'userV0/FETCH_HOTLISTS_SUCCESS';
+const FETCH_HOTLISTS_FAILURE = 'userV0/FETCH_HOTLISTS_FAILURE';
+
+const FETCH_PREFS_START = 'userV0/FETCH_PREFS_START';
+const FETCH_PREFS_SUCCESS = 'userV0/FETCH_PREFS_SUCCESS';
+const FETCH_PREFS_FAILURE = 'userV0/FETCH_PREFS_FAILURE';
+
+export const SET_PREFS_START = 'userV0/SET_PREFS_START';
+export const SET_PREFS_SUCCESS = 'userV0/SET_PREFS_SUCCESS';
+export const SET_PREFS_FAILURE = 'userV0/SET_PREFS_FAILURE';
+
+const GAPI_LOGIN_START = 'GAPI_LOGIN_START';
+export const GAPI_LOGIN_SUCCESS = 'GAPI_LOGIN_SUCCESS';
+const GAPI_LOGIN_FAILURE = 'GAPI_LOGIN_FAILURE';
+
+const GAPI_LOGOUT_START = 'GAPI_LOGOUT_START';
+export const GAPI_LOGOUT_SUCCESS = 'GAPI_LOGOUT_SUCCESS';
+const GAPI_LOGOUT_FAILURE = 'GAPI_LOGOUT_FAILURE';
+
+
+// Monorial UserPrefs are stored as plain strings in Monorail's backend.
+// We want boolean preferences to be converted into booleans for convenience.
+// Currently, there are no user prefs in Monorail that are NOT booleans, so
+// we default to converting all user prefs to booleans unless otherwise
+// specified.
+// See: https://source.chromium.org/chromium/infra/infra/+/main:appengine/monorail/framework/framework_bizobj.py;l=409
+const NON_BOOLEAN_PREFS = new Set(['test_non_bool']);
+
+
+/* State Shape
+{
+  currentUser: {
+    ...user: Object,
+    groups: Array,
+    hotlists: Array,
+    prefs: Object,
+    gapiEmail: String,
+  },
+  requests: {
+    fetch: Object,
+    fetchHotlists: Object,
+    fetchPrefs: Object,
+  },
+}
+*/
+
+// Reducers
+const USER_DEFAULT = {
+  groups: [],
+  hotlists: [],
+  projects: {},
+  prefs: {},
+  prefsLoaded: false,
+};
+
+const gapiEmailReducer = (user, action) => {
+  return {
+    ...user,
+    gapiEmail: action.email || '',
+  };
+};
+
+export const currentUserReducer = createReducer(USER_DEFAULT, {
+  [FETCH_SUCCESS]: (_user, action) => {
+    return {
+      ...USER_DEFAULT,
+      ...action.user,
+      groups: action.groups,
+    };
+  },
+  [FETCH_HOTLISTS_SUCCESS]: (user, action) => {
+    return {...user, hotlists: action.hotlists};
+  },
+  [FETCH_PREFS_SUCCESS]: (user, action) => {
+    return {
+      ...user,
+      prefs: action.prefs,
+      prefsLoaded: true,
+    };
+  },
+  [SET_PREFS_SUCCESS]: (user, action) => {
+    const newPrefs = action.newPrefs;
+    const prefs = Object.assign({}, user.prefs);
+    newPrefs.forEach(({name, value}) => {
+      prefs[name] = value;
+    });
+    return {
+      ...user,
+      prefs,
+    };
+  },
+  [GAPI_LOGIN_SUCCESS]: gapiEmailReducer,
+  [GAPI_LOGOUT_SUCCESS]: gapiEmailReducer,
+});
+
+export const usersByIdReducer = createReducer({}, {
+  [FETCH_PROJECTS_SUCCESS]: (state, action) => {
+    const newState = {...state};
+
+    action.usersProjects.forEach((userProjects) => {
+      const {userRef, ownerOf = [], memberOf = [], contributorTo = [],
+        starredProjects = []} = userProjects;
+
+      const userId = userRefToId(userRef);
+
+      newState[userId] = {
+        ...newState[userId],
+        projects: {
+          ownerOf,
+          memberOf,
+          contributorTo,
+          starredProjects,
+        },
+      };
+    });
+
+    return newState;
+  },
+});
+
+const requestsReducer = combineReducers({
+  // Request for getting backend metadata related to a user, such as
+  // which groups they belong to and whether they're a site admin.
+  fetch: createRequestReducer(FETCH_START, FETCH_SUCCESS, FETCH_FAILURE),
+  // Requests for fetching projects a user is related to.
+  fetchProjects: createRequestReducer(
+      FETCH_PROJECTS_START, FETCH_PROJECTS_SUCCESS, FETCH_PROJECTS_FAILURE),
+  // Request for getting a user's hotlists.
+  fetchHotlists: createRequestReducer(
+      FETCH_HOTLISTS_START, FETCH_HOTLISTS_SUCCESS, FETCH_HOTLISTS_FAILURE),
+  // Request for getting a user's prefs.
+  fetchPrefs: createRequestReducer(
+      FETCH_PREFS_START, FETCH_PREFS_SUCCESS, FETCH_PREFS_FAILURE),
+  // Request for setting a user's prefs.
+  setPrefs: createRequestReducer(
+      SET_PREFS_START, SET_PREFS_SUCCESS, SET_PREFS_FAILURE),
+});
+
+export const reducer = combineReducers({
+  currentUser: currentUserReducer,
+  usersById: usersByIdReducer,
+  requests: requestsReducer,
+});
+
+// Selectors
+export const requests = (state) => state.userV0.requests;
+export const currentUser = (state) => state.userV0.currentUser || {};
+// TODO(zhangtiff): Replace custom logic to check if the user is logged in
+// across the frontend.
+export const isLoggedIn = createSelector(
+    currentUser, (user) => user && user.userId);
+export const userRef = createSelector(
+    currentUser, (user) => userToUserRef(user));
+export const prefs = createSelector(
+    currentUser, viewedProjectName, (user, projectName = '') => {
+      const prefs = {
+        // Make Markdown default to true for projects who have opted in.
+        render_markdown: String(DEFAULT_MD_PROJECTS.has(projectName)),
+        ...user.prefs
+      };
+      for (let prefName of Object.keys(prefs)) {
+        if (!NON_BOOLEAN_PREFS.has(prefName)) {
+          // Monorail user preferences are stored as strings.
+          prefs[prefName] = prefs[prefName] === 'true';
+        }
+      }
+      return objectToMap(prefs);
+    });
+
+const _usersById = (state) => state.userV0.usersById || {};
+export const usersById = createSelector(_usersById,
+    (usersById) => objectToMap(usersById));
+
+export const projectsPerUser = createSelector(usersById, (usersById) => {
+  const map = new Map();
+  for (const [key, value] of usersById.entries()) {
+    if (value.projects) {
+      map.set(key, value.projects);
+    }
+  }
+  return map;
+});
+
+// Projects for just the current user.
+export const projects = createSelector(projectsPerUser, userRef,
+    (projectsMap, userRef) => projectsMap.get(userRefToId(userRef)) || {});
+
+// Action Creators
+/**
+ * Fetches the data required to view the logged in user.
+ * @param {string} displayName The display name of the logged in user. Note that
+ *   while usernames may be hidden for users in Monorail, the logged in user
+ *   will always be able to view their own name.
+ * @return {function(function): Promise<void>}
+ */
+export const fetch = (displayName) => async (dispatch) => {
+  dispatch({type: FETCH_START});
+
+  const message = {
+    userRef: {displayName},
+  };
+
+  try {
+    const resp = await Promise.all([
+      prpcClient.call(
+          'monorail.Users', 'GetUser', message),
+      prpcClient.call(
+          'monorail.Users', 'GetMemberships', message),
+    ]);
+
+    const user = resp[0];
+
+    dispatch({
+      type: FETCH_SUCCESS,
+      user,
+      groups: resp[1].groupRefs || [],
+    });
+
+    const userRef = userToUserRef(user);
+
+    dispatch(fetchProjects([userRef]));
+    const hotlistsPromise = dispatch(fetchHotlists(userRef));
+    dispatch(fetchPrefs());
+
+    hotlistsPromise.then((hotlists) => {
+      // TODO(crbug.com/monorail/5828): Remove
+      // window.TKR_populateHotlistAutocomplete once the old
+      // autocomplete code is deprecated.
+      window.TKR_populateHotlistAutocomplete(hotlists);
+    });
+  } catch (error) {
+    dispatch({type: FETCH_FAILURE, error});
+  };
+};
+
+export const fetchProjects = (userRefs) => async (dispatch) => {
+  dispatch({type: FETCH_PROJECTS_START});
+  try {
+    const resp = await prpcClient.call(
+        'monorail.Users', 'GetUsersProjects', {userRefs});
+    dispatch({type: FETCH_PROJECTS_SUCCESS, usersProjects: resp.usersProjects});
+  } catch (error) {
+    dispatch({type: FETCH_PROJECTS_FAILURE, error});
+  }
+};
+
+/**
+ * Fetches all of a given user's hotlists.
+ * @param {UserRef} userRef The user to fetch hotlists for.
+ * @return {function(function): Promise<Array<HotlistV0>>}
+ */
+export const fetchHotlists = (userRef) => async (dispatch) => {
+  dispatch({type: FETCH_HOTLISTS_START});
+
+  try {
+    const resp = await prpcClient.call(
+        'monorail.Features', 'ListHotlistsByUser', {user: userRef});
+
+    const hotlists = resp.hotlists || [];
+    hotlists.sort((hotlistA, hotlistB) => {
+      return hotlistA.name.localeCompare(hotlistB.name);
+    });
+    dispatch({type: FETCH_HOTLISTS_SUCCESS, hotlists});
+
+    return hotlists;
+  } catch (error) {
+    dispatch({type: FETCH_HOTLISTS_FAILURE, error});
+  };
+};
+
+/**
+ * Fetches user preferences for the logged in user.
+ * @return {function(function): Promise<void>}
+ */
+export const fetchPrefs = () => async (dispatch) => {
+  dispatch({type: FETCH_PREFS_START});
+
+  try {
+    const resp = await prpcClient.call(
+        'monorail.Users', 'GetUserPrefs', {});
+    const prefs = {};
+    (resp.prefs || []).forEach(({name, value}) => {
+      prefs[name] = value;
+    });
+    dispatch({type: FETCH_PREFS_SUCCESS, prefs});
+  } catch (error) {
+    dispatch({type: FETCH_PREFS_FAILURE, error});
+  };
+};
+
+/**
+ * Action creator for setting a user's preferences.
+ *
+ * @param {Object} newPrefs
+ * @param {boolean} saveChanges
+ * @return {function(function): Promise<void>}
+ */
+export const setPrefs = (newPrefs, saveChanges = true) => async (dispatch) => {
+  if (!saveChanges) {
+    dispatch({type: SET_PREFS_SUCCESS, newPrefs});
+    return;
+  }
+
+  dispatch({type: SET_PREFS_START});
+
+  try {
+    const message = {prefs: newPrefs};
+    await prpcClient.call(
+        'monorail.Users', 'SetUserPrefs', message);
+    dispatch({type: SET_PREFS_SUCCESS, newPrefs});
+
+    // Re-fetch the user's prefs after saving to prevent prefs from
+    // getting out of sync.
+    dispatch(fetchPrefs());
+  } catch (error) {
+    dispatch({type: SET_PREFS_FAILURE, error});
+  }
+};
+
+/**
+ * Action creator to initiate the gapi.js login flow.
+ *
+ * @return {Promise} Resolved only when gapi.js login succeeds.
+ */
+export const initGapiLogin = () => (dispatch) => {
+  dispatch({type: GAPI_LOGIN_START});
+
+  return new Promise(async (resolve) => {
+    try {
+      await loadGapi();
+      gapi.auth2.getAuthInstance().signIn().then(async () => {
+        const email = await fetchGapiEmail();
+        dispatch({type: GAPI_LOGIN_SUCCESS, email: email});
+        resolve();
+      });
+    } catch (error) {
+      // TODO(jeffcarp): Pop up a message that signIn failed.
+      dispatch({type: GAPI_LOGIN_FAILURE, error});
+    }
+  });
+};
+
+/**
+ * Action creator to log the user out of gapi.js
+ *
+ * @return {undefined}
+ */
+export const initGapiLogout = () => async (dispatch) => {
+  dispatch({type: GAPI_LOGOUT_START});
+
+  try {
+    await loadGapi();
+    gapi.auth2.getAuthInstance().signOut().then(() => {
+      dispatch({type: GAPI_LOGOUT_SUCCESS, email: ''});
+    });
+  } catch (error) {
+    // TODO(jeffcarp): Pop up a message that signOut failed.
+    dispatch({type: GAPI_LOGOUT_FAILURE, error});
+  }
+};
diff --git a/static_src/reducers/userV0.test.js b/static_src/reducers/userV0.test.js
new file mode 100644
index 0000000..aab7989
--- /dev/null
+++ b/static_src/reducers/userV0.test.js
@@ -0,0 +1,372 @@
+// 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 * as userV0 from './userV0.js';
+import {prpcClient} from 'prpc-client-instance.js';
+
+
+let dispatch;
+
+describe('userV0', () => {
+  describe('reducers', () => {
+    it('SET_PREFS_SUCCESS updates existing prefs with new prefs', () => {
+      const state = {prefs: {
+        testPref: 'true',
+        anotherPref: 'hello-world',
+      }};
+
+      const newPrefs = [
+        {name: 'anotherPref', value: 'override'},
+        {name: 'newPref', value: 'test-me'},
+      ];
+
+      const newState = userV0.currentUserReducer(state,
+          {type: userV0.SET_PREFS_SUCCESS, newPrefs});
+
+      assert.deepEqual(newState, {prefs: {
+        testPref: 'true',
+        anotherPref: 'override',
+        newPref: 'test-me',
+      }});
+    });
+
+    it('FETCH_PROJECTS_SUCCESS overrides existing entry in usersById', () => {
+      const state = {
+        ['123']: {
+          projects: {
+            ownerOf: [],
+            memberOf: [],
+            contributorTo: [],
+            starredProjects: [],
+          },
+        },
+      };
+
+      const usersProjects = [
+        {
+          userRef: {userId: '123'},
+          ownerOf: ['chromium'],
+        },
+      ];
+
+      const newState = userV0.usersByIdReducer(state,
+          {type: userV0.FETCH_PROJECTS_SUCCESS, usersProjects});
+
+      assert.deepEqual(newState, {
+        ['123']: {
+          projects: {
+            ownerOf: ['chromium'],
+            memberOf: [],
+            contributorTo: [],
+            starredProjects: [],
+          },
+        },
+      });
+    });
+
+    it('FETCH_PROJECTS_SUCCESS adds new entry to usersById', () => {
+      const state = {
+        ['123']: {
+          projects: {
+            ownerOf: [],
+            memberOf: [],
+            contributorTo: [],
+            starredProjects: [],
+          },
+        },
+      };
+
+      const usersProjects = [
+        {
+          userRef: {userId: '543'},
+          ownerOf: ['chromium'],
+        },
+        {
+          userRef: {userId: '789'},
+          memberOf: ['v8'],
+        },
+      ];
+
+      const newState = userV0.usersByIdReducer(state,
+          {type: userV0.FETCH_PROJECTS_SUCCESS, usersProjects});
+
+      assert.deepEqual(newState, {
+        ['123']: {
+          projects: {
+            ownerOf: [],
+            memberOf: [],
+            contributorTo: [],
+            starredProjects: [],
+          },
+        },
+        ['543']: {
+          projects: {
+            ownerOf: ['chromium'],
+            memberOf: [],
+            contributorTo: [],
+            starredProjects: [],
+          },
+        },
+        ['789']: {
+          projects: {
+            ownerOf: [],
+            memberOf: ['v8'],
+            contributorTo: [],
+            starredProjects: [],
+          },
+        },
+      });
+    });
+
+    describe('GAPI_LOGIN_SUCCESS', () => {
+      it('sets currentUser.gapiEmail', () => {
+        const newState = userV0.currentUserReducer({}, {
+          type: userV0.GAPI_LOGIN_SUCCESS,
+          email: 'rutabaga@rutabaga.com',
+        });
+        assert.deepEqual(newState, {
+          gapiEmail: 'rutabaga@rutabaga.com',
+        });
+      });
+
+      it('defaults to an empty string', () => {
+        const newState = userV0.currentUserReducer({}, {
+          type: userV0.GAPI_LOGIN_SUCCESS,
+        });
+        assert.deepEqual(newState, {
+          gapiEmail: '',
+        });
+      });
+    });
+
+    describe('GAPI_LOGOUT_SUCCESS', () => {
+      it('sets currentUser.gapiEmail', () => {
+        const newState = userV0.currentUserReducer({}, {
+          type: userV0.GAPI_LOGOUT_SUCCESS,
+          email: '',
+        });
+        assert.deepEqual(newState, {
+          gapiEmail: '',
+        });
+      });
+
+      it('defaults to an empty string', () => {
+        const state = {};
+        const newState = userV0.currentUserReducer(state, {
+          type: userV0.GAPI_LOGOUT_SUCCESS,
+        });
+        assert.deepEqual(newState, {
+          gapiEmail: '',
+        });
+      });
+    });
+  });
+
+  describe('selectors', () => {
+    it('prefs', () => {
+      const state = wrapCurrentUser({prefs: {
+        testPref: 'true',
+        test_non_bool: 'hello-world',
+      }});
+
+      assert.deepEqual(userV0.prefs(state), new Map([
+        ['render_markdown', false],
+        ['testPref', true],
+        ['test_non_bool', 'hello-world'],
+      ]));
+    });
+
+    it('prefs is set with the correct type', async () =>{
+      // When setting prefs it's important that they are set as their
+      // String value.
+      const state = wrapCurrentUser({prefs: {
+        render_markdown : 'true',
+      }});
+      const markdownPref = userV0.prefs(state).get('render_markdown');
+      assert.isTrue(markdownPref);
+    });
+
+    it('prefs is NOT set with the correct type', async () =>{
+      // Here the value is a boolean so when it gets set it would
+      // appear as false because it's compared with a String.
+      const state = wrapCurrentUser({prefs: {
+        render_markdown : true,
+      }});
+      const markdownPref = userV0.prefs(state).get('render_markdown');
+      // Thus this is false when it was meant to be true.
+      assert.isFalse(markdownPref);
+    });
+
+    it('projects', () => {
+      assert.deepEqual(userV0.projects(wrapUser({})), {});
+
+      const state = wrapUser({
+        currentUser: {userId: '123'},
+        usersById: {
+          ['123']: {
+            projects: {
+              ownerOf: ['chromium'],
+              memberOf: ['v8'],
+              contributorTo: [],
+              starredProjects: [],
+            },
+          },
+        },
+      });
+
+      assert.deepEqual(userV0.projects(state), {
+        ownerOf: ['chromium'],
+        memberOf: ['v8'],
+        contributorTo: [],
+        starredProjects: [],
+      });
+    });
+
+    it('projectPerUser', () => {
+      assert.deepEqual(userV0.projectsPerUser(wrapUser({})), new Map());
+
+      const state = wrapUser({
+        usersById: {
+          ['123']: {
+            projects: {
+              ownerOf: ['chromium'],
+              memberOf: ['v8'],
+              contributorTo: [],
+              starredProjects: [],
+            },
+          },
+        },
+      });
+
+      assert.deepEqual(userV0.projectsPerUser(state), new Map([
+        ['123', {
+          ownerOf: ['chromium'],
+          memberOf: ['v8'],
+          contributorTo: [],
+          starredProjects: [],
+        }],
+      ]));
+    });
+  });
+
+  describe('action creators', () => {
+    beforeEach(() => {
+      sinon.stub(prpcClient, 'call');
+
+      dispatch = sinon.stub();
+    });
+
+    afterEach(() => {
+      prpcClient.call.restore();
+    });
+
+    it('fetchProjects succeeds', async () => {
+      const action = userV0.fetchProjects([{userId: '123'}]);
+
+      prpcClient.call.returns(Promise.resolve({
+        usersProjects: [
+          {
+            userRef: {
+              userId: '123',
+            },
+            ownerOf: ['chromium'],
+          },
+        ],
+      }));
+
+      await action(dispatch);
+
+      sinon.assert.calledWith(dispatch, {type: userV0.FETCH_PROJECTS_START});
+
+      sinon.assert.calledWith(
+          prpcClient.call,
+          'monorail.Users',
+          'GetUsersProjects',
+          {userRefs: [{userId: '123'}]});
+
+      sinon.assert.calledWith(dispatch, {
+        type: userV0.FETCH_PROJECTS_SUCCESS,
+        usersProjects: [
+          {
+            userRef: {
+              userId: '123',
+            },
+            ownerOf: ['chromium'],
+          },
+        ],
+      });
+    });
+
+    it('fetchProjects fails', async () => {
+      const action = userV0.fetchProjects([{userId: '123'}]);
+
+      const error = new Error('mistakes were made');
+      prpcClient.call.returns(Promise.reject(error));
+
+      await action(dispatch);
+
+      sinon.assert.calledWith(dispatch, {type: userV0.FETCH_PROJECTS_START});
+
+      sinon.assert.calledWith(
+          prpcClient.call,
+          'monorail.Users',
+          'GetUsersProjects',
+          {userRefs: [{userId: '123'}]});
+
+      sinon.assert.calledWith(dispatch, {
+        type: userV0.FETCH_PROJECTS_FAILURE,
+        error,
+      });
+    });
+
+    it('setPrefs', async () => {
+      const action = userV0.setPrefs([{name: 'pref_name', value: 'true'}]);
+
+      prpcClient.call.returns(Promise.resolve({}));
+
+      await action(dispatch);
+
+      sinon.assert.calledWith(dispatch, {type: userV0.SET_PREFS_START});
+
+      sinon.assert.calledWith(
+          prpcClient.call,
+          'monorail.Users',
+          'SetUserPrefs',
+          {prefs: [{name: 'pref_name', value: 'true'}]});
+
+      sinon.assert.calledWith(dispatch, {
+        type: userV0.SET_PREFS_SUCCESS,
+        newPrefs: [{name: 'pref_name', value: 'true'}],
+      });
+    });
+
+    it('setPrefs fails', async () => {
+      const action = userV0.setPrefs([{name: 'pref_name', value: 'true'}]);
+
+      const error = new Error('mistakes were made');
+      prpcClient.call.returns(Promise.reject(error));
+
+      await action(dispatch);
+
+      sinon.assert.calledWith(dispatch, {type: userV0.SET_PREFS_START});
+
+      sinon.assert.calledWith(
+          prpcClient.call,
+          'monorail.Users',
+          'SetUserPrefs',
+          {prefs: [{name: 'pref_name', value: 'true'}]});
+
+      sinon.assert.calledWith(dispatch, {
+        type: userV0.SET_PREFS_FAILURE,
+        error,
+      });
+    });
+  });
+});
+
+
+const wrapCurrentUser = (currentUser = {}) => ({userV0: {currentUser}});
+const wrapUser = (user) => ({userV0: user});
diff --git a/static_src/reducers/users.js b/static_src/reducers/users.js
new file mode 100644
index 0000000..af8609c
--- /dev/null
+++ b/static_src/reducers/users.js
@@ -0,0 +1,222 @@
+// 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 User actions, selectors, and reducers organized into
+ * a single Redux "Duck" that manages updating and retrieving user state
+ * on the frontend.
+ *
+ * The User data is stored in a normalized format.
+ * `byName` stores all User data indexed by User name.
+ * `user` is a selector that gets the currently viewed User data.
+ *
+ * Reference: https://github.com/erikras/ducks-modular-redux
+ */
+
+import {combineReducers} from 'redux';
+import {createReducer, createKeyedRequestReducer} from './redux-helpers.js';
+import {prpcClient} from 'prpc-client-instance.js';
+import 'shared/typedef.js';
+
+/** @typedef {import('redux').AnyAction} AnyAction */
+
+// Actions
+export const LOG_IN = 'user/LOG_IN';
+
+export const BATCH_GET_START = 'user/BATCH_GET_START';
+export const BATCH_GET_SUCCESS = 'user/BATCH_GET_SUCCESS';
+export const BATCH_GET_FAILURE = 'user/BATCH_GET_FAILURE';
+
+export const FETCH_START = 'user/FETCH_START';
+export const FETCH_SUCCESS = 'user/FETCH_SUCCESS';
+export const FETCH_FAILURE = 'user/FETCH_FAILURE';
+
+export const GATHER_PROJECT_MEMBERSHIPS_START =
+  'user/GATHER_PROJECT_MEMBERSHIPS_START';
+export const GATHER_PROJECT_MEMBERSHIPS_SUCCESS =
+  'user/GATHER_PROJECT_MEMBERSHIPS_SUCCESS';
+export const GATHER_PROJECT_MEMBERSHIPS_FAILURE =
+  'user/GATHER_PROJECT_MEMBERSHIPS_FAILURE';
+
+/* State Shape
+{
+  currentUserName: ?string,
+
+  byName: Object<UserName, User>,
+
+  requests: {
+    batchGet: ReduxRequestState,
+    fetch: ReduxRequestState,
+    gatherProjectMemberships: ReduxRequestState,
+  },
+}
+*/
+
+// Reducers
+
+/**
+ * A reference to the currently logged in user.
+ * @param {?string} state The current user name.
+ * @param {AnyAction} action
+ * @param {User} action.user The user that was logged in.
+ * @return {?string}
+ */
+export const currentUserNameReducer = createReducer(null, {
+  [LOG_IN]: (_state, {user}) => user.name,
+});
+
+/**
+ * All User data indexed by User name.
+ * @param {Object<UserName, User>} state The existing User data.
+ * @param {AnyAction} action
+ * @param {User} action.user The user that was fetched.
+ * @return {Object<UserName, User>}
+ */
+export const byNameReducer = createReducer({}, {
+  [BATCH_GET_SUCCESS]: (state, {users}) => {
+    const newState = {...state};
+    for (const user of users) {
+      newState[user.name] = user;
+    }
+    return newState;
+  },
+  [FETCH_SUCCESS]: (state, {user}) => ({...state, [user.name]: user}),
+});
+
+/**
+ * ProjectMember data indexed by User name.
+ *
+ * Pragma: No normalization for ProjectMember objects. There is never a
+ *  situation when we will have access to ProjectMember names but not associated
+ *  ProjectMember objects so normalizing is unnecessary.
+ * @param {Object<UserName, Array<ProjectMember>>} state The existing User data.
+ * @param {AnyAction} action
+ * @param {UserName} action.userName The resource name of the user that was
+ *   fetched.
+ * @param {Array<ProjectMember>=} action.projectMemberships The project
+ *   memberships for the fetched user.
+ * @return {Object<UserName, Array<ProjectMember>>}
+ */
+export const projectMembershipsReducer = createReducer({}, {
+  [GATHER_PROJECT_MEMBERSHIPS_SUCCESS]: (state, {userName,
+    projectMemberships}) => {
+    const newState = {...state};
+
+    newState[userName] = projectMemberships || [];
+    return newState;
+  },
+});
+
+const requestsReducer = combineReducers({
+  batchGet: createKeyedRequestReducer(
+      BATCH_GET_START, BATCH_GET_SUCCESS, BATCH_GET_FAILURE),
+  fetch: createKeyedRequestReducer(
+      FETCH_START, FETCH_SUCCESS, FETCH_FAILURE),
+  gatherProjectMemberships: createKeyedRequestReducer(
+      GATHER_PROJECT_MEMBERSHIPS_START, GATHER_PROJECT_MEMBERSHIPS_SUCCESS,
+      GATHER_PROJECT_MEMBERSHIPS_FAILURE),
+});
+
+export const reducer = combineReducers({
+  currentUserName: currentUserNameReducer,
+  byName: byNameReducer,
+  projectMemberships: projectMembershipsReducer,
+
+  requests: requestsReducer,
+});
+
+// Selectors
+
+/**
+ * Returns the currently logged in user name, or null if there is none.
+ * @param {any} state
+ * @return {?string}
+ */
+export const currentUserName = (state) => state.users.currentUserName;
+
+/**
+ * Returns all the User data in the store as a mapping from name to User.
+ * @param {any} state
+ * @return {Object<UserName, User>}
+ */
+export const byName = (state) => state.users.byName;
+
+/**
+ * Returns all the ProjectMember data in the store, mapped to Users' names.
+ * @param {any} state
+ * @return {Object<UserName, ProjectMember>}
+ */
+export const projectMemberships = (state) => state.users.projectMemberships;
+
+// Action Creators
+
+/**
+ * Action creator to fetch multiple User objects.
+ * @param {Array<UserName>} names The names of the Users to fetch.
+ * @return {function(function): Promise<void>}
+ */
+export const batchGet = (names) => async (dispatch) => {
+  dispatch({type: BATCH_GET_START});
+
+  try {
+    /** @type {{users: Array<User>}} */
+    const {users} = await prpcClient.call(
+        'monorail.v3.Users', 'BatchGetUsers', {names});
+
+    dispatch({type: BATCH_GET_SUCCESS, users});
+  } catch (error) {
+    dispatch({type: BATCH_GET_FAILURE, error});
+  };
+};
+
+/**
+ * Action creator to fetch a single User object.
+ * TODO(https://crbug.com/monorail/7824): Maybe decouple LOG_IN from
+ * FETCH_SUCCESS once we move away from server-side logins.
+ * @param {UserName} name The resource name of the User to fetch.
+ * @return {function(function): Promise<void>}
+ */
+export const fetch = (name) => async (dispatch) => {
+  dispatch({type: FETCH_START});
+
+  try {
+    /** @type {User} */
+    const user = await prpcClient.call('monorail.v3.Users', 'GetUser', {name});
+    dispatch({type: FETCH_SUCCESS, user});
+    dispatch({type: LOG_IN, user});
+  } catch (error) {
+    dispatch({type: FETCH_FAILURE, error});
+  }
+};
+
+/**
+ * Action creator to fetch ProjectMember objects for a given User.
+ * @param {UserName} name The resource name of the User.
+ * @return {function(function): Promise<void>}
+ */
+export const gatherProjectMemberships = (name) => async (dispatch) => {
+  dispatch({type: GATHER_PROJECT_MEMBERSHIPS_START});
+
+  try {
+    /** @type {{projectMemberships: Array<ProjectMember>}} */
+    const {projectMemberships} = await prpcClient.call(
+        'monorail.v3.Frontend', 'GatherProjectMembershipsForUser',
+        {user: name});
+
+    dispatch({type: GATHER_PROJECT_MEMBERSHIPS_SUCCESS,
+      userName: name, projectMemberships});
+  } catch (error) {
+    // TODO(crbug.com/monorail/7627): Catch actual API errors.
+    dispatch({type: GATHER_PROJECT_MEMBERSHIPS_FAILURE, error});
+  };
+};
+
+export const users = {
+  currentUserName,
+  byName,
+  projectMemberships,
+  batchGet,
+  fetch,
+  gatherProjectMemberships,
+};
diff --git a/static_src/reducers/users.test.js b/static_src/reducers/users.test.js
new file mode 100644
index 0000000..ea0ce61
--- /dev/null
+++ b/static_src/reducers/users.test.js
@@ -0,0 +1,178 @@
+// 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 sinon from 'sinon';
+
+import * as users from './users.js';
+import * as example from 'shared/test/constants-users.js';
+
+import {prpcClient} from 'prpc-client-instance.js';
+
+let dispatch;
+
+describe('user reducers', () => {
+  it('root reducer initial state', () => {
+    const actual = users.reducer(undefined, {type: null});
+    const expected = {
+      currentUserName: null,
+      byName: {},
+      projectMemberships: {},
+      requests: {
+        batchGet: {},
+        fetch: {},
+        gatherProjectMemberships: {},
+      },
+    };
+    assert.deepEqual(actual, expected);
+  });
+
+  it('currentUserName updates on LOG_IN', () => {
+    const action = {type: users.LOG_IN, user: example.USER};
+    const actual = users.currentUserNameReducer(null, action);
+    assert.deepEqual(actual, example.NAME);
+  });
+
+  it('byName updates on BATCH_GET_SUCCESS', () => {
+    const action = {type: users.BATCH_GET_SUCCESS, users: [example.USER]};
+    const actual = users.byNameReducer({}, action);
+    assert.deepEqual(actual, {[example.NAME]: example.USER});
+  });
+
+  describe('projectMembershipsReducer', () => {
+    it('updates on GATHER_PROJECT_MEMBERSHIPS_SUCCESS', () => {
+      const action = {type: users.GATHER_PROJECT_MEMBERSHIPS_SUCCESS,
+        userName: example.NAME, projectMemberships: [example.PROJECT_MEMBER]};
+      const actual = users.projectMembershipsReducer({}, action);
+      assert.deepEqual(actual, {[example.NAME]: [example.PROJECT_MEMBER]});
+    });
+
+    it('sets empty on GATHER_PROJECT_MEMBERSHIPS_SUCCESS', () => {
+      const action = {type: users.GATHER_PROJECT_MEMBERSHIPS_SUCCESS,
+        userName: example.NAME, projectMemberships: undefined};
+      const actual = users.projectMembershipsReducer({}, action);
+      assert.deepEqual(actual, {[example.NAME]: []});
+    });
+  });
+});
+
+describe('user selectors', () => {
+  it('currentUserName', () => {
+    const state = {users: {currentUserName: example.NAME}};
+    assert.deepEqual(users.currentUserName(state), example.NAME);
+  });
+
+  it('byName', () => {
+    const state = {users: {byName: example.BY_NAME}};
+    assert.deepEqual(users.byName(state), example.BY_NAME);
+  });
+
+  it('projectMemberships', () => {
+    const membershipsByName = {[example.NAME]: [example.PROJECT_MEMBER]};
+    const state = {users: {projectMemberships: membershipsByName}};
+    assert.deepEqual(users.projectMemberships(state), membershipsByName);
+  });
+});
+
+describe('user action creators', () => {
+  beforeEach(() => {
+    sinon.stub(prpcClient, 'call');
+    dispatch = sinon.stub();
+  });
+
+  afterEach(() => {
+    prpcClient.call.restore();
+  });
+
+  describe('batchGet', () => {
+    it('success', async () => {
+      prpcClient.call.returns(Promise.resolve({users: [example.USER]}));
+
+      await users.batchGet([example.NAME])(dispatch);
+
+      sinon.assert.calledWith(dispatch, {type: users.BATCH_GET_START});
+
+      const args = {names: [example.NAME]};
+      sinon.assert.calledWith(
+          prpcClient.call, 'monorail.v3.Users', 'BatchGetUsers', args);
+
+      const action = {type: users.BATCH_GET_SUCCESS, users: [example.USER]};
+      sinon.assert.calledWith(dispatch, action);
+    });
+
+    it('failure', async () => {
+      prpcClient.call.throws();
+
+      await users.batchGet([example.NAME])(dispatch);
+
+      const action = {type: users.BATCH_GET_FAILURE, error: sinon.match.any};
+      sinon.assert.calledWith(dispatch, action);
+    });
+  });
+
+  describe('fetch', () => {
+    it('success', async () => {
+      prpcClient.call.returns(Promise.resolve(example.USER));
+
+      await users.fetch(example.NAME)(dispatch);
+
+      sinon.assert.calledWith(dispatch, {type: users.FETCH_START});
+
+      const args = {name: example.NAME};
+      sinon.assert.calledWith(
+          prpcClient.call, 'monorail.v3.Users', 'GetUser', args);
+
+      const fetchAction = {type: users.FETCH_SUCCESS, user: example.USER};
+      sinon.assert.calledWith(dispatch, fetchAction);
+
+      const logInAction = {type: users.LOG_IN, user: example.USER};
+      sinon.assert.calledWith(dispatch, logInAction);
+    });
+
+    it('failure', async () => {
+      prpcClient.call.throws();
+
+      await users.fetch(example.NAME)(dispatch);
+
+      const action = {type: users.FETCH_FAILURE, error: sinon.match.any};
+      sinon.assert.calledWith(dispatch, action);
+    });
+  });
+
+  describe('gatherProjectMemberships', () => {
+    it('success', async () => {
+      prpcClient.call.returns(Promise.resolve({projectMemberships: [
+        example.PROJECT_MEMBER,
+      ]}));
+
+      await users.gatherProjectMemberships(
+          example.NAME)(dispatch);
+
+      sinon.assert.calledWith(dispatch,
+          {type: users.GATHER_PROJECT_MEMBERSHIPS_START});
+
+      const args = {user: example.NAME};
+      sinon.assert.calledWith(
+          prpcClient.call, 'monorail.v3.Frontend',
+          'GatherProjectMembershipsForUser', args);
+
+      const action = {
+        type: users.GATHER_PROJECT_MEMBERSHIPS_SUCCESS,
+        projectMemberships: [example.PROJECT_MEMBER],
+        userName: example.NAME,
+      };
+      sinon.assert.calledWith(dispatch, action);
+    });
+
+    it('failure', async () => {
+      prpcClient.call.throws(new Error());
+
+      await users.batchGet([example.NAME])(dispatch);
+
+      const action = {type: users.BATCH_GET_FAILURE,
+        error: sinon.match.instanceOf(Error)};
+      sinon.assert.calledWith(dispatch, action);
+    });
+  });
+});