Merge "Merge branch 'stable-3.2'"
diff --git a/Documentation/dev-core-plugins.txt b/Documentation/dev-core-plugins.txt
index 04e2420..aa519806 100644
--- a/Documentation/dev-core-plugins.txt
+++ b/Documentation/dev-core-plugins.txt
@@ -155,6 +155,52 @@
link:https://www.gerritcodereview.com/news.html[project news].
--
+[[removing]]
+=== Removing Core Plugins
+
+A core plugin could be subject to NOT be considered core anymore if:
+
+1. Does not respect the license:
++
+The plugin code or the libraries used are not following anymore the
+Apache License Version 2.0.
+
+2. Is out of scope:
++
+The plugin functionality has gone outside the Gerrit-related scope,
+has a clear scope or conflict with other core plugins or existing and
+planned Gerrit core features.
+
+NOTE: The plugin would need to remain core until the planned replacement gets
+implemented. Otherwise the feature is likely missing between the removal and
+planned implementation times.
+
+3. Is not relevant:
++
+The plugin functionality is no more relevant to a majority of the Gerrit community:
++
+--
+** An out of the box Gerrit installation won’t be missing anything if the plugin is
+ not installed.
+** It isn’t anymore used by most sites.
+** Multiple parties (different organizations/companies) have abandoned the use of
+ the plugin and agree that it should not be anymore a core plugin.
+** If the same or similar functionality is provided by multiple plugins, the plugin
+ is not a clear recommended solution anymore by the community.
+** Whether a plugin is no more relevant to a majority of the Gerrit community must be
+ discussed on a case-by-case basis. In case of doubt, it’s up to the engineering
+ steering committee to make a decision.
+--
+
+4. Degraded code quality:
++
+The plugin code maintenance is lacking and has not anymore good test coverage.
+Maintaining the plugin code creates a significant overhead for the Gerrit maintainers.
+
+5. Outdated documentation:
++
+The plugin functionality documented is significantly outdated.
+
GERRIT
------
Part of link:index.html[Gerrit Code Review]
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/acceptance/testsuite/project/ProjectOperationsImpl.java b/java/com/google/gerrit/acceptance/testsuite/project/ProjectOperationsImpl.java
index c894059..e4d594b 100644
--- a/java/com/google/gerrit/acceptance/testsuite/project/ProjectOperationsImpl.java
+++ b/java/com/google/gerrit/acceptance/testsuite/project/ProjectOperationsImpl.java
@@ -14,6 +14,7 @@
package com.google.gerrit.acceptance.testsuite.project;
+import static com.google.common.collect.ImmutableList.toImmutableList;
import static com.google.gerrit.entities.RefNames.REFS_CONFIG;
import static com.google.gerrit.server.project.ProjectConfig.PROJECT_CONFIG;
import static java.nio.charset.StandardCharsets.UTF_8;
@@ -43,12 +44,10 @@
import com.google.inject.Inject;
import java.io.IOException;
import java.util.ArrayList;
-import java.util.Collections;
import org.apache.commons.lang.RandomStringUtils;
import org.eclipse.jgit.errors.ConfigInvalidException;
import org.eclipse.jgit.junit.TestRepository;
import org.eclipse.jgit.lib.Config;
-import org.eclipse.jgit.lib.Constants;
import org.eclipse.jgit.lib.ObjectLoader;
import org.eclipse.jgit.lib.Ref;
import org.eclipse.jgit.lib.Repository;
@@ -91,7 +90,8 @@
CreateProjectArgs args = new CreateProjectArgs();
args.setProjectName(name);
- args.branch = Collections.singletonList(Constants.R_HEADS + Constants.MASTER);
+ args.branch =
+ projectCreation.branches().stream().map(RefNames::fullName).collect(toImmutableList());
args.createEmptyCommit = projectCreation.createEmptyCommit().orElse(true);
projectCreation.parent().ifPresent(p -> args.newParent = p);
// ProjectCreator wants non-null owner IDs.
@@ -211,9 +211,7 @@
}
private RevCommit headOrNull(String branch) {
- if (!branch.startsWith(Constants.R_REFS)) {
- branch = RefNames.REFS_HEADS + branch;
- }
+ branch = RefNames.fullName(branch);
try (Repository repo = repoManager.openRepository(nameKey);
RevWalk rw = new RevWalk(repo)) {
diff --git a/java/com/google/gerrit/acceptance/testsuite/project/TestProjectCreation.java b/java/com/google/gerrit/acceptance/testsuite/project/TestProjectCreation.java
index 99e045c..2649dea 100644
--- a/java/com/google/gerrit/acceptance/testsuite/project/TestProjectCreation.java
+++ b/java/com/google/gerrit/acceptance/testsuite/project/TestProjectCreation.java
@@ -15,10 +15,14 @@
package com.google.gerrit.acceptance.testsuite.project;
import com.google.auto.value.AutoValue;
+import com.google.common.collect.ImmutableSet;
+import com.google.common.collect.Sets;
import com.google.gerrit.acceptance.testsuite.ThrowingFunction;
import com.google.gerrit.entities.Project;
import com.google.gerrit.extensions.client.SubmitType;
import java.util.Optional;
+import java.util.Set;
+import org.eclipse.jgit.lib.Constants;
@AutoValue
public abstract class TestProjectCreation {
@@ -27,6 +31,8 @@
public abstract Optional<Project.NameKey> parent();
+ public abstract ImmutableSet<String> branches();
+
public abstract Optional<Boolean> createEmptyCommit();
public abstract Optional<SubmitType> submitType();
@@ -35,7 +41,9 @@
public static Builder builder(
ThrowingFunction<TestProjectCreation, Project.NameKey> projectCreator) {
- return new AutoValue_TestProjectCreation.Builder().projectCreator(projectCreator);
+ return new AutoValue_TestProjectCreation.Builder()
+ .branches(Constants.R_HEADS + Constants.MASTER)
+ .projectCreator(projectCreator);
}
@AutoValue.Builder
@@ -46,6 +54,17 @@
public abstract TestProjectCreation.Builder submitType(SubmitType submitType);
+ /**
+ * Branches which should be created in the repository (with an empty root commit). The
+ * "refs/heads/" prefix of the branch name can be omitted. The specified branches are ignored if
+ * {@link #noEmptyCommit()} is used.
+ */
+ public TestProjectCreation.Builder branches(String branch1, String... otherBranches) {
+ return branches(Sets.union(ImmutableSet.of(branch1), ImmutableSet.copyOf(otherBranches)));
+ }
+
+ abstract TestProjectCreation.Builder branches(Set<String> branches);
+
public abstract TestProjectCreation.Builder createEmptyCommit(boolean value);
/** Skips the empty commit on creation. This means that project's branches will not exist. */
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/server/edit/ChangeEditModifier.java b/java/com/google/gerrit/server/edit/ChangeEditModifier.java
index 48683ea..a05c392 100644
--- a/java/com/google/gerrit/server/edit/ChangeEditModifier.java
+++ b/java/com/google/gerrit/server/edit/ChangeEditModifier.java
@@ -498,7 +498,7 @@
throws BadRequestException, IOException, InvalidChangeOperationException {
ObjectId newTreeId;
try {
- TreeCreator treeCreator = new TreeCreator(baseCommit);
+ TreeCreator treeCreator = TreeCreator.basedOn(baseCommit);
treeCreator.addTreeModifications(treeModifications);
newTreeId = treeCreator.createNewTreeAndGetId(repository);
} catch (InvalidPathException e) {
diff --git a/java/com/google/gerrit/server/edit/tree/ChangeFileContentModification.java b/java/com/google/gerrit/server/edit/tree/ChangeFileContentModification.java
index 0adacd8..4821a7a 100644
--- a/java/com/google/gerrit/server/edit/tree/ChangeFileContentModification.java
+++ b/java/com/google/gerrit/server/edit/tree/ChangeFileContentModification.java
@@ -18,6 +18,7 @@
import static org.eclipse.jgit.lib.Constants.OBJ_BLOB;
import com.google.common.annotations.VisibleForTesting;
+import com.google.common.collect.ImmutableList;
import com.google.common.flogger.FluentLogger;
import com.google.common.io.ByteStreams;
import com.google.gerrit.extensions.restapi.RawInput;
@@ -33,7 +34,6 @@
import org.eclipse.jgit.lib.ObjectId;
import org.eclipse.jgit.lib.ObjectInserter;
import org.eclipse.jgit.lib.Repository;
-import org.eclipse.jgit.revwalk.RevCommit;
/** A {@code TreeModification} which changes the content of a file. */
public class ChangeFileContentModification implements TreeModification {
@@ -48,7 +48,8 @@
}
@Override
- public List<DirCacheEditor.PathEdit> getPathEdits(Repository repository, RevCommit baseCommit) {
+ public List<DirCacheEditor.PathEdit> getPathEdits(
+ Repository repository, ObjectId treeId, ImmutableList<? extends ObjectId> parents) {
DirCacheEditor.PathEdit changeContentEdit = new ChangeContent(filePath, newContent, repository);
return Collections.singletonList(changeContentEdit);
}
diff --git a/java/com/google/gerrit/server/edit/tree/DeleteFileModification.java b/java/com/google/gerrit/server/edit/tree/DeleteFileModification.java
index feffb70..3b7826c 100644
--- a/java/com/google/gerrit/server/edit/tree/DeleteFileModification.java
+++ b/java/com/google/gerrit/server/edit/tree/DeleteFileModification.java
@@ -14,11 +14,12 @@
package com.google.gerrit.server.edit.tree;
+import com.google.common.collect.ImmutableList;
import java.util.Collections;
import java.util.List;
import org.eclipse.jgit.dircache.DirCacheEditor;
+import org.eclipse.jgit.lib.ObjectId;
import org.eclipse.jgit.lib.Repository;
-import org.eclipse.jgit.revwalk.RevCommit;
/** A {@code TreeModification} which deletes a file. */
public class DeleteFileModification implements TreeModification {
@@ -30,7 +31,8 @@
}
@Override
- public List<DirCacheEditor.PathEdit> getPathEdits(Repository repository, RevCommit baseCommit) {
+ public List<DirCacheEditor.PathEdit> getPathEdits(
+ Repository repository, ObjectId treeId, ImmutableList<? extends ObjectId> parents) {
DirCacheEditor.DeletePath deletePathEdit = new DirCacheEditor.DeletePath(filePath);
return Collections.singletonList(deletePathEdit);
}
diff --git a/java/com/google/gerrit/server/edit/tree/RenameFileModification.java b/java/com/google/gerrit/server/edit/tree/RenameFileModification.java
index b847599..d4412e4 100644
--- a/java/com/google/gerrit/server/edit/tree/RenameFileModification.java
+++ b/java/com/google/gerrit/server/edit/tree/RenameFileModification.java
@@ -14,13 +14,12 @@
package com.google.gerrit.server.edit.tree;
+import com.google.common.collect.ImmutableList;
import java.io.IOException;
-import java.util.Arrays;
-import java.util.Collections;
import java.util.List;
import org.eclipse.jgit.dircache.DirCacheEditor;
+import org.eclipse.jgit.lib.ObjectId;
import org.eclipse.jgit.lib.Repository;
-import org.eclipse.jgit.revwalk.RevCommit;
import org.eclipse.jgit.revwalk.RevWalk;
import org.eclipse.jgit.treewalk.TreeWalk;
@@ -36,19 +35,23 @@
}
@Override
- public List<DirCacheEditor.PathEdit> getPathEdits(Repository repository, RevCommit baseCommit)
+ public List<DirCacheEditor.PathEdit> getPathEdits(
+ Repository repository, ObjectId treeId, ImmutableList<? extends ObjectId> parents)
throws IOException {
+ if (ObjectId.zeroId().equals(treeId)) {
+ return ImmutableList.of();
+ }
+
try (RevWalk revWalk = new RevWalk(repository)) {
- revWalk.parseHeaders(baseCommit);
try (TreeWalk treeWalk =
- TreeWalk.forPath(revWalk.getObjectReader(), currentFilePath, baseCommit.getTree())) {
+ TreeWalk.forPath(revWalk.getObjectReader(), currentFilePath, treeId)) {
if (treeWalk == null) {
- return Collections.emptyList();
+ return ImmutableList.of();
}
DirCacheEditor.DeletePath deletePathEdit = new DirCacheEditor.DeletePath(currentFilePath);
AddPath addPathEdit =
new AddPath(newFilePath, treeWalk.getFileMode(0), treeWalk.getObjectId(0));
- return Arrays.asList(deletePathEdit, addPathEdit);
+ return ImmutableList.of(deletePathEdit, addPathEdit);
}
}
}
diff --git a/java/com/google/gerrit/server/edit/tree/RestoreFileModification.java b/java/com/google/gerrit/server/edit/tree/RestoreFileModification.java
index 393a866..730664b 100644
--- a/java/com/google/gerrit/server/edit/tree/RestoreFileModification.java
+++ b/java/com/google/gerrit/server/edit/tree/RestoreFileModification.java
@@ -14,10 +14,12 @@
package com.google.gerrit.server.edit.tree;
+import com.google.common.collect.ImmutableList;
import java.io.IOException;
import java.util.Collections;
import java.util.List;
import org.eclipse.jgit.dircache.DirCacheEditor;
+import org.eclipse.jgit.lib.ObjectId;
import org.eclipse.jgit.lib.Repository;
import org.eclipse.jgit.revwalk.RevCommit;
import org.eclipse.jgit.revwalk.RevWalk;
@@ -36,16 +38,16 @@
}
@Override
- public List<DirCacheEditor.PathEdit> getPathEdits(Repository repository, RevCommit baseCommit)
+ public List<DirCacheEditor.PathEdit> getPathEdits(
+ Repository repository, ObjectId treeId, ImmutableList<? extends ObjectId> parents)
throws IOException {
- if (baseCommit.getParentCount() == 0) {
+ if (parents.isEmpty()) {
DirCacheEditor.DeletePath deletePath = new DirCacheEditor.DeletePath(filePath);
return Collections.singletonList(deletePath);
}
- RevCommit base = baseCommit.getParent(0);
try (RevWalk revWalk = new RevWalk(repository)) {
- revWalk.parseHeaders(base);
+ RevCommit base = revWalk.parseCommit(parents.get(0));
try (TreeWalk treeWalk =
TreeWalk.forPath(revWalk.getObjectReader(), filePath, base.getTree())) {
if (treeWalk == null) {
diff --git a/java/com/google/gerrit/server/edit/tree/TreeCreator.java b/java/com/google/gerrit/server/edit/tree/TreeCreator.java
index e6caf97..3c400ec 100644
--- a/java/com/google/gerrit/server/edit/tree/TreeCreator.java
+++ b/java/com/google/gerrit/server/edit/tree/TreeCreator.java
@@ -16,6 +16,7 @@
import static java.util.Objects.requireNonNull;
+import com.google.common.collect.ImmutableList;
import java.io.IOException;
import java.util.ArrayList;
import java.util.List;
@@ -31,15 +32,26 @@
/**
* A creator for a new Git tree. To create the new tree, the tree of another commit is taken as a
- * basis and modified.
+ * basis and modified. Alternatively, an empty tree can serve as base.
*/
public class TreeCreator {
- private final RevCommit baseCommit;
+ private final ObjectId baseTreeId;
+ private final ImmutableList<? extends ObjectId> baseParents;
private final List<TreeModification> treeModifications = new ArrayList<>();
- public TreeCreator(RevCommit baseCommit) {
- this.baseCommit = requireNonNull(baseCommit, "baseCommit is required");
+ public static TreeCreator basedOn(RevCommit baseCommit) {
+ requireNonNull(baseCommit, "baseCommit is required");
+ return new TreeCreator(baseCommit.getTree(), ImmutableList.copyOf(baseCommit.getParents()));
+ }
+
+ public static TreeCreator basedOnEmptyTree() {
+ return new TreeCreator(ObjectId.zeroId(), ImmutableList.of());
+ }
+
+ private TreeCreator(ObjectId baseTreeId, ImmutableList<? extends ObjectId> baseParents) {
+ this.baseTreeId = requireNonNull(baseTreeId, "baseTree is required");
+ this.baseParents = baseParents;
}
/**
@@ -78,8 +90,9 @@
try (ObjectReader objectReader = repository.newObjectReader()) {
DirCache dirCache = DirCache.newInCore();
DirCacheBuilder dirCacheBuilder = dirCache.builder();
- dirCacheBuilder.addTree(
- new byte[0], DirCacheEntry.STAGE_0, objectReader, baseCommit.getTree());
+ if (!ObjectId.zeroId().equals(baseTreeId)) {
+ dirCacheBuilder.addTree(new byte[0], DirCacheEntry.STAGE_0, objectReader, baseTreeId);
+ }
dirCacheBuilder.finish();
return dirCache;
}
@@ -88,7 +101,8 @@
private List<DirCacheEditor.PathEdit> getPathEdits(Repository repository) throws IOException {
List<DirCacheEditor.PathEdit> pathEdits = new ArrayList<>();
for (TreeModification treeModification : treeModifications) {
- pathEdits.addAll(treeModification.getPathEdits(repository, baseCommit));
+ pathEdits.addAll(
+ treeModification.getPathEdits(repository, baseTreeId, ImmutableList.copyOf(baseParents)));
}
return pathEdits;
}
diff --git a/java/com/google/gerrit/server/edit/tree/TreeModification.java b/java/com/google/gerrit/server/edit/tree/TreeModification.java
index 2656707..68fd7ea 100644
--- a/java/com/google/gerrit/server/edit/tree/TreeModification.java
+++ b/java/com/google/gerrit/server/edit/tree/TreeModification.java
@@ -15,11 +15,12 @@
package com.google.gerrit.server.edit.tree;
import com.google.common.annotations.VisibleForTesting;
+import com.google.common.collect.ImmutableList;
import java.io.IOException;
import java.util.List;
import org.eclipse.jgit.dircache.DirCacheEditor;
+import org.eclipse.jgit.lib.ObjectId;
import org.eclipse.jgit.lib.Repository;
-import org.eclipse.jgit.revwalk.RevCommit;
/** A specific modification of a Git tree. */
public interface TreeModification {
@@ -30,11 +31,14 @@
* shouldn't be changed.
*
* @param repository the affected Git repository
- * @param baseCommit the commit to whose tree this modification is applied
+ * @param treeId tree to which the modification is applied. A value of {@code ObjectId.zero()}
+ * indicates an empty tree.
+ * @param parents parent commits of the commit to whose tree this modification is applied
* @return an ordered list of necessary {@code PathEdit}s
* @throws IOException if problems arise when accessing the repository
*/
- List<DirCacheEditor.PathEdit> getPathEdits(Repository repository, RevCommit baseCommit)
+ List<DirCacheEditor.PathEdit> getPathEdits(
+ Repository repository, ObjectId treeId, ImmutableList<? extends ObjectId> parents)
throws IOException;
/**
diff --git a/java/com/google/gerrit/server/project/ProjectCacheImpl.java b/java/com/google/gerrit/server/project/ProjectCacheImpl.java
index 663c9aa..d2e1086 100644
--- a/java/com/google/gerrit/server/project/ProjectCacheImpl.java
+++ b/java/com/google/gerrit/server/project/ProjectCacheImpl.java
@@ -56,7 +56,6 @@
import com.google.inject.name.Named;
import java.io.IOException;
import java.time.Duration;
-import java.util.Objects;
import java.util.Optional;
import java.util.Set;
import java.util.concurrent.ExecutionException;
@@ -224,9 +223,7 @@
@Override
public Set<AccountGroup.UUID> guessRelevantGroupUUIDs() {
try (Timer0.Context ignored = guessRelevantGroupsLatency.start()) {
- return all().stream()
- .map(n -> byName.getIfPresent(n))
- .filter(Objects::nonNull)
+ return byName.asMap().values().stream()
.flatMap(p -> p.getConfig().getAllGroupUUIDs().stream())
// getAllGroupUUIDs shouldn't really return null UUIDs, but harden
// against them just in case there is a bug or corner case.
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/rest/change/AttentionSetIT.java b/javatests/com/google/gerrit/acceptance/rest/change/AttentionSetIT.java
index c88dbff..da2c048 100644
--- a/javatests/com/google/gerrit/acceptance/rest/change/AttentionSetIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/change/AttentionSetIT.java
@@ -872,6 +872,40 @@
}
@Test
+ public void ownerNotAddedAsReviewerToAttentionSet() throws Exception {
+ PushOneCommit.Result r = createChange();
+ change(r).current().review(ReviewInput.approve());
+ assertThat(getAttentionSetUpdatesForUser(r, admin)).isEmpty();
+ }
+
+ @Test
+ public void ownerNotAddedAsReviewerToAttentionSetWithoutAutomaticRules() throws Exception {
+ PushOneCommit.Result r = createChange();
+ change(r).current().review(ReviewInput.approve().blockAutomaticAttentionSetRules());
+ assertThat(getAttentionSetUpdatesForUser(r, admin)).isEmpty();
+ }
+
+ @Test
+ public void uploaderNotAddedAsReviewerToAttentionSet() throws Exception {
+ PushOneCommit.Result r = createChange();
+ amendChangeWithUploader(r, project, user);
+ requestScopeOperations.setApiUser(user.id());
+
+ change(r).current().review(ReviewInput.recommend());
+ assertThat(getAttentionSetUpdatesForUser(r, user)).isEmpty();
+ }
+
+ @Test
+ public void uploaderNotAddedAsReviewerToAttentionSetWithoutAutomaticRules() throws Exception {
+ PushOneCommit.Result r = createChange();
+ amendChangeWithUploader(r, project, user);
+ requestScopeOperations.setApiUser(user.id());
+
+ change(r).current().review(ReviewInput.recommend().blockAutomaticAttentionSetRules());
+ assertThat(getAttentionSetUpdatesForUser(r, user)).isEmpty();
+ }
+
+ @Test
public void attentionSetStillChangesWithIgnoreAutomaticAttentionSetRulesWithInputList()
throws Exception {
PushOneCommit.Result r = createChange();
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/javatests/com/google/gerrit/acceptance/testsuite/project/ProjectOperationsImplTest.java b/javatests/com/google/gerrit/acceptance/testsuite/project/ProjectOperationsImplTest.java
index 76b8826..62dfc63 100644
--- a/javatests/com/google/gerrit/acceptance/testsuite/project/ProjectOperationsImplTest.java
+++ b/javatests/com/google/gerrit/acceptance/testsuite/project/ProjectOperationsImplTest.java
@@ -36,6 +36,7 @@
import com.google.common.collect.ImmutableList;
import com.google.common.collect.ImmutableListMultimap;
+import com.google.common.truth.Correspondence;
import com.google.gerrit.acceptance.AbstractDaemonTest;
import com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.TestPermission;
import com.google.gerrit.entities.Permission;
@@ -43,6 +44,7 @@
import com.google.gerrit.extensions.api.projects.BranchInfo;
import com.google.gerrit.extensions.api.projects.ConfigInput;
import com.google.gerrit.server.project.ProjectConfig;
+import com.google.gerrit.truth.NullAwareCorrespondence;
import com.google.inject.Inject;
import java.util.List;
import org.eclipse.jgit.junit.TestRepository;
@@ -82,6 +84,36 @@
}
@Test
+ public void specifiedBranchesAreCreatedInNewProject() throws Exception {
+ Project.NameKey project =
+ projectOperations
+ .newProject()
+ .branches("test-branch", "refs/heads/another-test-branch")
+ .create();
+
+ List<BranchInfo> branches = gApi.projects().name(project.get()).branches().get();
+ assertThat(branches)
+ .comparingElementsUsing(hasBranchName())
+ .containsAtLeast("refs/heads/test-branch", "refs/heads/another-test-branch");
+ }
+
+ @Test
+ public void specifiedBranchesAreNotCreatedInNewProjectIfNoEmptyCommitRequested()
+ throws Exception {
+ Project.NameKey project =
+ projectOperations
+ .newProject()
+ .branches("test-branch", "refs/heads/another-test-branch")
+ .noEmptyCommit()
+ .create();
+
+ List<BranchInfo> branches = gApi.projects().name(project.get()).branches().get();
+ assertThat(branches)
+ .comparingElementsUsing(hasBranchName())
+ .containsNoneOf("refs/heads/test-branch", "refs/heads/another-test-branch");
+ }
+
+ @Test
public void getProjectConfig() throws Exception {
Project.NameKey key = projectOperations.newProject().create();
assertThat(projectOperations.project(key).getProjectConfig().getProject().getDescription())
@@ -571,4 +603,8 @@
tr.delete(REFS_CONFIG);
}
}
+
+ private static Correspondence<BranchInfo, String> hasBranchName() {
+ return NullAwareCorrespondence.transforming(branch -> branch.ref, "hasName");
+ }
}
diff --git a/javatests/com/google/gerrit/server/edit/tree/TreeCreatorTest.java b/javatests/com/google/gerrit/server/edit/tree/TreeCreatorTest.java
new file mode 100644
index 0000000..926c345
--- /dev/null
+++ b/javatests/com/google/gerrit/server/edit/tree/TreeCreatorTest.java
@@ -0,0 +1,110 @@
+// 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.server.edit.tree;
+
+import static com.google.common.truth.Truth.assertThat;
+import static java.nio.charset.StandardCharsets.UTF_8;
+import static org.eclipse.jgit.lib.Constants.OBJ_BLOB;
+
+import com.google.common.collect.ImmutableList;
+import com.google.gerrit.common.RawInputUtil;
+import org.eclipse.jgit.internal.storage.dfs.DfsRepositoryDescription;
+import org.eclipse.jgit.internal.storage.dfs.InMemoryRepository;
+import org.eclipse.jgit.junit.TestRepository;
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.ObjectReader;
+import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.revwalk.RevObject;
+import org.eclipse.jgit.revwalk.RevTree;
+import org.eclipse.jgit.revwalk.RevWalk;
+import org.eclipse.jgit.treewalk.TreeWalk;
+import org.junit.After;
+import org.junit.Before;
+import org.junit.Test;
+
+public class TreeCreatorTest {
+
+ private Repository repository;
+ private TestRepository<?> testRepository;
+
+ @Before
+ public void setUp() throws Exception {
+ repository = new InMemoryRepository(new DfsRepositoryDescription("Test Repository"));
+ testRepository = new TestRepository<>(repository);
+ }
+
+ @After
+ public void tearDown() throws Exception {
+ if (testRepository != null) {
+ testRepository.close();
+ }
+ }
+
+ @Test
+ public void fileContentModificationWorksWithEmptyTree() throws Exception {
+ TreeCreator treeCreator = TreeCreator.basedOnEmptyTree();
+ treeCreator.addTreeModifications(
+ ImmutableList.of(
+ new ChangeFileContentModification("file.txt", RawInputUtil.create("Line 1"))));
+ ObjectId newTreeId = treeCreator.createNewTreeAndGetId(repository);
+
+ String fileContent = getFileContent(newTreeId, "file.txt");
+ assertThat(fileContent).isEqualTo("Line 1");
+ }
+
+ @Test
+ public void renameFileModificationDoesNotComplainAboutEmptyTree() throws Exception {
+ TreeCreator treeCreator = TreeCreator.basedOnEmptyTree();
+ treeCreator.addTreeModifications(
+ ImmutableList.of(new RenameFileModification("oldfileName", "newFileName")));
+ ObjectId newTreeId = treeCreator.createNewTreeAndGetId(repository);
+
+ assertThat(isEmptyTree(newTreeId)).isTrue();
+ }
+
+ @Test
+ public void deleteFileModificationDoesNotComplainAboutEmptyTree() throws Exception {
+ TreeCreator treeCreator = TreeCreator.basedOnEmptyTree();
+ treeCreator.addTreeModifications(ImmutableList.of(new DeleteFileModification("file.txt")));
+ ObjectId newTreeId = treeCreator.createNewTreeAndGetId(repository);
+
+ assertThat(isEmptyTree(newTreeId)).isTrue();
+ }
+
+ @Test
+ public void restoreFileModificationDoesNotComplainAboutEmptyTree() throws Exception {
+ TreeCreator treeCreator = TreeCreator.basedOnEmptyTree();
+ treeCreator.addTreeModifications(ImmutableList.of(new RestoreFileModification("file.txt")));
+ ObjectId newTreeId = treeCreator.createNewTreeAndGetId(repository);
+
+ assertThat(isEmptyTree(newTreeId)).isTrue();
+ }
+
+ private String getFileContent(ObjectId treeId, String filePath) throws Exception {
+ try (RevWalk revWalk = new RevWalk(repository);
+ ObjectReader reader = revWalk.getObjectReader()) {
+ RevTree revTree = revWalk.parseTree(treeId);
+ RevObject revObject = testRepository.get(revTree, filePath);
+ return new String(reader.open(revObject, OBJ_BLOB).getBytes(), UTF_8);
+ }
+ }
+
+ private boolean isEmptyTree(ObjectId treeId) throws Exception {
+ try (TreeWalk treeWalk = new TreeWalk(repository)) {
+ treeWalk.reset(treeId);
+ return !treeWalk.next();
+ }
+ }
+}
diff --git a/polygerrit-ui/app/.eslintrc.js b/polygerrit-ui/app/.eslintrc.js
index bf1fcc6..6d19ac9 100644
--- a/polygerrit-ui/app/.eslintrc.js
+++ b/polygerrit-ui/app/.eslintrc.js
@@ -268,6 +268,8 @@
"rules": {
// The following rules is required to match internal google rules
"@typescript-eslint/restrict-plus-operands": "error",
+ // https://github.com/mysticatea/eslint-plugin-node/blob/master/docs/rules/no-unsupported-features/node-builtins.md
+ "node/no-unsupported-features/node-builtins": "off",
},
"parserOptions": {
"project": path.resolve(__dirname, "./tsconfig_eslint.json"),
diff --git a/polygerrit-ui/app/constants/constants.ts b/polygerrit-ui/app/constants/constants.ts
index 32e1bed..00acc05 100644
--- a/polygerrit-ui/app/constants/constants.ts
+++ b/polygerrit-ui/app/constants/constants.ts
@@ -146,3 +146,12 @@
FIRST_NAME = 'FIRST_NAME',
FULL_NAME = 'FULL_NAME',
}
+
+/**
+ * @desc The state of the projects
+ */
+export enum ProjectState {
+ ACTIVE = 'ACTIVE',
+ READ_ONLY = 'READ_ONLY',
+ HIDDEN = 'HIDDEN',
+}
diff --git a/polygerrit-ui/app/elements/core/gr-navigation/gr-navigation.js b/polygerrit-ui/app/elements/core/gr-navigation/gr-navigation.js
deleted file mode 100644
index 3203e0c..0000000
--- a/polygerrit-ui/app/elements/core/gr-navigation/gr-navigation.js
+++ /dev/null
@@ -1,784 +0,0 @@
-/**
- * @license
- * Copyright (C) 2017 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.
- */
-
-// Navigation parameters object format:
-//
-// Each object has a `view` property with a value from GerritNav.View. The
-// remaining properties depend on the value used for view.
-//
-// - GerritNav.View.CHANGE:
-// - `changeNum`, required, String: the numeric ID of the change.
-// - `project`, optional, String: the project name.
-// - `patchNum`, optional, Number: the patch for the right-hand-side of
-// the diff.
-// - `basePatchNum`, optional, Number: the patch for the left-hand-side
-// of the diff. If `basePatchNum` is provided, then `patchNum` must
-// also be provided.
-// - `edit`, optional, Boolean: whether or not to load the file list with
-// edit controls.
-// - `messageHash`, optional, String: the hash of the change message to
-// scroll to.
-//
-// - GerritNav.View.SEARCH:
-// - `query`, optional, String: the literal search query. If provided,
-// the string will be used as the query, and all other params will be
-// ignored.
-// - `owner`, optional, String: the owner name.
-// - `project`, optional, String: the project name.
-// - `branch`, optional, String: the branch name.
-// - `topic`, optional, String: the topic name.
-// - `hashtag`, optional, String: the hashtag name.
-// - `statuses`, optional, Array<String>: the list of change statuses to
-// search for. If more than one is provided, the search will OR them
-// together.
-// - `offset`, optional, Number: the offset for the query.
-//
-// - GerritNav.View.DIFF:
-// - `changeNum`, required, String: the numeric ID of the change.
-// - `path`, required, String: the filepath of the diff.
-// - `patchNum`, required, Number: the patch for the right-hand-side of
-// the diff.
-// - `basePatchNum`, optional, Number: the patch for the left-hand-side
-// of the diff. If `basePatchNum` is provided, then `patchNum` must
-// also be provided.
-// - `lineNum`, optional, Number: the line number to be selected on load.
-// - `leftSide`, optional, Boolean: if a `lineNum` is provided, a value
-// of true selects the line from base of the patch range. False by
-// default.
-//
-// - GerritNav.View.GROUP:
-// - `groupId`, required, String: the ID of the group.
-// - `detail`, optional, String: the name of the group detail view.
-// Takes any value from GerritNav.GroupDetailView.
-//
-// - GerritNav.View.REPO:
-// - `repoName`, required, String: the name of the repo
-// - `detail`, optional, String: the name of the repo detail view.
-// Takes any value from GerritNav.RepoDetailView.
-//
-// - GerritNav.View.DASHBOARD
-// - `repo`, optional, String.
-// - `sections`, optional, Array of objects with `title` and `query`
-// strings.
-// - `user`, optional, String.
-//
-// - GerritNav.View.ROOT:
-// - no possible parameters.
-
-const uninitialized = () => {
- console.warn('Use of uninitialized routing');
-};
-
-const EDIT_PATCHNUM = 'edit';
-const PARENT_PATCHNUM = 'PARENT';
-
-const USER_PLACEHOLDER_PATTERN = /\${user}/g;
-
-// NOTE: These queries are tested in Java. Any changes made to definitions
-// here require corresponding changes to:
-// javatests/com/google/gerrit/server/query/change/AbstractQueryChangesTest.java
-const DEFAULT_SECTIONS = [
- {
- // Changes with unpublished draft comments. This section is omitted when
- // viewing other users, so we don't need to filter anything out.
- name: 'Has draft comments',
- query: 'has:draft',
- selfOnly: true,
- hideIfEmpty: true,
- suffixForDashboard: 'limit:10',
- },
- {
- // Changes where the user is in the attention set.
- name: 'Your Turn',
- query: 'attention:${user}',
- hideIfEmpty: false,
- suffixForDashboard: 'limit:25',
- attentionSetOnly: true,
- },
- {
- // Changes that are assigned to the viewed user.
- name: 'Assigned reviews',
- query: 'assignee:${user} (-is:wip OR owner:self OR assignee:self) ' +
- 'is:open -is:ignored',
- hideIfEmpty: true,
- suffixForDashboard: 'limit:25',
- assigneeOnly: true,
- },
- {
- // WIP open changes owned by viewing user. This section is omitted when
- // viewing other users, so we don't need to filter anything out.
- name: 'Work in progress',
- query: 'is:open owner:${user} is:wip',
- selfOnly: true,
- hideIfEmpty: true,
- suffixForDashboard: 'limit:25',
- },
- {
- // Non-WIP open changes owned by viewed user. Filter out changes ignored
- // by the viewing user.
- name: 'Outgoing reviews',
- query: 'is:open owner:${user} -is:wip -is:ignored',
- isOutgoing: true,
- suffixForDashboard: 'limit:25',
- },
- {
- // Non-WIP open changes not owned by the viewed user, that the viewed user
- // is associated with (as either a reviewer or the assignee). Changes
- // ignored by the viewing user are filtered out.
- name: 'Incoming reviews',
- query: 'is:open -owner:${user} -is:wip -is:ignored ' +
- '(reviewer:${user} OR assignee:${user})',
- suffixForDashboard: 'limit:25',
- },
- {
- // Open changes the viewed user is CCed on. Changes ignored by the viewing
- // user are filtered out.
- name: 'CCed on',
- query: 'is:open -is:ignored cc:${user}',
- suffixForDashboard: 'limit:10',
- },
- {
- name: 'Recently closed',
- // Closed changes where viewed user is owner, reviewer, or assignee.
- // Changes ignored by the viewing user are filtered out, and so are WIP
- // changes not owned by the viewing user (the one instance of
- // 'owner:self' is intentional and implements this logic).
- query: 'is:closed -is:ignored (-is:wip OR owner:self) ' +
- '(owner:${user} OR reviewer:${user} OR assignee:${user} ' +
- 'OR cc:${user})',
- suffixForDashboard: '-age:4w limit:10',
- },
-];
-
-// TODO(dmfilippov) Convert to class, extract consts, give better name and
-// expose as a service from appContext
-export const GerritNav = {
-
- View: {
- ADMIN: 'admin',
- AGREEMENTS: 'agreements',
- CHANGE: 'change',
- DASHBOARD: 'dashboard',
- DIFF: 'diff',
- DOCUMENTATION_SEARCH: 'documentation-search',
- EDIT: 'edit',
- GROUP: 'group',
- PLUGIN_SCREEN: 'plugin-screen',
- REPO: 'repo',
- ROOT: 'root',
- SEARCH: 'search',
- SETTINGS: 'settings',
- },
-
- GroupDetailView: {
- MEMBERS: 'members',
- LOG: 'log',
- },
-
- RepoDetailView: {
- ACCESS: 'access',
- BRANCHES: 'branches',
- COMMANDS: 'commands',
- DASHBOARDS: 'dashboards',
- TAGS: 'tags',
- },
-
- WeblinkType: {
- CHANGE: 'change',
- FILE: 'file',
- PATCHSET: 'patchset',
- },
-
- /** @type {Function} */
- _navigate: uninitialized,
-
- /** @type {Function} */
- _generateUrl: uninitialized,
-
- /** @type {Function} */
- _generateWeblinks: uninitialized,
-
- /** @type {Function} */
- mapCommentlinks: uninitialized,
-
- /**
- * @param {number=} patchNum
- * @param {number|string=} basePatchNum
- */
- _checkPatchRange(patchNum, basePatchNum) {
- if (basePatchNum && !patchNum) {
- throw new Error('Cannot use base patch number without patch number.');
- }
- },
-
- /**
- * Setup router implementation.
- *
- * @param {function(!string, boolean=)} navigate the router-abstracted equivalent of
- * `window.location.href = ...` or window.location.replace(...). The
- * string is a new location and boolean defines is it redirect or not
- * (true means redirect, i.e. equivalent of window.location.replace).
- * @param {function(!Object): string} generateUrl generates a URL given
- * navigation parameters, detailed in the file header.
- * @param {function(!Object): string} generateWeblinks weblinks generator
- * function takes single payload parameter with type property that
- * determines which
- * part of the UI is the consumer of the weblinks. type property can
- * be one of file, change, or patchset.
- * - For file type, payload will also contain string properties: repo,
- * commit, file.
- * - For patchset type, payload will also contain string properties:
- * repo, commit.
- * - For change type, payload will also contain string properties:
- * repo, commit. If server provides weblinks, those will be passed
- * as options.weblinks property on the main payload object.
- * @param {function(!Object): Object} mapCommentlinks provides an escape
- * hatch to modify the commentlinks object, e.g. if it contains any
- * relative URLs.
- */
- setup(navigate, generateUrl, generateWeblinks, mapCommentlinks) {
- this._navigate = navigate;
- this._generateUrl = generateUrl;
- this._generateWeblinks = generateWeblinks;
- this.mapCommentlinks = mapCommentlinks;
- },
-
- destroy() {
- this._navigate = uninitialized;
- this._generateUrl = uninitialized;
- this._generateWeblinks = uninitialized;
- this.mapCommentlinks = uninitialized;
- },
-
- /**
- * Generate a URL for the given route parameters.
- *
- * @param {Object} params
- * @return {string}
- */
- _getUrlFor(params) {
- return this._generateUrl(params);
- },
-
- getUrlForSearchQuery(query, opt_offset) {
- return this._getUrlFor({
- view: GerritNav.View.SEARCH,
- query,
- offset: opt_offset,
- });
- },
-
- /**
- * @param {!string} project The name of the project.
- * @param {boolean=} opt_openOnly When true, only search open changes in
- * the project.
- * @param {string=} opt_host The host in which to search.
- * @return {string}
- */
- getUrlForProjectChanges(project, opt_openOnly, opt_host) {
- return this._getUrlFor({
- view: GerritNav.View.SEARCH,
- project,
- statuses: opt_openOnly ? ['open'] : [],
- host: opt_host,
- });
- },
-
- /**
- * @param {string} branch The name of the branch.
- * @param {string} project The name of the project.
- * @param {string=} opt_status The status to search.
- * @param {string=} opt_host The host in which to search.
- * @return {string}
- */
- getUrlForBranch(branch, project, opt_status, opt_host) {
- return this._getUrlFor({
- view: GerritNav.View.SEARCH,
- branch,
- project,
- statuses: opt_status ? [opt_status] : undefined,
- host: opt_host,
- });
- },
-
- /**
- * @param {string} topic The name of the topic.
- * @param {string=} opt_host The host in which to search.
- * @return {string}
- */
- getUrlForTopic(topic, opt_host) {
- return this._getUrlFor({
- view: GerritNav.View.SEARCH,
- topic,
- statuses: ['open', 'merged'],
- host: opt_host,
- });
- },
-
- /**
- * @param {string} hashtag The name of the hashtag.
- * @return {string}
- */
- getUrlForHashtag(hashtag) {
- return this._getUrlFor({
- view: GerritNav.View.SEARCH,
- hashtag,
- statuses: ['open', 'merged'],
- });
- },
-
- /**
- * Navigate to a search for changes with the given status.
- *
- * @param {string} status
- */
- navigateToStatusSearch(status) {
- this._navigate(this._getUrlFor({
- view: GerritNav.View.SEARCH,
- statuses: [status],
- }));
- },
-
- /**
- * Navigate to a search query
- *
- * @param {string} query
- * @param {number=} opt_offset
- */
- navigateToSearchQuery(query, opt_offset) {
- return this._navigate(this.getUrlForSearchQuery(query, opt_offset));
- },
-
- /**
- * Navigate to the user's dashboard
- */
- navigateToUserDashboard() {
- return this._navigate(this.getUrlForUserDashboard('self'));
- },
-
- /**
- * @param {!Object} change The change object.
- * @param {number=} opt_patchNum
- * @param {number|string=} opt_basePatchNum The string 'PARENT' can be
- * used for none.
- * @param {boolean=} opt_isEdit
- * @param {string=} opt_messageHash
- * @return {string}
- */
- getUrlForChange(change, opt_patchNum, opt_basePatchNum, opt_isEdit,
- opt_messageHash) {
- if (opt_basePatchNum === PARENT_PATCHNUM) {
- opt_basePatchNum = undefined;
- }
-
- this._checkPatchRange(opt_patchNum, opt_basePatchNum);
- return this._getUrlFor({
- view: GerritNav.View.CHANGE,
- changeNum: change._number,
- project: change.project,
- patchNum: opt_patchNum,
- basePatchNum: opt_basePatchNum,
- edit: opt_isEdit,
- host: change.internalHost || undefined,
- messageHash: opt_messageHash,
- });
- },
-
- /**
- * @param {number} changeNum
- * @param {string} project The name of the project.
- * @param {number=} opt_patchNum
- * @return {string}
- */
- getUrlForChangeById(changeNum, project, opt_patchNum) {
- return this._getUrlFor({
- view: GerritNav.View.CHANGE,
- changeNum,
- project,
- patchNum: opt_patchNum,
- });
- },
-
- /**
- * @param {!Object} change The change object.
- * @param {number=} opt_patchNum
- * @param {number|string=} opt_basePatchNum The string 'PARENT' can be
- * used for none.
- * @param {boolean=} opt_isEdit
- * @param {boolean=} opt_redirect redirect to a change - if true, the current
- * location (i.e. page which makes redirect) is not added to a history.
- * I.e. back/forward buttons skip current location
- *
- */
- navigateToChange(change, opt_patchNum, opt_basePatchNum, opt_isEdit,
- opt_redirect) {
- this._navigate(this.getUrlForChange(change, opt_patchNum,
- opt_basePatchNum, opt_isEdit), opt_redirect);
- },
-
- /**
- * @param {{ _number: number, project: string }} change The change object.
- * @param {string} path The file path.
- * @param {number=} opt_patchNum
- * @param {number|string=} opt_basePatchNum The string 'PARENT' can be
- * used for none.
- * @param {number|string=} opt_lineNum
- * @return {string}
- */
- getUrlForDiff(change, path, opt_patchNum, opt_basePatchNum, opt_lineNum) {
- return this.getUrlForDiffById(change._number, change.project, path,
- opt_patchNum, opt_basePatchNum, opt_lineNum);
- },
-
- /**
- * @param {number} changeNum
- * @param {string} project The name of the project.
- * @param {string} path The file path.
- * @param {number} patchNum
- * @param {number} commentId
- * @return {string}
- */
- getUrlForComment(changeNum, project, commentId) {
- return this._getUrlFor({
- view: GerritNav.View.DIFF,
- changeNum,
- project,
- commentId,
- });
- },
-
- /**
- * @param {number} changeNum
- * @param {string} project The name of the project.
- * @param {string} path The file path.
- * @param {number=} opt_patchNum
- * @param {number|string=} opt_basePatchNum The string 'PARENT' can be
- * used for none.
- * @param {number=} opt_lineNum
- * @param {boolean=} opt_leftSide
- * @return {string}
- */
- getUrlForDiffById(changeNum, project, path, opt_patchNum,
- opt_basePatchNum, opt_lineNum, opt_leftSide) {
- if (opt_basePatchNum === PARENT_PATCHNUM) {
- opt_basePatchNum = undefined;
- }
-
- this._checkPatchRange(opt_patchNum, opt_basePatchNum);
- return this._getUrlFor({
- view: GerritNav.View.DIFF,
- changeNum,
- project,
- path,
- patchNum: opt_patchNum,
- basePatchNum: opt_basePatchNum,
- lineNum: opt_lineNum,
- leftSide: opt_leftSide,
- });
- },
-
- /**
- * @param {{ _number: number, project: string }} change The change object.
- * @param {string} path The file path.
- * @param {number=} opt_patchNum
- * @param {number=} opt_lineNum
- * @return {string}
- */
- getEditUrlForDiff(change, path, opt_patchNum, opt_lineNum) {
- return this.getEditUrlForDiffById(change._number, change.project, path,
- opt_patchNum, opt_lineNum);
- },
-
- /**
- * @param {number} changeNum
- * @param {string} project The name of the project.
- * @param {string} path The file path.
- * @param {number|string=} opt_patchNum The patchNum the file content
- * should be based on, or ${EDIT_PATCHNUM} if left undefined.
- * @param {number=} opt_lineNum The line number to pass to the inline editor.
- * @return {string}
- */
- getEditUrlForDiffById(changeNum, project, path, opt_patchNum, opt_lineNum) {
- return this._getUrlFor({
- view: GerritNav.View.EDIT,
- changeNum,
- project,
- path,
- patchNum: opt_patchNum || EDIT_PATCHNUM,
- lineNum: opt_lineNum,
- });
- },
-
- /**
- * @param {!Object} change The change object.
- * @param {string} path The file path.
- * @param {number=} opt_patchNum
- * @param {number|string=} opt_basePatchNum The string 'PARENT' can be
- * used for none.
- */
- navigateToDiff(change, path, opt_patchNum, opt_basePatchNum) {
- this._navigate(this.getUrlForDiff(change, path, opt_patchNum,
- opt_basePatchNum));
- },
-
- /**
- * @param {string} owner The name of the owner.
- * @return {string}
- */
- getUrlForOwner(owner) {
- return this._getUrlFor({
- view: GerritNav.View.SEARCH,
- owner,
- });
- },
-
- /**
- * @param {string} user The name of the user.
- * @return {string}
- */
- getUrlForUserDashboard(user) {
- return this._getUrlFor({
- view: GerritNav.View.DASHBOARD,
- user,
- });
- },
-
- /**
- * @return {string}
- */
- getUrlForRoot() {
- return this._getUrlFor({
- view: GerritNav.View.ROOT,
- });
- },
-
- /**
- * @param {string} repo The name of the repo.
- * @param {string} dashboard The ID of the dashboard, in the form of
- * '<ref>:<path>'.
- * @return {string}
- */
- getUrlForRepoDashboard(repo, dashboard) {
- return this._getUrlFor({
- view: GerritNav.View.DASHBOARD,
- repo,
- dashboard,
- });
- },
-
- /**
- * Navigate to an arbitrary relative URL.
- *
- * @param {string} relativeUrl
- */
- navigateToRelativeUrl(relativeUrl) {
- if (!relativeUrl.startsWith('/')) {
- throw new Error('navigateToRelativeUrl with non-relative URL');
- }
- this._navigate(relativeUrl);
- },
-
- /**
- * @param {string} repoName
- * @return {string}
- */
- getUrlForRepo(repoName) {
- return this._getUrlFor({
- view: GerritNav.View.REPO,
- repoName,
- });
- },
-
- /**
- * Navigate to a repo settings page.
- *
- * @param {string} repoName
- */
- navigateToRepo(repoName) {
- this._navigate(this.getUrlForRepo(repoName));
- },
-
- /**
- * @param {string} repoName
- * @return {string}
- */
- getUrlForRepoTags(repoName) {
- return this._getUrlFor({
- view: GerritNav.View.REPO,
- repoName,
- detail: GerritNav.RepoDetailView.TAGS,
- });
- },
-
- /**
- * @param {string} repoName
- * @return {string}
- */
- getUrlForRepoBranches(repoName) {
- return this._getUrlFor({
- view: GerritNav.View.REPO,
- repoName,
- detail: GerritNav.RepoDetailView.BRANCHES,
- });
- },
-
- /**
- * @param {string} repoName
- * @return {string}
- */
- getUrlForRepoAccess(repoName) {
- return this._getUrlFor({
- view: GerritNav.View.REPO,
- repoName,
- detail: GerritNav.RepoDetailView.ACCESS,
- });
- },
-
- /**
- * @param {string} repoName
- * @return {string}
- */
- getUrlForRepoCommands(repoName) {
- return this._getUrlFor({
- view: GerritNav.View.REPO,
- repoName,
- detail: GerritNav.RepoDetailView.COMMANDS,
- });
- },
-
- /**
- * @param {string} repoName
- * @return {string}
- */
- getUrlForRepoDashboards(repoName) {
- return this._getUrlFor({
- view: GerritNav.View.REPO,
- repoName,
- detail: GerritNav.RepoDetailView.DASHBOARDS,
- });
- },
-
- /**
- * @param {string} groupId
- * @return {string}
- */
- getUrlForGroup(groupId) {
- return this._getUrlFor({
- view: GerritNav.View.GROUP,
- groupId,
- });
- },
-
- /**
- * @param {string} groupId
- * @return {string}
- */
- getUrlForGroupLog(groupId) {
- return this._getUrlFor({
- view: GerritNav.View.GROUP,
- groupId,
- detail: GerritNav.GroupDetailView.LOG,
- });
- },
-
- /**
- * @param {string} groupId
- * @return {string}
- */
- getUrlForGroupMembers(groupId) {
- return this._getUrlFor({
- view: GerritNav.View.GROUP,
- groupId,
- detail: GerritNav.GroupDetailView.MEMBERS,
- });
- },
-
- getUrlForSettings() {
- return this._getUrlFor({view: GerritNav.View.SETTINGS});
- },
-
- /**
- * @param {string} repo
- * @param {string} commit
- * @param {string} file
- * @param {Object=} opt_options
- * @return {
- * Array<{label: string, url: string}>|
- * {label: string, url: string}
- * }
- */
- getFileWebLinks(repo, commit, file, opt_options) {
- const params = {type: GerritNav.WeblinkType.FILE, repo, commit, file};
- if (opt_options) {
- params.options = opt_options;
- }
- return [].concat(this._generateWeblinks(params));
- },
-
- /**
- * @param {string} repo
- * @param {string} commit
- * @param {Object=} opt_options
- * @return {{label: string, url: string}}
- */
- getPatchSetWeblink(repo, commit, opt_options) {
- const params = {type: GerritNav.WeblinkType.PATCHSET, repo, commit};
- if (opt_options) {
- params.options = opt_options;
- }
- const result = this._generateWeblinks(params);
- if (Array.isArray(result)) {
- return result.pop();
- } else {
- return result;
- }
- },
-
- /**
- * @param {string} repo
- * @param {string} commit
- * @param {Object=} opt_options
- * @return {
- * Array<{label: string, url: string}>|
- * {label: string, url: string}
- * }
- */
- getChangeWeblinks(repo, commit, opt_options) {
- const params = {type: GerritNav.WeblinkType.CHANGE, repo, commit};
- if (opt_options) {
- params.options = opt_options;
- }
- return [].concat(this._generateWeblinks(params));
- },
-
- getUserDashboard(user = 'self', sections = DEFAULT_SECTIONS,
- title = '', config = {}) {
- const attentionEnabled =
- config.change && !!config.change.enable_attention_set;
- const assigneeEnabled =
- config.change && !!config.change.enable_assignee;
- sections = sections
- .filter(section => (attentionEnabled || !section.attentionSetOnly))
- .filter(section => (assigneeEnabled || !section.assigneeOnly))
- .filter(section => (user === 'self' || !section.selfOnly))
- .map(section => {
- return {...section, name: section.name,
- query: section.query.replace(USER_PLACEHOLDER_PATTERN, user)};
- });
- return {title, sections};
- },
-};
diff --git a/polygerrit-ui/app/elements/core/gr-navigation/gr-navigation.ts b/polygerrit-ui/app/elements/core/gr-navigation/gr-navigation.ts
new file mode 100644
index 0000000..13b5572
--- /dev/null
+++ b/polygerrit-ui/app/elements/core/gr-navigation/gr-navigation.ts
@@ -0,0 +1,946 @@
+/**
+ * @license
+ * Copyright (C) 2017 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 {
+ BranchName,
+ ChangeInfo,
+ PatchSetNum,
+ ProjectName,
+ TopicName,
+ RepositoryName,
+ GroupId,
+ DashboardId,
+ NumericChangeId,
+ LegacyChangeId,
+ EditPatchSetNum,
+ ChangeConfigInfo,
+ CommitId,
+ Hashtag,
+ UrlEncodedCommentId,
+} from '../../../types/common';
+
+// Navigation parameters object format:
+//
+// Each object has a `view` property with a value from GerritNav.View. The
+// remaining properties depend on the value used for view.
+//
+// - GerritNav.View.CHANGE:
+// - `changeNum`, required, String: the numeric ID of the change.
+// - `project`, optional, String: the project name.
+// - `patchNum`, optional, Number: the patch for the right-hand-side of
+// the diff.
+// - `basePatchNum`, optional, Number: the patch for the left-hand-side
+// of the diff. If `basePatchNum` is provided, then `patchNum` must
+// also be provided.
+// - `edit`, optional, Boolean: whether or not to load the file list with
+// edit controls.
+// - `messageHash`, optional, String: the hash of the change message to
+// scroll to.
+//
+// - GerritNav.View.SEARCH:
+// - `query`, optional, String: the literal search query. If provided,
+// the string will be used as the query, and all other params will be
+// ignored.
+// - `owner`, optional, String: the owner name.
+// - `project`, optional, String: the project name.
+// - `branch`, optional, String: the branch name.
+// - `topic`, optional, String: the topic name.
+// - `hashtag`, optional, String: the hashtag name.
+// - `statuses`, optional, Array<String>: the list of change statuses to
+// search for. If more than one is provided, the search will OR them
+// together.
+// - `offset`, optional, Number: the offset for the query.
+//
+// - GerritNav.View.DIFF:
+// - `changeNum`, required, String: the numeric ID of the change.
+// - `path`, required, String: the filepath of the diff.
+// - `patchNum`, required, Number: the patch for the right-hand-side of
+// the diff.
+// - `basePatchNum`, optional, Number: the patch for the left-hand-side
+// of the diff. If `basePatchNum` is provided, then `patchNum` must
+// also be provided.
+// - `lineNum`, optional, Number: the line number to be selected on load.
+// - `leftSide`, optional, Boolean: if a `lineNum` is provided, a value
+// of true selects the line from base of the patch range. False by
+// default.
+//
+// - GerritNav.View.GROUP:
+// - `groupId`, required, String: the ID of the group.
+// - `detail`, optional, String: the name of the group detail view.
+// Takes any value from GerritNav.GroupDetailView.
+//
+// - GerritNav.View.REPO:
+// - `repoName`, required, String: the name of the repo
+// - `detail`, optional, String: the name of the repo detail view.
+// Takes any value from GerritNav.RepoDetailView.
+//
+// - GerritNav.View.DASHBOARD
+// - `repo`, optional, String.
+// - `sections`, optional, Array of objects with `title` and `query`
+// strings.
+// - `user`, optional, String.
+//
+// - GerritNav.View.ROOT:
+// - no possible parameters.
+
+const uninitialized = () => {
+ console.warn('Use of uninitialized routing');
+};
+
+const uninitializedNavigate: NavigateCallback = () => {
+ uninitialized();
+ return '';
+};
+
+const uninitializedGenerateUrl: GenerateUrlCallback = () => {
+ uninitialized();
+ return '';
+};
+
+const uninitializedGenerateWebLinks: GenerateWebLinksCallback = () => {
+ uninitialized();
+ return [];
+};
+
+const uninitializedMapCommentLinks: MapCommentLinksCallback = () => {
+ uninitialized();
+ return '';
+};
+
+// TODO(TS): PatchSetNum type express an API type, it is not good to add
+// PARENT into it. Find a better way to add PARENT patchset into our code
+type ParentPatchSetNum = 'PARENT';
+const PARENT_PATCHNUM: ParentPatchSetNum = 'PARENT';
+
+const USER_PLACEHOLDER_PATTERN = /\${user}/g;
+
+export interface DashboardSection {
+ name: string;
+ query: string;
+ suffixForDashboard: string;
+ attentionSetOnly?: boolean;
+ selfOnly?: boolean;
+ hideIfEmpty?: boolean;
+ assigneeOnly?: boolean;
+ isOutgoing?: boolean;
+}
+
+export interface UserDashboardConfig {
+ change?: ChangeConfigInfo;
+}
+
+export interface UserDashboard {
+ title: string;
+ sections: DashboardSection[];
+}
+
+// NOTE: These queries are tested in Java. Any changes made to definitions
+// here require corresponding changes to:
+// javatests/com/google/gerrit/server/query/change/AbstractQueryChangesTest.java
+const DEFAULT_SECTIONS: DashboardSection[] = [
+ {
+ // Changes with unpublished draft comments. This section is omitted when
+ // viewing other users, so we don't need to filter anything out.
+ name: 'Has draft comments',
+ query: 'has:draft',
+ selfOnly: true,
+ hideIfEmpty: true,
+ suffixForDashboard: 'limit:10',
+ },
+ {
+ // Changes where the user is in the attention set.
+ name: 'Your Turn',
+ query: 'attention:${user}',
+ hideIfEmpty: false,
+ suffixForDashboard: 'limit:25',
+ attentionSetOnly: true,
+ },
+ {
+ // Changes that are assigned to the viewed user.
+ name: 'Assigned reviews',
+ query:
+ 'assignee:${user} (-is:wip OR owner:self OR assignee:self) ' +
+ 'is:open -is:ignored',
+ hideIfEmpty: true,
+ suffixForDashboard: 'limit:25',
+ assigneeOnly: true,
+ },
+ {
+ // WIP open changes owned by viewing user. This section is omitted when
+ // viewing other users, so we don't need to filter anything out.
+ name: 'Work in progress',
+ query: 'is:open owner:${user} is:wip',
+ selfOnly: true,
+ hideIfEmpty: true,
+ suffixForDashboard: 'limit:25',
+ },
+ {
+ // Non-WIP open changes owned by viewed user. Filter out changes ignored
+ // by the viewing user.
+ name: 'Outgoing reviews',
+ query: 'is:open owner:${user} -is:wip -is:ignored',
+ isOutgoing: true,
+ suffixForDashboard: 'limit:25',
+ },
+ {
+ // Non-WIP open changes not owned by the viewed user, that the viewed user
+ // is associated with (as either a reviewer or the assignee). Changes
+ // ignored by the viewing user are filtered out.
+ name: 'Incoming reviews',
+ query:
+ 'is:open -owner:${user} -is:wip -is:ignored ' +
+ '(reviewer:${user} OR assignee:${user})',
+ suffixForDashboard: 'limit:25',
+ },
+ {
+ // Open changes the viewed user is CCed on. Changes ignored by the viewing
+ // user are filtered out.
+ name: 'CCed on',
+ query: 'is:open -is:ignored cc:${user}',
+ suffixForDashboard: 'limit:10',
+ },
+ {
+ name: 'Recently closed',
+ // Closed changes where viewed user is owner, reviewer, or assignee.
+ // Changes ignored by the viewing user are filtered out, and so are WIP
+ // changes not owned by the viewing user (the one instance of
+ // 'owner:self' is intentional and implements this logic).
+ query:
+ 'is:closed -is:ignored (-is:wip OR owner:self) ' +
+ '(owner:${user} OR reviewer:${user} OR assignee:${user} ' +
+ 'OR cc:${user})',
+ suffixForDashboard: '-age:4w limit:10',
+ },
+];
+
+export interface GenerateUrlSearchViewParameters {
+ view: GerritView.SEARCH;
+ query?: string;
+ offset?: number;
+ project?: ProjectName;
+ branch?: BranchName;
+ topic?: TopicName;
+ // TODO(TS): Define more precise type (enum?)
+ statuses?: string[];
+ hashtag?: string;
+ host?: string;
+ owner?: string;
+}
+
+export interface GenerateUrlChangeViewParameters {
+ view: GerritView.CHANGE;
+ // TODO(TS): NumericChangeId - not sure about it, may be it can be removeds
+ changeNum: NumericChangeId | LegacyChangeId;
+ project: ProjectName;
+ patchNum?: PatchSetNum;
+ basePatchNum?: PatchSetNum;
+ edit?: boolean;
+ host?: string;
+ messageHash?: string;
+}
+
+export interface GenerateUrlRepoViewParameters {
+ view: GerritView.REPO;
+ repoName: RepositoryName;
+ detail?: RepoDetailView;
+}
+
+export interface GenerateUrlDashboardViewParameters {
+ view: GerritView.DASHBOARD;
+ user?: string;
+ repo?: RepositoryName;
+ dashboard?: DashboardId;
+}
+
+export interface GenerateUrlGroupViewParameters {
+ view: GerritView.GROUP;
+ groupId: GroupId;
+ detail?: GroupDetailView;
+}
+
+export interface GenerateUrlEditViewParameters {
+ view: GerritView.EDIT;
+ changeNum: NumericChangeId | LegacyChangeId;
+ project: ProjectName;
+ path: string;
+ patchNum: PatchSetNum;
+ lineNum?: number;
+}
+
+export interface GenerateUrlRootViewParameters {
+ view: GerritView.ROOT;
+}
+
+export interface GenerateUrlSettingsViewParameters {
+ view: GerritView.SETTINGS;
+}
+
+export interface GenerateUrlDiffViewParameters {
+ view: GerritView.DIFF;
+ changeNum: NumericChangeId | LegacyChangeId;
+ project: ProjectName;
+ path?: string;
+ patchNum?: PatchSetNum;
+ basePatchNum?: PatchSetNum | ParentPatchSetNum;
+ lineNum?: number;
+ leftSide?: boolean;
+ commentId?: UrlEncodedCommentId;
+}
+
+export type GenerateUrlParameters =
+ | GenerateUrlSearchViewParameters
+ | GenerateUrlChangeViewParameters
+ | GenerateUrlRepoViewParameters
+ | GenerateUrlDashboardViewParameters
+ | GenerateUrlGroupViewParameters
+ | GenerateUrlEditViewParameters
+ | GenerateUrlRootViewParameters
+ | GenerateUrlSettingsViewParameters
+ | GenerateUrlDiffViewParameters;
+
+export interface GenerateWebLinksPatchsetParameters {
+ type: WeblinkType.PATCHSET;
+ repo: RepositoryName;
+ commit: CommitId;
+ // TODO(TS): provide better typing
+ options?: unknown;
+}
+export interface GenerateWebLinksFileParameters {
+ type: WeblinkType.FILE;
+ repo: RepositoryName;
+ commit: CommitId;
+ file: string;
+ // TODO(TS): provide better typing
+ options?: unknown;
+}
+export interface GenerateWebLinksChangeParameters {
+ type: WeblinkType.CHANGE;
+ repo: RepositoryName;
+ commit: CommitId;
+ // TODO(TS): provide better typing
+ options?: unknown;
+}
+
+export type GenerateWebLinksParameters =
+ | GenerateWebLinksPatchsetParameters
+ | GenerateWebLinksFileParameters
+ | GenerateWebLinksChangeParameters;
+
+export type NavigateCallback = (target: string, redirect?: boolean) => void;
+export type GenerateUrlCallback = (params: GenerateUrlParameters) => string;
+export type GenerateWebLinksCallback = (
+ params: GenerateWebLinksParameters
+) => WebLink[] | WebLink;
+
+// TODO(TS): type is not clear until more code converted to a typescript.
+export type MapCommentLinksCallback = (commentLink: unknown) => unknown;
+
+export interface WebLink {
+ label: string;
+ url: string;
+}
+
+export enum GerritView {
+ ADMIN = 'admin',
+ AGREEMENTS = 'agreements',
+ CHANGE = 'change',
+ DASHBOARD = 'dashboard',
+ DIFF = 'diff',
+ DOCUMENTATION_SEARCH = 'documentation-search',
+ EDIT = 'edit',
+ GROUP = 'group',
+ PLUGIN_SCREEN = 'plugin-screen',
+ REPO = 'repo',
+ ROOT = 'root',
+ SEARCH = 'search',
+ SETTINGS = 'settings',
+}
+
+export enum GroupDetailView {
+ MEMBERS = 'members',
+ LOG = 'log',
+}
+
+export enum RepoDetailView {
+ ACCESS = 'access',
+ BRANCHES = 'branches',
+ COMMANDS = 'commands',
+ DASHBOARDS = 'dashboards',
+ TAGS = 'tags',
+}
+
+export enum WeblinkType {
+ CHANGE = 'change',
+ FILE = 'file',
+ PATCHSET = 'patchset',
+}
+
+// TODO(dmfilippov) Convert to class, extract consts, give better name and
+// expose as a service from appContext
+export const GerritNav = {
+ View: GerritView,
+
+ GroupDetailView,
+
+ RepoDetailView,
+
+ WeblinkType,
+
+ _navigate: uninitializedNavigate,
+
+ _generateUrl: uninitializedGenerateUrl,
+
+ _generateWeblinks: uninitializedGenerateWebLinks,
+
+ mapCommentlinks: uninitializedMapCommentLinks,
+
+ _checkPatchRange(
+ patchNum?: PatchSetNum,
+ basePatchNum?: PatchSetNum | ParentPatchSetNum
+ ) {
+ if (basePatchNum && !patchNum) {
+ throw new Error('Cannot use base patch number without patch number.');
+ }
+ },
+
+ /**
+ * Setup router implementation.
+ *
+ * @param navigate the router-abstracted equivalent of
+ * `window.location.href = ...` or window.location.replace(...). The
+ * string is a new location and boolean defines is it redirect or not
+ * (true means redirect, i.e. equivalent of window.location.replace).
+ * @param generateUrl generates a URL given
+ * navigation parameters, detailed in the file header.
+ * @param generateWeblinks weblinks generator
+ * function takes single payload parameter with type property that
+ * determines which
+ * part of the UI is the consumer of the weblinks. type property can
+ * be one of file, change, or patchset.
+ * - For file type, payload will also contain string properties: repo,
+ * commit, file.
+ * - For patchset type, payload will also contain string properties:
+ * repo, commit.
+ * - For change type, payload will also contain string properties:
+ * repo, commit. If server provides weblinks, those will be passed
+ * as options.weblinks property on the main payload object.
+ * @param mapCommentlinks provides an escape
+ * hatch to modify the commentlinks object, e.g. if it contains any
+ * relative URLs.
+ */
+ setup(
+ navigate: NavigateCallback,
+ generateUrl: GenerateUrlCallback,
+ generateWeblinks: GenerateWebLinksCallback,
+ mapCommentlinks: MapCommentLinksCallback
+ ) {
+ this._navigate = navigate;
+ this._generateUrl = generateUrl;
+ this._generateWeblinks = generateWeblinks;
+ this.mapCommentlinks = mapCommentlinks;
+ },
+
+ destroy() {
+ this._navigate = uninitializedNavigate;
+ this._generateUrl = uninitializedGenerateUrl;
+ this._generateWeblinks = uninitializedGenerateWebLinks;
+ this.mapCommentlinks = uninitializedMapCommentLinks;
+ },
+
+ /**
+ * Generate a URL for the given route parameters.
+ */
+ _getUrlFor(params: GenerateUrlParameters) {
+ return this._generateUrl(params);
+ },
+
+ getUrlForSearchQuery(query: string, offset?: number) {
+ return this._getUrlFor({
+ view: GerritView.SEARCH,
+ query,
+ offset,
+ });
+ },
+
+ /**
+ * @param openOnly When true, only search open changes in the project.
+ * @param host The host in which to search.
+ */
+ getUrlForProjectChanges(
+ project: ProjectName,
+ openOnly?: boolean,
+ host?: string
+ ) {
+ return this._getUrlFor({
+ view: GerritView.SEARCH,
+ project,
+ statuses: openOnly ? ['open'] : [],
+ host,
+ });
+ },
+
+ /**
+ * @param status The status to search.
+ * @param host The host in which to search.
+ */
+ getUrlForBranch(
+ branch: BranchName,
+ project: ProjectName,
+ status?: string,
+ host?: string
+ ) {
+ return this._getUrlFor({
+ view: GerritView.SEARCH,
+ branch,
+ project,
+ statuses: status ? [status] : undefined,
+ host,
+ });
+ },
+
+ /**
+ * @param topic The name of the topic.
+ * @param host The host in which to search.
+ */
+ getUrlForTopic(topic: TopicName, host?: string) {
+ return this._getUrlFor({
+ view: GerritView.SEARCH,
+ topic,
+ statuses: ['open', 'merged'],
+ host,
+ });
+ },
+
+ /**
+ * @param hashtag The name of the hashtag.
+ */
+ getUrlForHashtag(hashtag: Hashtag) {
+ return this._getUrlFor({
+ view: GerritView.SEARCH,
+ hashtag,
+ statuses: ['open', 'merged'],
+ });
+ },
+
+ /**
+ * Navigate to a search for changes with the given status.
+ */
+ navigateToStatusSearch(status: string) {
+ this._navigate(
+ this._getUrlFor({
+ view: GerritView.SEARCH,
+ statuses: [status],
+ })
+ );
+ },
+
+ /**
+ * Navigate to a search query
+ */
+ navigateToSearchQuery(query: string, offset?: number) {
+ return this._navigate(this.getUrlForSearchQuery(query, offset));
+ },
+
+ /**
+ * Navigate to the user's dashboard
+ */
+ navigateToUserDashboard() {
+ return this._navigate(this.getUrlForUserDashboard('self'));
+ },
+
+ /**
+ * @param basePatchNum The string 'PARENT' can be used for none.
+ */
+ getUrlForChange(
+ change: ChangeInfo,
+ patchNum?: PatchSetNum,
+ basePatchNum?: PatchSetNum | ParentPatchSetNum,
+ isEdit?: boolean,
+ messageHash?: string
+ ) {
+ if (basePatchNum === PARENT_PATCHNUM) {
+ basePatchNum = undefined;
+ }
+
+ this._checkPatchRange(patchNum, basePatchNum);
+ return this._getUrlFor({
+ view: GerritView.CHANGE,
+ changeNum: change._number,
+ project: change.project,
+ patchNum,
+ basePatchNum,
+ edit: isEdit,
+ host: change.internalHost || undefined,
+ messageHash,
+ });
+ },
+
+ getUrlForChangeById(
+ changeNum: NumericChangeId,
+ project: ProjectName,
+ patchNum?: PatchSetNum
+ ) {
+ return this._getUrlFor({
+ view: GerritView.CHANGE,
+ changeNum,
+ project,
+ patchNum,
+ });
+ },
+
+ /**
+ * @param basePatchNum The string 'PARENT' can be used for none.
+ * @param redirect redirect to a change - if true, the current
+ * location (i.e. page which makes redirect) is not added to a history.
+ * I.e. back/forward buttons skip current location
+ *
+ */
+ navigateToChange(
+ change: ChangeInfo,
+ patchNum?: PatchSetNum,
+ basePatchNum?: PatchSetNum,
+ isEdit?: boolean,
+ redirect?: boolean
+ ) {
+ this._navigate(
+ this.getUrlForChange(change, patchNum, basePatchNum, isEdit),
+ redirect
+ );
+ },
+
+ /**
+ * @param basePatchNum The string 'PARENT' can be used for none.
+ */
+ getUrlForDiff(
+ change: ChangeInfo,
+ filePath: string,
+ patchNum?: PatchSetNum,
+ basePatchNum?: PatchSetNum | ParentPatchSetNum,
+ lineNum?: number
+ ) {
+ return this.getUrlForDiffById(
+ change._number,
+ change.project,
+ filePath,
+ patchNum,
+ basePatchNum,
+ lineNum
+ );
+ },
+
+ getUrlForComment(
+ changeNum: NumericChangeId | LegacyChangeId,
+ project: ProjectName,
+ commentId: UrlEncodedCommentId
+ ) {
+ return this._getUrlFor({
+ view: GerritView.DIFF,
+ changeNum,
+ project,
+ commentId,
+ });
+ },
+
+ /**
+ * @param basePatchNum The string 'PARENT' can be used for none.
+ */
+ getUrlForDiffById(
+ changeNum: NumericChangeId | LegacyChangeId,
+ project: ProjectName,
+ filePath: string,
+ patchNum?: PatchSetNum,
+ basePatchNum?: PatchSetNum | ParentPatchSetNum,
+ lineNum?: number,
+ leftSide?: boolean
+ ) {
+ if (basePatchNum === PARENT_PATCHNUM) {
+ basePatchNum = undefined;
+ }
+
+ this._checkPatchRange(patchNum, basePatchNum);
+ return this._getUrlFor({
+ view: GerritView.DIFF,
+ changeNum,
+ project,
+ path: filePath,
+ patchNum,
+ basePatchNum,
+ lineNum,
+ leftSide,
+ });
+ },
+
+ getEditUrlForDiff(
+ change: ChangeInfo,
+ filePath: string,
+ patchNum?: PatchSetNum,
+ lineNum?: number
+ ) {
+ return this.getEditUrlForDiffById(
+ change._number,
+ change.project,
+ filePath,
+ patchNum,
+ lineNum
+ );
+ },
+
+ /**
+ * @param patchNum The patchNum the file content should be based on, or
+ * ${EditPatchSetNum} if left undefined.
+ * @param lineNum The line number to pass to the inline editor.
+ */
+ getEditUrlForDiffById(
+ changeNum: NumericChangeId | LegacyChangeId,
+ project: ProjectName,
+ filePath: string,
+ patchNum?: PatchSetNum,
+ lineNum?: number
+ ) {
+ return this._getUrlFor({
+ view: GerritView.EDIT,
+ changeNum,
+ project,
+ path: filePath,
+ patchNum: patchNum || EditPatchSetNum,
+ lineNum,
+ });
+ },
+
+ /**
+ * @param basePatchNum The string 'PARENT' can be used for none.
+ */
+ navigateToDiff(
+ change: ChangeInfo,
+ filePath: string,
+ patchNum?: PatchSetNum,
+ basePatchNum?: PatchSetNum | ParentPatchSetNum
+ ) {
+ this._navigate(
+ this.getUrlForDiff(change, filePath, patchNum, basePatchNum)
+ );
+ },
+
+ /**
+ * @param owner The name of the owner.
+ */
+ getUrlForOwner(owner: string) {
+ return this._getUrlFor({
+ view: GerritView.SEARCH,
+ owner,
+ });
+ },
+
+ /**
+ * @param user The name of the user.
+ */
+ getUrlForUserDashboard(user: string) {
+ return this._getUrlFor({
+ view: GerritView.DASHBOARD,
+ user,
+ });
+ },
+
+ getUrlForRoot() {
+ return this._getUrlFor({
+ view: GerritView.ROOT,
+ });
+ },
+
+ /**
+ * @param repo The name of the repo.
+ * @param dashboard The ID of the dashboard, in the form of '<ref>:<path>'.
+ */
+ getUrlForRepoDashboard(repo: RepositoryName, dashboard: DashboardId) {
+ return this._getUrlFor({
+ view: GerritView.DASHBOARD,
+ repo,
+ dashboard,
+ });
+ },
+
+ /**
+ * Navigate to an arbitrary relative URL.
+ */
+ navigateToRelativeUrl(relativeUrl: string) {
+ if (!relativeUrl.startsWith('/')) {
+ throw new Error('navigateToRelativeUrl with non-relative URL');
+ }
+ this._navigate(relativeUrl);
+ },
+
+ getUrlForRepo(repoName: RepositoryName) {
+ return this._getUrlFor({
+ view: GerritView.REPO,
+ repoName,
+ });
+ },
+
+ /**
+ * Navigate to a repo settings page.
+ */
+ navigateToRepo(repoName: RepositoryName) {
+ this._navigate(this.getUrlForRepo(repoName));
+ },
+
+ getUrlForRepoTags(repoName: RepositoryName) {
+ return this._getUrlFor({
+ view: GerritView.REPO,
+ repoName,
+ detail: RepoDetailView.TAGS,
+ });
+ },
+
+ getUrlForRepoBranches(repoName: RepositoryName) {
+ return this._getUrlFor({
+ view: GerritView.REPO,
+ repoName,
+ detail: GerritNav.RepoDetailView.BRANCHES,
+ });
+ },
+
+ getUrlForRepoAccess(repoName: RepositoryName) {
+ return this._getUrlFor({
+ view: GerritView.REPO,
+ repoName,
+ detail: GerritNav.RepoDetailView.ACCESS,
+ });
+ },
+
+ getUrlForRepoCommands(repoName: RepositoryName) {
+ return this._getUrlFor({
+ view: GerritView.REPO,
+ repoName,
+ detail: GerritNav.RepoDetailView.COMMANDS,
+ });
+ },
+
+ getUrlForRepoDashboards(repoName: RepositoryName) {
+ return this._getUrlFor({
+ view: GerritView.REPO,
+ repoName,
+ detail: GerritNav.RepoDetailView.DASHBOARDS,
+ });
+ },
+
+ getUrlForGroup(groupId: GroupId) {
+ return this._getUrlFor({
+ view: GerritView.GROUP,
+ groupId,
+ });
+ },
+
+ getUrlForGroupLog(groupId: GroupId) {
+ return this._getUrlFor({
+ view: GerritView.GROUP,
+ groupId,
+ detail: GerritNav.GroupDetailView.LOG,
+ });
+ },
+
+ getUrlForGroupMembers(groupId: GroupId) {
+ return this._getUrlFor({
+ view: GerritView.GROUP,
+ groupId,
+ detail: GroupDetailView.MEMBERS,
+ });
+ },
+
+ getUrlForSettings() {
+ return this._getUrlFor({view: GerritView.SETTINGS});
+ },
+
+ getFileWebLinks(
+ repo: RepositoryName,
+ commit: CommitId,
+ file: string,
+ options?: unknown
+ ): WebLink[] {
+ const params: GenerateWebLinksFileParameters = {
+ type: WeblinkType.FILE,
+ repo,
+ commit,
+ file,
+ };
+ if (options) {
+ params.options = options;
+ }
+ return ([] as WebLink[]).concat(this._generateWeblinks(params));
+ },
+
+ getPatchSetWeblink(
+ repo: RepositoryName,
+ commit: CommitId,
+ options?: unknown
+ ): WebLink {
+ const params: GenerateWebLinksPatchsetParameters = {
+ type: WeblinkType.PATCHSET,
+ repo,
+ commit,
+ };
+ if (options) {
+ params.options = options;
+ }
+ const result = this._generateWeblinks(params);
+ if (Array.isArray(result)) {
+ // TODO(TS): Unclear what to do with empty array.
+ // Either write a comment why result can't be empty or change the return
+ // type or add a check.
+ return result.pop() as WebLink;
+ } else {
+ return result;
+ }
+ },
+
+ getChangeWeblinks(
+ repo: RepositoryName,
+ commit: CommitId,
+ options?: unknown
+ ): WebLink[] {
+ const params: GenerateWebLinksChangeParameters = {
+ type: WeblinkType.CHANGE,
+ repo,
+ commit,
+ };
+ if (options) {
+ params.options = options;
+ }
+ return ([] as WebLink[]).concat(this._generateWeblinks(params));
+ },
+
+ getUserDashboard(
+ user = 'self',
+ sections = DEFAULT_SECTIONS,
+ title = '',
+ config: UserDashboardConfig = {}
+ ): UserDashboard {
+ const attentionEnabled =
+ config.change && !!config.change.enable_attention_set;
+ const assigneeEnabled = config.change && !!config.change.enable_assignee;
+ sections = sections
+ .filter(section => attentionEnabled || !section.attentionSetOnly)
+ .filter(section => assigneeEnabled || !section.assigneeOnly)
+ .filter(section => user === 'self' || !section.selfOnly)
+ .map(section => {
+ return {
+ ...section,
+ name: section.name,
+ query: section.query.replace(USER_PLACEHOLDER_PATTERN, user),
+ };
+ });
+ return {title, sections};
+ },
+};
diff --git a/polygerrit-ui/app/elements/plugins/gr-admin-api/gr-admin-api.js b/polygerrit-ui/app/elements/plugins/gr-admin-api/gr-admin-api.js
deleted file mode 100644
index 2dfb79f..0000000
--- a/polygerrit-ui/app/elements/plugins/gr-admin-api/gr-admin-api.js
+++ /dev/null
@@ -1,35 +0,0 @@
-/**
- * @license
- * Copyright (C) 2018 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.
- */
-
-/** @constructor */
-export function GrAdminApi(plugin) {
- this.plugin = plugin;
- plugin.on('admin-menu-links', this);
- this._menuLinks = [];
-}
-
-/**
- * @param {string} text
- * @param {string} url
- */
-GrAdminApi.prototype.addMenuLink = function(text, url, opt_capability) {
- this._menuLinks.push({text, url, capability: opt_capability || null});
-};
-
-GrAdminApi.prototype.getMenuLinks = function() {
- return this._menuLinks.slice(0);
-};
diff --git a/polygerrit-ui/app/elements/plugins/gr-admin-api/gr-admin-api.ts b/polygerrit-ui/app/elements/plugins/gr-admin-api/gr-admin-api.ts
new file mode 100644
index 0000000..51ff0ab
--- /dev/null
+++ b/polygerrit-ui/app/elements/plugins/gr-admin-api/gr-admin-api.ts
@@ -0,0 +1,50 @@
+/**
+ * @license
+ * Copyright (C) 2018 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.
+ */
+
+/** Interface for menu link */
+export interface MenuLink {
+ text: string;
+ url: string;
+ capability: string | null;
+}
+
+// TODO(TS): replace with Plugin once gr-public-js-api migrated
+interface PluginApi {
+ on(eventName: string, adminApi: GrAdminApi): void;
+}
+
+/**
+ * GrAdminApi class.
+ *
+ * Defines common methods to register / retrieve menu links.
+ */
+export class GrAdminApi {
+ // TODO(TS): maybe define as enum if its a limited set
+ private menuLinks: MenuLink[] = [];
+
+ constructor(private readonly plugin: PluginApi) {
+ this.plugin.on('admin-menu-links', this);
+ }
+
+ addMenuLink(text: string, url: string, capability?: string) {
+ this.menuLinks.push({text, url, capability: capability || null});
+ }
+
+ getMenuLinks(): MenuLink[] {
+ return this.menuLinks.slice(0);
+ }
+}
diff --git a/polygerrit-ui/app/elements/plugins/gr-popup-interface/gr-plugin-popup.js b/polygerrit-ui/app/elements/plugins/gr-popup-interface/gr-plugin-popup.js
deleted file mode 100644
index eaecd29..0000000
--- a/polygerrit-ui/app/elements/plugins/gr-popup-interface/gr-plugin-popup.js
+++ /dev/null
@@ -1,48 +0,0 @@
-/**
- * @license
- * Copyright (C) 2017 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 '../../shared/gr-overlay/gr-overlay.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-plugin-popup_html.js';
-
-(function(window) {
- 'use strict';
-
- /** @extends PolymerElement */
- class GrPluginPopup extends GestureEventListeners(
- LegacyElementMixin(
- PolymerElement)) {
- static get template() { return htmlTemplate; }
-
- static get is() { return 'gr-plugin-popup'; }
-
- get opened() {
- return this.$.overlay.opened;
- }
-
- open() {
- return this.$.overlay.open();
- }
-
- close() {
- this.$.overlay.close();
- }
- }
-
- customElements.define(GrPluginPopup.is, GrPluginPopup);
-})(window);
diff --git a/polygerrit-ui/app/elements/plugins/gr-popup-interface/gr-plugin-popup.ts b/polygerrit-ui/app/elements/plugins/gr-popup-interface/gr-plugin-popup.ts
new file mode 100644
index 0000000..7c6587a
--- /dev/null
+++ b/polygerrit-ui/app/elements/plugins/gr-popup-interface/gr-plugin-popup.ts
@@ -0,0 +1,55 @@
+/**
+ * @license
+ * Copyright (C) 2017 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 '../../shared/gr-overlay/gr-overlay';
+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-plugin-popup_html';
+import {GrOverlay} from '../../shared/gr-overlay/gr-overlay';
+import {customElement} from '@polymer/decorators';
+
+declare global {
+ interface HTMLElementTagNameMap {
+ 'gr-plugin-popup': GrPluginPopup;
+ }
+}
+
+export interface GrPluginPopup {
+ $: {
+ overlay: GrOverlay;
+ };
+}
+@customElement('gr-plugin-popup')
+export class GrPluginPopup extends GestureEventListeners(
+ LegacyElementMixin(PolymerElement)
+) {
+ static get template() {
+ return htmlTemplate;
+ }
+
+ get opened() {
+ return this.$.overlay.opened;
+ }
+
+ open() {
+ return this.$.overlay.open();
+ }
+
+ close() {
+ this.$.overlay.close();
+ }
+}
diff --git a/polygerrit-ui/app/elements/plugins/gr-theme-api/gr-custom-plugin-header.js b/polygerrit-ui/app/elements/plugins/gr-theme-api/gr-custom-plugin-header.js
deleted file mode 100644
index 1e37603..0000000
--- a/polygerrit-ui/app/elements/plugins/gr-theme-api/gr-custom-plugin-header.js
+++ /dev/null
@@ -1,52 +0,0 @@
-/**
- * @license
- * Copyright (C) 2017 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 {PolymerElement} from '@polymer/polymer/polymer-element.js';
-import {html} from '@polymer/polymer/lib/utils/html-tag.js';
-
-class CustomPluginHeader extends PolymerElement {
- static get is() {
- return 'gr-custom-plugin-header';
- }
-
- static get properties() {
- return {
- logoUrl: String,
- title: String,
- };
- }
-
- static get template() {
- return html`
- <style>
- img {
- width: 1em;
- height: 1em;
- vertical-align: middle;
- }
- .title {
- margin-left: var(--spacing-xs);
- }
- </style>
- <span>
- <img src="[[logoUrl]]" hidden\$="[[!logoUrl]]">
- <span class="title">[[title]]</span>
- </span>
-`;
- }
-}
-
-customElements.define(CustomPluginHeader.is, CustomPluginHeader);
\ No newline at end of file
diff --git a/polygerrit-ui/app/elements/plugins/gr-theme-api/gr-custom-plugin-header.ts b/polygerrit-ui/app/elements/plugins/gr-theme-api/gr-custom-plugin-header.ts
new file mode 100644
index 0000000..7d82ff4
--- /dev/null
+++ b/polygerrit-ui/app/elements/plugins/gr-theme-api/gr-custom-plugin-header.ts
@@ -0,0 +1,53 @@
+/**
+ * @license
+ * Copyright (C) 2017 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 {PolymerElement} from '@polymer/polymer/polymer-element';
+import {html} from '@polymer/polymer/lib/utils/html-tag';
+import {customElement, property} from '@polymer/decorators';
+
+declare global {
+ interface HTMLElementTagNameMap {
+ 'gr-custom-plugin-header': GrCustomPluginHeader;
+ }
+}
+
+@customElement('gr-custom-plugin-header')
+export class GrCustomPluginHeader extends PolymerElement {
+ @property({type: String})
+ logoUrl = '';
+
+ @property({type: String})
+ title = '';
+
+ static get template() {
+ return html`
+ <style>
+ img {
+ width: 1em;
+ height: 1em;
+ vertical-align: middle;
+ }
+ .title {
+ margin-left: var(--spacing-xs);
+ }
+ </style>
+ <span>
+ <img src="[[logoUrl]]" hidden$="[[!logoUrl]]" />
+ <span class="title">[[title]]</span>
+ </span>
+ `;
+ }
+}
diff --git a/polygerrit-ui/app/elements/plugins/gr-theme-api/gr-theme-api.js b/polygerrit-ui/app/elements/plugins/gr-theme-api/gr-theme-api.js
deleted file mode 100644
index 48b14d3..0000000
--- a/polygerrit-ui/app/elements/plugins/gr-theme-api/gr-theme-api.js
+++ /dev/null
@@ -1,34 +0,0 @@
-/**
- * @license
- * Copyright (C) 2017 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-custom-plugin-header.js';
-
-/** @constructor */
-export function GrThemeApi(plugin) {
- this.plugin = plugin;
-}
-
-GrThemeApi.prototype.setHeaderLogoAndTitle = function(logoUrl, title) {
- this.plugin.hook('header-title', {replace: true}).onAttached(
- element => {
- const customHeader =
- document.createElement('gr-custom-plugin-header');
- customHeader.logoUrl = logoUrl;
- customHeader.title = title;
- element.appendChild(customHeader);
- });
-};
-
diff --git a/polygerrit-ui/app/elements/plugins/gr-theme-api/gr-theme-api.ts b/polygerrit-ui/app/elements/plugins/gr-theme-api/gr-theme-api.ts
new file mode 100644
index 0000000..613bde2
--- /dev/null
+++ b/polygerrit-ui/app/elements/plugins/gr-theme-api/gr-theme-api.ts
@@ -0,0 +1,46 @@
+/**
+ * @license
+ * Copyright (C) 2017 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-custom-plugin-header';
+import {GrCustomPluginHeader} from './gr-custom-plugin-header';
+
+// TODO(TS): replace with Plugin once gr-public-js-api migrated
+interface PluginApi {
+ hook(
+ endpointName: string,
+ option: {replace?: boolean}
+ ): {
+ onAttached(callback: (el: Element) => void): void;
+ };
+}
+
+/**
+ * Defines api for theme, can be used to set header logo and title.
+ */
+export class GrThemeApi {
+ constructor(private readonly plugin: PluginApi) {}
+
+ setHeaderLogoAndTitle(logoUrl: string, title: string) {
+ this.plugin.hook('header-title', {replace: true}).onAttached(element => {
+ const customHeader: GrCustomPluginHeader = document.createElement(
+ 'gr-custom-plugin-header'
+ );
+ customHeader.logoUrl = logoUrl;
+ customHeader.title = title;
+ element.appendChild(customHeader);
+ });
+ }
+}
diff --git a/polygerrit-ui/app/elements/shared/gr-button/gr-button.ts b/polygerrit-ui/app/elements/shared/gr-button/gr-button.ts
index a0d8d13e..4d75138 100644
--- a/polygerrit-ui/app/elements/shared/gr-button/gr-button.ts
+++ b/polygerrit-ui/app/elements/shared/gr-button/gr-button.ts
@@ -73,7 +73,7 @@
}
@property({
- computed: 'computeAriaDisable(disabled, loading)',
+ computed: 'computeAriaDisabled(disabled, loading)',
reflectToAttribute: true,
type: Boolean,
})
diff --git a/polygerrit-ui/app/elements/shared/gr-copy-clipboard/gr-copy-clipboard.js b/polygerrit-ui/app/elements/shared/gr-copy-clipboard/gr-copy-clipboard.js
deleted file mode 100644
index 39c149f..0000000
--- a/polygerrit-ui/app/elements/shared/gr-copy-clipboard/gr-copy-clipboard.js
+++ /dev/null
@@ -1,85 +0,0 @@
-/**
- * @license
- * Copyright (C) 2017 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 '@polymer/iron-input/iron-input.js';
-import '../../../styles/shared-styles.js';
-import '../gr-button/gr-button.js';
-import '../gr-icons/gr-icons.js';
-import {dom} from '@polymer/polymer/lib/legacy/polymer.dom.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-copy-clipboard_html.js';
-
-const COPY_TIMEOUT_MS = 1000;
-
-/** @extends PolymerElement */
-class GrCopyClipboard extends GestureEventListeners(
- LegacyElementMixin(
- PolymerElement)) {
- static get template() { return htmlTemplate; }
-
- static get is() { return 'gr-copy-clipboard'; }
-
- static get properties() {
- return {
- text: String,
- buttonTitle: String,
- hasTooltip: {
- type: Boolean,
- value: false,
- },
- hideInput: {
- type: Boolean,
- value: false,
- },
- };
- }
-
- focusOnCopy() {
- this.$.button.focus();
- }
-
- _computeInputClass(hideInput) {
- return hideInput ? 'hideInput' : '';
- }
-
- _handleInputClick(e) {
- e.preventDefault();
- dom(e).rootTarget.select();
- }
-
- _copyToClipboard(e) {
- e.preventDefault();
- e.stopPropagation();
-
- if (this.hideInput) {
- this.$.input.style.display = 'block';
- }
- this.$.input.focus();
- this.$.input.select();
- document.execCommand('copy');
- if (this.hideInput) {
- this.$.input.style.display = 'none';
- }
- this.$.icon.icon = 'gr-icons:check';
- this.async(
- () => this.$.icon.icon = 'gr-icons:content-copy',
- COPY_TIMEOUT_MS);
- }
-}
-
-customElements.define(GrCopyClipboard.is, GrCopyClipboard);
diff --git a/polygerrit-ui/app/elements/shared/gr-copy-clipboard/gr-copy-clipboard.ts b/polygerrit-ui/app/elements/shared/gr-copy-clipboard/gr-copy-clipboard.ts
new file mode 100644
index 0000000..47dacc3
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-copy-clipboard/gr-copy-clipboard.ts
@@ -0,0 +1,95 @@
+/**
+ * @license
+ * Copyright (C) 2017 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 '@polymer/iron-input/iron-input';
+import '../../../styles/shared-styles';
+import '../gr-button/gr-button';
+import '../gr-icons/gr-icons';
+import {dom, EventApi} from '@polymer/polymer/lib/legacy/polymer.dom';
+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-copy-clipboard_html';
+import {GrButton} from '../gr-button/gr-button';
+import {customElement, property} from '@polymer/decorators';
+import {IronIconElement} from '@polymer/iron-icon';
+
+const COPY_TIMEOUT_MS = 1000;
+
+declare global {
+ interface HTMLElementTagNameMap {
+ 'gr-copy-clipboard': GrCopyClipboard;
+ }
+}
+
+export interface GrCopyClipboard {
+ $: {button: GrButton; icon: IronIconElement; input: HTMLInputElement};
+}
+
+/** @extends PolymerElement */
+@customElement('gr-copy-clipboard')
+export class GrCopyClipboard extends GestureEventListeners(
+ LegacyElementMixin(PolymerElement)
+) {
+ static get template() {
+ return htmlTemplate;
+ }
+
+ @property({type: String})
+ text: string | undefined;
+
+ @property({type: String})
+ buttonTitle: string | undefined;
+
+ @property({type: Boolean})
+ hasTooltip = false;
+
+ @property({type: Boolean})
+ hideInput = false;
+
+ focusOnCopy() {
+ this.$.button.focus();
+ }
+
+ _computeInputClass(hideInput: boolean) {
+ return hideInput ? 'hideInput' : '';
+ }
+
+ _handleInputClick(e: MouseEvent) {
+ e.preventDefault();
+ ((dom(e) as EventApi).rootTarget as HTMLInputElement).select();
+ }
+
+ _copyToClipboard(e: MouseEvent) {
+ e.preventDefault();
+ e.stopPropagation();
+
+ if (this.hideInput) {
+ this.$.input.style.display = 'block';
+ }
+ this.$.input.focus();
+ this.$.input.select();
+ document.execCommand('copy');
+ if (this.hideInput) {
+ this.$.input.style.display = 'none';
+ }
+ this.$.icon.icon = 'gr-icons:check';
+ this.async(
+ () => (this.$.icon.icon = 'gr-icons:content-copy'),
+ COPY_TIMEOUT_MS
+ );
+ }
+}
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';
+ }
+}
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-api-utils.js b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-api-utils.js
deleted file mode 100644
index 9bef3a2..0000000
--- a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-api-utils.js
+++ /dev/null
@@ -1,103 +0,0 @@
-/**
- * @license
- * Copyright (C) 2019 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 {getBaseUrl} from '../../../utils/url-util.js';
-
-export const PRELOADED_PROTOCOL = 'preloaded:';
-export const PLUGIN_LOADING_TIMEOUT_MS = 10000;
-
-let _restAPI;
-export function getRestAPI() {
- if (!_restAPI) {
- _restAPI = document.createElement('gr-rest-api-interface');
- }
- return _restAPI;
-}
-
-/**
- * Retrieves the name of the plugin base on the url.
- *
- * @param {string|URL} url
- */
-export function getPluginNameFromUrl(url) {
- if (!(url instanceof URL)) {
- try {
- url = new URL(url);
- } catch (e) {
- console.warn(e);
- return null;
- }
- }
- if (url.protocol === PRELOADED_PROTOCOL) {
- return url.pathname;
- }
- const base = getBaseUrl();
- let pathname = url.pathname.replace(base, '');
- // Load from ASSETS_PATH
- if (window.ASSETS_PATH && url.href.includes(window.ASSETS_PATH)) {
- pathname = url.href.replace(window.ASSETS_PATH, '');
- }
- // Site theme is server from predefined path.
- if ([
- '/static/gerrit-theme.html',
- '/static/gerrit-theme.js',
- ].includes(pathname)) {
- return 'gerrit-theme';
- } else if (!pathname.startsWith('/plugins')) {
- console.warn('Plugin not being loaded from /plugins base path:',
- url.href, '— Unable to determine name.');
- return null;
- }
-
- // Pathname should normally look like this:
- // /plugins/PLUGINNAME/static/SCRIPTNAME.html
- // Or, for app/samples:
- // /plugins/PLUGINNAME.html
- // TODO(taoalpha): guard with a regex
- return pathname.split('/')[2].split('.')[0];
-}
-
-// TODO(taoalpha): to be deprecated.
-export function send(method, url, opt_callback, opt_payload) {
- return getRestAPI().send(method, url, opt_payload)
- .then(response => {
- if (response.status < 200 || response.status >= 300) {
- return response.text().then(text => {
- if (text) {
- return Promise.reject(new Error(text));
- } else {
- return Promise.reject(new Error(response.status));
- }
- });
- } else {
- return getRestAPI().getResponseObject(response);
- }
- })
- .then(response => {
- if (opt_callback) {
- opt_callback(response);
- }
- return response;
- });
-}
-
-// TEST only methods / properties
-
-export function testOnly_resetInternalState() {
- _restAPI = undefined;
-}
-
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-api-utils.ts b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-api-utils.ts
new file mode 100644
index 0000000..2b60e94
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-api-utils.ts
@@ -0,0 +1,113 @@
+/**
+ * @license
+ * Copyright (C) 2019 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 {getBaseUrl} from '../../../utils/url-util';
+import {RestApiService} from '../../../services/services/gr-rest-api/gr-rest-api';
+
+export const PRELOADED_PROTOCOL = 'preloaded:';
+export const PLUGIN_LOADING_TIMEOUT_MS = 10000;
+
+let _restAPI: RestApiService | undefined;
+export function getRestAPI() {
+ if (!_restAPI) {
+ _restAPI = (document.createElement(
+ 'gr-rest-api-interface'
+ ) as unknown) as RestApiService;
+ }
+ return _restAPI;
+}
+
+/**
+ * Retrieves the name of the plugin base on the url.
+ *
+ * @param {string|URL} url
+ */
+export function getPluginNameFromUrl(url: URL | string) {
+ if (!(url instanceof URL)) {
+ try {
+ url = new URL(url);
+ } catch (e) {
+ console.warn(e);
+ return null;
+ }
+ }
+ if (url.protocol === PRELOADED_PROTOCOL) {
+ return url.pathname;
+ }
+ const base = getBaseUrl();
+ let pathname = url.pathname.replace(base, '');
+ // Load from ASSETS_PATH
+ if (window.ASSETS_PATH && url.href.includes(window.ASSETS_PATH)) {
+ pathname = url.href.replace(window.ASSETS_PATH, '');
+ }
+ // Site theme is server from predefined path.
+ if (
+ ['/static/gerrit-theme.html', '/static/gerrit-theme.js'].includes(pathname)
+ ) {
+ return 'gerrit-theme';
+ } else if (!pathname.startsWith('/plugins')) {
+ console.warn(
+ 'Plugin not being loaded from /plugins base path:',
+ url.href,
+ '— Unable to determine name.'
+ );
+ return null;
+ }
+
+ // Pathname should normally look like this:
+ // /plugins/PLUGINNAME/static/SCRIPTNAME.html
+ // Or, for app/samples:
+ // /plugins/PLUGINNAME.html
+ // TODO(taoalpha): guard with a regex
+ return pathname.split('/')[2].split('.')[0];
+}
+
+// TODO(taoalpha): to be deprecated.
+export function send(
+ method: string,
+ url: string,
+ opt_callback?: (response: unknown) => void,
+ opt_payload?: unknown
+) {
+ return getRestAPI()
+ .send(method, url, opt_payload)
+ .then(response => {
+ if (response.status < 200 || response.status >= 300) {
+ return response.text().then((text: string | undefined) => {
+ if (text) {
+ return Promise.reject(new Error(text));
+ } else {
+ return Promise.reject(new Error(`${response.status}`));
+ }
+ });
+ } else {
+ return getRestAPI().getResponseObject(response);
+ }
+ })
+ .then(response => {
+ if (opt_callback) {
+ opt_callback(response);
+ }
+ return response;
+ });
+}
+
+// TEST only methods / properties
+
+export function testOnly_resetInternalState() {
+ _restAPI = undefined;
+}
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-rest-api.js b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-rest-api.js
deleted file mode 100644
index 31ff8ee..0000000
--- a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-rest-api.js
+++ /dev/null
@@ -1,151 +0,0 @@
-/**
- * @license
- * Copyright (C) 2017 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.
- */
-
-let restApi;
-
-export function _testOnlyResetRestApi() {
- restApi = null;
-}
-
-function getRestApi() {
- if (!restApi) {
- restApi = document.createElement('gr-rest-api-interface');
- }
- return restApi;
-}
-
-export function GrPluginRestApi(opt_prefix) {
- this.opt_prefix = opt_prefix || '';
-}
-
-GrPluginRestApi.prototype.getLoggedIn = function() {
- return getRestApi().getLoggedIn();
-};
-
-GrPluginRestApi.prototype.getVersion = function() {
- return getRestApi().getVersion();
-};
-
-GrPluginRestApi.prototype.getConfig = function() {
- return getRestApi().getConfig();
-};
-
-GrPluginRestApi.prototype.invalidateReposCache = function() {
- getRestApi().invalidateReposCache();
-};
-
-GrPluginRestApi.prototype.getAccount = function() {
- return getRestApi().getAccount();
-};
-
-GrPluginRestApi.prototype.getAccountCapabilities = function(capabilities) {
- return getRestApi().getAccountCapabilities(capabilities);
-};
-
-GrPluginRestApi.prototype.getRepos =
- function(filter, reposPerPage, opt_offset) {
- return getRestApi().getRepos(filter, reposPerPage, opt_offset);
- };
-
-/**
- * Fetch and return native browser REST API Response.
- *
- * @param {string} method HTTP Method (GET, POST, etc)
- * @param {string} url URL without base path or plugin prefix
- * @param {Object=} payload Respected for POST and PUT only.
- * @param {?function(?Response, string=)=} opt_errFn
- * passed as null sometimes.
- * @return {!Promise}
- */
-GrPluginRestApi.prototype.fetch = function(method, url, opt_payload,
- opt_errFn, opt_contentType) {
- return getRestApi().send(method, this.opt_prefix + url, opt_payload,
- opt_errFn, opt_contentType);
-};
-
-/**
- * Fetch and parse REST API response, if request succeeds.
- *
- * @param {string} method HTTP Method (GET, POST, etc)
- * @param {string} url URL without base path or plugin prefix
- * @param {Object=} payload Respected for POST and PUT only.
- * @param {?function(?Response, string=)=} opt_errFn
- * passed as null sometimes.
- * @return {!Promise} resolves on success, rejects on error.
- */
-GrPluginRestApi.prototype.send = function(method, url, opt_payload,
- opt_errFn, opt_contentType) {
- return this.fetch(method, url, opt_payload, opt_errFn, opt_contentType)
- .then(response => {
- if (response.status < 200 || response.status >= 300) {
- return response.text().then(text => {
- if (text) {
- return Promise.reject(new Error(text));
- } else {
- return Promise.reject(new Error(response.status));
- }
- });
- } else {
- return getRestApi().getResponseObject(response);
- }
- });
-};
-
-/**
- * @param {string} url URL without base path or plugin prefix
- * @return {!Promise} resolves on success, rejects on error.
- */
-GrPluginRestApi.prototype.get = function(url) {
- return this.send('GET', url);
-};
-
-/**
- * @param {string} url URL without base path or plugin prefix
- * @return {!Promise} resolves on success, rejects on error.
- */
-GrPluginRestApi.prototype.post = function(url, opt_payload, opt_errFn,
- opt_contentType) {
- return this.send('POST', url, opt_payload, opt_errFn, opt_contentType);
-};
-
-/**
- * @param {string} url URL without base path or plugin prefix
- * @return {!Promise} resolves on success, rejects on error.
- */
-GrPluginRestApi.prototype.put = function(url, opt_payload, opt_errFn,
- opt_contentType) {
- return this.send('PUT', url, opt_payload, opt_errFn, opt_contentType);
-};
-
-/**
- * @param {string} url URL without base path or plugin prefix
- * @return {!Promise} resolves on 204, rejects on error.
- */
-GrPluginRestApi.prototype.delete = function(url) {
- return this.fetch('DELETE', url).then(response => {
- if (response.status !== 204) {
- return response.text().then(text => {
- if (text) {
- return Promise.reject(new Error(text));
- } else {
- return Promise.reject(new Error(response.status));
- }
- });
- }
- return response;
- });
-};
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-rest-api.ts b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-rest-api.ts
new file mode 100644
index 0000000..a82e90c
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-rest-api.ts
@@ -0,0 +1,161 @@
+/**
+ * @license
+ * Copyright (C) 2017 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 {
+ ErrorCallback,
+ RestApiService,
+} from '../../../services/services/gr-rest-api/gr-rest-api';
+
+/**
+ * Enum for all http methods used in Gerrit.
+ * TODO(TS): might move to common later.
+ */
+export enum HttpMethod {
+ POST = 'POST',
+ GET = 'GET',
+ DELETE = 'DELETE',
+ PUT = 'PUT',
+}
+
+let restApi: RestApiService | null = null;
+
+export function _testOnlyResetRestApi() {
+ restApi = null;
+}
+
+function getRestApi(): RestApiService {
+ if (!restApi) {
+ restApi = (document.createElement(
+ 'gr-rest-api-interface'
+ ) as unknown) as RestApiService;
+ }
+ return restApi;
+}
+
+export class GrPluginRestApi {
+ constructor(private readonly prefix = '') {}
+
+ getLoggedIn() {
+ return getRestApi().getLoggedIn();
+ }
+
+ getVersion() {
+ return getRestApi().getVersion();
+ }
+
+ getConfig() {
+ return getRestApi().getConfig();
+ }
+
+ invalidateReposCache() {
+ getRestApi().invalidateReposCache();
+ }
+
+ getAccount() {
+ return getRestApi().getAccount();
+ }
+
+ getAccountCapabilities(capabilities: string[]) {
+ return getRestApi().getAccountCapabilities(capabilities);
+ }
+
+ getRepos(filter: string, reposPerPage: number, offset?: number) {
+ return getRestApi().getRepos(filter, reposPerPage, offset);
+ }
+
+ /**
+ * Fetch and return native browser REST API Response.
+ */
+ fetch(
+ method: HttpMethod,
+ url: string,
+ payload?: unknown,
+ errFn?: ErrorCallback,
+ contentType?: string
+ ): Promise<Response> {
+ return getRestApi().send(
+ method,
+ this.prefix + url,
+ payload,
+ errFn,
+ contentType
+ );
+ }
+
+ /**
+ * Fetch and parse REST API response, if request succeeds.
+ */
+ send(
+ method: HttpMethod,
+ url: string,
+ payload?: unknown,
+ errFn?: ErrorCallback,
+ contentType?: string
+ ) {
+ return this.fetch(method, url, payload, errFn, contentType).then(
+ response => {
+ if (response.status < 200 || response.status >= 300) {
+ return response.text().then(text => {
+ if (text) {
+ return Promise.reject(new Error(text));
+ } else {
+ return Promise.reject(new Error(`${response.status}`));
+ }
+ });
+ } else {
+ return getRestApi().getResponseObject(response);
+ }
+ }
+ );
+ }
+
+ get(url: string) {
+ return this.send(HttpMethod.GET, url);
+ }
+
+ post(
+ url: string,
+ payload?: unknown,
+ errFn?: ErrorCallback,
+ contentType?: string
+ ) {
+ return this.send(HttpMethod.POST, url, payload, errFn, contentType);
+ }
+
+ put(
+ url: string,
+ payload?: unknown,
+ errFn?: ErrorCallback,
+ contentType?: string
+ ) {
+ return this.send(HttpMethod.PUT, url, payload, errFn, contentType);
+ }
+
+ delete(url: string) {
+ return this.fetch(HttpMethod.DELETE, url).then(response => {
+ if (response.status !== 204) {
+ return response.text().then(text => {
+ if (text) {
+ return Promise.reject(new Error(text));
+ } else {
+ return Promise.reject(new Error(`${response.status}`));
+ }
+ });
+ }
+ return response;
+ });
+ }
+}
diff --git a/polygerrit-ui/app/elements/shared/gr-overlay/gr-overlay.js b/polygerrit-ui/app/elements/shared/gr-overlay/gr-overlay.js
deleted file mode 100644
index 083b802..0000000
--- a/polygerrit-ui/app/elements/shared/gr-overlay/gr-overlay.js
+++ /dev/null
@@ -1,130 +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 {IronOverlayBehaviorImpl} from '@polymer/iron-overlay-behavior/iron-overlay-behavior.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-overlay_html.js';
-import {IronOverlayMixin} from '../../../mixins/iron-overlay-mixin/iron-overlay-mixin.js';
-
-const AWAIT_MAX_ITERS = 10;
-const AWAIT_STEP = 5;
-const BREAKPOINT_FULLSCREEN_OVERLAY = '50em';
-
-/**
- * @extends PolymerElement
- */
-class GrOverlay extends IronOverlayMixin(GestureEventListeners(
- LegacyElementMixin(PolymerElement))) {
- static get template() { return htmlTemplate; }
-
- static get is() { return 'gr-overlay'; }
- /**
- * Fired when a fullscreen overlay is closed
- *
- * @event fullscreen-overlay-closed
- */
-
- /**
- * Fired when an overlay is opened in full screen mode
- *
- * @event fullscreen-overlay-opened
- */
-
- static get properties() {
- return {
- _fullScreenOpen: {
- type: Boolean,
- value: false,
- },
- };
- }
-
- /** @override */
- created() {
- this._boundHandleClose = () => this.close();
- super.created();
- this.addEventListener('iron-overlay-closed',
- () => this._overlayClosed());
- this.addEventListener('iron-overlay-cancelled',
- () => this._overlayClosed());
- }
-
- open(...args) {
- window.addEventListener('popstate', this._boundHandleClose);
- return new Promise((resolve, reject) => {
- IronOverlayBehaviorImpl.open.apply(this, args);
- if (this._isMobile()) {
- this.dispatchEvent(new CustomEvent('fullscreen-overlay-opened', {
- composed: true, bubbles: true,
- }));
- this._fullScreenOpen = true;
- }
- this._awaitOpen(resolve, reject);
- });
- }
-
- _isMobile() {
- return window.matchMedia(`(max-width: ${BREAKPOINT_FULLSCREEN_OVERLAY})`);
- }
-
- // called after iron-overlay is closed. Does not actually close the overlay
- _overlayClosed() {
- window.removeEventListener('popstate', this._boundHandleClose);
- if (this._fullScreenOpen) {
- this.dispatchEvent(new CustomEvent('fullscreen-overlay-closed', {
- composed: true, bubbles: true,
- }));
- this._fullScreenOpen = false;
- }
- }
-
- /**
- * Override the focus stops that iron-overlay-behavior tries to find.
- */
- setFocusStops(stops) {
- this.__firstFocusableNode = stops.start;
- this.__lastFocusableNode = stops.end;
- }
-
- /**
- * NOTE: (wyatta) Slightly hacky way to listen to the overlay actually
- * opening. Eventually replace with a direct way to listen to the overlay.
- */
- _awaitOpen(fn, reject) {
- let iters = 0;
- const step = () => {
- this.async(() => {
- if (this.style.display !== 'none') {
- fn.call(this);
- } else if (iters++ < AWAIT_MAX_ITERS) {
- step.call(this);
- } else {
- reject(new Error('gr-overlay _awaitOpen failed to resolve'));
- }
- }, AWAIT_STEP);
- };
- step.call(this);
- }
-
- _id() {
- return this.getAttribute('id') || 'global';
- }
-}
-
-customElements.define(GrOverlay.is, GrOverlay);
diff --git a/polygerrit-ui/app/elements/shared/gr-overlay/gr-overlay.ts b/polygerrit-ui/app/elements/shared/gr-overlay/gr-overlay.ts
new file mode 100644
index 0000000..aabbac1
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-overlay/gr-overlay.ts
@@ -0,0 +1,148 @@
+/**
+ * @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 '../../../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-overlay_html';
+import {IronOverlayMixin} from '../../../mixins/iron-overlay-mixin/iron-overlay-mixin';
+import {customElement, property} from '@polymer/decorators';
+
+const AWAIT_MAX_ITERS = 10;
+const AWAIT_STEP = 5;
+const BREAKPOINT_FULLSCREEN_OVERLAY = '50em';
+
+declare global {
+ interface HTMLElementTagNameMap {
+ 'gr-overlay': GrOverlay;
+ }
+}
+
+@customElement('gr-overlay')
+export class GrOverlay extends IronOverlayMixin(
+ GestureEventListeners(LegacyElementMixin(PolymerElement))
+) {
+ static get template() {
+ return htmlTemplate;
+ }
+
+ /**
+ * Fired when a fullscreen overlay is closed
+ *
+ * @event fullscreen-overlay-closed
+ */
+
+ /**
+ * Fired when an overlay is opened in full screen mode
+ *
+ * @event fullscreen-overlay-opened
+ */
+
+ @property({type: Boolean})
+ private _fullScreenOpen = false;
+
+ private _boundHandleClose: () => void = () => super.close();
+
+ private focusableNodes: Node[] | undefined;
+
+ get _focusableNodes() {
+ if (this.focusableNodes) {
+ return this.focusableNodes;
+ }
+ return super._focusableNodes;
+ }
+
+ /** @override */
+ created() {
+ super.created();
+ this.addEventListener('iron-overlay-closed', () => this._overlayClosed());
+ this.addEventListener('iron-overlay-cancelled', () =>
+ this._overlayClosed()
+ );
+ }
+
+ open() {
+ window.addEventListener('popstate', this._boundHandleClose);
+ return new Promise((resolve, reject) => {
+ super.open.apply(this);
+ if (this._isMobile()) {
+ this.dispatchEvent(
+ new CustomEvent('fullscreen-overlay-opened', {
+ composed: true,
+ bubbles: true,
+ })
+ );
+ this._fullScreenOpen = true;
+ }
+ this._awaitOpen(resolve, reject);
+ });
+ }
+
+ _isMobile() {
+ return window.matchMedia(`(max-width: ${BREAKPOINT_FULLSCREEN_OVERLAY})`);
+ }
+
+ // called after iron-overlay is closed. Does not actually close the overlay
+ _overlayClosed() {
+ window.removeEventListener('popstate', this._boundHandleClose);
+ if (this._fullScreenOpen) {
+ this.dispatchEvent(
+ new CustomEvent('fullscreen-overlay-closed', {
+ composed: true,
+ bubbles: true,
+ })
+ );
+ this._fullScreenOpen = false;
+ }
+ }
+
+ /**
+ * Override the focus stops that iron-overlay-behavior tries to find.
+ */
+ setFocusStops(stops: GrOverlayStops) {
+ this.focusableNodes = [stops.start, stops.end];
+ }
+
+ /**
+ * NOTE: (wyatta) Slightly hacky way to listen to the overlay actually
+ * opening. Eventually replace with a direct way to listen to the overlay.
+ */
+ _awaitOpen(fn: (this: GrOverlay) => void, reject: (error: Error) => void) {
+ let iters = 0;
+ const step = () => {
+ this.async(() => {
+ if (this.style.display !== 'none') {
+ fn.call(this);
+ } else if (iters++ < AWAIT_MAX_ITERS) {
+ step.call(this);
+ } else {
+ reject(new Error('gr-overlay _awaitOpen failed to resolve'));
+ }
+ }, AWAIT_STEP);
+ };
+ step.call(this);
+ }
+
+ _id() {
+ return this.getAttribute('id') || 'global';
+ }
+}
+
+export interface GrOverlayStops {
+ start: Node;
+ end: Node;
+}
diff --git a/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-etag-decorator.js b/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-etag-decorator.js
deleted file mode 100644
index b364caf..0000000
--- a/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-etag-decorator.js
+++ /dev/null
@@ -1,97 +0,0 @@
-/**
- * @license
- * Copyright (C) 2017 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.
- */
-
-// Limit cache size because /change/detail responses may be large.
-const MAX_CACHE_SIZE = 30;
-
-/** @constructor */
-export function GrEtagDecorator() {
- this._etags = new Map();
- this._payloadCache = new Map();
-}
-
-/**
- * Get or upgrade fetch options to include an ETag in a request.
- *
- * @param {string} url The URL being fetched.
- * @param {!Object=} opt_options Optional options object in which to include
- * the ETag request header. If omitted, the result will be a fresh option
- * set.
- * @return {!Object}
- */
-GrEtagDecorator.prototype.getOptions = function(url, opt_options) {
- const etag = this._etags.get(url);
- if (!etag) {
- return opt_options;
- }
- const options = {...opt_options};
- options.headers = options.headers || new Headers();
- options.headers.set('If-None-Match', this._etags.get(url));
- return options;
-};
-
-/**
- * Handle a response to a request with ETag headers, potentially incorporating
- * its result in the payload cache.
- *
- * @param {string} url The URL of the request.
- * @param {!Response} response The response object.
- * @param {string} payload The raw, unparsed JSON contained in the response
- * body. Note: because response.text() cannot be read twice, this must be
- * provided separately.
- */
-GrEtagDecorator.prototype.collect = function(url, response, payload) {
- if (!response ||
- !response.ok ||
- response.status !== 200 ||
- response.status === 304) {
- // 304 Not Modified means etag is still valid.
- return;
- }
- this._payloadCache.set(url, payload);
- const etag = response.headers && response.headers.get('etag');
- if (!etag) {
- this._etags.delete(url);
- } else {
- this._etags.set(url, etag);
- this._truncateCache();
- }
-};
-
-/**
- * Get the cached payload for a given URL.
- *
- * @param {string} url
- * @return {string|undefined} Returns the unparsed JSON payload from the
- * cache.
- */
-GrEtagDecorator.prototype.getCachedPayload = function(url) {
- return this._payloadCache.get(url);
-};
-
-/**
- * Limit the cache size to MAX_CACHE_SIZE.
- */
-GrEtagDecorator.prototype._truncateCache = function() {
- for (const url of this._etags.keys()) {
- if (this._etags.size <= MAX_CACHE_SIZE) {
- break;
- }
- this._etags.delete(url);
- this._payloadCache.delete(url);
- }
-};
diff --git a/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-etag-decorator.ts b/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-etag-decorator.ts
new file mode 100644
index 0000000..2c1ba70
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-etag-decorator.ts
@@ -0,0 +1,98 @@
+/**
+ * @license
+ * Copyright (C) 2017 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.
+ */
+
+// Limit cache size because /change/detail responses may be large.
+const MAX_CACHE_SIZE = 30;
+
+/**
+ * Option to send with etag requests.
+ */
+export interface ETagOption {
+ headers?: Headers;
+}
+
+/**
+ * GrTagDecorator class.
+ *
+ * Defines common methods to help cache and build ETag into a request header.
+ */
+export class GrEtagDecorator {
+ _etags = new Map<string, string | null>();
+
+ _payloadCache = new Map<string, string>();
+
+ /**
+ * Get or upgrade fetch options to include an ETag in a request.
+ *
+ */
+ getOptions(url: string, options?: ETagOption) {
+ const etag = this._etags.get(url);
+ if (!etag) {
+ return options;
+ }
+ const optionsCopy: ETagOption = {...options};
+ optionsCopy.headers = optionsCopy.headers || new Headers();
+ optionsCopy.headers.set('If-None-Match', etag);
+ return optionsCopy;
+ }
+
+ /**
+ * Handle a response to a request with ETag headers, potentially incorporating
+ * its result in the payload cache.
+ *
+ *
+ * @param url The URL of the request.
+ * @param response The response object.
+ * @param payload The raw, unparsed JSON contained in the response
+ * body. Note: because response.text() cannot be read twice, this must be
+ * provided separately.
+ */
+ collect(url: string, response: Response, payload: string) {
+ if (!response || !response.ok || response.status !== 200) {
+ // 304 Not Modified means etag is still valid.
+ return;
+ }
+ this._payloadCache.set(url, payload);
+ const etag = response.headers && response.headers.get('etag');
+ if (!etag) {
+ this._etags.delete(url);
+ } else {
+ this._etags.set(url, etag);
+ this._truncateCache();
+ }
+ }
+
+ /**
+ * Get the cached payload for a given URL.
+ */
+ getCachedPayload(url: string) {
+ return this._payloadCache.get(url);
+ }
+
+ /**
+ * Limit the cache size to MAX_CACHE_SIZE.
+ */
+ _truncateCache() {
+ for (const url of this._etags.keys()) {
+ if (this._etags.size <= MAX_CACHE_SIZE) {
+ break;
+ }
+ this._etags.delete(url);
+ this._payloadCache.delete(url);
+ }
+ }
+}
diff --git a/polygerrit-ui/app/elements/shared/gr-shell-command/gr-shell-command.js b/polygerrit-ui/app/elements/shared/gr-shell-command/gr-shell-command.js
deleted file mode 100644
index a5212fc..0000000
--- a/polygerrit-ui/app/elements/shared/gr-shell-command/gr-shell-command.js
+++ /dev/null
@@ -1,44 +0,0 @@
-/**
- * @license
- * Copyright (C) 2018 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 '../../../styles/shared-styles.js';
-import '../gr-copy-clipboard/gr-copy-clipboard.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-shell-command_html.js';
-
-/** @extends PolymerElement */
-class GrShellCommand extends GestureEventListeners(
- LegacyElementMixin(
- PolymerElement)) {
- static get template() { return htmlTemplate; }
-
- static get is() { return 'gr-shell-command'; }
-
- static get properties() {
- return {
- command: String,
- label: String,
- };
- }
-
- focusOnCopy() {
- this.shadowRoot.querySelector('gr-copy-clipboard').focusOnCopy();
- }
-}
-
-customElements.define(GrShellCommand.is, GrShellCommand);
diff --git a/polygerrit-ui/app/elements/shared/gr-shell-command/gr-shell-command.ts b/polygerrit-ui/app/elements/shared/gr-shell-command/gr-shell-command.ts
new file mode 100644
index 0000000..27f4069
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-shell-command/gr-shell-command.ts
@@ -0,0 +1,53 @@
+/**
+ * @license
+ * Copyright (C) 2018 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 '../../../styles/shared-styles';
+import '../gr-copy-clipboard/gr-copy-clipboard';
+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-shell-command_html';
+import {customElement, property} from '@polymer/decorators';
+
+declare global {
+ interface HTMLElementTagNameMap {
+ 'gr-shell-command': GrShellCommand;
+ }
+}
+
+@customElement('gr-shell-command')
+class GrShellCommand extends GestureEventListeners(
+ LegacyElementMixin(PolymerElement)
+) {
+ static get template() {
+ return htmlTemplate;
+ }
+
+ @property({type: String})
+ command: string | undefined;
+
+ @property({type: String})
+ label: string | undefined;
+
+ focusOnCopy() {
+ if (this.shadowRoot !== null) {
+ const copyClipboard = this.shadowRoot.querySelector('gr-copy-clipboard');
+ if (copyClipboard !== null) {
+ copyClipboard.focusOnCopy();
+ }
+ }
+ }
+}
diff --git a/polygerrit-ui/app/mixins/gr-tooltip-mixin/gr-tooltip-mixin.ts b/polygerrit-ui/app/mixins/gr-tooltip-mixin/gr-tooltip-mixin.ts
index 516877e..5ef17e2 100644
--- a/polygerrit-ui/app/mixins/gr-tooltip-mixin/gr-tooltip-mixin.ts
+++ b/polygerrit-ui/app/mixins/gr-tooltip-mixin/gr-tooltip-mixin.ts
@@ -101,7 +101,7 @@
@observe('hasTooltip')
_setupTooltipListeners() {
if (!this.mouseenterHandler) {
- this.mouseenterHandler = () => this._handleShowTooltip();
+ this.mouseenterHandler = this.showHandler;
}
if (!this.hasTooltip) {
@@ -151,7 +151,7 @@
this._tooltip = tooltip;
window.addEventListener('scroll', this.windowScrollHandler);
- this.addEventListener('mouseleave', this.showHandler);
+ this.addEventListener('mouseleave', this.hideHandler);
this.addEventListener('click', this.hideHandler);
}
diff --git a/polygerrit-ui/app/services/services/gr-rest-api/gr-rest-api.ts b/polygerrit-ui/app/services/services/gr-rest-api/gr-rest-api.ts
index 8038764..e1f0ffc 100644
--- a/polygerrit-ui/app/services/services/gr-rest-api/gr-rest-api.ts
+++ b/polygerrit-ui/app/services/services/gr-rest-api/gr-rest-api.ts
@@ -16,10 +16,13 @@
*/
import {
+ AccountDetailInfo,
AccountInfo,
+ CapabilityInfo,
GroupBaseInfo,
NumericChangeId,
ServerInfo,
+ ProjectInfo,
} from '../../../types/common';
export type ErrorCallback = (response?: Response, err?: Error) => void;
@@ -62,6 +65,26 @@
export interface RestApiService {
getConfig(): Promise<ServerInfo>;
getLoggedIn(): Promise<boolean>;
+ getVersion(): Promise<string>;
+ invalidateReposCache(): void;
+ getAccount(): Promise<AccountDetailInfo>;
+ getAccountCapabilities(params?: string[]): Promise<CapabilityInfo>;
+ getRepos(
+ filter: string,
+ reposPerPage: number,
+ offset?: number
+ ): Promise<ProjectInfo>;
+ send(
+ method: string,
+ url: string,
+ body?: unknown,
+ errFn?: ErrorCallback,
+ contentType?: string,
+ headers?: unknown
+ ): Promise<Response>;
+
+ getResponseObject(response: Response): null | unknown;
+
getChangeSuggestedReviewers(
changeNum: NumericChangeId,
input: string,
diff --git a/polygerrit-ui/app/types/common.ts b/polygerrit-ui/app/types/common.ts
index ed89a42..bc8c28c 100644
--- a/polygerrit-ui/app/types/common.ts
+++ b/polygerrit-ui/app/types/common.ts
@@ -21,6 +21,7 @@
FileInfoStatus,
GpgKeyInfoStatus,
ProblemInfoStatus,
+ ProjectState,
RequirementStatus,
ReviewerState,
RevisionKind,
@@ -30,11 +31,14 @@
{[__brand in BrandName]: never};
export type PatchSetNum = BrandType<'edit' | number, '_patchSet'>;
+export const EditPatchSetNum = 'edit' as PatchSetNum;
+
export type ChangeId = BrandType<string, '_changeId'>;
export type ChangeMessageId = BrandType<string, '_changeMessageId'>;
export type LegacyChangeId = BrandType<number, '_legacyChangeId'>;
export type NumericChangeId = BrandType<number, '_numericChangeId'>;
export type ProjectName = BrandType<string, '_projectName'>;
+export type UrlEncodedProjectName = BrandType<string, '_urlEncodedProjectName'>;
export type TopicName = BrandType<string, '_topicName'>;
export type AccountId = BrandType<number, '_accountId'>;
export type HttpMethod = BrandType<string, '_httpMethod'>;
@@ -42,6 +46,13 @@
export type RequirementType = BrandType<string, '_requirementType'>;
export type TrackingId = BrandType<string, '_trackingId'>;
export type ReviewInputTag = BrandType<string, '_reviewInputTag'>;
+export type RepositoryName = BrandType<string, '_repositoryName'>;
+
+// The URL encoded UUID of the comment
+export type UrlEncodedCommentId = BrandType<string, '_urlEncodedCommentId'>;
+
+// The ID of the dashboard, in the form of '<ref>:<path>'
+export type DashboardId = BrandType<string, '_dahsboardId'>;
// The 8-char hex GPG key ID.
export type GpgKeyId = BrandType<string, '_gpgKeyId'>;
@@ -166,6 +177,7 @@
cherry_pick_of_change?: NumericChangeId;
cherry_pick_of_patch_set?: PatchSetNum;
contains_git_conflicts?: boolean;
+ internalHost?: string; // TODO(TS): provide an explanation what is its
}
/**
@@ -186,6 +198,14 @@
}
/**
+ * The AccountDetailInfo entity contains detailed information about an account.
+ * https://gerrit-review.googlesource.com/Documentation/rest-api-accounts.html#account-detail-info
+ */
+export interface AccountDetailInfo extends AccountInfo {
+ registered_on: Timestamp;
+}
+
+/**
* The GroupAuditEventInfo entity contains information about an auditevent of a group.
* https://gerrit-review.googlesource.com/Documentation/rest-api-groups.html
*/
@@ -211,7 +231,7 @@
* https://gerrit-review.googlesource.com/Documentation/rest-api-groups.html
*/
export interface GroupInfo {
- id: string;
+ id: GroupId;
name: string;
url?: string;
options?: GroupOptionsInfo;
@@ -920,7 +940,7 @@
*/
export interface CommentInfo {
patch_set?: PatchSetNum;
- id: string;
+ id: UrlEncodedCommentId;
path?: string;
side?: string;
parent?: string;
@@ -946,3 +966,33 @@
end_line: string;
end_character: string;
}
+
+/**
+ * The ProjectInfo entity contains information about a project
+ * https://gerrit-review.googlesource.com/Documentation/rest-api-projects.html#project-info
+ */
+export interface ProjectInfo {
+ id: UrlEncodedProjectName;
+ // name is not set if returned in a map where the project name is used as
+ // map key
+ name?: ProjectName;
+ // ?-<n> if the parent project is not visible (<n> is a number which
+ // is increased for each non-visible project).
+ parent?: ProjectName;
+ description?: string;
+ state?: ProjectState;
+ branches?: {[branchName: string]: CommitId};
+ // labels is filled for Create Project and Get Project calls.
+ labels?: {[labelName: string]: LabelTypeInfo};
+ // Links to the project in external sites
+ web_links?: WebLinkInfo[];
+}
+
+/**
+ * The LabelTypeInfo entity contains metadata about the labels that a project
+ * has.
+ */
+export interface LabelTypeInfo {
+ values: {[value: string]: string};
+ default_value: number;
+}
diff --git a/polygerrit-ui/app/types/globals.ts b/polygerrit-ui/app/types/globals.ts
index 81db291..c15cee6 100644
--- a/polygerrit-ui/app/types/globals.ts
+++ b/polygerrit-ui/app/types/globals.ts
@@ -27,6 +27,7 @@
text: string,
options: {callback: (text: string, href?: string) => void}
): void;
+ ASSETS_PATH?: string;
}
interface Performance {