Merge changes from topic "gr-plugin-popup-to-ts"

* changes:
  Move gr-plugin-popup to typescript
  Rename files to preserve history
diff --git a/java/com/google/gerrit/acceptance/GerritServer.java b/java/com/google/gerrit/acceptance/GerritServer.java
index 2d62608..07aab3b 100644
--- a/java/com/google/gerrit/acceptance/GerritServer.java
+++ b/java/com/google/gerrit/acceptance/GerritServer.java
@@ -30,6 +30,9 @@
 import com.google.gerrit.acceptance.config.GlobalPluginConfigs;
 import com.google.gerrit.acceptance.testsuite.account.AccountOperations;
 import com.google.gerrit.acceptance.testsuite.account.AccountOperationsImpl;
+import com.google.gerrit.acceptance.testsuite.change.ChangeOperations;
+import com.google.gerrit.acceptance.testsuite.change.ChangeOperationsImpl;
+import com.google.gerrit.acceptance.testsuite.change.PerPatchsetOperationsImpl;
 import com.google.gerrit.acceptance.testsuite.group.GroupOperations;
 import com.google.gerrit.acceptance.testsuite.group.GroupOperationsImpl;
 import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
@@ -506,6 +509,8 @@
             bind(GroupOperations.class).to(GroupOperationsImpl.class);
             bind(ProjectOperations.class).to(ProjectOperationsImpl.class);
             bind(RequestScopeOperations.class).to(RequestScopeOperationsImpl.class);
+            bind(ChangeOperations.class).to(ChangeOperationsImpl.class);
+            factory(PerPatchsetOperationsImpl.Factory.class);
             factory(PushOneCommit.Factory.class);
             install(InProcessProtocol.module());
             install(new NoSshModule());
diff --git a/java/com/google/gerrit/acceptance/testsuite/change/ChangeOperations.java b/java/com/google/gerrit/acceptance/testsuite/change/ChangeOperations.java
new file mode 100644
index 0000000..cc96d5b
--- /dev/null
+++ b/java/com/google/gerrit/acceptance/testsuite/change/ChangeOperations.java
@@ -0,0 +1,115 @@
+// Copyright (C) 2020 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.acceptance.testsuite.change;
+
+import com.google.gerrit.entities.Change;
+import com.google.gerrit.entities.PatchSet;
+
+/**
+ * An aggregation of operations on changes for test purposes.
+ *
+ * <p>To execute the operations, no Gerrit permissions are necessary.
+ *
+ * <p><strong>Note:</strong> This interface is not implemented using the REST or extension API.
+ * Hence, it cannot be used for testing those APIs.
+ */
+public interface ChangeOperations {
+
+  /**
+   * Starts the fluent chain for querying or modifying a change. Please see the methods of {@link
+   * PerChangeOperations} for details on possible operations.
+   *
+   * @return an aggregation of operations on a specific change
+   */
+  PerChangeOperations change(Change.Id changeId);
+
+  /**
+   * Starts the fluent chain to create a change. The returned builder can be used to specify the
+   * attributes of the new change. To create the change for real, {@link
+   * TestChangeCreation.Builder#create()} must be called.
+   *
+   * <p>Example:
+   *
+   * <pre>
+   * Change.Id createdChangeId = changeOperations
+   *     .newChange()
+   *     .file("file1")
+   *     .content("Line 1\nLine2\n")
+   *     .create();
+   * </pre>
+   *
+   * <p><strong>Note:</strong> There must be at least one existing user and repository.
+   *
+   * @return a builder to create the new change
+   */
+  TestChangeCreation.Builder newChange();
+
+  /** An aggregation of methods on a specific change. */
+  interface PerChangeOperations {
+
+    /**
+     * Checks whether the change exists.
+     *
+     * @return {@code true} if the change exists
+     */
+    boolean exists();
+
+    /**
+     * Retrieves the change.
+     *
+     * <p><strong>Note:</strong> This call will fail with an exception if the requested change
+     * doesn't exist. If you want to check for the existence of a change, use {@link #exists()}
+     * instead.
+     *
+     * @return the corresponding {@code TestChange}
+     */
+    TestChange get();
+
+    /**
+     * Starts the fluent chain to create a new patchset. The returned builder can be used to specify
+     * the attributes of the new patchset. To create the patchset for real, {@link
+     * TestPatchsetCreation.Builder#create()} must be called.
+     *
+     * <p>Example:
+     *
+     * <pre>
+     * PatchSet.Id createdPatchsetId = changeOperations
+     *     .change(changeId)
+     *     .newPatchset()
+     *     .file("file1")
+     *     .content("Line 1\nLine2\n")
+     *     .create();
+     * </pre>
+     *
+     * @return builder to create a new patchset
+     */
+    TestPatchsetCreation.Builder newPatchset();
+
+    /**
+     * Starts the fluent chain for querying or modifying a patchset. Please see the methods of
+     * {@link PerPatchsetOperations} for details on possible operations.
+     *
+     * @return an aggregation of operations on a specific patchset
+     */
+    PerPatchsetOperations patchset(PatchSet.Id patchsetId);
+
+    /**
+     * Like {@link #patchset(PatchSet.Id)} but for the current patchset.
+     *
+     * @return an aggregation of operations on a specific patchset
+     */
+    PerPatchsetOperations currentPatchset();
+  }
+}
diff --git a/java/com/google/gerrit/acceptance/testsuite/change/ChangeOperationsImpl.java b/java/com/google/gerrit/acceptance/testsuite/change/ChangeOperationsImpl.java
new file mode 100644
index 0000000..3bd6475
--- /dev/null
+++ b/java/com/google/gerrit/acceptance/testsuite/change/ChangeOperationsImpl.java
@@ -0,0 +1,412 @@
+// Copyright (C) 2020 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.acceptance.testsuite.change;
+
+import static com.google.common.base.Preconditions.checkState;
+import static com.google.common.collect.ImmutableList.toImmutableList;
+
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableSet;
+import com.google.common.collect.Streams;
+import com.google.gerrit.entities.Account;
+import com.google.gerrit.entities.Change;
+import com.google.gerrit.entities.PatchSet;
+import com.google.gerrit.entities.Project;
+import com.google.gerrit.entities.RefNames;
+import com.google.gerrit.extensions.restapi.BadRequestException;
+import com.google.gerrit.extensions.restapi.RestApiException;
+import com.google.gerrit.server.ChangeUtil;
+import com.google.gerrit.server.GerritPersonIdent;
+import com.google.gerrit.server.IdentifiedUser;
+import com.google.gerrit.server.account.AccountResolver;
+import com.google.gerrit.server.change.ChangeFinder;
+import com.google.gerrit.server.change.ChangeInserter;
+import com.google.gerrit.server.change.PatchSetInserter;
+import com.google.gerrit.server.edit.tree.TreeCreator;
+import com.google.gerrit.server.edit.tree.TreeModification;
+import com.google.gerrit.server.git.GitRepositoryManager;
+import com.google.gerrit.server.notedb.ChangeNotes;
+import com.google.gerrit.server.notedb.Sequences;
+import com.google.gerrit.server.project.ProjectCache;
+import com.google.gerrit.server.update.BatchUpdate;
+import com.google.gerrit.server.update.UpdateException;
+import com.google.gerrit.server.util.CommitMessageUtil;
+import com.google.gerrit.server.util.time.TimeUtil;
+import com.google.inject.Inject;
+import java.io.IOException;
+import java.sql.Timestamp;
+import java.util.Arrays;
+import java.util.Date;
+import java.util.Objects;
+import java.util.Optional;
+import org.eclipse.jgit.errors.ConfigInvalidException;
+import org.eclipse.jgit.lib.CommitBuilder;
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.ObjectInserter;
+import org.eclipse.jgit.lib.PersonIdent;
+import org.eclipse.jgit.lib.Ref;
+import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.revwalk.RevCommit;
+import org.eclipse.jgit.revwalk.RevWalk;
+import org.eclipse.jgit.util.ChangeIdUtil;
+
+/**
+ * The implementation of {@link ChangeOperations}.
+ *
+ * <p>There is only one implementation of {@link ChangeOperations}. Nevertheless, we keep the
+ * separation between interface and implementation to enhance clarity.
+ */
+public class ChangeOperationsImpl implements ChangeOperations {
+  private final Sequences seq;
+  private final ChangeInserter.Factory changeInserterFactory;
+  private final PatchSetInserter.Factory patchsetInserterFactory;
+  private final GitRepositoryManager repositoryManager;
+  private final AccountResolver resolver;
+  private final IdentifiedUser.GenericFactory userFactory;
+  private final PersonIdent serverIdent;
+  private final BatchUpdate.Factory batchUpdateFactory;
+  private final ProjectCache projectCache;
+  private final ChangeFinder changeFinder;
+  private final PerPatchsetOperationsImpl.Factory perPatchsetOperationsFactory;
+
+  @Inject
+  public ChangeOperationsImpl(
+      Sequences seq,
+      ChangeInserter.Factory changeInserterFactory,
+      PatchSetInserter.Factory patchsetInserterFactory,
+      GitRepositoryManager repositoryManager,
+      AccountResolver resolver,
+      IdentifiedUser.GenericFactory userFactory,
+      @GerritPersonIdent PersonIdent serverIdent,
+      BatchUpdate.Factory batchUpdateFactory,
+      ProjectCache projectCache,
+      ChangeFinder changeFinder,
+      PerPatchsetOperationsImpl.Factory perPatchsetOperationsFactory) {
+    this.seq = seq;
+    this.changeInserterFactory = changeInserterFactory;
+    this.patchsetInserterFactory = patchsetInserterFactory;
+    this.repositoryManager = repositoryManager;
+    this.resolver = resolver;
+    this.userFactory = userFactory;
+    this.serverIdent = serverIdent;
+    this.batchUpdateFactory = batchUpdateFactory;
+    this.projectCache = projectCache;
+    this.changeFinder = changeFinder;
+    this.perPatchsetOperationsFactory = perPatchsetOperationsFactory;
+  }
+
+  @Override
+  public PerChangeOperations change(Change.Id changeId) {
+    return new PerChangeOperationsImpl(changeId);
+  }
+
+  @Override
+  public TestChangeCreation.Builder newChange() {
+    return TestChangeCreation.builder(this::createChange);
+  }
+
+  private Change.Id createChange(TestChangeCreation changeCreation) throws Exception {
+    Change.Id changeId = Change.id(seq.nextChangeId());
+    Project.NameKey project = getTargetProject(changeCreation);
+
+    try (Repository repository = repositoryManager.openRepository(project);
+        ObjectInserter objectInserter = repository.newObjectInserter();
+        RevWalk revWalk = new RevWalk(objectInserter.newReader())) {
+      Timestamp now = TimeUtil.nowTs();
+      IdentifiedUser changeOwner = getChangeOwner(changeCreation);
+      PersonIdent authorAndCommitter =
+          changeOwner.newCommitterIdent(now, serverIdent.getTimeZone());
+      ObjectId commitId =
+          createCommit(repository, revWalk, objectInserter, changeCreation, authorAndCommitter);
+
+      String refName = RefNames.fullName(changeCreation.branch());
+      ChangeInserter inserter = getChangeInserter(changeId, refName, commitId);
+
+      try (BatchUpdate batchUpdate = batchUpdateFactory.create(project, changeOwner, now)) {
+        batchUpdate.setRepository(repository, revWalk, objectInserter);
+        batchUpdate.insertChange(inserter);
+        batchUpdate.execute();
+      }
+      return changeId;
+    }
+  }
+
+  private Project.NameKey getTargetProject(TestChangeCreation changeCreation) {
+    if (changeCreation.project().isPresent()) {
+      return changeCreation.project().get();
+    }
+
+    return getArbitraryProject();
+  }
+
+  private Project.NameKey getArbitraryProject() {
+    Project.NameKey allProjectsName = projectCache.getAllProjects().getNameKey();
+    Project.NameKey allUsersName = projectCache.getAllUsers().getNameKey();
+    Optional<Project.NameKey> arbitraryProject =
+        projectCache.all().stream()
+            .filter(
+                name ->
+                    !Objects.equals(name, allProjectsName) && !Objects.equals(name, allUsersName))
+            .findFirst();
+    checkState(
+        arbitraryProject.isPresent(),
+        "At least one repository must be available on the Gerrit server");
+    return arbitraryProject.get();
+  }
+
+  private IdentifiedUser getChangeOwner(TestChangeCreation changeCreation)
+      throws IOException, ConfigInvalidException {
+    if (changeCreation.owner().isPresent()) {
+      return userFactory.create(changeCreation.owner().get());
+    }
+
+    return getArbitraryUser();
+  }
+
+  private IdentifiedUser getArbitraryUser() throws ConfigInvalidException, IOException {
+    ImmutableSet<Account.Id> foundAccounts = resolver.resolveIgnoreVisibility("").asIdSet();
+    checkState(
+        !foundAccounts.isEmpty(),
+        "At least one user account must be available on the Gerrit server");
+    return userFactory.create(foundAccounts.iterator().next());
+  }
+
+  private ObjectId createCommit(
+      Repository repository,
+      RevWalk revWalk,
+      ObjectInserter objectInserter,
+      TestChangeCreation changeCreation,
+      PersonIdent authorAndCommitter)
+      throws IOException, BadRequestException {
+    Optional<ObjectId> branchTip = getTip(repository, changeCreation.branch());
+
+    ObjectId tree =
+        createNewTree(
+            repository,
+            revWalk,
+            branchTip.orElse(ObjectId.zeroId()),
+            changeCreation.treeModifications());
+
+    String commitMessage = correctCommitMessage(changeCreation.commitMessage());
+
+    ImmutableList<ObjectId> parentCommitIds = Streams.stream(branchTip).collect(toImmutableList());
+    return createCommit(
+        objectInserter,
+        tree,
+        parentCommitIds,
+        authorAndCommitter,
+        authorAndCommitter,
+        commitMessage);
+  }
+
+  private Optional<ObjectId> getTip(Repository repository, String branch) throws IOException {
+    Optional<Ref> ref = Optional.ofNullable(repository.findRef(branch));
+    return ref.map(Ref::getObjectId);
+  }
+
+  private static ObjectId createNewTree(
+      Repository repository,
+      RevWalk revWalk,
+      ObjectId baseCommitId,
+      ImmutableList<TreeModification> treeModifications)
+      throws IOException {
+    TreeCreator treeCreator = getTreeCreator(revWalk, baseCommitId);
+    treeCreator.addTreeModifications(treeModifications);
+    return treeCreator.createNewTreeAndGetId(repository);
+  }
+
+  private static TreeCreator getTreeCreator(RevWalk revWalk, ObjectId baseCommitId)
+      throws IOException {
+    if (ObjectId.zeroId().equals(baseCommitId)) {
+      return TreeCreator.basedOnEmptyTree();
+    }
+    RevCommit baseCommit = revWalk.parseCommit(baseCommitId);
+    return TreeCreator.basedOn(baseCommit);
+  }
+
+  private String correctCommitMessage(String desiredCommitMessage) throws BadRequestException {
+    String commitMessage = CommitMessageUtil.checkAndSanitizeCommitMessage(desiredCommitMessage);
+
+    if (ChangeIdUtil.indexOfChangeId(commitMessage, "\n") == -1) {
+      ObjectId id = CommitMessageUtil.generateChangeId();
+      commitMessage = ChangeIdUtil.insertId(commitMessage, id);
+    }
+
+    return commitMessage;
+  }
+
+  private ObjectId createCommit(
+      ObjectInserter objectInserter,
+      ObjectId tree,
+      ImmutableList<ObjectId> parentCommitIds,
+      PersonIdent author,
+      PersonIdent committer,
+      String commitMessage)
+      throws IOException {
+    CommitBuilder builder = new CommitBuilder();
+    builder.setTreeId(tree);
+    builder.setParentIds(parentCommitIds);
+    builder.setAuthor(author);
+    builder.setCommitter(committer);
+    builder.setMessage(commitMessage);
+    ObjectId newCommitId = objectInserter.insert(builder);
+    objectInserter.flush();
+    return newCommitId;
+  }
+
+  private ChangeInserter getChangeInserter(Change.Id changeId, String refName, ObjectId commitId) {
+    ChangeInserter inserter = changeInserterFactory.create(changeId, commitId, refName);
+    inserter.setMessage(String.format("Uploaded patchset %d.", inserter.getPatchSetId().get()));
+    return inserter;
+  }
+
+  private class PerChangeOperationsImpl implements PerChangeOperations {
+
+    private final Change.Id changeId;
+
+    public PerChangeOperationsImpl(Change.Id changeId) {
+      this.changeId = changeId;
+    }
+
+    @Override
+    public boolean exists() {
+      return changeFinder.findOne(changeId).isPresent();
+    }
+
+    @Override
+    public TestChange get() {
+      return toTestChange(getChangeNotes().getChange());
+    }
+
+    private ChangeNotes getChangeNotes() {
+      Optional<ChangeNotes> changeNotes = changeFinder.findOne(changeId);
+      checkState(changeNotes.isPresent(), "Tried to get non-existing test change.");
+      return changeNotes.get();
+    }
+
+    private TestChange toTestChange(Change change) {
+      return TestChange.builder()
+          .numericChangeId(change.getId())
+          .changeId(change.getKey().get())
+          .build();
+    }
+
+    @Override
+    public TestPatchsetCreation.Builder newPatchset() {
+      return TestPatchsetCreation.builder(this::createPatchset);
+    }
+
+    private PatchSet.Id createPatchset(TestPatchsetCreation patchsetCreation)
+        throws IOException, RestApiException, UpdateException {
+      ChangeNotes changeNotes = getChangeNotes();
+      Project.NameKey project = changeNotes.getProjectName();
+      try (Repository repository = repositoryManager.openRepository(project);
+          ObjectInserter objectInserter = repository.newObjectInserter();
+          RevWalk revWalk = new RevWalk(objectInserter.newReader())) {
+        Timestamp now = TimeUtil.nowTs();
+        ObjectId newPatchsetCommit =
+            createPatchsetCommit(
+                repository, revWalk, objectInserter, changeNotes, patchsetCreation, now);
+
+        PatchSet.Id patchsetId =
+            ChangeUtil.nextPatchSetId(repository, changeNotes.getCurrentPatchSet().id());
+        PatchSetInserter patchSetInserter =
+            getPatchSetInserter(changeNotes, newPatchsetCommit, patchsetId);
+
+        IdentifiedUser changeOwner = userFactory.create(changeNotes.getChange().getOwner());
+        try (BatchUpdate batchUpdate = batchUpdateFactory.create(project, changeOwner, now)) {
+          batchUpdate.setRepository(repository, revWalk, objectInserter);
+          batchUpdate.addOp(changeId, patchSetInserter);
+          batchUpdate.execute();
+        }
+        return patchsetId;
+      }
+    }
+
+    private ObjectId createPatchsetCommit(
+        Repository repository,
+        RevWalk revWalk,
+        ObjectInserter objectInserter,
+        ChangeNotes changeNotes,
+        TestPatchsetCreation patchsetCreation,
+        Timestamp now)
+        throws IOException {
+      ObjectId oldPatchsetCommitId = changeNotes.getCurrentPatchSet().commitId();
+      RevCommit oldPatchsetCommit = repository.parseCommit(oldPatchsetCommitId);
+
+      ObjectId tree =
+          createNewTree(
+              repository, revWalk, oldPatchsetCommitId, patchsetCreation.treeModifications());
+
+      String commitMessage = oldPatchsetCommit.getFullMessage();
+
+      ImmutableList<ObjectId> parentCommitIds = getParents(oldPatchsetCommit);
+      PersonIdent author = getAuthor(oldPatchsetCommit);
+      PersonIdent committer = getCommitter(oldPatchsetCommit, now);
+      return createCommit(objectInserter, tree, parentCommitIds, author, committer, commitMessage);
+    }
+
+    private PersonIdent getAuthor(RevCommit oldPatchsetCommit) {
+      return Optional.ofNullable(oldPatchsetCommit.getAuthorIdent()).orElse(serverIdent);
+    }
+
+    private PersonIdent getCommitter(RevCommit oldPatchsetCommit, Timestamp now) {
+      PersonIdent oldPatchsetCommitter =
+          Optional.ofNullable(oldPatchsetCommit.getCommitterIdent()).orElse(serverIdent);
+      if (asSeconds(now) == asSeconds(oldPatchsetCommitter.getWhen())) {
+        /* We need to ensure that the resulting commit SHA-1 is different from the old patchset.
+         * In real situations, this automatically happens as two patchsets won't have exactly the
+         * same commit timestamp even when the tree and commit message are the same. In tests,
+         * we can easily end up with the same timestamp as Git uses second precision for timestamps.
+         * We could of course require that tests must use TestTimeUtil#setClockStep but
+         * that would be an unnecessary nuisance for test writers. Hence, go with a simple solution
+         * here and simply add a second. */
+        now = Timestamp.from(now.toInstant().plusSeconds(1));
+      }
+      return new PersonIdent(oldPatchsetCommitter, now);
+    }
+
+    private long asSeconds(Date date) {
+      return date.getTime() / 1000;
+    }
+
+    private ImmutableList<ObjectId> getParents(RevCommit oldPatchsetCommit) {
+      return Arrays.stream(oldPatchsetCommit.getParents())
+          .map(ObjectId::toObjectId)
+          .collect(toImmutableList());
+    }
+
+    private PatchSetInserter getPatchSetInserter(
+        ChangeNotes changeNotes, ObjectId newPatchsetCommit, PatchSet.Id patchsetId) {
+      PatchSetInserter patchSetInserter =
+          patchsetInserterFactory.create(changeNotes, patchsetId, newPatchsetCommit);
+      patchSetInserter.setCheckAddPatchSetPermission(false);
+      patchSetInserter.setMessage(String.format("Uploaded patchset %d.", patchsetId.get()));
+      return patchSetInserter;
+    }
+
+    @Override
+    public PerPatchsetOperations patchset(PatchSet.Id patchsetId) {
+      return perPatchsetOperationsFactory.create(getChangeNotes(), patchsetId);
+    }
+
+    @Override
+    public PerPatchsetOperations currentPatchset() {
+      ChangeNotes changeNotes = getChangeNotes();
+      return perPatchsetOperationsFactory.create(
+          changeNotes, changeNotes.getChange().currentPatchSetId());
+    }
+  }
+}
diff --git a/java/com/google/gerrit/acceptance/testsuite/change/FileContentBuilder.java b/java/com/google/gerrit/acceptance/testsuite/change/FileContentBuilder.java
new file mode 100644
index 0000000..bae8407
--- /dev/null
+++ b/java/com/google/gerrit/acceptance/testsuite/change/FileContentBuilder.java
@@ -0,0 +1,60 @@
+// Copyright (C) 2020 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.acceptance.testsuite.change;
+
+import static com.google.common.base.Preconditions.checkNotNull;
+
+import com.google.common.base.Strings;
+import com.google.gerrit.common.RawInputUtil;
+import com.google.gerrit.server.edit.tree.ChangeFileContentModification;
+import com.google.gerrit.server.edit.tree.DeleteFileModification;
+import com.google.gerrit.server.edit.tree.RenameFileModification;
+import com.google.gerrit.server.edit.tree.TreeModification;
+import java.util.function.Consumer;
+
+/** Builder to simplify file content specification. */
+public class FileContentBuilder<T> {
+  private final T builder;
+  private final String filePath;
+  private final Consumer<TreeModification> modificationToBuilderAdder;
+
+  FileContentBuilder(
+      T builder, String filePath, Consumer<TreeModification> modificationToBuilderAdder) {
+    checkNotNull(Strings.emptyToNull(filePath), "File path must not be null or empty.");
+    this.builder = builder;
+    this.filePath = filePath;
+    this.modificationToBuilderAdder = modificationToBuilderAdder;
+  }
+
+  /** Content of the file. Must not be empty. */
+  public T content(String content) {
+    checkNotNull(
+        Strings.emptyToNull(content),
+        "Empty file content is not supported. Adjust test API if necessary.");
+    modificationToBuilderAdder.accept(
+        new ChangeFileContentModification(filePath, RawInputUtil.create(content)));
+    return builder;
+  }
+
+  public T delete() {
+    modificationToBuilderAdder.accept(new DeleteFileModification(filePath));
+    return builder;
+  }
+
+  public T renameTo(String newFilePath) {
+    modificationToBuilderAdder.accept(new RenameFileModification(filePath, newFilePath));
+    return builder;
+  }
+}
diff --git a/java/com/google/gerrit/acceptance/testsuite/change/PerPatchsetOperations.java b/java/com/google/gerrit/acceptance/testsuite/change/PerPatchsetOperations.java
new file mode 100644
index 0000000..c095551
--- /dev/null
+++ b/java/com/google/gerrit/acceptance/testsuite/change/PerPatchsetOperations.java
@@ -0,0 +1,28 @@
+// Copyright (C) 2020 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.acceptance.testsuite.change;
+
+/** An aggregation of methods on a specific patchset. */
+public interface PerPatchsetOperations {
+
+  /**
+   * Retrieves the patchset.
+   *
+   * <p><strong>Note:</strong> This call will fail with an exception if the requested patchset
+   * doesn't exist.
+   *
+   * @return the corresponding {@code TestPatchset}
+   */
+  TestPatchset get();
+}
diff --git a/java/com/google/gerrit/acceptance/testsuite/change/PerPatchsetOperationsImpl.java b/java/com/google/gerrit/acceptance/testsuite/change/PerPatchsetOperationsImpl.java
new file mode 100644
index 0000000..8c9a495
--- /dev/null
+++ b/java/com/google/gerrit/acceptance/testsuite/change/PerPatchsetOperationsImpl.java
@@ -0,0 +1,48 @@
+// Copyright (C) 2020 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.acceptance.testsuite.change;
+
+import com.google.gerrit.entities.PatchSet;
+import com.google.gerrit.server.notedb.ChangeNotes;
+import com.google.inject.Inject;
+import com.google.inject.assistedinject.Assisted;
+
+/**
+ * The implementation of {@link PerPatchsetOperations}.
+ *
+ * <p>There is only one implementation of {@link PerPatchsetOperations}. Nevertheless, we keep the
+ * separation between interface and implementation to enhance clarity.
+ */
+public class PerPatchsetOperationsImpl implements PerPatchsetOperations {
+  private final ChangeNotes changeNotes;
+  private final PatchSet.Id patchsetId;
+
+  public interface Factory {
+    PerPatchsetOperationsImpl create(ChangeNotes changeNotes, PatchSet.Id patchsetId);
+  }
+
+  @Inject
+  private PerPatchsetOperationsImpl(
+      @Assisted ChangeNotes changeNotes, @Assisted PatchSet.Id patchsetId) {
+    this.changeNotes = changeNotes;
+    this.patchsetId = patchsetId;
+  }
+
+  @Override
+  public TestPatchset get() {
+    PatchSet patchset = changeNotes.getPatchSets().get(patchsetId);
+    return TestPatchset.builder().patchsetId(patchsetId).commitId(patchset.commitId()).build();
+  }
+}
diff --git a/java/com/google/gerrit/acceptance/testsuite/change/TestChange.java b/java/com/google/gerrit/acceptance/testsuite/change/TestChange.java
new file mode 100644
index 0000000..ea2acaa
--- /dev/null
+++ b/java/com/google/gerrit/acceptance/testsuite/change/TestChange.java
@@ -0,0 +1,48 @@
+// Copyright (C) 2020 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.acceptance.testsuite.change;
+
+import com.google.auto.value.AutoValue;
+import com.google.gerrit.entities.Change;
+
+/** Representation of a change used for testing purposes. */
+@AutoValue
+public abstract class TestChange {
+
+  /**
+   * The numeric change ID, sometimes also called change number or legacy change ID. Unique per
+   * host.
+   */
+  public abstract Change.Id numericChangeId();
+
+  /**
+   * The Change-Id as specified in the commit message. Consists of an {@code I} followed by a 40-hex
+   * string. Only unique per project-branch.
+   */
+  public abstract String changeId();
+
+  static Builder builder() {
+    return new AutoValue_TestChange.Builder();
+  }
+
+  @AutoValue.Builder
+  abstract static class Builder {
+    abstract Builder numericChangeId(Change.Id numericChangeId);
+
+    abstract Builder changeId(String changeId);
+
+    abstract TestChange build();
+  }
+}
diff --git a/java/com/google/gerrit/acceptance/testsuite/change/TestChangeCreation.java b/java/com/google/gerrit/acceptance/testsuite/change/TestChangeCreation.java
new file mode 100644
index 0000000..65db967
--- /dev/null
+++ b/java/com/google/gerrit/acceptance/testsuite/change/TestChangeCreation.java
@@ -0,0 +1,89 @@
+// Copyright (C) 2020 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.acceptance.testsuite.change;
+
+import com.google.auto.value.AutoValue;
+import com.google.common.collect.ImmutableList;
+import com.google.gerrit.acceptance.testsuite.ThrowingFunction;
+import com.google.gerrit.entities.Account;
+import com.google.gerrit.entities.Change;
+import com.google.gerrit.entities.Project;
+import com.google.gerrit.server.edit.tree.TreeModification;
+import java.util.Optional;
+import org.eclipse.jgit.lib.Constants;
+
+/** Initial attributes of the change. If not provided, arbitrary values will be used. */
+@AutoValue
+public abstract class TestChangeCreation {
+  public abstract Optional<Project.NameKey> project();
+
+  public abstract String branch();
+
+  public abstract Optional<Account.Id> owner();
+
+  public abstract String commitMessage();
+
+  public abstract ImmutableList<TreeModification> treeModifications();
+
+  abstract ThrowingFunction<TestChangeCreation, Change.Id> changeCreator();
+
+  public static Builder builder(ThrowingFunction<TestChangeCreation, Change.Id> changeCreator) {
+    return new AutoValue_TestChangeCreation.Builder()
+        .changeCreator(changeCreator)
+        .branch(Constants.R_HEADS + Constants.MASTER)
+        .commitMessage("A test change");
+  }
+
+  @AutoValue.Builder
+  public abstract static class Builder {
+    /** Target project/Repository of the change. Must be an existing project. */
+    public abstract Builder project(Project.NameKey project);
+
+    /**
+     * Target branch of the change. Neither needs to exist nor needs to point to an actual commit.
+     */
+    public abstract Builder branch(String branch);
+
+    /** The change owner. Must be an existing user account. */
+    public abstract Builder owner(Account.Id owner);
+
+    /**
+     * The commit message. The message may contain a {@code Change-Id} footer but does not need to.
+     * If the footer is absent, it will be generated.
+     */
+    public abstract Builder commitMessage(String commitMessage);
+
+    /** Modified file of the change. The file content is specified via the returned builder. */
+    public FileContentBuilder<Builder> file(String filePath) {
+      return new FileContentBuilder<>(this, filePath, treeModificationsBuilder()::add);
+    }
+
+    abstract ImmutableList.Builder<TreeModification> treeModificationsBuilder();
+
+    abstract Builder changeCreator(ThrowingFunction<TestChangeCreation, Change.Id> changeCreator);
+
+    abstract TestChangeCreation autoBuild();
+
+    /**
+     * Creates the change.
+     *
+     * @return the {@code Change.Id} of the created change
+     */
+    public Change.Id create() {
+      TestChangeCreation changeUpdate = autoBuild();
+      return changeUpdate.changeCreator().applyAndThrowSilently(changeUpdate);
+    }
+  }
+}
diff --git a/java/com/google/gerrit/acceptance/testsuite/change/TestPatchset.java b/java/com/google/gerrit/acceptance/testsuite/change/TestPatchset.java
new file mode 100644
index 0000000..1ba242a
--- /dev/null
+++ b/java/com/google/gerrit/acceptance/testsuite/change/TestPatchset.java
@@ -0,0 +1,43 @@
+// Copyright (C) 2020 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.acceptance.testsuite.change;
+
+import com.google.auto.value.AutoValue;
+import com.google.gerrit.entities.PatchSet;
+import org.eclipse.jgit.lib.ObjectId;
+
+/** Representation of a patchset used for testing purposes. */
+@AutoValue
+public abstract class TestPatchset {
+
+  /** The numeric patchset ID. */
+  public abstract PatchSet.Id patchsetId();
+
+  /** The commit SHA-1 of the patchset. */
+  public abstract ObjectId commitId();
+
+  static Builder builder() {
+    return new AutoValue_TestPatchset.Builder();
+  }
+
+  @AutoValue.Builder
+  abstract static class Builder {
+    abstract Builder patchsetId(PatchSet.Id patchsetId);
+
+    abstract Builder commitId(ObjectId commitId);
+
+    abstract TestPatchset build();
+  }
+}
diff --git a/java/com/google/gerrit/acceptance/testsuite/change/TestPatchsetCreation.java b/java/com/google/gerrit/acceptance/testsuite/change/TestPatchsetCreation.java
new file mode 100644
index 0000000..571b1e1
--- /dev/null
+++ b/java/com/google/gerrit/acceptance/testsuite/change/TestPatchsetCreation.java
@@ -0,0 +1,61 @@
+// Copyright (C) 2020 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.acceptance.testsuite.change;
+
+import com.google.auto.value.AutoValue;
+import com.google.common.collect.ImmutableList;
+import com.google.gerrit.acceptance.testsuite.ThrowingFunction;
+import com.google.gerrit.entities.PatchSet;
+import com.google.gerrit.server.edit.tree.TreeModification;
+
+/** Initial attributes of the patchset. If not provided, arbitrary values will be used. */
+@AutoValue
+public abstract class TestPatchsetCreation {
+
+  public abstract ImmutableList<TreeModification> treeModifications();
+
+  abstract ThrowingFunction<TestPatchsetCreation, PatchSet.Id> patchsetCreator();
+
+  public static TestPatchsetCreation.Builder builder(
+      ThrowingFunction<TestPatchsetCreation, PatchSet.Id> patchsetCreator) {
+    return new AutoValue_TestPatchsetCreation.Builder().patchsetCreator(patchsetCreator);
+  }
+
+  @AutoValue.Builder
+  public abstract static class Builder {
+
+    /** Modified file of the patchset. The file content is specified via the returned builder. */
+    public FileContentBuilder<Builder> file(String filePath) {
+      return new FileContentBuilder<>(this, filePath, treeModificationsBuilder()::add);
+    }
+
+    abstract ImmutableList.Builder<TreeModification> treeModificationsBuilder();
+
+    abstract TestPatchsetCreation.Builder patchsetCreator(
+        ThrowingFunction<TestPatchsetCreation, PatchSet.Id> patchsetCreator);
+
+    abstract TestPatchsetCreation autoBuild();
+
+    /**
+     * Creates the patchset.
+     *
+     * @return the {@code PatchSet.Id} of the created patchset
+     */
+    public PatchSet.Id create() {
+      TestPatchsetCreation patchsetCreation = autoBuild();
+      return patchsetCreation.patchsetCreator().applyAndThrowSilently(patchsetCreation);
+    }
+  }
+}
diff --git a/java/com/google/gerrit/extensions/common/testing/CommitInfoSubject.java b/java/com/google/gerrit/extensions/common/testing/CommitInfoSubject.java
index d6fcb37..d344e18 100644
--- a/java/com/google/gerrit/extensions/common/testing/CommitInfoSubject.java
+++ b/java/com/google/gerrit/extensions/common/testing/CommitInfoSubject.java
@@ -18,11 +18,13 @@
 import static com.google.gerrit.extensions.common.testing.GitPersonSubject.gitPersons;
 import static com.google.gerrit.truth.ListSubject.elements;
 
+import com.google.common.truth.Correspondence;
 import com.google.common.truth.FailureMetadata;
 import com.google.common.truth.StringSubject;
 import com.google.common.truth.Subject;
 import com.google.gerrit.extensions.common.CommitInfo;
 import com.google.gerrit.truth.ListSubject;
+import com.google.gerrit.truth.NullAwareCorrespondence;
 
 public class CommitInfoSubject extends Subject {
 
@@ -65,4 +67,8 @@
     isNotNull();
     return check("message").that(commitInfo.message);
   }
+
+  public static Correspondence<CommitInfo, String> hasCommit() {
+    return NullAwareCorrespondence.transforming(commitInfo -> commitInfo.commit, "hasCommit");
+  }
 }
diff --git a/java/com/google/gerrit/server/config/PluginConfig.java b/java/com/google/gerrit/server/config/PluginConfig.java
index 7dd981c..2459d0b 100644
--- a/java/com/google/gerrit/server/config/PluginConfig.java
+++ b/java/com/google/gerrit/server/config/PluginConfig.java
@@ -61,6 +61,13 @@
         pluginName, copyConfig(cfg), Optional.ofNullable(projectConfig), groupReferences.build());
   }
 
+  public static PluginConfig createFromGerritConfig(String pluginName, Config cfg) {
+    // There is no need to make a defensive copy here because this value won't be cached.
+    // gerrit.config uses baseConfig's (a member of Config) which would also make defensive copies
+    // fail.
+    return new AutoValue_PluginConfig(pluginName, cfg, Optional.empty(), ImmutableMap.of());
+  }
+
   PluginConfig withInheritance(ProjectState.Factory projectStateFactory) {
     checkState(projectConfig().isPresent(), "no project config provided");
 
diff --git a/java/com/google/gerrit/server/config/PluginConfigFactory.java b/java/com/google/gerrit/server/config/PluginConfigFactory.java
index 0028d63..a9abd1e 100644
--- a/java/com/google/gerrit/server/config/PluginConfigFactory.java
+++ b/java/com/google/gerrit/server/config/PluginConfigFactory.java
@@ -111,7 +111,7 @@
       cfgSnapshot = FileSnapshot.save(configFile);
       cfg = cfgProvider.get();
     }
-    return PluginConfig.create(pluginName, cfg, null);
+    return PluginConfig.createFromGerritConfig(pluginName, cfg);
   }
 
   /**
diff --git a/java/com/google/gerrit/truth/MapSubject.java b/java/com/google/gerrit/truth/MapSubject.java
index 95a0e0c..4eba753 100644
--- a/java/com/google/gerrit/truth/MapSubject.java
+++ b/java/com/google/gerrit/truth/MapSubject.java
@@ -17,6 +17,7 @@
 import static com.google.common.truth.Truth.assertAbout;
 
 import com.google.common.truth.FailureMetadata;
+import com.google.common.truth.IntegerSubject;
 import com.google.common.truth.IterableSubject;
 import com.google.common.truth.Subject;
 import java.util.Map;
@@ -51,4 +52,9 @@
     isNotNull();
     return check("values()").that(map.values());
   }
+
+  public IntegerSubject size() {
+    isNotNull();
+    return check("size()").that(map.size());
+  }
 }
diff --git a/javatests/com/google/gerrit/acceptance/testsuite/change/ChangeOperationsImplTest.java b/javatests/com/google/gerrit/acceptance/testsuite/change/ChangeOperationsImplTest.java
new file mode 100644
index 0000000..4783db7
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/testsuite/change/ChangeOperationsImplTest.java
@@ -0,0 +1,462 @@
+// Copyright (C) 2020 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.acceptance.testsuite.change;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.permissionKey;
+import static com.google.gerrit.extensions.common.testing.CommitInfoSubject.assertThat;
+import static com.google.gerrit.extensions.common.testing.CommitInfoSubject.hasCommit;
+import static com.google.gerrit.extensions.restapi.testing.BinaryResultSubject.assertThat;
+import static com.google.gerrit.testing.GerritJUnit.assertThrows;
+import static com.google.gerrit.truth.MapSubject.assertThatMap;
+
+import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.acceptance.testsuite.account.AccountOperations;
+import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
+import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
+import com.google.gerrit.entities.Account;
+import com.google.gerrit.entities.Change;
+import com.google.gerrit.entities.PatchSet;
+import com.google.gerrit.entities.Permission;
+import com.google.gerrit.entities.Project;
+import com.google.gerrit.extensions.client.ChangeKind;
+import com.google.gerrit.extensions.common.ChangeInfo;
+import com.google.gerrit.extensions.common.CommitInfo;
+import com.google.gerrit.extensions.common.FileInfo;
+import com.google.gerrit.extensions.common.RevisionInfo;
+import com.google.gerrit.extensions.restapi.BinaryResult;
+import com.google.gerrit.extensions.restapi.RestApiException;
+import com.google.inject.Inject;
+import java.util.Map;
+import org.eclipse.jgit.lib.ObjectId;
+import org.junit.Test;
+
+public class ChangeOperationsImplTest extends AbstractDaemonTest {
+
+  @Inject private ChangeOperations changeOperations;
+  @Inject private ProjectOperations projectOperations;
+  @Inject private AccountOperations accountOperations;
+  @Inject private RequestScopeOperations requestScopeOperations;
+
+  @Test
+  public void changeCanBeCreatedWithoutSpecifyingAnyParameters() throws Exception {
+    Change.Id numericChangeId = changeOperations.newChange().create();
+
+    ChangeInfo change = getChangeFromServer(numericChangeId);
+    assertThat(change._number).isEqualTo(numericChangeId.get());
+    assertThat(change.changeId).isNotEmpty();
+  }
+
+  @Test
+  public void changeCanBeCreatedEvenWithRequestScopeOfArbitraryUser() throws Exception {
+    Account.Id user = accountOperations.newAccount().create();
+
+    requestScopeOperations.setApiUser(user);
+    Change.Id numericChangeId = changeOperations.newChange().create();
+
+    ChangeInfo change = getChangeFromServer(numericChangeId);
+    assertThat(change._number).isEqualTo(numericChangeId.get());
+  }
+
+  @Test
+  public void twoChangesWithoutAnyParametersDoNotClash() {
+    Change.Id changeId1 = changeOperations.newChange().create();
+    Change.Id changeId2 = changeOperations.newChange().create();
+
+    TestChange change1 = changeOperations.change(changeId1).get();
+    TestChange change2 = changeOperations.change(changeId2).get();
+    assertThat(change1.numericChangeId()).isNotEqualTo(change2.numericChangeId());
+    assertThat(change1.changeId()).isNotEqualTo(change2.changeId());
+  }
+
+  @Test
+  public void twoSubsequentlyCreatedChangesDoNotDependOnEachOther() throws Exception {
+    Change.Id changeId1 = changeOperations.newChange().create();
+    Change.Id changeId2 = changeOperations.newChange().create();
+
+    ChangeInfo change1 = getChangeFromServer(changeId1);
+    ChangeInfo change2 = getChangeFromServer(changeId2);
+    CommitInfo currentPatchsetCommit1 = change1.revisions.get(change1.currentRevision).commit;
+    CommitInfo currentPatchsetCommit2 = change2.revisions.get(change2.currentRevision).commit;
+    assertThat(currentPatchsetCommit1)
+        .parents()
+        .comparingElementsUsing(hasCommit())
+        .doesNotContain(currentPatchsetCommit2.commit);
+    assertThat(currentPatchsetCommit2)
+        .parents()
+        .comparingElementsUsing(hasCommit())
+        .doesNotContain(currentPatchsetCommit1.commit);
+  }
+
+  @Test
+  public void createdChangeHasAtLeastOnePatchset() throws Exception {
+    Change.Id changeId = changeOperations.newChange().create();
+
+    ChangeInfo change = getChangeFromServer(changeId);
+    assertThatMap(change.revisions).size().isAtLeast(1);
+  }
+
+  @Test
+  public void createdChangeIsInSpecifiedProject() throws Exception {
+    Project.NameKey project = projectOperations.newProject().create();
+    Change.Id changeId = changeOperations.newChange().project(project).create();
+
+    ChangeInfo change = getChangeFromServer(changeId);
+    assertThat(change.project).isEqualTo(project.get());
+  }
+
+  @Test
+  public void changeCanBeCreatedInEmptyRepository() throws Exception {
+    Project.NameKey project = projectOperations.newProject().noEmptyCommit().create();
+    Change.Id changeId = changeOperations.newChange().project(project).create();
+
+    ChangeInfo change = getChangeFromServer(changeId);
+    assertThat(change.project).isEqualTo(project.get());
+  }
+
+  @Test
+  public void createdChangeHasSpecifiedTargetBranch() throws Exception {
+    Project.NameKey project = projectOperations.newProject().branches("test-branch").create();
+    Change.Id changeId =
+        changeOperations.newChange().project(project).branch("test-branch").create();
+
+    ChangeInfo change = getChangeFromServer(changeId);
+    assertThat(change.branch).isEqualTo("test-branch");
+  }
+
+  @Test
+  public void createdChangeUsesTipOfTargetBranchAsParentByDefault() throws Exception {
+    Project.NameKey project = projectOperations.newProject().branches("test-branch").create();
+    ObjectId parentCommitId = projectOperations.project(project).getHead("test-branch").getId();
+    Change.Id changeId =
+        changeOperations.newChange().project(project).branch("test-branch").create();
+
+    ChangeInfo change = getChangeFromServer(changeId);
+    CommitInfo currentPatchsetCommit = change.revisions.get(change.currentRevision).commit;
+    assertThat(currentPatchsetCommit)
+        .parents()
+        .onlyElement()
+        .commit()
+        .isEqualTo(parentCommitId.getName());
+  }
+
+  @Test
+  public void createdChangeHasSpecifiedOwner() throws Exception {
+    Account.Id changeOwner = accountOperations.newAccount().create();
+    Change.Id changeId = changeOperations.newChange().owner(changeOwner).create();
+
+    ChangeInfo change = getChangeFromServer(changeId);
+    assertThat(change.owner._accountId).isEqualTo(changeOwner.get());
+  }
+
+  @Test
+  public void changeOwnerDoesNotNeedAnyPermissionsForChangeCreation() throws Exception {
+    Account.Id changeOwner = accountOperations.newAccount().create();
+    Project.NameKey project = projectOperations.newProject().branches("test-branch").create();
+    // Remove any read and push permissions which might potentially exist. Without read, users
+    // shouldn't be able to do anything. The newly created project should only inherit from
+    // All-Projects.
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .remove(permissionKey(Permission.READ).ref("refs/heads/test-branch"))
+        .remove(permissionKey(Permission.PUSH).ref("refs/heads/test-branch"))
+        .update();
+    projectOperations
+        .allProjectsForUpdate()
+        .remove(permissionKey(Permission.READ).ref("refs/heads/test-branch"))
+        .remove(permissionKey(Permission.PUSH).ref("refs/heads/test-branch"))
+        .update();
+
+    Change.Id changeId =
+        changeOperations
+            .newChange()
+            .owner(changeOwner)
+            .branch("test-branch")
+            .project(project)
+            .create();
+
+    ChangeInfo change = getChangeFromServer(changeId);
+    assertThat(change.owner._accountId).isEqualTo(changeOwner.get());
+  }
+
+  @Test
+  public void createdChangeHasSpecifiedCommitMessage() throws Exception {
+    Change.Id changeId =
+        changeOperations
+            .newChange()
+            .commitMessage("Summary line\n\nDetailed description.")
+            .create();
+
+    ChangeInfo change = getChangeFromServer(changeId);
+    CommitInfo currentPatchsetCommit = change.revisions.get(change.currentRevision).commit;
+    assertThat(currentPatchsetCommit).message().startsWith("Summary line\n\nDetailed description.");
+  }
+
+  @Test
+  public void changeCannotBeCreatedWithoutCommitMessage() {
+    assertThrows(
+        IllegalStateException.class, () -> changeOperations.newChange().commitMessage("").create());
+  }
+
+  @Test
+  public void commitMessageOfCreatedChangeAutomaticallyGetsChangeId() throws Exception {
+    Change.Id changeId =
+        changeOperations
+            .newChange()
+            .commitMessage("Summary line\n\nDetailed description.")
+            .create();
+
+    ChangeInfo change = getChangeFromServer(changeId);
+    CommitInfo currentPatchsetCommit = change.revisions.get(change.currentRevision).commit;
+    assertThat(currentPatchsetCommit).message().contains("Change-Id:");
+  }
+
+  @Test
+  public void changeIdSpecifiedInCommitMessageIsKeptForCreatedChange() throws Exception {
+    Change.Id changeId =
+        changeOperations
+            .newChange()
+            .commitMessage("Summary line\n\nChange-Id: I0123456789012345678901234567890123456789")
+            .create();
+
+    ChangeInfo change = getChangeFromServer(changeId);
+    CommitInfo currentPatchsetCommit = change.revisions.get(change.currentRevision).commit;
+    assertThat(currentPatchsetCommit)
+        .message()
+        .contains("Change-Id: I0123456789012345678901234567890123456789");
+    assertThat(change.changeId).isEqualTo("I0123456789012345678901234567890123456789");
+  }
+
+  @Test
+  public void createdChangeHasSpecifiedFiles() throws Exception {
+    Change.Id changeId =
+        changeOperations
+            .newChange()
+            .file("file1")
+            .content("Line 1")
+            .file("path/to/file2.txt")
+            .content("Line one")
+            .create();
+
+    ChangeInfo change = getChangeFromServer(changeId);
+    Map<String, FileInfo> files = change.revisions.get(change.currentRevision).files;
+    assertThatMap(files).keys().containsExactly("file1", "path/to/file2.txt");
+    BinaryResult fileContent1 = gApi.changes().id(changeId.get()).current().file("file1").content();
+    assertThat(fileContent1).asString().isEqualTo("Line 1");
+    BinaryResult fileContent2 =
+        gApi.changes().id(changeId.get()).current().file("path/to/file2.txt").content();
+    assertThat(fileContent2).asString().isEqualTo("Line one");
+  }
+
+  @Test
+  public void existingChangeCanBeCheckedForExistence() {
+    Change.Id changeId = changeOperations.newChange().create();
+
+    boolean exists = changeOperations.change(changeId).exists();
+
+    assertThat(exists).isTrue();
+  }
+
+  @Test
+  public void notExistingChangeCanBeCheckedForExistence() {
+    Change.Id changeId = Change.id(123456789);
+
+    boolean exists = changeOperations.change(changeId).exists();
+
+    assertThat(exists).isFalse();
+  }
+
+  @Test
+  public void retrievingNotExistingChangeFails() {
+    Change.Id changeId = Change.id(123456789);
+    assertThrows(IllegalStateException.class, () -> changeOperations.change(changeId).get());
+  }
+
+  @Test
+  public void numericChangeIdOfExistingChangeCanBeRetrieved() {
+    Change.Id changeId = changeOperations.newChange().create();
+
+    TestChange change = changeOperations.change(changeId).get();
+    assertThat(change.numericChangeId()).isEqualTo(changeId);
+  }
+
+  @Test
+  public void changeIdOfExistingChangeCanBeRetrieved() {
+    Change.Id changeId =
+        changeOperations
+            .newChange()
+            .commitMessage("Summary line\n\nChange-Id: I0123456789012345678901234567890123456789")
+            .create();
+
+    TestChange change = changeOperations.change(changeId).get();
+    assertThat(change.changeId()).isEqualTo("I0123456789012345678901234567890123456789");
+  }
+
+  @Test
+  public void currentPatchsetOfExistingChangeCanBeRetrieved() throws Exception {
+    Change.Id changeId = changeOperations.newChange().create();
+
+    TestPatchset patchset = changeOperations.change(changeId).currentPatchset().get();
+
+    ChangeInfo expectedChange = getChangeFromServer(changeId);
+    String expectedCommitId = expectedChange.currentRevision;
+    int expectedPatchsetNumber = expectedChange.revisions.get(expectedCommitId)._number;
+    assertThat(patchset.commitId()).isEqualTo(ObjectId.fromString(expectedCommitId));
+    assertThat(patchset.patchsetId()).isEqualTo(PatchSet.id(changeId, expectedPatchsetNumber));
+  }
+
+  @Test
+  public void earlierPatchsetOfExistingChangeCanBeRetrieved() {
+    Change.Id changeId = changeOperations.newChange().create();
+    PatchSet.Id earlierPatchsetId =
+        changeOperations.change(changeId).currentPatchset().get().patchsetId();
+    PatchSet.Id currentPatchsetId = changeOperations.change(changeId).newPatchset().create();
+
+    TestPatchset earlierPatchset =
+        changeOperations.change(changeId).patchset(earlierPatchsetId).get();
+
+    assertThat(earlierPatchset.patchsetId()).isEqualTo(earlierPatchsetId);
+    assertThat(earlierPatchset.patchsetId()).isNotEqualTo(currentPatchsetId);
+  }
+
+  @Test
+  public void newPatchsetCanBeCreatedWithoutSpecifyingAnyParameters() throws Exception {
+    Change.Id changeId = changeOperations.newChange().create();
+    ChangeInfo unmodifiedChange = getChangeFromServer(changeId);
+    int originalPatchsetCount = unmodifiedChange.revisions.size();
+
+    PatchSet.Id patchsetId = changeOperations.change(changeId).newPatchset().create();
+
+    ChangeInfo change = getChangeFromServer(changeId);
+    assertThatMap(change.revisions).hasSize(originalPatchsetCount + 1);
+    RevisionInfo currentRevision = change.revisions.get(change.currentRevision);
+    assertThat(currentRevision._number).isEqualTo(patchsetId.get());
+  }
+
+  @Test
+  public void newPatchsetIsCopyOfPreviousPatchsetByDefault() throws Exception {
+    Change.Id changeId = changeOperations.newChange().create();
+
+    PatchSet.Id patchsetId = changeOperations.change(changeId).newPatchset().create();
+
+    ChangeInfo change = getChangeFromServer(changeId);
+    RevisionInfo patchsetRevision = getRevision(change, patchsetId);
+    assertThat(patchsetRevision.kind).isEqualTo(ChangeKind.NO_CHANGE);
+  }
+
+  @Test
+  public void newPatchsetCanHaveReplacedFileContent() throws Exception {
+    Change.Id changeId = changeOperations.newChange().file("file1").content("Line 1").create();
+
+    PatchSet.Id patchsetId =
+        changeOperations
+            .change(changeId)
+            .newPatchset()
+            .file("file1")
+            .content("Different content")
+            .create();
+
+    ChangeInfo change = getChangeFromServer(changeId);
+    Map<String, FileInfo> files = change.revisions.get(change.currentRevision).files;
+    assertThatMap(files).keys().containsExactly("file1");
+    BinaryResult fileContent = getFileContent(changeId, patchsetId, "file1");
+    assertThat(fileContent).asString().isEqualTo("Different content");
+  }
+
+  @Test
+  public void newPatchsetCanHaveAdditionalFile() throws Exception {
+    Change.Id changeId = changeOperations.newChange().file("file1").content("Line 1").create();
+
+    PatchSet.Id patchsetId =
+        changeOperations
+            .change(changeId)
+            .newPatchset()
+            .file("file2")
+            .content("My file content")
+            .create();
+
+    ChangeInfo change = getChangeFromServer(changeId);
+    Map<String, FileInfo> files = change.revisions.get(change.currentRevision).files;
+    assertThatMap(files).keys().containsExactly("file1", "file2");
+    BinaryResult fileContent = getFileContent(changeId, patchsetId, "file2");
+    assertThat(fileContent).asString().isEqualTo("My file content");
+  }
+
+  @Test
+  public void newPatchsetCanHaveLessFiles() throws Exception {
+    Change.Id changeId =
+        changeOperations
+            .newChange()
+            .file("file1")
+            .content("Line 1")
+            .file("file2")
+            .content("Line one")
+            .create();
+
+    changeOperations.change(changeId).newPatchset().file("file2").delete().create();
+
+    ChangeInfo change = getChangeFromServer(changeId);
+    Map<String, FileInfo> files = change.revisions.get(change.currentRevision).files;
+    assertThatMap(files).keys().containsExactly("file1");
+  }
+
+  @Test
+  public void newPatchsetCanHaveRenamedFile() throws Exception {
+    Change.Id changeId =
+        changeOperations
+            .newChange()
+            .file("file1")
+            .content("Line 1")
+            .file("file2")
+            .content("Line one")
+            .create();
+
+    PatchSet.Id patchsetId =
+        changeOperations
+            .change(changeId)
+            .newPatchset()
+            .file("file2")
+            .renameTo("renamed file")
+            .create();
+
+    ChangeInfo change = getChangeFromServer(changeId);
+    Map<String, FileInfo> files = change.revisions.get(change.currentRevision).files;
+    assertThatMap(files).keys().containsExactly("file1", "renamed file");
+    BinaryResult fileContent = getFileContent(changeId, patchsetId, "renamed file");
+    assertThat(fileContent).asString().isEqualTo("Line one");
+  }
+
+  private ChangeInfo getChangeFromServer(Change.Id changeId) throws RestApiException {
+    return gApi.changes().id(changeId.get()).get();
+  }
+
+  private RevisionInfo getRevision(ChangeInfo change, PatchSet.Id patchsetId) {
+    return change.revisions.values().stream()
+        .filter(revision -> revision._number == patchsetId.get())
+        .findAny()
+        .orElseThrow(
+            () ->
+                new IllegalStateException(
+                    String.format(
+                        "Change %d doesn't have specified patchset %d.",
+                        change._number, patchsetId.get())));
+  }
+
+  private BinaryResult getFileContent(Change.Id changeId, PatchSet.Id patchsetId, String filePath)
+      throws RestApiException {
+    return gApi.changes().id(changeId.get()).revision(patchsetId.get()).file(filePath).content();
+  }
+}
diff --git a/polygerrit-ui/app/elements/shared/gr-dialog/gr-dialog.js b/polygerrit-ui/app/elements/shared/gr-dialog/gr-dialog.js
deleted file mode 100644
index 2292ae7..0000000
--- a/polygerrit-ui/app/elements/shared/gr-dialog/gr-dialog.js
+++ /dev/null
@@ -1,115 +0,0 @@
-/**
- * @license
- * Copyright (C) 2016 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 '../gr-button/gr-button.js';
-import '../../../styles/shared-styles.js';
-import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners.js';
-import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin.js';
-import {PolymerElement} from '@polymer/polymer/polymer-element.js';
-import {htmlTemplate} from './gr-dialog_html.js';
-
-/**
- * @extends PolymerElement
- */
-class GrDialog extends GestureEventListeners(
-    LegacyElementMixin(PolymerElement)) {
-  static get template() { return htmlTemplate; }
-
-  static get is() { return 'gr-dialog'; }
-  /**
-   * Fired when the confirm button is pressed.
-   *
-   * @event confirm
-   */
-
-  /**
-   * Fired when the cancel button is pressed.
-   *
-   * @event cancel
-   */
-
-  static get properties() {
-    return {
-      confirmLabel: {
-        type: String,
-        value: 'Confirm',
-      },
-      // Supplying an empty cancel label will hide the button completely.
-      cancelLabel: {
-        type: String,
-        value: 'Cancel',
-      },
-      disabled: {
-        type: Boolean,
-        value: false,
-      },
-      confirmOnEnter: {
-        type: Boolean,
-        value: false,
-      },
-      confirmTooltip: {
-        type: String,
-        observer: '_handleConfirmTooltipUpdate',
-      },
-    };
-  }
-
-  /** @override */
-  ready() {
-    super.ready();
-    this._ensureAttribute('role', 'dialog');
-  }
-
-  _handleConfirmTooltipUpdate(confirmTooltip) {
-    if (confirmTooltip) {
-      this.$.confirm.setAttribute('has-tooltip', true);
-    } else {
-      this.$.confirm.removeAttribute('has-tooltip');
-    }
-  }
-
-  _handleConfirm(e) {
-    if (this.disabled) { return; }
-
-    e.preventDefault();
-    e.stopPropagation();
-    this.dispatchEvent(new CustomEvent('confirm', {
-      composed: true, bubbles: false,
-    }));
-  }
-
-  _handleCancelTap(e) {
-    e.preventDefault();
-    e.stopPropagation();
-    this.dispatchEvent(new CustomEvent('cancel', {
-      composed: true, bubbles: false,
-    }));
-  }
-
-  _handleKeydown(e) {
-    if (this.confirmOnEnter && e.keyCode === 13) { this._handleConfirm(e); }
-  }
-
-  resetFocus() {
-    this.$.confirm.focus();
-  }
-
-  _computeCancelClass(cancelLabel) {
-    return cancelLabel.length ? '' : 'hidden';
-  }
-}
-
-customElements.define(GrDialog.is, GrDialog);
diff --git a/polygerrit-ui/app/elements/shared/gr-dialog/gr-dialog.ts b/polygerrit-ui/app/elements/shared/gr-dialog/gr-dialog.ts
new file mode 100644
index 0000000..fa6403a
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-dialog/gr-dialog.ts
@@ -0,0 +1,128 @@
+/**
+ * @license
+ * Copyright (C) 2016 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 '../gr-button/gr-button';
+import '../../../styles/shared-styles';
+import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
+import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
+import {PolymerElement} from '@polymer/polymer/polymer-element';
+import {htmlTemplate} from './gr-dialog_html';
+import {customElement, property, observe} from '@polymer/decorators';
+import {GrButton} from '../gr-button/gr-button';
+
+declare global {
+  interface HTMLElementTagNameMap {
+    'gr-dialog': GrDialog;
+  }
+}
+
+export interface GrDialog {
+  $: {
+    confirm: GrButton;
+  };
+}
+
+@customElement('gr-dialog')
+export class GrDialog extends GestureEventListeners(
+  LegacyElementMixin(PolymerElement)
+) {
+  static get template() {
+    return htmlTemplate;
+  }
+
+  /**
+   * Fired when the confirm button is pressed.
+   *
+   * @event confirm
+   */
+
+  /**
+   * Fired when the cancel button is pressed.
+   *
+   * @event cancel
+   */
+
+  @property({type: String})
+  confirmLabel = 'Confirm';
+
+  // Supplying an empty cancel label will hide the button completely.
+  @property({type: String})
+  cancelLabel = 'Cancel';
+
+  @property({type: Boolean})
+  disabled = false;
+
+  @property({type: Boolean})
+  confirmOnEnter = false;
+
+  @property({type: String})
+  confirmTooltip?: string;
+
+  /** @override */
+  ready() {
+    super.ready();
+    this._ensureAttribute('role', 'dialog');
+  }
+
+  @observe('confirmTooltip')
+  _handleConfirmTooltipUpdate(confirmTooltip?: string) {
+    if (confirmTooltip) {
+      this.$.confirm.setAttribute('has-tooltip', 'true');
+    } else {
+      this.$.confirm.removeAttribute('has-tooltip');
+    }
+  }
+
+  _handleConfirm(e: KeyboardEvent) {
+    if (this.disabled) {
+      return;
+    }
+
+    e.preventDefault();
+    e.stopPropagation();
+    this.dispatchEvent(
+      new CustomEvent('confirm', {
+        composed: true,
+        bubbles: false,
+      })
+    );
+  }
+
+  _handleCancelTap(e: MouseEvent) {
+    e.preventDefault();
+    e.stopPropagation();
+    this.dispatchEvent(
+      new CustomEvent('cancel', {
+        composed: true,
+        bubbles: false,
+      })
+    );
+  }
+
+  _handleKeydown(e: KeyboardEvent) {
+    if (this.confirmOnEnter && e.keyCode === 13) {
+      this._handleConfirm(e);
+    }
+  }
+
+  resetFocus() {
+    this.$.confirm.focus();
+  }
+
+  _computeCancelClass(cancelLabel: string) {
+    return cancelLabel.length ? '' : 'hidden';
+  }
+}