Merge "Convert gr-app_test to typescript"
diff --git a/Documentation/concept-changes.txt b/Documentation/concept-changes.txt
index 6de787c..0444fab 100644
--- a/Documentation/concept-changes.txt
+++ b/Documentation/concept-changes.txt
@@ -104,6 +104,9 @@
 Assigning a topic to a change can be done in the change screen or through a `git
 push` command.
 
+For more information about using topics, see the user guide:
+link:cross-repository-changes.html[Submitting Changes Across Repositories by using Topics].
+
 [[submit-strategies]]
 == Submit strategies
 
diff --git a/Documentation/config-validation.txt b/Documentation/config-validation.txt
index cb953c1..56c9ecd 100644
--- a/Documentation/config-validation.txt
+++ b/Documentation/config-validation.txt
@@ -21,6 +21,14 @@
 Out of the box, Gerrit includes a plugin that checks the length of the
 subject and body lines of commit messages on uploaded commits.
 
+[plugin-push-options]]
+=== Plugin push options
+
+Plugins can register push options by implementing the `PluginPushOption`
+interface. If a plugin push option was specified it is available from
+the `CommitReceivedEvent` that is passed into `CommitValidationListener`.
+This way the plugin commit validation can be controlled by push options.
+
 [[user-ref-operations-validation]]
 == User ref operations validation
 
diff --git a/Documentation/cross-repository-changes.txt b/Documentation/cross-repository-changes.txt
new file mode 100644
index 0000000..136219c
--- /dev/null
+++ b/Documentation/cross-repository-changes.txt
@@ -0,0 +1,252 @@
+:linkattrs:
+= Gerrit Code Review - Submitting Changes Across Repositories by using Topics
+
+== Goal
+
+This document describes how to propose and submit code changes across multiple
+Git repositories together in Gerrit.
+
+== When to Use
+
+Oftentimes, especially for larger code bases, code is split across multiple
+repositories. The Android operating system’s code base, for example, consists of
+https://android.googlesource.com/[hundreds] of separate repositories. When
+making a change, you might make code changes that span multiple repositories.
+For example, one repository could define an API which is used in another
+repository. Submitting these changes across these repositories separately could
+cause the build to break for other developers.
+
+Gerrit provides a mechanism called link:intro-user.html#topics[Topics] to submit
+changes together to prevent this problem.
+
+|===
+|NOTE: Usage of topics to submit multiple changes together requires your
+Gerrit host having
+link:config-gerrit.html#change.submitWholeTopic[config.submitWholeTopic] set to
+true. Ask your Gerrit administrator if you're not sure if this is enabled for
+your Gerrit instance.
+|===
+
+== What is a Topic?
+
+* A topic is a string that can be associated with a change.
+* Multiple changes can use that topic to be submitted at the same time (assuming
+  approvals, etc.).
+* Submitting a change with a topic causes all of the changes in the topic *to be
+  submitted together*
+  ** Topics that span only a single repository are guaranteed to be submitted
+  together
+  ** Topics that span multiple repositories simply triggers submission of all
+  changes. No other guarantees are given. Submission of all changes could
+  fail, so you could get a partial topic submission. This is very rare but
+  can happen in some of the following situations:
+  *** Storage layer failures. This is unlikely in single-master installation and
+  more likely with multi-master setups.
+  *** Race conditions. Concurrent submits to the same repository or concurrent
+  updates of the pending changes.
+
+Here are a few intricacies you should be aware of:
+
+1. Topics can only be used for changes within a single Gerrit instance. There is
+no builtin support for synchronizing with other Gerrit or Git hosting sites.
+
+2. A topic can be any string, and they are not namespaced in a Gerrit instance;
+there is a chance for collisions and inadvertently grouping changes together
+that weren’t meant to be grouped. This could even happen with changes you can’t
+see, leading to more confusion e.g. (change not submittable, but you can't see
+why it's not submittable.). We suggest prefixing topic strings with the author’s
+username e.g. “username-” to help avoid this.
+
+You can view the assigned topic from the change screen in Gerrit:
+
+image::images/cross-repository-changes-topic.png[width=600]
+
+=== Topic submission behavior
+* Submitting a topic will submit any dependent changes as well. For example,
+  an unsubmitted parent change will also be submitted, even if it isn’t in the
+  original topic.
+* A change with a topic is submittable when *all changes* in the topic are
+  submittable and *all of the changes’ dependent changes* (and their topics!)
+  are also submittable.
+* Gerrit calls the totality of these changes "Submitted Together", and they can
+be found with the
+  link:rest-api-changes.html#submitted-together[Submitted Together endpoint] or
+  on the change screen.
+
+image::images/cross-repository-changes-submitted-together.png[width=600]
+
+* A submission creates a unique submission ID
+    (link:rest-api-changes.html#change-info[`submission_id`]), which can be
+    used in Gerrit's search bar to find all the submitted changes for the
+    submission. This ID is relevant when <<reverting,reverting a submission>>.
+
+To better underestand this behavior, consider this following example.
+
+==== Example Submission[[example_submission]]
+
+image::images/cross-repository-changes-example.png[width=600]
+
+* Two repositories: A and B
+* Two changes in A: A1 and A2, where A2 is the child change.
+* Two changes in B: B1 and B2, where B2 is the child change.
+* Topic X contains change A1 and B1
+* Topic Y contains change A2 and B2
+
+Submission of A2 will submit all four of these changes because submission of A2
+submits all of topic Y as well as all dependent changes and their topics i.e. A1
+and topic X.
+
+Because of this, any submission is blocked until all four of these changes are
+submittable.
+
+|===
+| Important point: B1 can unexpectedly block the submission of A2!
+This kind of situation is hard to immediately grok: B1 isn't in the topic you're
+trying to submit, and it isn't a depnedent change of A2. If your topic isn’t
+submittable and you can’t figure out why, this might be a reason.
+|===
+
+== Submitting Changes Using Topics
+
+=== 1. *Associate the changes to a topic*
+
+The first step is to associate all the changes you want to be submitted together
+with the same topic. There are multiple ways to associate changes with a topic.
+
+==== From the command line
+You can set the topic name when uploading to Gerrit
+
+----
+$ git push origin HEAD:refs/heads/master -o topic=[YOUR_TOPIC_NAME]
+----
+
+*OR*
+
+----
+$ git push origin HEAD:refs/for/master%topic=[YOUR_TOPIC_NAME]
+----
+
+If you’re using https://source.android.com/setup/develop[repo] to upload a
+change to Android Gerrit, you can associate a topic via:
+
+----
+$ repo upload -o topic=[YOUR_TOPIC_NAME]
+----
+
+If you’re using
+https://commondatastorage.googleapis.com/chrome-infra-docs/flat/depot_tools/docs/html/depot_tools.html[depot_tools]
+to upload a change to Chromium Gerrit, you can associate a topic via:
+
+----
+$ git cl upload --topic=[YOUR_TOPIC_NAME]
+----
+
+==== From the UI
+
+If the change has already been created, you can add a topic from the change page
+by clicking ADD TOPIC, found on the left side of the top of the Change screen.
+
+image::images/cross-repository-changes-add-topic.png[width=600]
+
+=== 2. *Go through the normal code review process*
+
+Each change still goes through the normal code review process where reviewers
+vote on each change individually. The changes won’t be able to be submitted
+until *all* changes in the topic are submittable.
+
+The requirements for submittability vary based on rules set by your repository
+administrators; often this includes being approved by all requisite parties,
+passing presubmit testing, and being able to merge cleanly (without conflicts)
+into the target branch.
+
+=== 3. *Submit the change*
+
+When all changes in the topic are submittable, you’ll see *SUBMIT WHOLE TOPIC*
+at the top of the _Change screen_. Clicking it will submit all the changes in
+"Submitted Together."
+
+image::images/cross-repository-changes-submit-topic.png[width=600]
+
+== Reverting a Submission[[reverting]]
+
+After a topic is submitted, you can revert all or one of the changes by clicking
+the *REVERT* button on any change.
+
+image::images/cross-repository-changes-revert-topic.png[width=600]
+
+This will give you the option to either revert just the change in question or
+the entire topic:
+
+image::images/cross-repository-changes-revert-topic-options.png[width=600]
+
+Reverting the entire submission creates revert commits for each change and
+automatically associates them together under the same topic. To submit
+these changes, go through the normal review process.
+
+When submitting a topic, dependent changes and their topics are submitted as
+well. The RevertSubmission creates reverts for all the changes that were
+submitted at that time. When reverting the submission described in
+<<example_submission,Example Submission>>, all 4 of those changes will get
+reverted.
+
+|===
+| NOTE: We say “reverting a submission” instead of “reverting a submitted
+  topic” because submissions are defined by submission id, not by the topic
+  string. So even though topics names could be reused, this doesn't effect
+  reverting. For example:
+
+  1. Submission #1 uses topic A
+
+  2. Later, Submission #2 uses topic A again
+
+  Reverting submission #2 only reverts the changes in that submission, not all
+  changes included in topic A.
+|===
+
+== Cherry-Picking a Topic
+
+You may want to cherry-pick the changes (i.e. copy the changes) of a topic to
+another branch, perhaps because you have multiple branches that all need to be
+updated with the same change (e.g. you're porting a security fix across
+branches). Gerrit provides a mechanism to create these changes.
+
+From the overflow menu (3 dot icon) in the top right of the Change Screen,
+select “Cherry pick.” In the screenshot below, we’re showing this on a
+submitted change, but this option is available if the change is pending as
+well.
+
+image::images/cross-repository-changes-cp-menu.png[width=600]
+
+Afterwards, you’ll be presented with a modal where you can “Cherry Pick entire
+topic.”
+
+image::images/cross-repository-changes-cp-modal.png[width=600]
+
+Enter the branch name that you want to target for these repositories. The
+branch must already exist on all of the repositories. After clicking
+“CHERRY PICK,” Gerrit will create new changes all targeting the entered
+branch in their respective repositories, and these new changes will all be
+associated with a new, uniquely-generated topic name.
+
+To submit the cherry-picked changes, go through the normal submission
+process.
+
+|===
+| NOTE: You cannot cherry pick two or more changes that all target the same
+ repository from the Gerrit UI at this time; you’ll get an error message saying
+ “changes cannot be of the same repository.” To accomplish this, you’d
+ need to do the cherry-pick locally.
+|===
+
+== Searching for Topics
+
+In the Gerrit search bar, you can search for changes attached to a specific
+topic using the `topic` operator e.g. `topic:MY_TOPIC_NAME`. The `intopic`
+operator works similary but supports free-text and regular expression search.
+
+You can also search for a submission using the `submissionid` operator. Topic
+submission IDs are "<id>-<topic>" where id is the change number of the change
+that triggered the submission (though this could change in the future). As a
+full example, if the topic name is my-topic and change 12345 was the one that
+triggered submission, you could find it with `submissionid:12345-my-topic`.
+
diff --git a/Documentation/images/cross-repository-changes-add-topic.png b/Documentation/images/cross-repository-changes-add-topic.png
new file mode 100644
index 0000000..fc85b8f
--- /dev/null
+++ b/Documentation/images/cross-repository-changes-add-topic.png
Binary files differ
diff --git a/Documentation/images/cross-repository-changes-cp-menu.png b/Documentation/images/cross-repository-changes-cp-menu.png
new file mode 100644
index 0000000..e9004f8
--- /dev/null
+++ b/Documentation/images/cross-repository-changes-cp-menu.png
Binary files differ
diff --git a/Documentation/images/cross-repository-changes-cp-modal.png b/Documentation/images/cross-repository-changes-cp-modal.png
new file mode 100644
index 0000000..a4790fb
--- /dev/null
+++ b/Documentation/images/cross-repository-changes-cp-modal.png
Binary files differ
diff --git a/Documentation/images/cross-repository-changes-example.png b/Documentation/images/cross-repository-changes-example.png
new file mode 100644
index 0000000..e790f71
--- /dev/null
+++ b/Documentation/images/cross-repository-changes-example.png
Binary files differ
diff --git a/Documentation/images/cross-repository-changes-revert-topic-options.png b/Documentation/images/cross-repository-changes-revert-topic-options.png
new file mode 100644
index 0000000..f2e9f1a
--- /dev/null
+++ b/Documentation/images/cross-repository-changes-revert-topic-options.png
Binary files differ
diff --git a/Documentation/images/cross-repository-changes-revert-topic.png b/Documentation/images/cross-repository-changes-revert-topic.png
new file mode 100644
index 0000000..8d87191
--- /dev/null
+++ b/Documentation/images/cross-repository-changes-revert-topic.png
Binary files differ
diff --git a/Documentation/images/cross-repository-changes-submit-topic.png b/Documentation/images/cross-repository-changes-submit-topic.png
new file mode 100644
index 0000000..7e96743
--- /dev/null
+++ b/Documentation/images/cross-repository-changes-submit-topic.png
Binary files differ
diff --git a/Documentation/images/cross-repository-changes-submitted-together.png b/Documentation/images/cross-repository-changes-submitted-together.png
new file mode 100644
index 0000000..e7ea334
--- /dev/null
+++ b/Documentation/images/cross-repository-changes-submitted-together.png
Binary files differ
diff --git a/Documentation/images/cross-repository-changes-topic.png b/Documentation/images/cross-repository-changes-topic.png
new file mode 100644
index 0000000..12d0e38
--- /dev/null
+++ b/Documentation/images/cross-repository-changes-topic.png
Binary files differ
diff --git a/Documentation/index.txt b/Documentation/index.txt
index 8f36ecc..164039b 100644
--- a/Documentation/index.txt
+++ b/Documentation/index.txt
@@ -38,6 +38,7 @@
 ... link:user-changeid.html[Change-Id Lines]
 ... link:user-signedoffby.html[Signed-off-by Lines]
 ... link:user-change-cleanup.html[Change Cleanup]
+... link:cross-repository-changes.html[Cross Repository Changes using Topics]
 
 == Project Management
 . link:project-configuration.html[Project Configuration]
@@ -78,6 +79,7 @@
 . link:note-db.html[NoteDb]
 . link:config-accounts.html[Accounts on NoteDb]
 . link:config-groups.html[Groups on NoteDb]
+. link:user-privacy.html[User data and privacy]
 
 == Concepts
 . link:config-labels.html[Review Labels]
diff --git a/Documentation/intro-user.txt b/Documentation/intro-user.txt
index eb2025c..0408d5d 100644
--- a/Documentation/intro-user.txt
+++ b/Documentation/intro-user.txt
@@ -514,6 +514,9 @@
   $ git push origin HEAD:refs/heads/master -o topic=multi-master
 ----
 
+For more information about using topics, see the user guide:
+link:cross-repository-changes.html[Submitting Changes Across Repositories by using Topics].
+
 [[hashtags]]
 == Using Hashtags
 
diff --git a/Documentation/user-privacy.txt b/Documentation/user-privacy.txt
new file mode 100644
index 0000000..d61ee76
--- /dev/null
+++ b/Documentation/user-privacy.txt
@@ -0,0 +1,113 @@
+:linkattrs:
+= Gerrit Code Review - User Privacy
+
+== Purpose
+
+This page documents how Gerrit handles user data.
+
+|===
+| Note: Gerrit has extensive support for link:config-plugins.html[plugins]
+  which extend Gerrits functionality, and these plugins could access, export, or
+  maniuplate user data. This document only focuses on the behavior of Gerrit
+  core and its link:dev-core-plugins.html[core plugins].
+|===
+
+== Types of User Data
+
+Gerrit stores account data required for collaborating on source code changes.
+This data is described by
+link:config-accounts.html#account-data-in-user-branch[Account Data in User
+Branch] and includes link:config-accounts.html#external-ids[External IDs],
+link:config-accounts.html#preferences[User Preferences],
+link:config-accounts.html#project-watches[Project Watches] and personally
+identifiable information, including  name and email address. The email
+address is required to associate Git commits with a Gerrit user account. All
+data except passwords is made accessible to other users who you are visible to,
+as detailed below.
+
+== User Visibility
+
+Gerrit has a concept of link:config-gerrit.html#accounts[account visibility]
+which determines what users a given user can see. This visibility configuration
+applies in account search, reviewer suggestion, and when accessing data through
+the link:rest-api-accounts.html#account-endpoints[Account REST endpoints]. If
+you can see a user, you have read access to most of the
+link:rest-api-accounts.html#account-info[AccountInfo] for that user, including
+name and email address. Additional information, including secondary emails, is
+included in AccountInfo if the caller has “Modify Account” permissions.
+
+Additionally, all users on a change (author, cc’d, reviewer) can see each other,
+irrespective of the  account visibility settings. For example: Say you are a
+reviewer on a change where user Foo is also a reviewer. Even if by account
+visibility you could not search for Foo, you'd still see their avatar, name,
+and email now because you can see the change; this information is required to
+collaborate on a code review. If Foo wasn't on that change, you could not add
+them because reviewer suggestions would not find them due to the account
+visibility settings.
+
+By default, account visibility on a Gerrit instance is set to `ALL` which allows
+all users to be visible to other users, even anonymous (i.e. unauthenticated)
+users. Depending on your installation type, you may want to change this:
+
+* For completely company-internal Gerrit installations (no external users), the
+`ALL` default may make sense.
+
+* If you work with multiple vendors who have
+access to their own independent sets of repos, `VISIBLE_GROUP` may be more
+appropriate as you wouldn’t want vendor A to see accounts from vendor B.
+
+* For public installations, e.g. for open source projects, you may want to
+change this setting or add a notice for users when they create an account e.g.
+“Most of what you submit on this site, including your email address and name,
+will be visible to others who use this service. You may prefer to use an email
+account specifically for this purpose.” One way to do this is using
+link:config-gerrit.html[`auth.registerPageUrl`] in `gerrit.config`.
+
+== ACLs and User Visibility
+
+User suggestions for changes, when adding a reviewer or cc-ing someone, always
+respect ACLs for that change: only users who can see the change are suggested.
+The suggested users are an intersection of who you can see and who can see the
+change.
+
+Consider the following situation:
+
+* `READ` permission for Registered Users on the host
+* User visibility is set to `VISIBILE_GROUP`, so only users of the same domain can
+  see each other
+* a@foo.com creates change 123
+
+This would mean:
+
+* a@foo.com cannot add b@bar.com to the change because these users cannot see
+  each other due to the user visibility setting.
+* b@bar.com can find change 123
+  because they have READ permission and could add themselves to the change.
+* a@foo.com would then be able to see b@bar.com’s name, avatar, and email on
+  change 123
+
+The only caveat to the above are Private Changes, which are only visible to the
+owner and reviewers; reviewers can only see the change once they are added to
+the change (if ACLs allow them to be added in the first place), not before.
+
+## Right to be Forgotten Limitations
+
+As a source control system, Gerrit has limited abilities to remove personally
+identifiable information. Notably, Gerrit cannot:
+
+* Remove a user's e-mail from all existing commits
+* Remove a user's username
+
+There is also a known
+link:https://bugs.chromium.org/p/gerrit/issues/detail?id=14185[bug] where a
+user's username is stored in metadata for link:user-attention-set.html[Attention
+Set].
+
+
+## Open Source Software Limitations
+
+Gerrit is open-source software licensed under the Apache 2.0 license.  Unless
+required by applicable law or agreed to in writing, software distributed under
+the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS
+OF ANY KIND, either express or implied. See the License for the specific
+language governing permissions and limitations under the License.
\ No newline at end of file
diff --git a/java/com/google/gerrit/acceptance/ExtensionRegistry.java b/java/com/google/gerrit/acceptance/ExtensionRegistry.java
index 5d01dcb..35f8ce6 100644
--- a/java/com/google/gerrit/acceptance/ExtensionRegistry.java
+++ b/java/com/google/gerrit/acceptance/ExtensionRegistry.java
@@ -39,6 +39,7 @@
 import com.google.gerrit.server.change.ChangeETagComputation;
 import com.google.gerrit.server.config.ProjectConfigEntry;
 import com.google.gerrit.server.git.ChangeMessageModifier;
+import com.google.gerrit.server.git.receive.PluginPushOption;
 import com.google.gerrit.server.git.validators.CommitValidationListener;
 import com.google.gerrit.server.git.validators.OnSubmitValidationListener;
 import com.google.gerrit.server.git.validators.RefOperationValidationListener;
@@ -84,6 +85,7 @@
   private final DynamicMap<CapabilityDefinition> capabilityDefinitions;
   private final DynamicMap<PluginProjectPermissionDefinition> pluginProjectPermissionDefinitions;
   private final DynamicMap<ProjectConfigEntry> pluginConfigEntries;
+  private final DynamicSet<PluginPushOption> pluginPushOptions;
   private final DynamicSet<OnPostReview> onPostReviews;
 
   @Inject
@@ -116,6 +118,7 @@
       DynamicMap<CapabilityDefinition> capabilityDefinitions,
       DynamicMap<PluginProjectPermissionDefinition> pluginProjectPermissionDefinitions,
       DynamicMap<ProjectConfigEntry> pluginConfigEntries,
+      DynamicSet<PluginPushOption> pluginPushOption,
       DynamicSet<OnPostReview> onPostReviews) {
     this.accountIndexedListeners = accountIndexedListeners;
     this.changeIndexedListeners = changeIndexedListeners;
@@ -145,6 +148,7 @@
     this.capabilityDefinitions = capabilityDefinitions;
     this.pluginProjectPermissionDefinitions = pluginProjectPermissionDefinitions;
     this.pluginConfigEntries = pluginConfigEntries;
+    this.pluginPushOptions = pluginPushOption;
     this.onPostReviews = onPostReviews;
   }
 
@@ -274,6 +278,10 @@
       return add(pluginConfigEntries, pluginConfigEntry, exportName);
     }
 
+    public Registration add(PluginPushOption pluginPushOption) {
+      return add(pluginPushOptions, pluginPushOption);
+    }
+
     public Registration add(OnPostReview onPostReview) {
       return add(onPostReviews, onPostReview);
     }
diff --git a/java/com/google/gerrit/server/change/ChangeInserter.java b/java/com/google/gerrit/server/change/ChangeInserter.java
index 6091091..2b49a06 100644
--- a/java/com/google/gerrit/server/change/ChangeInserter.java
+++ b/java/com/google/gerrit/server/change/ChangeInserter.java
@@ -25,6 +25,7 @@
 
 import com.google.common.base.MoreObjects;
 import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableListMultimap;
 import com.google.common.collect.Iterables;
 import com.google.common.collect.Streams;
 import com.google.common.flogger.FluentLogger;
@@ -546,6 +547,7 @@
               cmd,
               projectState.getProject(),
               change.getDest().branch(),
+              ImmutableListMultimap.of(),
               ctx.getRepoView().getConfig(),
               ctx.getRevWalk().getObjectReader(),
               commitId,
diff --git a/java/com/google/gerrit/server/change/PatchSetInserter.java b/java/com/google/gerrit/server/change/PatchSetInserter.java
index ef06ea1..4c1e9fb 100644
--- a/java/com/google/gerrit/server/change/PatchSetInserter.java
+++ b/java/com/google/gerrit/server/change/PatchSetInserter.java
@@ -20,6 +20,7 @@
 import static com.google.gerrit.server.project.ProjectCache.illegalState;
 import static java.util.Objects.requireNonNull;
 
+import com.google.common.collect.ImmutableListMultimap;
 import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.entities.Change;
 import com.google.gerrit.entities.ChangeMessage;
@@ -342,6 +343,7 @@
                 .orElseThrow(illegalState(origNotes.getProjectName()))
                 .getProject(),
             origNotes.getChange().getDest().branch(),
+            ImmutableListMultimap.of(),
             ctx.getRepoView().getConfig(),
             ctx.getRevWalk().getObjectReader(),
             commitId,
diff --git a/java/com/google/gerrit/server/config/GerritGlobalModule.java b/java/com/google/gerrit/server/config/GerritGlobalModule.java
index 52de9d5..3da3fd3 100644
--- a/java/com/google/gerrit/server/config/GerritGlobalModule.java
+++ b/java/com/google/gerrit/server/config/GerritGlobalModule.java
@@ -130,6 +130,7 @@
 import com.google.gerrit.server.git.ReceivePackInitializer;
 import com.google.gerrit.server.git.TagCache;
 import com.google.gerrit.server.git.TransferConfig;
+import com.google.gerrit.server.git.receive.PluginPushOption;
 import com.google.gerrit.server.git.receive.ReceiveCommitsModule;
 import com.google.gerrit.server.git.validators.CommentCountValidator;
 import com.google.gerrit.server.git.validators.CommentCumulativeSizeValidator;
@@ -388,6 +389,7 @@
         .toInstance(SuggestReviewers.configListener());
     DynamicSet.setOf(binder(), ExternalIncludedIn.class);
     DynamicMap.mapOf(binder(), ProjectConfigEntry.class);
+    DynamicSet.setOf(binder(), PluginPushOption.class);
     DynamicSet.setOf(binder(), PatchSetWebLink.class);
     DynamicSet.setOf(binder(), ParentWebLink.class);
     DynamicSet.setOf(binder(), FileWebLink.class);
diff --git a/java/com/google/gerrit/server/events/CommitReceivedEvent.java b/java/com/google/gerrit/server/events/CommitReceivedEvent.java
index eb4d9ee..de355ea 100644
--- a/java/com/google/gerrit/server/events/CommitReceivedEvent.java
+++ b/java/com/google/gerrit/server/events/CommitReceivedEvent.java
@@ -14,6 +14,7 @@
 
 package com.google.gerrit.server.events;
 
+import com.google.common.collect.ImmutableListMultimap;
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.server.IdentifiedUser;
 import java.io.IOException;
@@ -29,6 +30,7 @@
   public ReceiveCommand command;
   public Project project;
   public String refName;
+  public ImmutableListMultimap<String, String> pushOptions;
   public Config repoConfig;
   public RevWalk revWalk;
   public RevCommit commit;
@@ -42,6 +44,7 @@
       ReceiveCommand command,
       Project project,
       String refName,
+      ImmutableListMultimap<String, String> pushOptions,
       Config repoConfig,
       ObjectReader reader,
       ObjectId commitId,
@@ -51,6 +54,7 @@
     this.command = command;
     this.project = project;
     this.refName = refName;
+    this.pushOptions = pushOptions;
     this.repoConfig = repoConfig;
     this.revWalk = new RevWalk(reader);
     this.commit = revWalk.parseCommit(commitId);
diff --git a/java/com/google/gerrit/server/git/receive/BranchCommitValidator.java b/java/com/google/gerrit/server/git/receive/BranchCommitValidator.java
index 55261223..f680b7b 100644
--- a/java/com/google/gerrit/server/git/receive/BranchCommitValidator.java
+++ b/java/com/google/gerrit/server/git/receive/BranchCommitValidator.java
@@ -19,6 +19,7 @@
 
 import com.google.auto.value.AutoValue;
 import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableListMultimap;
 import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.BranchNameKey;
@@ -109,12 +110,13 @@
       ObjectReader objectReader,
       ReceiveCommand cmd,
       RevCommit commit,
+      ImmutableListMultimap<String, String> pushOptions,
       boolean isMerged,
       NoteMap rejectCommits,
       @Nullable Change change)
       throws IOException {
     return validateCommit(
-        repository, objectReader, cmd, commit, isMerged, rejectCommits, change, false);
+        repository, objectReader, cmd, commit, pushOptions, isMerged, rejectCommits, change, false);
   }
 
   /**
@@ -134,6 +136,7 @@
       ObjectReader objectReader,
       ReceiveCommand cmd,
       RevCommit commit,
+      ImmutableListMultimap<String, String> pushOptions,
       boolean isMerged,
       NoteMap rejectCommits,
       @Nullable Change change,
@@ -146,6 +149,7 @@
               cmd,
               project,
               branch.branch(),
+              pushOptions,
               new Config(repository.getConfig()),
               objectReader,
               commit,
diff --git a/java/com/google/gerrit/server/git/receive/PluginPushOption.java b/java/com/google/gerrit/server/git/receive/PluginPushOption.java
new file mode 100644
index 0000000..788df70
--- /dev/null
+++ b/java/com/google/gerrit/server/git/receive/PluginPushOption.java
@@ -0,0 +1,29 @@
+// Copyright (C) 2021 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.git.receive;
+
+/**
+ * Push option that can be specified on push.
+ *
+ * <p>On push the option has to be specified as {@code -o <pluginName>~<name>=<value>}, or if a
+ * value is not required as {@code -o <pluginName>~<name>}.
+ */
+public interface PluginPushOption {
+  /** The name of the push option. */
+  public String getName();
+
+  /** The description of the push option. */
+  public String getDescription();
+}
diff --git a/java/com/google/gerrit/server/git/receive/ReceiveCommits.java b/java/com/google/gerrit/server/git/receive/ReceiveCommits.java
index 4c90ef9..4600f6d 100644
--- a/java/com/google/gerrit/server/git/receive/ReceiveCommits.java
+++ b/java/com/google/gerrit/server/git/receive/ReceiveCommits.java
@@ -17,6 +17,7 @@
 import static com.google.common.base.MoreObjects.firstNonNull;
 import static com.google.common.base.Preconditions.checkState;
 import static com.google.common.collect.ImmutableList.toImmutableList;
+import static com.google.common.collect.ImmutableListMultimap.toImmutableListMultimap;
 import static com.google.common.collect.ImmutableSet.toImmutableSet;
 import static com.google.common.flogger.LazyArgs.lazy;
 import static com.google.gerrit.entities.RefNames.REFS_CHANGES;
@@ -48,6 +49,7 @@
 import com.google.common.collect.BiMap;
 import com.google.common.collect.HashBiMap;
 import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableListMultimap;
 import com.google.common.collect.ImmutableMap;
 import com.google.common.collect.ImmutableSet;
 import com.google.common.collect.ImmutableSetMultimap;
@@ -211,6 +213,7 @@
 import java.util.concurrent.Future;
 import java.util.stream.Collectors;
 import java.util.stream.Stream;
+import java.util.stream.StreamSupport;
 import org.eclipse.jgit.errors.ConfigInvalidException;
 import org.eclipse.jgit.errors.IncorrectObjectTypeException;
 import org.eclipse.jgit.errors.MissingObjectException;
@@ -326,6 +329,7 @@
   private final CreateGroupPermissionSyncer createGroupPermissionSyncer;
   private final CreateRefControl createRefControl;
   private final DynamicMap<ProjectConfigEntry> pluginConfigEntries;
+  private final DynamicSet<PluginPushOption> pluginPushOptions;
   private final PluginSetContext<ReceivePackInitializer> initializers;
   private final MergedByPushOp.Factory mergedByPushOpFactory;
   private final PatchSetInfoFactory patchSetInfoFactory;
@@ -408,6 +412,7 @@
       CreateGroupPermissionSyncer createGroupPermissionSyncer,
       CreateRefControl createRefControl,
       DynamicMap<ProjectConfigEntry> pluginConfigEntries,
+      DynamicSet<PluginPushOption> pluginPushOptions,
       PluginSetContext<ReceivePackInitializer> initializers,
       PluginSetContext<CommentValidator> commentValidators,
       MergedByPushOp.Factory mergedByPushOpFactory,
@@ -467,6 +472,7 @@
     this.patchSetInfoFactory = patchSetInfoFactory;
     this.permissionBackend = permissionBackend;
     this.pluginConfigEntries = pluginConfigEntries;
+    this.pluginPushOptions = pluginPushOptions;
     this.projectCache = projectCache;
     this.psUtil = psUtil;
     this.performanceLoggers = performanceLoggers;
@@ -1788,8 +1794,13 @@
       String ref;
       magicBranch.cmdLineParser = optionParserFactory.create(magicBranch);
 
+      // Filter out plugin push options, as the parser would reject them as unknown.
+      ImmutableListMultimap<String, String> pushOptionsToParse =
+          pushOptions.entries().stream()
+              .filter(e -> !isPluginPushOption(e.getKey()))
+              .collect(toImmutableListMultimap(e -> e.getKey(), e -> e.getValue()));
       try {
-        ref = magicBranch.parse(pushOptions);
+        ref = magicBranch.parse(pushOptionsToParse);
       } catch (CmdLineException e) {
         if (!magicBranch.cmdLineParser.wasHelpRequestedByOption()) {
           logger.atFine().log("Invalid branch syntax");
@@ -1808,6 +1819,20 @@
         StringWriter w = new StringWriter();
         w.write("\nHelp for refs/for/branch:\n\n");
         magicBranch.cmdLineParser.printUsage(w, null);
+
+        String pluginPushOptionsHelp =
+            StreamSupport.stream(pluginPushOptions.entries().spliterator(), /* parallel= */ false)
+                .map(
+                    e ->
+                        String.format(
+                            "-o %s~%s: %s",
+                            e.getPluginName(), e.get().getName(), e.get().getDescription()))
+                .sorted()
+                .collect(joining("\n"));
+        if (!pluginPushOptionsHelp.isEmpty()) {
+          w.write("\nPlugin push options:\n" + pluginPushOptionsHelp);
+        }
+
         addMessage(w.toString());
         reject(cmd, "see help");
         return;
@@ -1972,6 +1997,11 @@
     }
   }
 
+  private boolean isPluginPushOption(String pushOptionName) {
+    return StreamSupport.stream(pluginPushOptions.entries().spliterator(), /* parallel= */ false)
+        .anyMatch(e -> pushOptionName.equals(e.getPluginName() + "~" + e.get().getName()));
+  }
+
   // Validate that the new commits are connected with the target
   // branch.  If they aren't, we want to abort. We do this check by
   // looking to see if we can compute a merge base between the new
@@ -2219,6 +2249,7 @@
                   receivePack.getRevWalk().getObjectReader(),
                   magicBranch.cmd,
                   c,
+                  ImmutableListMultimap.copyOf(pushOptions),
                   magicBranch.merged,
                   rejectCommits,
                   null);
@@ -3231,7 +3262,15 @@
 
           BranchCommitValidator.Result validationResult =
               validator.validateCommit(
-                  repo, walk.getObjectReader(), cmd, c, false, rejectCommits, null, skipValidation);
+                  repo,
+                  walk.getObjectReader(),
+                  cmd,
+                  c,
+                  ImmutableListMultimap.copyOf(pushOptions),
+                  false,
+                  rejectCommits,
+                  null,
+                  skipValidation);
           messages.addAll(validationResult.messages());
           if (!validationResult.isValid()) {
             break;
diff --git a/javatests/com/google/gerrit/acceptance/git/AbstractPushForReview.java b/javatests/com/google/gerrit/acceptance/git/AbstractPushForReview.java
index 845c461..eac0f1b 100644
--- a/javatests/com/google/gerrit/acceptance/git/AbstractPushForReview.java
+++ b/javatests/com/google/gerrit/acceptance/git/AbstractPushForReview.java
@@ -62,6 +62,7 @@
 import com.google.gerrit.acceptance.config.GerritConfig;
 import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
 import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
+import com.google.gerrit.common.Nullable;
 import com.google.gerrit.common.data.GlobalCapability;
 import com.google.gerrit.entities.AccountGroup;
 import com.google.gerrit.entities.Address;
@@ -100,6 +101,7 @@
 import com.google.gerrit.server.ChangeMessagesUtil;
 import com.google.gerrit.server.events.CommitReceivedEvent;
 import com.google.gerrit.server.git.receive.NoteDbPushOption;
+import com.google.gerrit.server.git.receive.PluginPushOption;
 import com.google.gerrit.server.git.receive.ReceiveConstants;
 import com.google.gerrit.server.git.validators.CommitValidationListener;
 import com.google.gerrit.server.git.validators.CommitValidationMessage;
@@ -2364,6 +2366,8 @@
     private final AtomicInteger count = new AtomicInteger();
     private final boolean validateAll;
 
+    @Nullable private CommitReceivedEvent receivedEvent;
+
     TestValidator(boolean validateAll) {
       this.validateAll = validateAll;
     }
@@ -2373,7 +2377,8 @@
     }
 
     @Override
-    public List<CommitValidationMessage> onCommitReceived(CommitReceivedEvent receiveEvent) {
+    public List<CommitValidationMessage> onCommitReceived(CommitReceivedEvent receivedEvent) {
+      this.receivedEvent = receivedEvent;
       count.incrementAndGet();
       return Collections.emptyList();
     }
@@ -2386,6 +2391,31 @@
     public int count() {
       return count.get();
     }
+
+    @Nullable
+    public CommitReceivedEvent getReceivedEvent() {
+      return receivedEvent;
+    }
+  }
+
+  private static class TestPluginPushOption implements PluginPushOption {
+    private final String name;
+    private final String description;
+
+    TestPluginPushOption(String name, String description) {
+      this.name = name;
+      this.description = description;
+    }
+
+    @Override
+    public String getName() {
+      return name;
+    }
+
+    @Override
+    public String getDescription() {
+      return description;
+    }
   }
 
   private static class TopicValidator implements TopicEditedListener {
@@ -2449,6 +2479,38 @@
   }
 
   @Test
+  public void pushOptionsArePassedToCommitValidationListener() throws Exception {
+    TestValidator validator = new TestValidator();
+    PluginPushOption fooOption = new TestPluginPushOption("foo", "some description");
+    PluginPushOption barOption = new TestPluginPushOption("bar", "other description");
+    try (Registration registration =
+        extensionRegistry.newRegistration().add(validator).add(fooOption).add(barOption)) {
+      PushOneCommit push =
+          pushFactory.create(admin.newIdent(), testRepo, "change2", "b.txt", "content");
+      push.setPushOptions(ImmutableList.of("trace=123", "gerrit~foo", "gerrit~bar=456"));
+      PushOneCommit.Result r = push.to("refs/for/master");
+      r.assertOkStatus();
+      assertThat(validator.getReceivedEvent().pushOptions)
+          .containsExactly("trace", "123", "gerrit~foo", "", "gerrit~bar", "456");
+    }
+  }
+
+  @Test
+  public void pluginPushOptionsHelp() throws Exception {
+    PluginPushOption fooOption = new TestPluginPushOption("foo", "some description");
+    PluginPushOption barOption = new TestPluginPushOption("bar", "other description");
+    try (Registration registration =
+        extensionRegistry.newRegistration().add(fooOption).add(barOption)) {
+      PushOneCommit push =
+          pushFactory.create(admin.newIdent(), testRepo, "change2", "b.txt", "content");
+      push.setPushOptions(ImmutableList.of("help"));
+      PushOneCommit.Result r = push.to("refs/for/master");
+      r.assertErrorStatus("see help");
+      r.assertMessage("-o gerrit~bar: other description\n-o gerrit~foo: some description\n");
+    }
+  }
+
+  @Test
   public void pushNoteDbRef() throws Exception {
     String ref = "refs/changes/34/1234/meta";
     RevCommit c = testRepo.commit().message("Junk NoteDb commit").create();
diff --git a/polygerrit-ui/app/elements/change/gr-change-requirements/gr-change-requirements_html.ts b/polygerrit-ui/app/elements/change/gr-change-requirements/gr-change-requirements_html.ts
index d1c780d..678fa5c 100644
--- a/polygerrit-ui/app/elements/change/gr-change-requirements/gr-change-requirements_html.ts
+++ b/polygerrit-ui/app/elements/change/gr-change-requirements/gr-change-requirements_html.ts
@@ -51,8 +51,7 @@
       float: right;
     }
     .title {
-      /* 225 is length of gr-limited-text with limit 25 */
-      width: 225px;
+      min-width: 10em;
       padding: var(--spacing-s) var(--spacing-m) 0
         var(--requirements-horizontal-padding);
     }
diff --git a/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog.ts b/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog.ts
index 7fb09df..e0e2e46 100644
--- a/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog.ts
+++ b/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog.ts
@@ -21,7 +21,6 @@
 import '../../shared/gr-button/gr-button';
 import '../../shared/gr-formatted-text/gr-formatted-text';
 import '../../shared/gr-overlay/gr-overlay';
-import '../../shared/gr-storage/gr-storage';
 import '../../shared/gr-account-list/gr-account-list';
 import '../gr-label-scores/gr-label-scores';
 import '../gr-thread-list/gr-thread-list';
@@ -98,7 +97,6 @@
 import {GrTextarea} from '../../shared/gr-textarea/gr-textarea';
 import {GrAccountChip} from '../../shared/gr-account-chip/gr-account-chip';
 import {GrOverlay} from '../../shared/gr-overlay/gr-overlay';
-import {GrStorage, StorageLocation} from '../../shared/gr-storage/gr-storage';
 import {isAttentionSetEnabled} from '../../../utils/attention-set-util';
 import {
   CODE_REVIEW,
@@ -114,6 +112,7 @@
 } from '../../../utils/event-util';
 import {ErrorCallback} from '../../../api/rest';
 import {debounce, DelayedTask} from '../../../utils/async-util';
+import {StorageLocation} from '../../../services/storage/gr-storage';
 
 const STORAGE_DEBOUNCE_INTERVAL_MS = 400;
 
@@ -374,7 +373,7 @@
 
   private readonly restApiService = appContext.restApiService;
 
-  private readonly storage = new GrStorage();
+  private readonly storage = appContext.storageService;
 
   private readonly jsAPI = appContext.jsApiService;
 
diff --git a/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog_test.js b/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog_test.js
index d0d40ca..c1e2564 100644
--- a/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog_test.js
+++ b/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog_test.js
@@ -18,7 +18,7 @@
 import '../../../test/common-test-setup-karma.js';
 import {IronOverlayManager} from '@polymer/iron-overlay-behavior/iron-overlay-manager.js';
 import './gr-reply-dialog.js';
-import {mockPromise} from '../../../test/test-utils.js';
+import {mockPromise, stubStorage} from '../../../test/test-utils.js';
 import {SpecialFilePath} from '../../../constants/constants.js';
 import {appContext} from '../../../services/app-context.js';
 import {addListenerForTest} from '../../../test/test-utils.js';
@@ -113,9 +113,9 @@
       ],
     };
 
-    getDraftCommentStub = sinon.stub(element.storage, 'getDraftComment');
-    setDraftCommentStub = sinon.stub(element.storage, 'setDraftComment');
-    eraseDraftCommentStub = sinon.stub(element.storage, 'eraseDraftComment');
+    getDraftCommentStub = stubStorage('getDraftComment');
+    setDraftCommentStub = stubStorage('setDraftComment');
+    eraseDraftCommentStub = stubStorage('eraseDraftComment');
 
     // sinon.stub(patchSetUtilMockProxy, 'fetchChangeUpdates')
     //     .returns(Promise.resolve({isLatest: true}));
diff --git a/polygerrit-ui/app/elements/edit/gr-editor-view/gr-editor-view.ts b/polygerrit-ui/app/elements/edit/gr-editor-view/gr-editor-view.ts
index 7c4e8d6..fb8c2e0 100644
--- a/polygerrit-ui/app/elements/edit/gr-editor-view/gr-editor-view.ts
+++ b/polygerrit-ui/app/elements/edit/gr-editor-view/gr-editor-view.ts
@@ -18,7 +18,6 @@
 import '../../plugins/gr-endpoint-param/gr-endpoint-param';
 import '../../shared/gr-button/gr-button';
 import '../../shared/gr-editable-label/gr-editable-label';
-import '../../shared/gr-storage/gr-storage';
 import '../gr-default-editor/gr-default-editor';
 import '../../../styles/shared-styles';
 import {PolymerElement} from '@polymer/polymer/polymer-element';
@@ -38,7 +37,6 @@
   NumericChangeId,
   EditPatchSetNum,
 } from '../../../types/common';
-import {GrStorage} from '../../shared/gr-storage/gr-storage';
 import {HttpMethod, NotifyType} from '../../../constants/constants';
 import {fireAlert, fireTitleChange} from '../../../utils/event-util';
 import {appContext} from '../../../services/app-context';
@@ -117,7 +115,7 @@
 
   private readonly restApiService = appContext.restApiService;
 
-  private readonly storage = new GrStorage();
+  private readonly storage = appContext.storageService;
 
   private storeTask?: DelayedTask;
 
diff --git a/polygerrit-ui/app/elements/shared/gr-change-star/gr-change-star_test.js b/polygerrit-ui/app/elements/shared/gr-change-star/gr-change-star_test.js
deleted file mode 100644
index f05ed21..0000000
--- a/polygerrit-ui/app/elements/shared/gr-change-star/gr-change-star_test.js
+++ /dev/null
@@ -1,72 +0,0 @@
-/**
- * @license
- * Copyright (C) 2015 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-import '../../../test/common-test-setup-karma.js';
-import './gr-change-star.js';
-
-const basicFixture = fixtureFromElement('gr-change-star');
-
-suite('gr-change-star tests', () => {
-  let element;
-
-  setup(() => {
-    element = basicFixture.instantiate();
-    element.change = {
-      _number: 2,
-      starred: true,
-    };
-  });
-
-  test('star visibility states', () => {
-    element.set('change.starred', true);
-    let icon = element.shadowRoot
-        .querySelector('iron-icon');
-    assert.isTrue(icon.classList.contains('active'));
-    assert.equal(icon.icon, 'gr-icons:star');
-
-    element.set('change.starred', false);
-    icon = element.shadowRoot
-        .querySelector('iron-icon');
-    assert.isFalse(icon.classList.contains('active'));
-    assert.equal(icon.icon, 'gr-icons:star-border');
-  });
-
-  test('starring', async () => {
-    let resolve;
-    const promise = new Promise(r => resolve = r);
-    element.addEventListener('toggle-star', resolve);
-    element.set('change.starred', false);
-    MockInteractions.tap(element.shadowRoot
-        .querySelector('button'));
-
-    await promise;
-    assert.equal(element.change.starred, true);
-  });
-
-  test('unstarring', async () => {
-    let resolve;
-    const promise = new Promise(r => resolve = r);
-    element.addEventListener('toggle-star', resolve);
-    element.set('change.starred', true);
-    MockInteractions.tap(element.shadowRoot
-        .querySelector('button'));
-
-    await promise;
-    assert.equal(element.change.starred, false);
-  });
-});
-
diff --git a/polygerrit-ui/app/elements/shared/gr-change-star/gr-change-star_test.ts b/polygerrit-ui/app/elements/shared/gr-change-star/gr-change-star_test.ts
new file mode 100644
index 0000000..8f411ae
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-change-star/gr-change-star_test.ts
@@ -0,0 +1,71 @@
+/**
+ * @license
+ * Copyright (C) 2015 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import {IronIconElement} from '@polymer/iron-icon';
+import '../../../test/common-test-setup-karma';
+import {queryAndAssert} from '../../../test/test-utils';
+import {GrChangeStar} from './gr-change-star';
+import * as MockInteractions from '@polymer/iron-test-helpers/mock-interactions';
+import {createChange} from '../../../test/test-data-generators';
+
+const basicFixture = fixtureFromElement('gr-change-star');
+
+suite('gr-change-star tests', () => {
+  let element: GrChangeStar;
+
+  setup(() => {
+    element = basicFixture.instantiate();
+    element.change = {
+      ...createChange(),
+      starred: true,
+    };
+  });
+
+  test('star visibility states', async () => {
+    element.set('change.starred', true);
+    await flush();
+    let icon = queryAndAssert<IronIconElement>(element, 'iron-icon');
+    assert.isTrue(icon.classList.contains('active'));
+    assert.equal(icon.icon, 'gr-icons:star');
+
+    element.set('change.starred', false);
+    await flush();
+    icon = queryAndAssert<IronIconElement>(element, 'iron-icon');
+    assert.isFalse(icon.classList.contains('active'));
+    assert.equal(icon.icon, 'gr-icons:star-border');
+  });
+
+  test('starring', async () => {
+    element.set('change.starred', false);
+    await flush();
+    assert.equal(element.change!.starred, false);
+
+    MockInteractions.tap(queryAndAssert(element, 'button'));
+    await flush();
+    assert.equal(element.change!.starred, true);
+  });
+
+  test('unstarring', async () => {
+    element.set('change.starred', true);
+    await flush();
+    assert.equal(element.change!.starred, true);
+
+    MockInteractions.tap(queryAndAssert(element, 'button'));
+    await flush();
+    assert.equal(element.change!.starred, false);
+  });
+});
diff --git a/polygerrit-ui/app/elements/shared/gr-comment-thread/gr-comment-thread.ts b/polygerrit-ui/app/elements/shared/gr-comment-thread/gr-comment-thread.ts
index a57bdfa..7e67fb8 100644
--- a/polygerrit-ui/app/elements/shared/gr-comment-thread/gr-comment-thread.ts
+++ b/polygerrit-ui/app/elements/shared/gr-comment-thread/gr-comment-thread.ts
@@ -15,7 +15,6 @@
  * limitations under the License.
  */
 import '../../../styles/shared-styles';
-import '../gr-storage/gr-storage';
 import '../gr-comment/gr-comment';
 import '../../diff/gr-diff/gr-diff';
 import {dom, EventApi} from '@polymer/polymer/lib/legacy/polymer.dom';
@@ -51,7 +50,6 @@
 } from '../../../types/common';
 import {GrComment} from '../gr-comment/gr-comment';
 import {PolymerDeepPropertyChange} from '@polymer/polymer/interfaces';
-import {GrStorage, StorageLocation} from '../gr-storage/gr-storage';
 import {CustomKeyboardEvent} from '../../../types/events';
 import {LineNumber, FILE} from '../../diff/gr-diff/gr-diff-line';
 import {GrButton} from '../gr-button/gr-button';
@@ -61,6 +59,7 @@
 import {check, assertIsDefined} from '../../../utils/common-util';
 import {waitForEventOnce} from '../../../utils/event-util';
 import {GrSyntaxLayer} from '../../diff/gr-syntax-layer/gr-syntax-layer';
+import {StorageLocation} from '../../../services/storage/gr-storage';
 
 const UNRESOLVED_EXPAND_COUNT = 5;
 const NEWLINE_PATTERN = /\n/g;
@@ -208,7 +207,7 @@
 
   flagsService = appContext.flagsService;
 
-  readonly storage = new GrStorage();
+  readonly storage = appContext.storageService;
 
   private readonly syntaxLayer = new GrSyntaxLayer();
 
diff --git a/polygerrit-ui/app/elements/shared/gr-comment-thread/gr-comment-thread_test.ts b/polygerrit-ui/app/elements/shared/gr-comment-thread/gr-comment-thread_test.ts
index 8b15203..35b0d8c 100644
--- a/polygerrit-ui/app/elements/shared/gr-comment-thread/gr-comment-thread_test.ts
+++ b/polygerrit-ui/app/elements/shared/gr-comment-thread/gr-comment-thread_test.ts
@@ -44,8 +44,8 @@
   tap,
   pressAndReleaseKeyOn,
 } from '@polymer/iron-test-helpers/mock-interactions';
-import {html} from '@polymer/polymer/lib/utils/html-tag';
-import {stubRestApi} from '../../../test/test-utils';
+import {html} from '@polymer/polymer/lib/utils/html-tag.js';
+import {stubRestApi, stubStorage} from '../../../test/test-utils';
 
 const basicFixture = fixtureFromElement('gr-comment-thread');
 
@@ -652,7 +652,7 @@
           __draft: true,
         },
       ];
-      const storageStub = sinon.stub(element.storage, 'setDraftComment');
+      const storageStub = stubStorage('setDraftComment');
       flush();
 
       const draftEl = element.root?.querySelectorAll('gr-comment')[1];
diff --git a/polygerrit-ui/app/elements/shared/gr-comment/gr-comment.ts b/polygerrit-ui/app/elements/shared/gr-comment/gr-comment.ts
index 7fb85a4..b7f1bcc 100644
--- a/polygerrit-ui/app/elements/shared/gr-comment/gr-comment.ts
+++ b/polygerrit-ui/app/elements/shared/gr-comment/gr-comment.ts
@@ -24,7 +24,6 @@
 import '../gr-formatted-text/gr-formatted-text';
 import '../gr-icons/gr-icons';
 import '../gr-overlay/gr-overlay';
-import '../gr-storage/gr-storage';
 import '../gr-textarea/gr-textarea';
 import '../gr-tooltip-content/gr-tooltip-content';
 import '../gr-confirm-delete-comment-dialog/gr-confirm-delete-comment-dialog';
@@ -38,7 +37,6 @@
 import {customElement, observe, property} from '@polymer/decorators';
 import {GerritNav} from '../../core/gr-navigation/gr-navigation';
 import {GrTextarea} from '../gr-textarea/gr-textarea';
-import {GrStorage, StorageLocation} from '../gr-storage/gr-storage';
 import {GrOverlay} from '../gr-overlay/gr-overlay';
 import {
   AccountDetailInfo,
@@ -62,6 +60,7 @@
 import {pluralize} from '../../../utils/string-util';
 import {assertIsDefined} from '../../../utils/common-util';
 import {debounce, DelayedTask} from '../../../utils/async-util';
+import {StorageLocation} from '../../../services/storage/gr-storage';
 
 const STORAGE_DEBOUNCE_INTERVAL = 400;
 const TOAST_DEBOUNCE_INTERVAL = 200;
@@ -269,7 +268,7 @@
 
   private readonly restApiService = appContext.restApiService;
 
-  private readonly storage = new GrStorage();
+  private readonly storage = appContext.storageService;
 
   reporting = appContext.reportingService;
 
diff --git a/polygerrit-ui/app/elements/shared/gr-comment/gr-comment_test.js b/polygerrit-ui/app/elements/shared/gr-comment/gr-comment_test.js
index b5205a6..be647ab 100644
--- a/polygerrit-ui/app/elements/shared/gr-comment/gr-comment_test.js
+++ b/polygerrit-ui/app/elements/shared/gr-comment/gr-comment_test.js
@@ -20,7 +20,7 @@
 import {html} from '@polymer/polymer/lib/utils/html-tag.js';
 import {__testOnly_UNSAVED_MESSAGE} from './gr-comment.js';
 import {SpecialFilePath, Side} from '../../../constants/constants.js';
-import {stubRestApi} from '../../../test/test-utils.js';
+import {stubRestApi, stubStorage} from '../../../test/test-utils.js';
 
 const basicFixture = fixtureFromElement('gr-comment');
 
@@ -115,7 +115,7 @@
     });
 
     test('message is not retrieved from storage when other edits', done => {
-      const storageStub = sinon.stub(element.storage, 'getDraftComment');
+      const storageStub = stubStorage('getDraftComment');
       const loadSpy = sinon.spy(element, '_loadLocalDraft');
 
       element.changeNum = 1;
@@ -135,7 +135,7 @@
     });
 
     test('message is retrieved from storage when no other edits', done => {
-      const storageStub = sinon.stub(element.storage, 'getDraftComment');
+      const storageStub = stubStorage('getDraftComment');
       const loadSpy = sinon.spy(element, '_loadLocalDraft');
 
       element.changeNum = 1;
@@ -1022,8 +1022,8 @@
 
     test('cancelling an unsaved draft discards, persists in storage', () => {
       const discardSpy = sinon.spy(element, '_fireDiscard');
-      const storeStub = sinon.stub(element.storage, 'setDraftComment');
-      const eraseStub = sinon.stub(element.storage, 'eraseDraftComment');
+      const storeStub = stubStorage('setDraftComment');
+      const eraseStub = stubStorage('eraseDraftComment');
       element._messageText = 'test text';
       flush();
       element.storeTask.flush();
@@ -1038,7 +1038,7 @@
     test('cancelling edit on a saved draft does not store', () => {
       element.comment.id = 'foo';
       const discardSpy = sinon.spy(element, '_fireDiscard');
-      const storeStub = sinon.stub(element.storage, 'setDraftComment');
+      const storeStub = stubStorage('setDraftComment');
       element._messageText = 'test text';
       flush();
       if (element.storeTask) element.storeTask.flush();
diff --git a/polygerrit-ui/app/elements/shared/gr-editable-content/gr-editable-content.ts b/polygerrit-ui/app/elements/shared/gr-editable-content/gr-editable-content.ts
index 12f07ba..096697f 100644
--- a/polygerrit-ui/app/elements/shared/gr-editable-content/gr-editable-content.ts
+++ b/polygerrit-ui/app/elements/shared/gr-editable-content/gr-editable-content.ts
@@ -16,9 +16,7 @@
  */
 import '@polymer/iron-autogrow-textarea/iron-autogrow-textarea';
 import '../../../styles/shared-styles';
-import '../gr-storage/gr-storage';
 import '../gr-button/gr-button';
-import {GrStorage} from '../gr-storage/gr-storage';
 import {PolymerElement} from '@polymer/polymer/polymer-element';
 import {customElement, property} from '@polymer/decorators';
 import {htmlTemplate} from './gr-editable-content_html';
@@ -111,7 +109,7 @@
   @property({type: Boolean})
   _isNewChangeSummaryUiEnabled = false;
 
-  private readonly storage = new GrStorage();
+  private readonly storage = appContext.storageService;
 
   private readonly flagsService = appContext.flagsService;
 
diff --git a/polygerrit-ui/app/services/app-context-init.ts b/polygerrit-ui/app/services/app-context-init.ts
index 0369ccf..479ab88 100644
--- a/polygerrit-ui/app/services/app-context-init.ts
+++ b/polygerrit-ui/app/services/app-context-init.ts
@@ -23,6 +23,7 @@
 import {ChangeService} from './change/change-service';
 import {ChecksService} from './checks/checks-service';
 import {GrJsApiInterface} from '../elements/shared/gr-js-api-interface/gr-js-api-interface-element';
+import {GrStorageService} from './storage/gr-storage_impl';
 
 type ServiceName = keyof AppContext;
 type ServiceCreator<T> = () => T;
@@ -73,5 +74,6 @@
     changeService: () => new ChangeService(),
     checksService: () => new ChecksService(),
     jsApiService: () => new GrJsApiInterface(),
+    storageService: () => new GrStorageService(),
   });
 }
diff --git a/polygerrit-ui/app/services/app-context.ts b/polygerrit-ui/app/services/app-context.ts
index 1f618fd..cf186f0 100644
--- a/polygerrit-ui/app/services/app-context.ts
+++ b/polygerrit-ui/app/services/app-context.ts
@@ -22,6 +22,7 @@
 import {ChangeService} from './change/change-service';
 import {ChecksService} from './checks/checks-service';
 import {JsApiService} from '../elements/shared/gr-js-api-interface/gr-js-api-types';
+import {StorageService} from './storage/gr-storage';
 
 export interface AppContext {
   flagsService: FlagsService;
@@ -32,6 +33,7 @@
   changeService: ChangeService;
   checksService: ChecksService;
   jsApiService: JsApiService;
+  storageService: StorageService;
 }
 
 /**
diff --git a/polygerrit-ui/app/services/storage/gr-storage.ts b/polygerrit-ui/app/services/storage/gr-storage.ts
new file mode 100644
index 0000000..08a3387
--- /dev/null
+++ b/polygerrit-ui/app/services/storage/gr-storage.ts
@@ -0,0 +1,49 @@
+/**
+ * @license
+ * Copyright (C) 2021 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import {CommentRange, PatchSetNum} from '../../types/common';
+
+export interface StorageLocation {
+  changeNum: number;
+  patchNum: PatchSetNum | '@change';
+  path?: string;
+  line?: number;
+  range?: CommentRange;
+}
+
+export interface StorageObject {
+  message?: string;
+  updated: number;
+}
+
+export interface StorageService {
+  getDraftComment(location: StorageLocation): StorageObject | null;
+
+  setDraftComment(location: StorageLocation, message: string): void;
+
+  eraseDraftComment(location: StorageLocation): void;
+
+  getEditableContentItem(key: string): StorageObject | null;
+
+  setEditableContentItem(key: string, message: string): void;
+
+  getRespectfulTipVisibility(): StorageObject | null;
+
+  setRespectfulTipVisibility(delayDays?: number): void;
+
+  eraseEditableContentItem(key: string): void;
+}
diff --git a/polygerrit-ui/app/elements/shared/gr-storage/gr-storage.ts b/polygerrit-ui/app/services/storage/gr-storage_impl.ts
similarity index 72%
rename from polygerrit-ui/app/elements/shared/gr-storage/gr-storage.ts
rename to polygerrit-ui/app/services/storage/gr-storage_impl.ts
index a86d8f2..0c0d151 100644
--- a/polygerrit-ui/app/elements/shared/gr-storage/gr-storage.ts
+++ b/polygerrit-ui/app/services/storage/gr-storage_impl.ts
@@ -14,22 +14,10 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-import {CommentRange, PatchSetNum} from '../../../types/common';
 
-export interface StorageLocation {
-  changeNum: number;
-  patchNum: PatchSetNum | '@change';
-  path?: string;
-  line?: number;
-  range?: CommentRange;
-}
+import {StorageLocation, StorageObject, StorageService} from './gr-storage';
 
-export interface StorageObject {
-  message?: string;
-  updated: number;
-}
-
-const DURATION_DAY = 24 * 60 * 60 * 1000;
+export const DURATION_DAY = 24 * 60 * 60 * 1000;
 
 // Clean up old entries no more frequently than one day.
 const CLEANUP_THROTTLE_INTERVAL = DURATION_DAY;
@@ -39,7 +27,7 @@
 CLEANUP_PREFIXES_MAX_AGE_MAP.set('draft', DURATION_DAY);
 CLEANUP_PREFIXES_MAX_AGE_MAP.set('editablecontent', DURATION_DAY);
 
-export class GrStorage {
+export class GrStorageService implements StorageService {
   private lastCleanup = 0;
 
   private readonly storage = window.localStorage;
@@ -47,49 +35,49 @@
   private exceededQuota = false;
 
   getDraftComment(location: StorageLocation): StorageObject | null {
-    this._cleanupItems();
-    return this._getObject(this._getDraftKey(location));
+    this.cleanupItems();
+    return this.getObject(this.getDraftKey(location));
   }
 
   setDraftComment(location: StorageLocation, message: string) {
-    const key = this._getDraftKey(location);
-    this._setObject(key, {message, updated: Date.now()});
+    const key = this.getDraftKey(location);
+    this.setObject(key, {message, updated: Date.now()});
   }
 
   eraseDraftComment(location: StorageLocation) {
-    const key = this._getDraftKey(location);
+    const key = this.getDraftKey(location);
     this.storage.removeItem(key);
   }
 
   getEditableContentItem(key: string): StorageObject | null {
-    this._cleanupItems();
-    return this._getObject(this._getEditableContentKey(key));
+    this.cleanupItems();
+    return this.getObject(this.getEditableContentKey(key));
   }
 
   setEditableContentItem(key: string, message: string) {
-    this._setObject(this._getEditableContentKey(key), {
+    this.setObject(this.getEditableContentKey(key), {
       message,
       updated: Date.now(),
     });
   }
 
   getRespectfulTipVisibility(): StorageObject | null {
-    this._cleanupItems();
-    return this._getObject('respectfultip:visibility');
+    this.cleanupItems();
+    return this.getObject('respectfultip:visibility');
   }
 
   setRespectfulTipVisibility(delayDays = 0) {
-    this._cleanupItems();
-    this._setObject('respectfultip:visibility', {
+    this.cleanupItems();
+    this.setObject('respectfultip:visibility', {
       updated: Date.now() + delayDays * DURATION_DAY,
     });
   }
 
   eraseEditableContentItem(key: string) {
-    this.storage.removeItem(this._getEditableContentKey(key));
+    this.storage.removeItem(this.getEditableContentKey(key));
   }
 
-  _getDraftKey(location: StorageLocation): string {
+  private getDraftKey(location: StorageLocation): string {
     const range = location.range
       ? `${location.range.start_line}-${location.range.start_character}` +
         `-${location.range.end_character}-${location.range.end_line}`
@@ -107,11 +95,11 @@
     return key;
   }
 
-  _getEditableContentKey(key: string): string {
+  private getEditableContentKey(key: string): string {
     return `editablecontent:${key}`;
   }
 
-  _cleanupItems() {
+  private cleanupItems() {
     // Throttle cleanup to the throttle interval.
     if (
       this.lastCleanup &&
@@ -125,7 +113,7 @@
       const entries = CLEANUP_PREFIXES_MAX_AGE_MAP.entries();
       for (const [prefix, expiration] of entries) {
         if (key.startsWith(prefix)) {
-          const item = this._getObject(key);
+          const item = this.getObject(key);
           if (!item || Date.now() - item.updated > expiration) {
             this.storage.removeItem(key);
           }
@@ -134,7 +122,7 @@
     });
   }
 
-  _getObject(key: string): StorageObject | null {
+  private getObject(key: string): StorageObject | null {
     const serial = this.storage.getItem(key);
     if (!serial) {
       return null;
@@ -142,7 +130,7 @@
     return JSON.parse(serial) as StorageObject;
   }
 
-  _setObject(key: string, obj: StorageObject) {
+  private setObject(key: string, obj: StorageObject) {
     if (this.exceededQuota) {
       return;
     }
diff --git a/polygerrit-ui/app/services/storage/gr-storage_mock.ts b/polygerrit-ui/app/services/storage/gr-storage_mock.ts
new file mode 100644
index 0000000..02215a8
--- /dev/null
+++ b/polygerrit-ui/app/services/storage/gr-storage_mock.ts
@@ -0,0 +1,91 @@
+/**
+ * @license
+ * Copyright (C) 2021 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import {StorageLocation, StorageObject, StorageService} from './gr-storage';
+import {DURATION_DAY} from './gr-storage_impl';
+
+const storage = new Map();
+
+const getDraftKey = (location: StorageLocation): string => {
+  const range = location.range
+    ? `${location.range.start_line}-${location.range.start_character}` +
+      `-${location.range.end_character}-${location.range.end_line}`
+    : null;
+  let key = [
+    'draft',
+    location.changeNum,
+    location.patchNum,
+    location.path,
+    location.line || '',
+  ].join(':');
+  if (range) {
+    key = key + ':' + range;
+  }
+  return key;
+};
+
+const getEditableContentKey = (key: string): string => {
+  return `editablecontent:${key}`;
+};
+
+export function cleanUpStorage() {
+  storage.clear();
+}
+
+export const grStorageMock: StorageService = {
+  getDraftComment(location: StorageLocation): StorageObject | null {
+    return storage.get(getDraftKey(location));
+  },
+
+  setDraftComment(location: StorageLocation, message: string) {
+    const key = getDraftKey(location);
+    storage.set(key, {message, updated: Date.now()});
+  },
+
+  eraseDraftComment(location: StorageLocation) {
+    const key = getDraftKey(location);
+    storage.delete(key);
+  },
+
+  getEditableContentItem(key: string): StorageObject | null {
+    return storage.get(getEditableContentKey(key));
+  },
+
+  setEditableContentItem(key: string, message: string): void {
+    storage.set(
+      getEditableContentKey(key),
+      JSON.stringify({
+        message,
+        updated: Date.now(),
+      })
+    );
+  },
+
+  getRespectfulTipVisibility(): StorageObject | null {
+    return storage.get('respectfultip:visibility');
+  },
+
+  setRespectfulTipVisibility(delayDays = 0): void {
+    storage.set('respectfultip:visibility', {
+      updated: Date.now() + delayDays * DURATION_DAY,
+    });
+  },
+
+  eraseEditableContentItem(key: string): void {
+    storage.delete(getEditableContentKey(key));
+  },
+};
diff --git a/polygerrit-ui/app/elements/shared/gr-storage/gr-storage_test.js b/polygerrit-ui/app/services/storage/gr-storage_test.js
similarity index 87%
rename from polygerrit-ui/app/elements/shared/gr-storage/gr-storage_test.js
rename to polygerrit-ui/app/services/storage/gr-storage_test.js
index 64d3750..6cbfacf 100644
--- a/polygerrit-ui/app/elements/shared/gr-storage/gr-storage_test.js
+++ b/polygerrit-ui/app/services/storage/gr-storage_test.js
@@ -15,8 +15,8 @@
  * limitations under the License.
  */
 
-import '../../../test/common-test-setup-karma.js';
-import {GrStorage} from './gr-storage.js';
+import '../../test/common-test-setup-karma.js';
+import {GrStorageService} from './gr-storage_impl.js';
 
 suite('gr-storage tests', () => {
   let grStorage;
@@ -34,7 +34,7 @@
   }
 
   setup(() => {
-    grStorage = new GrStorage();
+    grStorage = new GrStorageService();
     grStorage.storage = mockStorage();
   });
 
@@ -51,7 +51,7 @@
     };
 
     // The key is in the expected format.
-    const key = grStorage._getDraftKey(location);
+    const key = grStorage.getDraftKey(location);
     assert.equal(key, ['draft', changeNum, patchNum, path, line].join(':'));
 
     // There should be no draft initially.
@@ -82,12 +82,12 @@
       line,
     };
 
-    const key = grStorage._getDraftKey(location);
+    const key = grStorage.getDraftKey(location);
 
     // Make sure that the call to cleanup doesn't get throttled.
     grStorage.lastCleanup = 0;
 
-    const cleanupSpy = sinon.spy(grStorage, '_cleanupItems');
+    const cleanupSpy = sinon.spy(grStorage, 'cleanupItems');
 
     // Create a message with a timestamp that is a second behind the max age.
     grStorage.storage.setItem(key, JSON.stringify({
@@ -103,7 +103,7 @@
     assert.isNotOk(grStorage.storage.getItem(key));
   });
 
-  test('_getDraftKey', () => {
+  test('getDraftKey', () => {
     const changeNum = 1234;
     const patchNum = 5;
     const path = 'my_source_file.js';
@@ -115,7 +115,7 @@
       line,
     };
     let expectedResult = 'draft:1234:5:my_source_file.js:123';
-    assert.equal(grStorage._getDraftKey(location), expectedResult);
+    assert.equal(grStorage.getDraftKey(location), expectedResult);
     location.range = {
       start_character: 1,
       start_line: 1,
@@ -123,7 +123,7 @@
       end_line: 2,
     };
     expectedResult = 'draft:1234:5:my_source_file.js:123:1-1-1-2';
-    assert.equal(grStorage._getDraftKey(location), expectedResult);
+    assert.equal(grStorage.getDraftKey(location), expectedResult);
   });
 
   test('exceeded quota disables storage', () => {
@@ -140,16 +140,16 @@
       path,
       line,
     };
-    const key = grStorage._getDraftKey(location);
+    const key = grStorage.getDraftKey(location);
     grStorage.setDraftComment(location, 'my comment');
     assert.isTrue(grStorage.exceededQuota);
     assert.isNotOk(grStorage.storage.getItem(key));
   });
 
   test('editable content items', () => {
-    const cleanupStub = sinon.stub(grStorage, '_cleanupItems');
+    const cleanupStub = sinon.stub(grStorage, 'cleanupItems');
     const key = 'testKey';
-    const computedKey = grStorage._getEditableContentKey(key);
+    const computedKey = grStorage.getEditableContentKey(key);
     // Key correctly computed.
     assert.equal(computedKey, 'editablecontent:testKey');
 
diff --git a/polygerrit-ui/app/test/common-test-setup.ts b/polygerrit-ui/app/test/common-test-setup.ts
index 016c1e4..641e4b3 100644
--- a/polygerrit-ui/app/test/common-test-setup.ts
+++ b/polygerrit-ui/app/test/common-test-setup.ts
@@ -43,6 +43,7 @@
   installPolymerResin,
 } from '../scripts/polymer-resin-install';
 import {_testOnly_allTasks} from '../utils/async-util';
+import {cleanUpStorage} from '../services/storage/gr-storage_mock';
 
 declare global {
   interface Window {
@@ -200,6 +201,7 @@
   checkGlobalSpace();
   removeIronOverlayBackdropStyleEl();
   cancelAllTasks();
+  cleanUpStorage();
   const testTeardownTimestampMs = new Date().getTime();
   const elapsedMs = testTeardownTimestampMs - testSetupTimestampMs;
   if (elapsedMs > 1000) {
diff --git a/polygerrit-ui/app/test/test-app-context-init.ts b/polygerrit-ui/app/test/test-app-context-init.ts
index 57afd8a..d74a9c1 100644
--- a/polygerrit-ui/app/test/test-app-context-init.ts
+++ b/polygerrit-ui/app/test/test-app-context-init.ts
@@ -20,6 +20,7 @@
 import {grReportingMock} from '../services/gr-reporting/gr-reporting_mock';
 import {AppContext, appContext} from '../services/app-context';
 import {grRestApiMock} from './mocks/gr-rest-api_mock';
+import {grStorageMock} from '../services/storage/gr-storage_mock';
 
 export function _testOnlyInitAppContext() {
   initAppContext();
@@ -36,4 +37,5 @@
   }
   setMock('reportingService', grReportingMock);
   setMock('restApiService', grRestApiMock);
+  setMock('storageService', grStorageMock);
 }
diff --git a/polygerrit-ui/app/test/test-utils.ts b/polygerrit-ui/app/test/test-utils.ts
index 50f465f..26b99ac 100644
--- a/polygerrit-ui/app/test/test-utils.ts
+++ b/polygerrit-ui/app/test/test-utils.ts
@@ -24,6 +24,7 @@
 import {appContext} from '../services/app-context';
 import {RestApiService} from '../services/gr-rest-api/gr-rest-api';
 import {SinonSpy} from 'sinon/pkg/sinon-esm';
+import {StorageService} from '../services/storage/gr-storage';
 
 export interface MockPromise extends Promise<unknown> {
   resolve: (value?: unknown) => void;
@@ -165,6 +166,10 @@
   return sinon.spy(appContext.restApiService, method);
 }
 
+export function stubStorage<K extends keyof StorageService>(method: K) {
+  return sinon.stub(appContext.storageService, method);
+}
+
 export type SinonSpyMember<F extends (...args: any) => any> = SinonSpy<
   Parameters<F>,
   ReturnType<F>
diff --git a/tools/nongoogle.bzl b/tools/nongoogle.bzl
index 9c3c933..3f5ef20 100644
--- a/tools/nongoogle.bzl
+++ b/tools/nongoogle.bzl
@@ -1,5 +1,4 @@
 load("//tools/bzl:maven_jar.bzl", "maven_jar")
-load("@bazel_tools//tools/build_defs/repo:http.bzl", "http_archive", "http_file")
 
 GUAVA_VERSION = "30.1-jre"