Merge "Compute known groups lazily when writing groups to the trace"
diff --git a/.bazelrc b/.bazelrc
index bf3aa6c..b9189c1 100644
--- a/.bazelrc
+++ b/.bazelrc
@@ -2,7 +2,7 @@
build --repository_cache=~/.gerritcodereview/bazel-cache/repository
build --action_env=PATH
build --disk_cache=~/.gerritcodereview/bazel-cache/cas
-build --java_toolchain //tools:error_prone_warnings_toolchain
+build --java_toolchain=//tools:error_prone_warnings_toolchain_java11
# Enable strict_action_env flag to. For more information on this feature see
# https://groups.google.com/forum/#!topic/bazel-discuss/_VmRfMyyHBk.
@@ -13,5 +13,6 @@
test --build_tests_only
test --test_output=errors
+test --java_toolchain=//tools:error_prone_warnings_toolchain_java11
import %workspace%/tools/remote-bazelrc
diff --git a/.bazelversion b/.bazelversion
index 1545d96..d5c0c99 100644
--- a/.bazelversion
+++ b/.bazelversion
@@ -1 +1 @@
-3.5.0
+3.5.1
diff --git a/Documentation/config-gerrit.txt b/Documentation/config-gerrit.txt
index 879ec99..05370cc 100644
--- a/Documentation/config-gerrit.txt
+++ b/Documentation/config-gerrit.txt
@@ -973,6 +973,11 @@
be expensive to compute (60 or more seconds for a large history
like the Linux kernel repository).
+cache `"comment_context"`::
++
+Caches the context lines of comments, which are the lines of the source file
+highlighted by the user when the comment was written.
+
cache `"groups"`::
+
Caches the basic group information of internal groups by group ID,
diff --git a/Documentation/dev-bazel.txt b/Documentation/dev-bazel.txt
index ee2f4a1..086e836 100644
--- a/Documentation/dev-bazel.txt
+++ b/Documentation/dev-bazel.txt
@@ -18,7 +18,7 @@
To build Gerrit from source, you need:
* A Linux or macOS system (Windows is not supported at this time)
-* A JDK for Java 8|9|10|11|...
+* A JDK for Java 8|11|...
* Python 2 or 3
* link:https://github.com/nodesource/distributions/blob/master/README.md[Node.js (including npm),role=external,window=_blank]
* Bower (`npm install -g bower`)
@@ -54,6 +54,27 @@
`java -version`
+[[java-8]]
+==== Java 8 support (deprecated)
+
+Java 8 is a legacy Java release and support for Java 8 will be discontinued
+in future gerrit releases. To build Gerrit with Java 8 language level, run:
+
+```
+ $ bazel build --java_toolchain //tools:error_prone_warnings_toolchain_java8
+ :release
+```
+
+[[java-11]]
+==== Java 11 support
+
+Java language level 11 is the default. To build Gerrit with Java 11 language
+level, run:
+
+```
+ $ bazel build :release
+```
+
[[java-13]]
==== Java 13 support
@@ -101,22 +122,6 @@
Now, invoking Bazel with just `bazel build :release` would include
all those options.
-[[java-11]]
-==== Java 11 support
-
-Java 11 is supported through alternative java toolchain
-link:https://docs.bazel.build/versions/master/toolchains.html[Bazel option].
-To build Gerrit with Java 11, specify JDK 11 java toolchain:
-
-```
- $ bazel build \
- --host_javabase=@bazel_tools//tools/jdk:remote_jdk11 \
- --javabase=@bazel_tools//tools/jdk:remote_jdk11 \
- --host_java_toolchain=@bazel_tools//tools/jdk:toolchain_java11 \
- --java_toolchain=@bazel_tools//tools/jdk:toolchain_java11 \
- :release
-```
-
=== Node.js and npm packages
See link:https://gerrit.googlesource.com/gerrit/+/master/polygerrit-ui/README.md#installing-node_js-and-npm-packages[Installing Node.js and npm packages,role=external,window=_blank].
diff --git a/Documentation/dev-eclipse.txt b/Documentation/dev-eclipse.txt
index 742cf42..bbe227a 100644
--- a/Documentation/dev-eclipse.txt
+++ b/Documentation/dev-eclipse.txt
@@ -4,7 +4,8 @@
This document is about configuring Gerrit Code Review into an
Eclipse workspace for development.
-Java 8 or later SDK is required.
+Java 11 or later SDK is required.
+Otherwise, java 8 can still be used for now as described below.
[[setup]]
== Project Setup
@@ -30,6 +31,10 @@
----
First, generate the Eclipse project by running the `tools/eclipse/project.py` script.
+If running Eclipse on Java 8, add the extra parameter
+`-e='--java_toolchain=//tools:error_prone_warnings_toolchain'`
+for generating a compatible project.
+
Then, in Eclipse, choose 'Import existing project' and select the `gerrit` project
from the current working directory.
@@ -79,15 +84,16 @@
link:dev-build-plugins.html#_bundle_custom_plugin_in_release_war[bundling in release.war]
and run `tools/eclipse/project.py`.
-[[Newer Java versions]]
+== Java Versions
-Java 9 and later are supported, but some adjustments must be done, because
-Java 8 is still the default:
+Java 11 is supported as a default, but some adjustments must be done for other JDKs:
* Add JRE, e.g.: directory: /usr/lib64/jvm/java-9-openjdk, name: java-9-openjdk-9
* Change execution environment for gerrit project to: JavaSE-9 (java-9-openjdk-9)
* Check that compiler compliance level in gerrit project is set to: 9
+Moreover, the actual java 11 language features are not supported yet.
+
[[Formatting]]
== Code Formatter Settings
diff --git a/Documentation/dev-intellij.txt b/Documentation/dev-intellij.txt
index b67d546..149b14a 100644
--- a/Documentation/dev-intellij.txt
+++ b/Documentation/dev-intellij.txt
@@ -9,7 +9,7 @@
<<dev-bazel#installation,Building with Bazel - Installation>>.
It's strongly recommended to verify you can build your Gerrit tree with Bazel
-for Java 8 from the command line first. Ensure that at least
+for Java 11 from the command line first. Ensure that at least
`bazel build gerrit` runs successfully before you proceed.
=== IntelliJ version and Bazel plugin
@@ -21,12 +21,12 @@
Also note that the version of the Bazel plugin used in turn may or may not be
compatible with the Bazel version used.
-In addition, Java 8 must be specified on your path or via `JAVA_HOME` so that
+In addition, Java 11 must be specified on your path or via `JAVA_HOME` so that
building with Bazel via the Bazel plugin is possible.
TIP: If the synchronization of the project with the BUILD files using the Bazel
plugin fails and IntelliJ reports the error **Could not get Bazel roots**, this
-indicates that the Bazel plugin couldn't find Java 8.
+indicates that the Bazel plugin couldn't find Java 11.
=== Installation of IntelliJ IDEA
diff --git a/Documentation/licenses.txt b/Documentation/licenses.txt
index 98e99d4..605b493 100644
--- a/Documentation/licenses.txt
+++ b/Documentation/licenses.txt
@@ -44,6 +44,7 @@
* auto:auto-value
* auto:auto-value-annotations
+* auto:auto-value-gson
* commons:codec
* commons:compress
* commons:dbcp
diff --git a/Documentation/rest-api-accounts.txt b/Documentation/rest-api-accounts.txt
index 32f8656..2a59d0c 100644
--- a/Documentation/rest-api-accounts.txt
+++ b/Documentation/rest-api-accounts.txt
@@ -2807,9 +2807,11 @@
|`email_strategy` ||
The type of email strategy to use. On `ENABLED`, the user will receive emails
from Gerrit. On `CC_ON_OWN_COMMENTS` the user will also receive emails for
-their own comments. On `DISABLED` the user will not receive any email
-notifications from Gerrit.
-Allowed values are `ENABLED`, `CC_ON_OWN_COMMENTS`, `DISABLED`.
+their own comments. On `ATTENTION_SET_ONLY`, on emails about changes, the user
+will receive emails only if they are in the attention set of that change.
+On `DISABLED` the user will not receive any email notifications from Gerrit.
+Allowed values are `ENABLED`, `CC_ON_OWN_COMMENTS`, `ATTENTION_SET_ONLY`,
+`DISABLED`.
|`default_base_for_merges` ||
The base which should be pre-selected in the 'Diff Against' drop-down
list when the change screen is opened for a merge commit.
@@ -2870,9 +2872,11 @@
|`email_strategy` |optional|
The type of email strategy to use. On `ENABLED`, the user will receive emails
from Gerrit. On `CC_ON_OWN_COMMENTS` the user will also receive emails for
-their own comments. On `DISABLED` the user will not receive any email
-notifications from Gerrit.
-Allowed values are `ENABLED`, `CC_ON_OWN_COMMENTS`, `DISABLED`.
+their own comments. On `ATTENTION_SET_ONLY`, on emails about changes, the user
+will receive emails only if they are in the attention set of that change.
+On `DISABLED` the user will not receive any email notifications from Gerrit.
+Allowed values are `ENABLED`, `CC_ON_OWN_COMMENTS`, `ATTENTION_SET_ONLY`,
+`DISABLED`.
|`default_base_for_merges` |optional|
The base which should be pre-selected in the 'Diff Against' drop-down
list when the change screen is opened for a merge commit.
diff --git a/Documentation/rest-api-changes.txt b/Documentation/rest-api-changes.txt
index 8bd02a8..6b73482 100644
--- a/Documentation/rest-api-changes.txt
+++ b/Documentation/rest-api-changes.txt
@@ -7325,6 +7325,13 @@
|`merge` ||
The detail of the source commit for merge as a link:#merge-input[MergeInput]
entity.
+|`author` |optional|
+An link:rest-api-accounts.html#account-input[AccountInput] entity
+that will set the author of the commit to create. The author must be
+specified as name/email combination.
+The caller needs "Forge Author" permission when using this field.
+This field does not affect the owner of the change, which will
+continue to use the identity of the caller.
|==================================
[[move-input]]
diff --git a/Documentation/rest-api-projects.txt b/Documentation/rest-api-projects.txt
index 92759b6..d34ccb4 100644
--- a/Documentation/rest-api-projects.txt
+++ b/Documentation/rest-api-projects.txt
@@ -3393,6 +3393,8 @@
|`status` ||The HTTP status code for the access.
200 means success and 403 means denied.
|`message` |optional|A clarifying message if `status` is not 200.
+|`debug_logs` |optional|
+Debug logs that may help to understand why a permission is denied or allowed.
|=========================================
[[auto_closeable_changes_check_input]]
diff --git a/WORKSPACE b/WORKSPACE
index 4c2fe35..48fa1d6 100644
--- a/WORKSPACE
+++ b/WORKSPACE
@@ -640,6 +640,38 @@
sha1 = "eff48ed53995db2dadf0456426cc1f8700136f86",
)
+AUTO_VALUE_GSON_VERSION = "1.3.0"
+
+maven_jar(
+ name = "auto-value-gson-runtime",
+ artifact = "com.ryanharter.auto.value:auto-value-gson-runtime:" + AUTO_VALUE_GSON_VERSION,
+ sha1 = "a69a9db5868bb039bd80f60661a771b643eaba59",
+)
+
+maven_jar(
+ name = "auto-value-gson-extension",
+ artifact = "com.ryanharter.auto.value:auto-value-gson-extension:" + AUTO_VALUE_GSON_VERSION,
+ sha1 = "6a61236d17b58b05e32b4c532bcb348280d2212b",
+)
+
+maven_jar(
+ name = "auto-value-gson-factory",
+ artifact = "com.ryanharter.auto.value:auto-value-gson-factory:" + AUTO_VALUE_GSON_VERSION,
+ sha1 = "b1f01918c0d6cb1f5482500e6b9e62589334dbb0",
+)
+
+maven_jar(
+ name = "javapoet",
+ artifact = "com.squareup:javapoet:1.13.0",
+ sha1 = "d6562d385049f35eb50403fa86bb11cce76b866a",
+)
+
+maven_jar(
+ name = "autotransient",
+ artifact = "io.sweers.autotransient:autotransient:1.0.0",
+ sha1 = "38b1c630b8e76560221622289f37be40105abb3d",
+)
+
declare_nongoogle_deps()
LUCENE_VERS = "6.6.5"
diff --git a/java/com/google/gerrit/acceptance/AbstractDaemonTest.java b/java/com/google/gerrit/acceptance/AbstractDaemonTest.java
index 78a621c..c35c9c2 100644
--- a/java/com/google/gerrit/acceptance/AbstractDaemonTest.java
+++ b/java/com/google/gerrit/acceptance/AbstractDaemonTest.java
@@ -388,6 +388,15 @@
initSsh();
}
+ protected void restart() throws Exception {
+ server = GerritServer.restart(server, createModule(), createSshModule());
+ server.getTestInjector().injectMembers(this);
+ if (resetter != null) {
+ server.getTestInjector().injectMembers(resetter);
+ }
+ initSsh();
+ }
+
protected void reindexAccount(Account.Id accountId) {
accountIndexer.index(accountId);
}
@@ -430,15 +439,18 @@
baseConfig.setInt("receive", null, "changeUpdateThreads", 4);
Module module = createModule();
Module auditModule = createAuditModule();
+ Module sshModule = createSshModule();
if (classDesc.equals(methodDesc) && !classDesc.sandboxed() && !methodDesc.sandboxed()) {
if (commonServer == null) {
commonServer =
- GerritServer.initAndStart(temporaryFolder, classDesc, baseConfig, module, auditModule);
+ GerritServer.initAndStart(
+ temporaryFolder, classDesc, baseConfig, module, auditModule, sshModule);
}
server = commonServer;
} else {
server =
- GerritServer.initAndStart(temporaryFolder, methodDesc, baseConfig, module, auditModule);
+ GerritServer.initAndStart(
+ temporaryFolder, methodDesc, baseConfig, module, auditModule, sshModule);
}
server.getTestInjector().injectMembers(this);
@@ -536,6 +548,11 @@
return null;
}
+ /** Override to bind an additional Guice module for SSH injector */
+ public Module createSshModule() {
+ return null;
+ }
+
protected void initSsh() throws Exception {
if (testRequiresSsh
&& SshMode.useSsh()
diff --git a/java/com/google/gerrit/acceptance/GerritServer.java b/java/com/google/gerrit/acceptance/GerritServer.java
index 5942c0f..0025396 100644
--- a/java/com/google/gerrit/acceptance/GerritServer.java
+++ b/java/com/google/gerrit/acceptance/GerritServer.java
@@ -323,6 +323,7 @@
* @param desc server description.
* @param baseConfig default config values; merged with config from {@code desc}.
* @param testSysModule additional Guice module to use.
+ * @param testSshModule additional Guice module to use.
* @return started server.
* @throws Exception
*/
@@ -331,14 +332,15 @@
Description desc,
Config baseConfig,
@Nullable Module testSysModule,
- @Nullable Module testAuditModule)
+ @Nullable Module testAuditModule,
+ @Nullable Module testSshModule)
throws Exception {
Path site = temporaryFolder.newFolder().toPath();
try {
if (!desc.memory()) {
init(desc, baseConfig, site);
}
- return start(desc, baseConfig, site, testSysModule, testAuditModule, null);
+ return start(desc, baseConfig, site, testSysModule, testAuditModule, testSshModule, null);
} catch (Exception e) {
throw e;
}
@@ -354,6 +356,7 @@
* initialize this directory. Can be retrieved from the returned instance via {@link
* #getSitePath()}.
* @param testSysModule optional additional module to add to the system injector.
+ * @param testSshModule optional additional module to add to the ssh injector.
* @param inMemoryRepoManager {@link InMemoryRepositoryManager} that should be used if the site is
* started in memory
* @param additionalArgs additional command-line arguments for the daemon program; only allowed if
@@ -367,6 +370,7 @@
Path site,
@Nullable Module testSysModule,
@Nullable Module testAuditModule,
+ @Nullable Module testSshModule,
@Nullable InMemoryRepositoryManager inMemoryRepoManager,
String... additionalArgs)
throws Exception {
@@ -390,6 +394,9 @@
if (testSysModule != null) {
daemon.addAdditionalSysModuleForTesting(testSysModule);
}
+ if (testSshModule != null) {
+ daemon.addAdditionalSshModuleForTesting(testSshModule);
+ }
daemon.setEnableSshd(desc.useSsh());
if (desc.memory()) {
@@ -614,7 +621,24 @@
server.close();
server.daemon.stop();
- return start(server.desc, cfg, site, null, null, inMemoryRepoManager);
+ return start(server.desc, cfg, site, null, null, null, inMemoryRepoManager);
+ }
+
+ public static GerritServer restart(
+ GerritServer server, @Nullable Module testSysModule, @Nullable Module testSshModule)
+ throws Exception {
+ checkState(server.desc.sandboxed(), "restarting as slave requires @Sandboxed");
+ Config cfg = server.testInjector.getInstance(Key.get(Config.class, GerritServerConfig.class));
+ Path site = server.testInjector.getInstance(Key.get(Path.class, SitePath.class));
+
+ InMemoryRepositoryManager inMemoryRepoManager = null;
+ if (hasBinding(server.testInjector, InMemoryRepositoryManager.class)) {
+ inMemoryRepoManager = server.testInjector.getInstance(InMemoryRepositoryManager.class);
+ }
+
+ server.close();
+ server.daemon.stop();
+ return start(server.desc, cfg, site, testSysModule, null, testSshModule, inMemoryRepoManager);
}
private static boolean hasBinding(Injector injector, Class<?> clazz) {
diff --git a/java/com/google/gerrit/acceptance/SshSession.java b/java/com/google/gerrit/acceptance/SshSession.java
index 6ecf85f..6698657 100644
--- a/java/com/google/gerrit/acceptance/SshSession.java
+++ b/java/com/google/gerrit/acceptance/SshSession.java
@@ -65,6 +65,22 @@
}
}
+ @SuppressWarnings("resource")
+ public int execAndReturnStatus(String command) throws Exception {
+ ChannelExec channel = (ChannelExec) getSession().openChannel("exec");
+ try {
+ channel.setCommand(command);
+ InputStream err = channel.getErrStream();
+ channel.connect();
+
+ Scanner s = new Scanner(err, UTF_8.name()).useDelimiter("\\A");
+ error = s.hasNext() ? s.next() : null;
+ return channel.getExitStatus();
+ } finally {
+ channel.disconnect();
+ }
+ }
+
private boolean hasError() {
return error != null;
}
diff --git a/java/com/google/gerrit/acceptance/StandaloneSiteTest.java b/java/com/google/gerrit/acceptance/StandaloneSiteTest.java
index 43fe4eb..dcb49a5 100644
--- a/java/com/google/gerrit/acceptance/StandaloneSiteTest.java
+++ b/java/com/google/gerrit/acceptance/StandaloneSiteTest.java
@@ -187,7 +187,14 @@
private GerritServer startImpl(@Nullable Module testSysModule, String... additionalArgs)
throws Exception {
return GerritServer.start(
- serverDesc, baseConfig, sitePaths.site_path, testSysModule, null, null, additionalArgs);
+ serverDesc,
+ baseConfig,
+ sitePaths.site_path,
+ testSysModule,
+ null,
+ null,
+ null,
+ additionalArgs);
}
protected static void runGerrit(String... args) throws Exception {
diff --git a/java/com/google/gerrit/acceptance/ssh/GracefulCommand.java b/java/com/google/gerrit/acceptance/ssh/GracefulCommand.java
new file mode 100644
index 0000000..ddaf341
--- /dev/null
+++ b/java/com/google/gerrit/acceptance/ssh/GracefulCommand.java
@@ -0,0 +1,31 @@
+// 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.ssh;
+
+import static com.google.gerrit.sshd.CommandMetaData.Mode.MASTER_OR_SLAVE;
+
+import com.google.gerrit.sshd.CommandMetaData;
+
+@CommandMetaData(
+ name = "graceful",
+ description = "Test command for graceful shutdown",
+ runsAt = MASTER_OR_SLAVE)
+public class GracefulCommand extends TestCommand {
+
+ @Override
+ boolean isGraceful() {
+ return true;
+ }
+}
diff --git a/java/com/google/gerrit/acceptance/ssh/NonGracefulCommand.java b/java/com/google/gerrit/acceptance/ssh/NonGracefulCommand.java
new file mode 100644
index 0000000..ed635c8
--- /dev/null
+++ b/java/com/google/gerrit/acceptance/ssh/NonGracefulCommand.java
@@ -0,0 +1,31 @@
+// 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.ssh;
+
+import static com.google.gerrit.sshd.CommandMetaData.Mode.MASTER_OR_SLAVE;
+
+import com.google.gerrit.sshd.CommandMetaData;
+
+@CommandMetaData(
+ name = "non-graceful",
+ description = "Test command for immediate shutdown",
+ runsAt = MASTER_OR_SLAVE)
+public class NonGracefulCommand extends TestCommand {
+
+ @Override
+ boolean isGraceful() {
+ return false;
+ }
+}
diff --git a/java/com/google/gerrit/acceptance/ssh/TestCommand.java b/java/com/google/gerrit/acceptance/ssh/TestCommand.java
new file mode 100644
index 0000000..7839578
--- /dev/null
+++ b/java/com/google/gerrit/acceptance/ssh/TestCommand.java
@@ -0,0 +1,49 @@
+// 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.ssh;
+
+import com.google.common.flogger.FluentLogger;
+import com.google.gerrit.sshd.SshCommand;
+import java.util.concurrent.CyclicBarrier;
+import org.kohsuke.args4j.Option;
+
+public abstract class TestCommand extends SshCommand {
+ private static final FluentLogger logger = FluentLogger.forEnclosingClass();
+ public static final CyclicBarrier syncPoint = new CyclicBarrier(2);
+
+ @Option(
+ name = "--duration",
+ aliases = {"-d"},
+ required = true,
+ usage = "Duration of the command execution in seconds")
+ private int duration;
+
+ @Override
+ protected void run() throws UnloggedFailure, Failure, Exception {
+ logger.atFine().log("Starting command.");
+ if (isGraceful()) {
+ enableGracefulStop();
+ }
+ try {
+ syncPoint.await();
+ Thread.sleep(duration * 1000);
+ logger.atFine().log("Stopping command.");
+ } catch (Exception e) {
+ throw die("Command ended prematurely.", e);
+ }
+ }
+
+ abstract boolean isGraceful();
+}
diff --git a/java/com/google/gerrit/acceptance/ssh/TestSshCommandModule.java b/java/com/google/gerrit/acceptance/ssh/TestSshCommandModule.java
new file mode 100644
index 0000000..626092b
--- /dev/null
+++ b/java/com/google/gerrit/acceptance/ssh/TestSshCommandModule.java
@@ -0,0 +1,25 @@
+// 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.ssh;
+
+import com.google.gerrit.sshd.CommandModule;
+
+public class TestSshCommandModule extends CommandModule {
+ @Override
+ protected void configure() {
+ command("graceful").to(GracefulCommand.class);
+ command("non-graceful").to(NonGracefulCommand.class);
+ }
+}
diff --git a/java/com/google/gerrit/acceptance/testsuite/project/ProjectOperationsImpl.java b/java/com/google/gerrit/acceptance/testsuite/project/ProjectOperationsImpl.java
index e4d594b..0138290 100644
--- a/java/com/google/gerrit/acceptance/testsuite/project/ProjectOperationsImpl.java
+++ b/java/com/google/gerrit/acceptance/testsuite/project/ProjectOperationsImpl.java
@@ -90,6 +90,7 @@
CreateProjectArgs args = new CreateProjectArgs();
args.setProjectName(name);
+ args.permissionsOnly = projectCreation.permissionOnly().orElse(false);
args.branch =
projectCreation.branches().stream().map(RefNames::fullName).collect(toImmutableList());
args.createEmptyCommit = projectCreation.createEmptyCommit().orElse(true);
diff --git a/java/com/google/gerrit/acceptance/testsuite/project/TestProjectCreation.java b/java/com/google/gerrit/acceptance/testsuite/project/TestProjectCreation.java
index 2649dea..00759a0 100644
--- a/java/com/google/gerrit/acceptance/testsuite/project/TestProjectCreation.java
+++ b/java/com/google/gerrit/acceptance/testsuite/project/TestProjectCreation.java
@@ -35,6 +35,8 @@
public abstract Optional<Boolean> createEmptyCommit();
+ public abstract Optional<Boolean> permissionOnly();
+
public abstract Optional<SubmitType> submitType();
abstract ThrowingFunction<TestProjectCreation, Project.NameKey> projectCreator();
@@ -67,6 +69,8 @@
public abstract TestProjectCreation.Builder createEmptyCommit(boolean value);
+ public abstract TestProjectCreation.Builder permissionOnly(boolean value);
+
/** Skips the empty commit on creation. This means that project's branches will not exist. */
public TestProjectCreation.Builder noEmptyCommit() {
return createEmptyCommit(false);
diff --git a/java/com/google/gerrit/entities/BUILD b/java/com/google/gerrit/entities/BUILD
index 66d1869..c0f5de6 100644
--- a/java/com/google/gerrit/entities/BUILD
+++ b/java/com/google/gerrit/entities/BUILD
@@ -10,11 +10,13 @@
deps = [
"//java/com/google/gerrit/common:annotations",
"//java/com/google/gerrit/extensions:api",
+ "//lib:gson",
"//lib:guava",
"//lib:jgit",
"//lib:protobuf",
"//lib/auto:auto-value",
"//lib/auto:auto-value-annotations",
+ "//lib/auto:auto-value-gson",
"//lib/errorprone:annotations",
"//lib/flogger:api",
"//proto:cache_java_proto",
diff --git a/java/com/google/gerrit/entities/Change.java b/java/com/google/gerrit/entities/Change.java
index 845a9bb..aab72ea72 100644
--- a/java/com/google/gerrit/entities/Change.java
+++ b/java/com/google/gerrit/entities/Change.java
@@ -21,6 +21,9 @@
import com.google.common.primitives.Ints;
import com.google.gerrit.common.Nullable;
import com.google.gerrit.extensions.client.ChangeStatus;
+import com.google.gson.Gson;
+import com.google.gson.TypeAdapter;
+import com.google.gson.annotations.SerializedName;
import java.sql.Timestamp;
import java.util.Arrays;
import java.util.Optional;
@@ -283,6 +286,7 @@
return Change.key(KeyUtil.decode(str));
}
+ @SerializedName("id")
abstract String key();
public String get() {
@@ -307,6 +311,10 @@
public final String toString() {
return get();
}
+
+ public static TypeAdapter<Key> typeAdapter(Gson gson) {
+ return new AutoValue_Change_Key.GsonTypeAdapter(gson);
+ }
}
/** Minimum database status constant for an open change. */
diff --git a/java/com/google/gerrit/entities/CommentContext.java b/java/com/google/gerrit/entities/CommentContext.java
new file mode 100644
index 0000000..183f6d0
--- /dev/null
+++ b/java/com/google/gerrit/entities/CommentContext.java
@@ -0,0 +1,29 @@
+// 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.entities;
+
+import com.google.auto.value.AutoValue;
+import com.google.common.collect.ImmutableMap;
+
+/** An entity class representing all context lines of a comment. */
+@AutoValue
+public abstract class CommentContext {
+ public static CommentContext create(ImmutableMap<Integer, String> lines) {
+ return new AutoValue_CommentContext(lines);
+ }
+
+ /** Map of {line number, line text} of the context lines of a comment */
+ public abstract ImmutableMap<Integer, String> lines();
+}
diff --git a/java/com/google/gerrit/entities/EntitiesAdapterFactory.java b/java/com/google/gerrit/entities/EntitiesAdapterFactory.java
new file mode 100644
index 0000000..e6a06fd
--- /dev/null
+++ b/java/com/google/gerrit/entities/EntitiesAdapterFactory.java
@@ -0,0 +1,25 @@
+// 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.entities;
+
+import com.google.gson.TypeAdapterFactory;
+import com.ryanharter.auto.value.gson.GsonTypeAdapterFactory;
+
+@GsonTypeAdapterFactory
+public abstract class EntitiesAdapterFactory implements TypeAdapterFactory {
+ public static TypeAdapterFactory create() {
+ return new AutoValueGson_EntitiesAdapterFactory();
+ }
+}
diff --git a/java/com/google/gerrit/extensions/api/config/AccessCheckInfo.java b/java/com/google/gerrit/extensions/api/config/AccessCheckInfo.java
index fab2ec4..423ac49 100644
--- a/java/com/google/gerrit/extensions/api/config/AccessCheckInfo.java
+++ b/java/com/google/gerrit/extensions/api/config/AccessCheckInfo.java
@@ -14,10 +14,15 @@
package com.google.gerrit.extensions.api.config;
+import java.util.List;
+
public class AccessCheckInfo {
public String message;
// HTTP status code
public int status;
+ /** Debug logs that may help to understand why a permission is denied or allowed. */
+ public List<String> debugLogs;
+
// for future extension, we may add inputs / results for bulk checks.
}
diff --git a/java/com/google/gerrit/extensions/client/GeneralPreferencesInfo.java b/java/com/google/gerrit/extensions/client/GeneralPreferencesInfo.java
index c6555b9..30514a6 100644
--- a/java/com/google/gerrit/extensions/client/GeneralPreferencesInfo.java
+++ b/java/com/google/gerrit/extensions/client/GeneralPreferencesInfo.java
@@ -75,6 +75,7 @@
public enum EmailStrategy {
ENABLED,
CC_ON_OWN_COMMENTS,
+ ATTENTION_SET_ONLY,
DISABLED
}
diff --git a/java/com/google/gerrit/extensions/common/MergePatchSetInput.java b/java/com/google/gerrit/extensions/common/MergePatchSetInput.java
index 53f5e07..734d7e9 100644
--- a/java/com/google/gerrit/extensions/common/MergePatchSetInput.java
+++ b/java/com/google/gerrit/extensions/common/MergePatchSetInput.java
@@ -14,9 +14,12 @@
package com.google.gerrit.extensions.common;
+import com.google.gerrit.extensions.api.accounts.AccountInput;
+
public class MergePatchSetInput {
public String subject;
public boolean inheritParent;
public String baseChange;
public MergeInput merge;
+ public AccountInput author;
}
diff --git a/java/com/google/gerrit/httpd/CacheBasedWebSession.java b/java/com/google/gerrit/httpd/CacheBasedWebSession.java
index 5c4830c..a3a67e5 100644
--- a/java/com/google/gerrit/httpd/CacheBasedWebSession.java
+++ b/java/com/google/gerrit/httpd/CacheBasedWebSession.java
@@ -28,6 +28,7 @@
import com.google.gerrit.server.AnonymousUser;
import com.google.gerrit.server.CurrentUser;
import com.google.gerrit.server.IdentifiedUser;
+import com.google.gerrit.server.PropertyMap;
import com.google.gerrit.server.account.AccountCache;
import com.google.gerrit.server.account.AuthResult;
import com.google.gerrit.server.account.externalids.ExternalId;
@@ -161,15 +162,11 @@
}
@Override
- public ExternalId.Key getLastLoginExternalId() {
- return val != null ? val.getExternalId() : null;
- }
-
- @Override
public CurrentUser getUser() {
if (user == null) {
if (isSignedIn()) {
- user = identified.create(val.getAccountId());
+
+ user = identified.create(val.getAccountId(), getUserProperties(val));
} else {
user = anonymousProvider.get();
}
@@ -177,6 +174,15 @@
return user;
}
+ private static PropertyMap getUserProperties(@Nullable WebSessionManager.Val val) {
+ if (val == null || val.getExternalId() == null) {
+ return PropertyMap.EMPTY;
+ }
+ return PropertyMap.builder()
+ .put(CurrentUser.LAST_LOGIN_EXTERNAL_ID_PROPERTY_KEY, val.getExternalId())
+ .build();
+ }
+
@Override
public void login(AuthResult res, boolean rememberMe) {
Account.Id id = res.getAccountId();
@@ -194,7 +200,7 @@
key = manager.createKey(id);
val = manager.createVal(key, id, rememberMe, identity, null, null);
saveCookie();
- user = identified.create(val.getAccountId());
+ user = identified.create(val.getAccountId(), getUserProperties(val));
}
/** Set the user account for this current request only. */
@@ -202,7 +208,7 @@
public void setUserAccountId(Account.Id id) {
key = new Key("id:" + id);
val = new Val(id, 0, false, null, 0, null, null);
- user = identified.runAs(id, user);
+ user = identified.runAs(id, user, PropertyMap.EMPTY);
}
@Override
diff --git a/java/com/google/gerrit/httpd/WebSession.java b/java/com/google/gerrit/httpd/WebSession.java
index e8b54fe..daf30ff 100644
--- a/java/com/google/gerrit/httpd/WebSession.java
+++ b/java/com/google/gerrit/httpd/WebSession.java
@@ -19,7 +19,6 @@
import com.google.gerrit.server.AccessPath;
import com.google.gerrit.server.CurrentUser;
import com.google.gerrit.server.account.AuthResult;
-import com.google.gerrit.server.account.externalids.ExternalId;
public interface WebSession {
boolean isSignedIn();
@@ -29,8 +28,6 @@
boolean isValidXGerritAuth(String keyIn);
- ExternalId.Key getLastLoginExternalId();
-
CurrentUser getUser();
void login(AuthResult res, boolean rememberMe);
diff --git a/java/com/google/gerrit/httpd/auth/container/HttpAuthFilter.java b/java/com/google/gerrit/httpd/auth/container/HttpAuthFilter.java
index 509a9f1..e20c9b9 100644
--- a/java/com/google/gerrit/httpd/auth/container/HttpAuthFilter.java
+++ b/java/com/google/gerrit/httpd/auth/container/HttpAuthFilter.java
@@ -35,6 +35,7 @@
import java.io.IOException;
import java.io.OutputStream;
import java.util.Locale;
+import java.util.Optional;
import javax.servlet.Filter;
import javax.servlet.FilterChain;
import javax.servlet.FilterConfig;
@@ -124,8 +125,8 @@
}
private static boolean correctUser(String user, WebSession session) {
- ExternalId.Key id = session.getLastLoginExternalId();
- return id != null && id.equals(ExternalId.Key.create(SCHEME_GERRIT, user));
+ Optional<ExternalId.Key> id = session.getUser().getLastLoginExternalIdKey();
+ return id.map(i -> i.equals(ExternalId.Key.create(SCHEME_GERRIT, user))).orElse(false);
}
String getRemoteUser(HttpServletRequest req) {
diff --git a/java/com/google/gerrit/httpd/restapi/RestApiServlet.java b/java/com/google/gerrit/httpd/restapi/RestApiServlet.java
index f743578..a1197ef 100644
--- a/java/com/google/gerrit/httpd/restapi/RestApiServlet.java
+++ b/java/com/google/gerrit/httpd/restapi/RestApiServlet.java
@@ -108,7 +108,6 @@
import com.google.gerrit.server.RequestInfo;
import com.google.gerrit.server.RequestListener;
import com.google.gerrit.server.audit.ExtendedHttpAuditEvent;
-import com.google.gerrit.server.cache.PerThreadCache;
import com.google.gerrit.server.change.ChangeFinder;
import com.google.gerrit.server.config.GerritServerConfig;
import com.google.gerrit.server.group.GroupAuditService;
@@ -328,7 +327,7 @@
try (TraceContext traceContext = enableTracing(req, res)) {
List<IdString> path = splitPath(req);
- try (PerThreadCache ignored = PerThreadCache.create()) {
+ try {
RequestInfo requestInfo = createRequestInfo(traceContext, requestUri(req), path);
globals.requestListeners.runEach(l -> l.onRequest(requestInfo));
@@ -1640,9 +1639,6 @@
"Invalid authentication method. In order to authenticate, "
+ "prefix the REST endpoint URL with /a/ (e.g. http://example.com/a/projects/).");
}
- if (user.isIdentifiedUser()) {
- user.setLastLoginExternalIdKey(globals.webSession.get().getLastLoginExternalId());
- }
}
private List<String> getParameterNames(HttpServletRequest req) {
diff --git a/java/com/google/gerrit/pgm/Daemon.java b/java/com/google/gerrit/pgm/Daemon.java
index ba037d9..2eb19aa 100644
--- a/java/com/google/gerrit/pgm/Daemon.java
+++ b/java/com/google/gerrit/pgm/Daemon.java
@@ -206,6 +206,7 @@
private AbstractModule luceneModule;
private Module emailModule;
private List<Module> testSysModules = new ArrayList<>();
+ private List<Module> testSshModules = new ArrayList<>();
private Module auditEventModule;
private Runnable serverStarted;
@@ -337,6 +338,11 @@
}
@VisibleForTesting
+ public void addAdditionalSshModuleForTesting(@Nullable Module... modules) {
+ testSshModules.addAll(Arrays.asList(modules));
+ }
+
+ @VisibleForTesting
public void start() throws IOException {
if (dbInjector == null) {
dbInjector = createDbInjector(true /* enableMetrics */);
@@ -532,6 +538,8 @@
replica,
sysInjector.getInstance(DownloadConfig.class),
sysInjector.getInstance(LfsPluginAuthCommand.Module.class)));
+
+ modules.addAll(testSshModules);
if (!replica) {
modules.add(new IndexCommandsModule(sysInjector));
modules.add(new SequenceCommandsModule());
diff --git a/java/com/google/gerrit/pgm/init/SitePathInitializer.java b/java/com/google/gerrit/pgm/init/SitePathInitializer.java
index 8a71c1c..ddc4f79 100644
--- a/java/com/google/gerrit/pgm/init/SitePathInitializer.java
+++ b/java/com/google/gerrit/pgm/init/SitePathInitializer.java
@@ -125,7 +125,8 @@
extractMailExample("DeleteVoteHtml.soy");
extractMailExample("Footer.soy");
extractMailExample("FooterHtml.soy");
- extractMailExample("HeaderHtml.soy");
+ extractMailExample("ChangeHeader.soy");
+ extractMailExample("ChangeHeaderHtml.soy");
extractMailExample("HttpPasswordUpdate.soy");
extractMailExample("HttpPasswordUpdateHtml.soy");
extractMailExample("InboundEmailRejection.soy");
diff --git a/java/com/google/gerrit/server/AnonymousUser.java b/java/com/google/gerrit/server/AnonymousUser.java
index 91d2d05..c96d61a 100644
--- a/java/com/google/gerrit/server/AnonymousUser.java
+++ b/java/com/google/gerrit/server/AnonymousUser.java
@@ -27,12 +27,6 @@
}
@Override
- public Object getCacheKey() {
- // Treat all anonymous users as a single user
- return "anonymous";
- }
-
- @Override
public String toString() {
return "ANONYMOUS";
}
diff --git a/java/com/google/gerrit/server/CommentContextLoader.java b/java/com/google/gerrit/server/CommentContextLoader.java
index 7f84693..68a80c3 100644
--- a/java/com/google/gerrit/server/CommentContextLoader.java
+++ b/java/com/google/gerrit/server/CommentContextLoader.java
@@ -17,18 +17,20 @@
import static java.util.stream.Collectors.groupingBy;
import com.google.auto.value.AutoValue;
+import com.google.common.collect.ImmutableMap;
+import com.google.common.collect.Iterables;
+import com.google.common.collect.Streams;
import com.google.common.flogger.FluentLogger;
+import com.google.gerrit.entities.Comment;
+import com.google.gerrit.entities.CommentContext;
import com.google.gerrit.entities.Project;
import com.google.gerrit.exceptions.StorageException;
-import com.google.gerrit.extensions.common.CommentInfo;
import com.google.gerrit.extensions.common.ContextLineInfo;
import com.google.gerrit.server.git.GitRepositoryManager;
import com.google.gerrit.server.patch.Text;
import com.google.inject.Inject;
import com.google.inject.assistedinject.Assisted;
import java.io.IOException;
-import java.util.ArrayList;
-import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;
@@ -48,7 +50,6 @@
private final GitRepositoryManager repoManager;
private final Project.NameKey project;
- private Map<ContextData, List<ContextLineInfo>> candidates;
public interface Factory {
CommentContextLoader create(Project.NameKey project);
@@ -58,80 +59,66 @@
CommentContextLoader(GitRepositoryManager repoManager, @Assisted Project.NameKey project) {
this.repoManager = repoManager;
this.project = project;
- this.candidates = new HashMap<>();
}
/**
- * Returns an empty list of {@link ContextLineInfo}. Clients are expected to call this method one
- * or more times. Each call returns a reference to an empty {@link List<ContextLineInfo>}.
+ * Load the comment context for multiple comments at once. This method will open the repository
+ * and read the source files for all necessary comments' file paths.
*
- * <p>A single call to {@link #fill()} will cause all list references returned from this method to
- * be populated. If a client calls this method again with a comment that was passed before calling
- * {@link #fill()}, the new populated list will be returned.
- *
- * @param comment the comment entity for which we want to load the context
- * @return a list of {@link ContextLineInfo}
+ * @param comments a list of comments.
+ * @return a Map where all entries consist of the input comments and the values are their
+ * corresponding {@link CommentContext}.
*/
- public List<ContextLineInfo> getContext(CommentInfo comment) {
- ContextData key =
- ContextData.create(
- comment.id,
- ObjectId.fromString(comment.commitId),
- comment.path,
- getStartAndEndLines(comment));
- List<ContextLineInfo> context = candidates.get(key);
- if (context == null) {
- context = new ArrayList<>();
- candidates.put(key, context);
- }
- return context;
- }
+ public Map<Comment, CommentContext> getContext(Iterable<Comment> comments) {
+ ImmutableMap.Builder<Comment, CommentContext> result =
+ ImmutableMap.builderWithExpectedSize(Iterables.size(comments));
- /**
- * A call to this method loads the context for all comments stored in {@link
- * CommentContextLoader#candidates}. This is useful so that the repository is opened once for all
- * comments.
- */
- public void fill() {
// Group comments by commit ID so that each commit is parsed only once
- Map<ObjectId, List<ContextData>> commentsByCommitId =
- candidates.keySet().stream().collect(groupingBy(ContextData::commitId));
+ Map<ObjectId, List<Comment>> commentsByCommitId =
+ Streams.stream(comments).collect(groupingBy(Comment::getCommitId));
try (Repository repo = repoManager.openRepository(project);
RevWalk rw = new RevWalk(repo)) {
for (ObjectId commitId : commentsByCommitId.keySet()) {
RevCommit commit = rw.parseCommit(commitId);
- for (ContextData k : commentsByCommitId.get(commitId)) {
- if (!k.range().isPresent()) {
+ for (Comment comment : commentsByCommitId.get(commitId)) {
+ Optional<Range> range = getStartAndEndLines(comment);
+ if (!range.isPresent()) {
continue;
}
- try (TreeWalk tw = TreeWalk.forPath(rw.getObjectReader(), k.path(), commit.getTree())) {
+ // TODO(ghareeb): We can further group the comments by file paths to avoid opening
+ // the same file multiple times.
+ try (TreeWalk tw =
+ TreeWalk.forPath(rw.getObjectReader(), comment.key.filename, commit.getTree())) {
if (tw == null) {
logger.atWarning().log(
"Failed to find path %s in the git tree of ID %s.",
- k.path(), commit.getTree().getId());
+ comment.key.filename, commit.getTree().getId());
continue;
}
ObjectId id = tw.getObjectId(0);
Text src = new Text(repo.open(id, Constants.OBJ_BLOB));
- List<ContextLineInfo> contextLines = candidates.get(k);
- Range r = k.range().get();
- for (int i = r.start(); i <= r.end(); i++) {
- contextLines.add(new ContextLineInfo(i, src.getString(i - 1)));
+ Range r = range.get();
+ ImmutableMap.Builder<Integer, String> context =
+ ImmutableMap.builderWithExpectedSize(r.end() - r.start());
+ for (int i = r.start(); i < r.end(); i++) {
+ context.put(i, src.getString(i - 1));
}
+ result.put(comment, CommentContext.create(context.build()));
}
}
}
+ return result.build();
} catch (IOException e) {
throw new StorageException("Failed to load the comment context", e);
}
}
- private static Optional<Range> getStartAndEndLines(CommentInfo comment) {
+ private static Optional<Range> getStartAndEndLines(Comment comment) {
if (comment.range != null) {
- return Optional.of(Range.create(comment.range.startLine, comment.range.endLine));
- } else if (comment.line != null) {
- return Optional.of(Range.create(comment.line, comment.line));
+ return Optional.of(Range.create(comment.range.startLine, comment.range.endLine + 1));
+ } else if (comment.lineNbr > 0) {
+ return Optional.of(Range.create(comment.lineNbr, comment.lineNbr + 1));
}
return Optional.empty();
}
@@ -142,23 +129,10 @@
return new AutoValue_CommentContextLoader_Range(start, end);
}
+ /** Start line of the comment (inclusive). */
abstract int start();
+ /** End line of the comment (exclusive). */
abstract int end();
}
-
- @AutoValue
- abstract static class ContextData {
- static ContextData create(String id, ObjectId commitId, String path, Optional<Range> range) {
- return new AutoValue_CommentContextLoader_ContextData(id, commitId, path, range);
- }
-
- abstract String id();
-
- abstract ObjectId commitId();
-
- abstract String path();
-
- abstract Optional<Range> range();
- }
}
diff --git a/java/com/google/gerrit/server/CurrentUser.java b/java/com/google/gerrit/server/CurrentUser.java
index 75afc04..43d3c7b 100644
--- a/java/com/google/gerrit/server/CurrentUser.java
+++ b/java/com/google/gerrit/server/CurrentUser.java
@@ -14,7 +14,6 @@
package com.google.gerrit.server;
-import com.google.gerrit.common.Nullable;
import com.google.gerrit.entities.Account;
import com.google.gerrit.server.account.GroupMembership;
import com.google.gerrit.server.account.externalids.ExternalId;
@@ -31,17 +30,19 @@
* @see IdentifiedUser
*/
public abstract class CurrentUser {
- /** Unique key for plugin/extension specific data on a CurrentUser. */
- public static final class PropertyKey<T> {
- public static <T> PropertyKey<T> create() {
- return new PropertyKey<>();
- }
+ public static final PropertyMap.Key<ExternalId.Key> LAST_LOGIN_EXTERNAL_ID_PROPERTY_KEY =
+ PropertyMap.key();
- private PropertyKey() {}
+ private final PropertyMap properties;
+ private AccessPath accessPath = AccessPath.UNKNOWN;
+
+ protected CurrentUser() {
+ this.properties = PropertyMap.EMPTY;
}
- private AccessPath accessPath = AccessPath.UNKNOWN;
- private PropertyKey<ExternalId.Key> lastLoginExternalIdPropertyKey = PropertyKey.create();
+ protected CurrentUser(PropertyMap properties) {
+ this.properties = properties;
+ }
/** How this user is accessing the Gerrit Code Review application. */
public final AccessPath getAccessPath() {
@@ -90,12 +91,6 @@
*/
public abstract GroupMembership getEffectiveGroups();
- /**
- * Returns a unique identifier for this user that is intended to be used as a cache key. Returned
- * object should to implement {@code equals()} and {@code hashCode()} for effective caching.
- */
- public abstract Object getCacheKey();
-
/** Unique name of the user on this server, if one has been assigned. */
public Optional<String> getUserName() {
return Optional.empty();
@@ -133,29 +128,18 @@
}
/**
- * Lookup a previously stored property.
+ * Lookup a stored property.
*
- * @param key unique property key.
- * @return previously stored value, or {@code Optional#empty()}.
+ * @param key unique property key. This key has to be the same instance that was used to store the
+ * value when constructing the {@link PropertyMap}
+ * @return stored value, or {@code Optional#empty()}.
*/
- public <T> Optional<T> get(PropertyKey<T> key) {
- return Optional.empty();
- }
-
- /**
- * Store a property for later retrieval.
- *
- * @param key unique property key.
- * @param value value to store; or {@code null} to clear the value.
- */
- public <T> void put(PropertyKey<T> key, @Nullable T value) {}
-
- public void setLastLoginExternalIdKey(ExternalId.Key externalIdKey) {
- put(lastLoginExternalIdPropertyKey, externalIdKey);
+ public <T> Optional<T> get(PropertyMap.Key<T> key) {
+ return properties.get(key);
}
public Optional<ExternalId.Key> getLastLoginExternalIdKey() {
- return get(lastLoginExternalIdPropertyKey);
+ return get(LAST_LOGIN_EXTERNAL_ID_PROPERTY_KEY);
}
/**
diff --git a/java/com/google/gerrit/server/IdentifiedUser.java b/java/com/google/gerrit/server/IdentifiedUser.java
index 7cafdc0..ec2eb81 100644
--- a/java/com/google/gerrit/server/IdentifiedUser.java
+++ b/java/com/google/gerrit/server/IdentifiedUser.java
@@ -17,11 +17,13 @@
import static com.google.common.base.MoreObjects.firstNonNull;
import static com.google.common.flogger.LazyArgs.lazy;
+import com.google.common.annotations.VisibleForTesting;
import com.google.common.base.Strings;
import com.google.common.collect.ImmutableSet;
import com.google.common.collect.Sets;
import com.google.common.flogger.FluentLogger;
import com.google.gerrit.common.Nullable;
+import com.google.gerrit.common.UsedAt;
import com.google.gerrit.entities.Account;
import com.google.gerrit.server.account.AccountCache;
import com.google.gerrit.server.account.AccountState;
@@ -46,8 +48,6 @@
import java.net.SocketAddress;
import java.net.URL;
import java.util.Date;
-import java.util.HashMap;
-import java.util.Map;
import java.util.Optional;
import java.util.Set;
import java.util.TimeZone;
@@ -105,12 +105,26 @@
return create(null, id);
}
+ @VisibleForTesting
+ @UsedAt(UsedAt.Project.GOOGLE)
+ public IdentifiedUser forTest(Account.Id id, PropertyMap properties) {
+ return runAs(null, id, null, properties);
+ }
+
public IdentifiedUser create(SocketAddress remotePeer, Account.Id id) {
return runAs(remotePeer, id, null);
}
public IdentifiedUser runAs(
SocketAddress remotePeer, Account.Id id, @Nullable CurrentUser caller) {
+ return runAs(remotePeer, id, caller, PropertyMap.EMPTY);
+ }
+
+ private IdentifiedUser runAs(
+ SocketAddress remotePeer,
+ Account.Id id,
+ @Nullable CurrentUser caller,
+ PropertyMap properties) {
return new IdentifiedUser(
authConfig,
realm,
@@ -121,7 +135,8 @@
enableReverseDnsLookup,
Providers.of(remotePeer),
id,
- caller);
+ caller,
+ properties);
}
}
@@ -163,20 +178,10 @@
}
public IdentifiedUser create(Account.Id id) {
- return new IdentifiedUser(
- authConfig,
- realm,
- anonymousCowardName,
- canonicalUrl,
- accountCache,
- groupBackend,
- enableReverseDnsLookup,
- remotePeerProvider,
- id,
- null);
+ return create(id, PropertyMap.EMPTY);
}
- public IdentifiedUser runAs(Account.Id id, CurrentUser caller) {
+ public <T> IdentifiedUser create(Account.Id id, PropertyMap properties) {
return new IdentifiedUser(
authConfig,
realm,
@@ -187,7 +192,23 @@
enableReverseDnsLookup,
remotePeerProvider,
id,
- caller);
+ null,
+ properties);
+ }
+
+ public IdentifiedUser runAs(Account.Id id, CurrentUser caller, PropertyMap properties) {
+ return new IdentifiedUser(
+ authConfig,
+ realm,
+ anonymousCowardName,
+ canonicalUrl,
+ accountCache,
+ groupBackend,
+ enableReverseDnsLookup,
+ remotePeerProvider,
+ id,
+ caller,
+ properties);
}
}
@@ -212,7 +233,6 @@
private boolean loadedAllEmails;
private Set<String> invalidEmails;
private GroupMembership effectiveGroups;
- private Map<PropertyKey<Object>, Object> properties;
private IdentifiedUser(
AuthConfig authConfig,
@@ -235,7 +255,8 @@
enableReverseDnsLookup,
remotePeerProvider,
state.account().id(),
- realUser);
+ realUser,
+ PropertyMap.EMPTY);
this.state = state;
}
@@ -249,7 +270,9 @@
Boolean enableReverseDnsLookup,
@Nullable Provider<SocketAddress> remotePeerProvider,
Account.Id id,
- @Nullable CurrentUser realUser) {
+ @Nullable CurrentUser realUser,
+ PropertyMap properties) {
+ super(properties);
this.canonicalUrl = canonicalUrl;
this.accountCache = accountCache;
this.groupBackend = groupBackend;
@@ -390,11 +413,6 @@
return effectiveGroups;
}
- @Override
- public Object getCacheKey() {
- return getAccountId();
- }
-
public PersonIdent newRefLogIdent() {
return newRefLogIdent(new Date(), TimeZone.getDefault());
}
@@ -463,40 +481,6 @@
return true;
}
- @Override
- public synchronized <T> Optional<T> get(PropertyKey<T> key) {
- if (properties != null) {
- @SuppressWarnings("unchecked")
- T value = (T) properties.get(key);
- return Optional.ofNullable(value);
- }
- return Optional.empty();
- }
-
- /**
- * Store a property for later retrieval.
- *
- * @param key unique property key.
- * @param value value to store; or {@code null} to clear the value.
- */
- @Override
- public synchronized <T> void put(PropertyKey<T> key, @Nullable T value) {
- if (properties == null) {
- if (value == null) {
- return;
- }
- properties = new HashMap<>();
- }
-
- @SuppressWarnings("unchecked")
- PropertyKey<Object> k = (PropertyKey<Object>) key;
- if (value != null) {
- properties.put(k, value);
- } else {
- properties.remove(k);
- }
- }
-
/**
* Returns a materialized copy of the user with all dependencies.
*
diff --git a/java/com/google/gerrit/server/InternalUser.java b/java/com/google/gerrit/server/InternalUser.java
index 381819d..821a0c6 100644
--- a/java/com/google/gerrit/server/InternalUser.java
+++ b/java/com/google/gerrit/server/InternalUser.java
@@ -36,11 +36,6 @@
}
@Override
- public String getCacheKey() {
- return "internal";
- }
-
- @Override
public boolean isInternalUser() {
return true;
}
diff --git a/java/com/google/gerrit/server/PeerDaemonUser.java b/java/com/google/gerrit/server/PeerDaemonUser.java
index b27e05c..8a8b67a 100644
--- a/java/com/google/gerrit/server/PeerDaemonUser.java
+++ b/java/com/google/gerrit/server/PeerDaemonUser.java
@@ -40,11 +40,6 @@
return GroupMembership.EMPTY;
}
- @Override
- public Object getCacheKey() {
- return getRemoteAddress();
- }
-
public SocketAddress getRemoteAddress() {
return peer;
}
diff --git a/java/com/google/gerrit/server/PropertyMap.java b/java/com/google/gerrit/server/PropertyMap.java
new file mode 100644
index 0000000..da3a2495
--- /dev/null
+++ b/java/com/google/gerrit/server/PropertyMap.java
@@ -0,0 +1,84 @@
+// 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;
+
+import com.google.common.collect.ImmutableMap;
+import java.util.Optional;
+
+/**
+ * Immutable map that holds a collection of random objects allowing for a type-safe retrieval.
+ *
+ * <p>Intended to be used in {@link CurrentUser} when the object is constructed during login and
+ * holds per-request state. This functionality allows plugins/extensions to contribute specific data
+ * to {@link CurrentUser} that is unknown to Gerrit core.
+ */
+public class PropertyMap {
+ /** Empty instance to be referenced once per JVM. */
+ public static final PropertyMap EMPTY = builder().build();
+
+ /**
+ * Typed key for {@link PropertyMap}. This class intentionally does not implement {@link
+ * Object#equals(Object)} and {@link Object#hashCode()} so that the same instance has to be used
+ * to retrieve a stored value.
+ *
+ * <p>We require the exact same key instance because {@link PropertyMap} is implemented in a
+ * type-safe fashion by using Java generics to guarantee the return type. The generic type can't
+ * be recovered at runtime, so there is no way to just use the type's full name as key - we'd have
+ * to pass additional arguments. At the same time, this is in-line with how we'd want callers to
+ * use {@link PropertyMap}: Instantiate a static, per-JVM key that is reused when setting and
+ * getting values.
+ */
+ public static class Key<T> {}
+
+ public static <T> Key<T> key() {
+ return new Key<>();
+ }
+
+ public static class Builder {
+ private ImmutableMap.Builder<Object, Object> mutableMap;
+
+ private Builder() {
+ this.mutableMap = ImmutableMap.builder();
+ }
+
+ /** Adds the provided {@code value} to the {@link PropertyMap} that is being built. */
+ public <T> Builder put(Key<T> key, T value) {
+ mutableMap.put(key, value);
+ return this;
+ }
+
+ /** Builds and returns an immutable {@link PropertyMap}. */
+ public PropertyMap build() {
+ return new PropertyMap(mutableMap.build());
+ }
+ }
+
+ private final ImmutableMap<Object, Object> map;
+
+ private PropertyMap(ImmutableMap<Object, Object> map) {
+ this.map = map;
+ }
+
+ /** Returns a new {@link Builder} instance. */
+ public static Builder builder() {
+ return new Builder();
+ }
+
+ /** Returns the requested value wrapped as {@link Optional}. */
+ @SuppressWarnings("unchecked")
+ public <T> Optional<T> get(Key<T> key) {
+ return Optional.ofNullable((T) map.get(key));
+ }
+}
diff --git a/java/com/google/gerrit/server/cache/PerThreadCache.java b/java/com/google/gerrit/server/cache/PerThreadCache.java
deleted file mode 100644
index b4f79d1..0000000
--- a/java/com/google/gerrit/server/cache/PerThreadCache.java
+++ /dev/null
@@ -1,146 +0,0 @@
-// 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.
-
-package com.google.gerrit.server.cache;
-
-import static com.google.common.base.Preconditions.checkState;
-
-import com.google.common.base.Objects;
-import com.google.common.collect.ImmutableList;
-import com.google.common.collect.Maps;
-import com.google.gerrit.common.Nullable;
-import java.util.Map;
-import java.util.function.Supplier;
-
-/**
- * Caches object instances for a request as {@link ThreadLocal} in the serving thread.
- *
- * <p>This class is intended to cache objects that have a high instantiation cost, are specific to
- * the current request and potentially need to be instantiated multiple times while serving a
- * request.
- *
- * <p>This is different from the key-value storage in {@code CurrentUser}: {@code CurrentUser}
- * offers a key-value storage by providing thread-safe {@code get} and {@code put} methods. Once the
- * value is retrieved through {@code get} there is not thread-safety anymore - apart from the
- * retrieved object guarantees. Depending on the implementation of {@code CurrentUser}, it might be
- * shared between the request serving thread as well as sub- or background treads.
- *
- * <p>In comparison to that, this class guarantees thread safety even on non-thread-safe objects as
- * its cache is tied to the serving thread only. While allowing to cache non-thread-safe objects, it
- * has the downside of not sharing any objects with background threads or executors.
- *
- * <p>Lastly, this class offers a cache, that requires callers to also provide a {@code Supplier} in
- * case the object is not present in the cache, while {@code CurrentUser} provides a storage where
- * just retrieving stored values is a valid operation.
- *
- * <p>To prevent OOM errors on requests that would cache a lot of objects, this class enforces an
- * internal limit after which no new elements are cached. All {@code get} calls are served by
- * invoking the {@code Supplier} after that.
- */
-public class PerThreadCache implements AutoCloseable {
- private static final ThreadLocal<PerThreadCache> CACHE = new ThreadLocal<>();
- /**
- * Cache at maximum 25 values per thread. This value was chosen arbitrarily. Some endpoints (like
- * ListProjects) break the assumption that the data cached in a request is limited. To prevent
- * this class from accumulating an unbound number of objects, we enforce this limit.
- */
- private static final int PER_THREAD_CACHE_SIZE = 25;
-
- /**
- * Unique key for key-value mappings stored in PerThreadCache. The key is based on the value's
- * class and a list of identifiers that in combination uniquely set the object apart form others
- * of the same class.
- */
- public static final class Key<T> {
- private final Class<T> clazz;
- private final ImmutableList<Object> identifiers;
-
- /**
- * Returns a key based on the value's class and an identifier that uniquely identify the value.
- * The identifier needs to implement {@code equals()} and {@hashCode()}.
- */
- public static <T> Key<T> create(Class<T> clazz, Object identifier) {
- return new Key<>(clazz, ImmutableList.of(identifier));
- }
-
- /**
- * Returns a key based on the value's class and a set of identifiers that uniquely identify the
- * value. Identifiers need to implement {@code equals()} and {@hashCode()}.
- */
- public static <T> Key<T> create(Class<T> clazz, Object... identifiers) {
- return new Key<>(clazz, ImmutableList.copyOf(identifiers));
- }
-
- private Key(Class<T> clazz, ImmutableList<Object> identifiers) {
- this.clazz = clazz;
- this.identifiers = identifiers;
- }
-
- @Override
- public int hashCode() {
- return Objects.hashCode(clazz, identifiers);
- }
-
- @Override
- public boolean equals(Object o) {
- if (!(o instanceof Key)) {
- return false;
- }
- Key<?> other = (Key<?>) o;
- return this.clazz == other.clazz && this.identifiers.equals(other.identifiers);
- }
- }
-
- public static PerThreadCache create() {
- checkState(CACHE.get() == null, "called create() twice on the same request");
- PerThreadCache cache = new PerThreadCache();
- CACHE.set(cache);
- return cache;
- }
-
- @Nullable
- public static PerThreadCache get() {
- return CACHE.get();
- }
-
- public static <T> T getOrCompute(Key<T> key, Supplier<T> loader) {
- PerThreadCache cache = get();
- return cache != null ? cache.get(key, loader) : loader.get();
- }
-
- private final Map<Key<?>, Object> cache = Maps.newHashMapWithExpectedSize(PER_THREAD_CACHE_SIZE);
-
- private PerThreadCache() {}
-
- /**
- * Returns an instance of {@code T} that was either loaded from the cache or obtained from the
- * provided {@link Supplier}.
- */
- public <T> T get(Key<T> key, Supplier<T> loader) {
- @SuppressWarnings("unchecked")
- T value = (T) cache.get(key);
- if (value == null) {
- value = loader.get();
- if (cache.size() < PER_THREAD_CACHE_SIZE) {
- cache.put(key, value);
- }
- }
- return value;
- }
-
- @Override
- public void close() {
- CACHE.remove();
- }
-}
diff --git a/java/com/google/gerrit/server/change/ChangeKeyAdapter.java b/java/com/google/gerrit/server/change/ChangeKeyAdapter.java
deleted file mode 100644
index 0db4cea..0000000
--- a/java/com/google/gerrit/server/change/ChangeKeyAdapter.java
+++ /dev/null
@@ -1,51 +0,0 @@
-// 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.
-
-package com.google.gerrit.server.change;
-
-import com.google.gerrit.entities.Change;
-import com.google.gson.JsonDeserializationContext;
-import com.google.gson.JsonDeserializer;
-import com.google.gson.JsonElement;
-import com.google.gson.JsonObject;
-import com.google.gson.JsonParseException;
-import com.google.gson.JsonSerializationContext;
-import com.google.gson.JsonSerializer;
-import java.lang.reflect.Type;
-
-/**
- * Adapter that serializes {@link com.google.gerrit.entities.Change.Key}'s {@code key} field as
- * {@code id}, for backwards compatibility in stream-events.
- */
-// TODO(dborowitz): auto-value-gson should support this directly using @SerializedName on the
-// AutoValue method.
-public class ChangeKeyAdapter implements JsonSerializer<Change.Key>, JsonDeserializer<Change.Key> {
- @Override
- public JsonElement serialize(Change.Key src, Type typeOfSrc, JsonSerializationContext context) {
- JsonObject obj = new JsonObject();
- obj.addProperty("id", src.get());
- return obj;
- }
-
- @Override
- public Change.Key deserialize(JsonElement json, Type typeOfT, JsonDeserializationContext context)
- throws JsonParseException {
- JsonElement keyJson = json.getAsJsonObject().get("id");
- if (keyJson == null || !keyJson.isJsonPrimitive() || !keyJson.getAsJsonPrimitive().isString()) {
- throw new JsonParseException("Key is not a string: " + keyJson);
- }
- String key = keyJson.getAsJsonPrimitive().getAsString();
- return Change.key(key);
- }
-}
diff --git a/java/com/google/gerrit/server/comment/CommentContextCache.java b/java/com/google/gerrit/server/comment/CommentContextCache.java
new file mode 100644
index 0000000..8c40763
--- /dev/null
+++ b/java/com/google/gerrit/server/comment/CommentContextCache.java
@@ -0,0 +1,42 @@
+// 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.comment;
+
+import com.google.common.collect.ImmutableMap;
+import com.google.gerrit.entities.CommentContext;
+
+/**
+ * Caches the context lines of comments (source file content surrounding and including the lines
+ * where the comment was written)
+ */
+public interface CommentContextCache {
+
+ /**
+ * Returns the context lines for a single comment.
+ *
+ * @param key a key representing a subset of fields for a comment that serves as an identifier.
+ * @return a {@link CommentContext} object containing all line numbers and text of the context.
+ */
+ CommentContext get(CommentContextKey key);
+
+ /**
+ * Returns the context lines for multiple comments - identified by their {@code keys}.
+ *
+ * @param keys list of keys, where each key represents a single comment through its project,
+ * change ID, patchset, path and ID. The keys can belong to different projects and changes.
+ * @return {@code Map} of {@code CommentContext} containing the context for all comments.
+ */
+ ImmutableMap<CommentContextKey, CommentContext> getAll(Iterable<CommentContextKey> keys);
+}
diff --git a/java/com/google/gerrit/server/comment/CommentContextCacheImpl.java b/java/com/google/gerrit/server/comment/CommentContextCacheImpl.java
new file mode 100644
index 0000000..c4e29d8
--- /dev/null
+++ b/java/com/google/gerrit/server/comment/CommentContextCacheImpl.java
@@ -0,0 +1,256 @@
+// 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.comment;
+
+import static java.nio.charset.StandardCharsets.UTF_8;
+
+import com.google.common.cache.CacheLoader;
+import com.google.common.cache.LoadingCache;
+import com.google.common.cache.Weigher;
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableMap;
+import com.google.common.collect.Iterables;
+import com.google.common.collect.Streams;
+import com.google.common.hash.Hashing;
+import com.google.gerrit.entities.Change;
+import com.google.gerrit.entities.Comment;
+import com.google.gerrit.entities.CommentContext;
+import com.google.gerrit.entities.HumanComment;
+import com.google.gerrit.entities.Project;
+import com.google.gerrit.exceptions.StorageException;
+import com.google.gerrit.proto.Protos;
+import com.google.gerrit.server.CommentContextLoader;
+import com.google.gerrit.server.CommentsUtil;
+import com.google.gerrit.server.cache.CacheModule;
+import com.google.gerrit.server.cache.proto.Cache.AllCommentContextProto;
+import com.google.gerrit.server.cache.proto.Cache.AllCommentContextProto.CommentContextProto;
+import com.google.gerrit.server.cache.serialize.CacheSerializer;
+import com.google.gerrit.server.notedb.ChangeNotes;
+import com.google.inject.Inject;
+import com.google.inject.Module;
+import com.google.inject.name.Named;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.concurrent.ExecutionException;
+import java.util.function.Function;
+import java.util.stream.Collectors;
+
+/** Implementation of {@link CommentContextCache}. */
+public class CommentContextCacheImpl implements CommentContextCache {
+ private static final String CACHE_NAME = "comment_context";
+
+ public static Module module() {
+ return new CacheModule() {
+ @Override
+ protected void configure() {
+ persist(CACHE_NAME, CommentContextKey.class, CommentContext.class)
+ .version(1)
+ .diskLimit(1 << 30) // limit the total cache size to 1 GB
+ .maximumWeight(1 << 23) // Limit the size of the in-memory cache to 8 MB
+ .weigher(CommentContextWeigher.class)
+ .keySerializer(CommentContextKey.Serializer.INSTANCE)
+ .valueSerializer(CommentContextSerializer.INSTANCE)
+ .loader(Loader.class);
+
+ bind(CommentContextCache.class).to(CommentContextCacheImpl.class);
+ }
+ };
+ }
+
+ private final LoadingCache<CommentContextKey, CommentContext> contextCache;
+
+ @Inject
+ CommentContextCacheImpl(
+ @Named(CACHE_NAME) LoadingCache<CommentContextKey, CommentContext> contextCache) {
+ this.contextCache = contextCache;
+ }
+
+ @Override
+ public CommentContext get(CommentContextKey comment) {
+ return getAll(ImmutableList.of(comment)).get(comment);
+ }
+
+ @Override
+ public ImmutableMap<CommentContextKey, CommentContext> getAll(
+ Iterable<CommentContextKey> inputKeys) {
+ ImmutableMap.Builder<CommentContextKey, CommentContext> result = ImmutableMap.builder();
+
+ // Convert the input keys to the same keys but with their file paths hashed
+ Map<CommentContextKey, CommentContextKey> keysToCacheKeys =
+ Streams.stream(inputKeys)
+ .collect(
+ Collectors.toMap(
+ Function.identity(),
+ k -> k.toBuilder().path(Loader.hashPath(k.path())).build()));
+
+ try {
+ ImmutableMap<CommentContextKey, CommentContext> allContext =
+ contextCache.getAll(keysToCacheKeys.values());
+
+ for (CommentContextKey inputKey : inputKeys) {
+ CommentContextKey cacheKey = keysToCacheKeys.get(inputKey);
+ result.put(inputKey, allContext.get(cacheKey));
+ }
+ return result.build();
+ } catch (ExecutionException e) {
+ throw new StorageException("Failed to retrieve comments' context", e);
+ }
+ }
+
+ public enum CommentContextSerializer implements CacheSerializer<CommentContext> {
+ INSTANCE;
+
+ @Override
+ public byte[] serialize(CommentContext commentContext) {
+ AllCommentContextProto.Builder allBuilder = AllCommentContextProto.newBuilder();
+
+ commentContext
+ .lines()
+ .entrySet()
+ .forEach(
+ c ->
+ allBuilder.addContext(
+ CommentContextProto.newBuilder()
+ .setLineNumber(c.getKey())
+ .setContextLine(c.getValue())));
+ return Protos.toByteArray(allBuilder.build());
+ }
+
+ @Override
+ public CommentContext deserialize(byte[] in) {
+ ImmutableMap.Builder<Integer, String> contextLinesMap = ImmutableMap.builder();
+ Protos.parseUnchecked(AllCommentContextProto.parser(), in).getContextList().stream()
+ .forEach(c -> contextLinesMap.put(c.getLineNumber(), c.getContextLine()));
+ return CommentContext.create(contextLinesMap.build());
+ }
+ }
+
+ static class Loader extends CacheLoader<CommentContextKey, CommentContext> {
+ private final ChangeNotes.Factory notesFactory;
+ private final CommentsUtil commentsUtil;
+ private final CommentContextLoader.Factory factory;
+
+ @Inject
+ Loader(
+ CommentsUtil commentsUtil,
+ ChangeNotes.Factory notesFactory,
+ CommentContextLoader.Factory factory) {
+ this.commentsUtil = commentsUtil;
+ this.notesFactory = notesFactory;
+ this.factory = factory;
+ }
+
+ @Override
+ public CommentContext load(CommentContextKey key) {
+ return loadAll(ImmutableList.of(key)).get(key);
+ }
+
+ @Override
+ public Map<CommentContextKey, CommentContext> loadAll(
+ Iterable<? extends CommentContextKey> keys) {
+ ImmutableMap.Builder<CommentContextKey, CommentContext> result =
+ ImmutableMap.builderWithExpectedSize(Iterables.size(keys));
+
+ Map<Project.NameKey, Map<Change.Id, List<CommentContextKey>>> groupedKeys =
+ Streams.stream(keys)
+ .distinct()
+ .map(k -> (CommentContextKey) k)
+ .collect(
+ Collectors.groupingBy(
+ CommentContextKey::project,
+ Collectors.groupingBy(CommentContextKey::changeId)));
+
+ for (Map.Entry<Project.NameKey, Map<Change.Id, List<CommentContextKey>>> perProject :
+ groupedKeys.entrySet()) {
+ Map<Change.Id, List<CommentContextKey>> keysPerProject = perProject.getValue();
+
+ for (Map.Entry<Change.Id, List<CommentContextKey>> perChange : keysPerProject.entrySet()) {
+ Map<CommentContextKey, CommentContext> context =
+ loadForSameChange(perChange.getValue(), perProject.getKey(), perChange.getKey());
+ result.putAll(context);
+ }
+ }
+ return result.build();
+ }
+
+ /**
+ * Load the comment context for comments of the same project and change ID.
+ *
+ * @param keys a list of keys corresponding to some comments
+ * @param project a gerrit project/repository
+ * @param changeId an identifier for a change
+ * @return a map of the input keys to their corresponding {@link CommentContext}
+ */
+ private Map<CommentContextKey, CommentContext> loadForSameChange(
+ List<CommentContextKey> keys, Project.NameKey project, Change.Id changeId) {
+ ChangeNotes notes = notesFactory.createChecked(project, changeId);
+ List<HumanComment> humanComments = commentsUtil.publishedHumanCommentsByChange(notes);
+ CommentContextLoader loader = factory.create(project);
+ Map<Comment, CommentContextKey> commentsToKeys = new HashMap<>();
+ for (CommentContextKey key : keys) {
+ commentsToKeys.put(getCommentForKey(humanComments, key), key);
+ }
+ Map<Comment, CommentContext> allContext = loader.getContext(commentsToKeys.keySet());
+ return allContext.entrySet().stream()
+ .collect(Collectors.toMap(e -> commentsToKeys.get(e.getKey()), Map.Entry::getValue));
+ }
+
+ /**
+ * Return the single comment from the {@code allComments} input list corresponding to the key
+ * parameter.
+ *
+ * @param allComments a list of comments.
+ * @param key a key representing a single comment.
+ * @return the single comment corresponding to the key parameter.
+ */
+ private Comment getCommentForKey(List<HumanComment> allComments, CommentContextKey key) {
+ return allComments.stream()
+ .filter(
+ c ->
+ key.id().equals(c.key.uuid)
+ && key.patchset() == c.key.patchSetId
+ && key.path().equals(hashPath(c.key.filename)))
+ .findFirst()
+ .orElseThrow(() -> new IllegalArgumentException("Unable to find comment for key " + key));
+ }
+
+ /**
+ * Hash an input String using the general {@link Hashing#murmur3_128()} hash.
+ *
+ * @param input the input String
+ * @return a hashed representation of the input String
+ */
+ static String hashPath(String input) {
+ return Hashing.murmur3_128().hashString(input, UTF_8).toString();
+ }
+ }
+
+ private static class CommentContextWeigher implements Weigher<CommentContextKey, CommentContext> {
+ @Override
+ public int weigh(CommentContextKey key, CommentContext commentContext) {
+ int size = 0;
+ size += key.id().length();
+ size += key.path().length();
+ size += key.project().get().length();
+ size += 4;
+ for (String line : commentContext.lines().values()) {
+ size += 4; // line number
+ size += line.length(); // number of characters in the context line
+ }
+ return size;
+ }
+ }
+}
diff --git a/java/com/google/gerrit/server/comment/CommentContextKey.java b/java/com/google/gerrit/server/comment/CommentContextKey.java
new file mode 100644
index 0000000..e4a927a
--- /dev/null
+++ b/java/com/google/gerrit/server/comment/CommentContextKey.java
@@ -0,0 +1,82 @@
+package com.google.gerrit.server.comment;
+
+import com.google.auto.value.AutoValue;
+import com.google.gerrit.entities.Change;
+import com.google.gerrit.entities.Project;
+import com.google.gerrit.proto.Protos;
+import com.google.gerrit.server.cache.proto.Cache;
+import com.google.gerrit.server.cache.serialize.CacheSerializer;
+import java.util.Collection;
+
+/**
+ * An identifier of a comment that should be used to load the comment context using {@link
+ * CommentContextCache#get(CommentContextKey)}, or {@link CommentContextCache#getAll(Collection)}.
+ *
+ * <p>The {@link CommentContextCacheImpl} implementation uses this class as the cache key, while
+ * replacing the {@link #path()} field with the hashed path.
+ */
+@AutoValue
+public abstract class CommentContextKey {
+ abstract Project.NameKey project();
+
+ abstract Change.Id changeId();
+
+ /** The unique comment ID. */
+ abstract String id();
+
+ /** File path at which the comment was written. */
+ abstract String path();
+
+ abstract Integer patchset();
+
+ abstract Builder toBuilder();
+
+ public static Builder builder() {
+ return new AutoValue_CommentContextKey.Builder();
+ }
+
+ @AutoValue.Builder
+ public abstract static class Builder {
+
+ public abstract Builder project(Project.NameKey nameKey);
+
+ public abstract Builder changeId(Change.Id changeId);
+
+ public abstract Builder id(String id);
+
+ public abstract Builder path(String path);
+
+ public abstract Builder patchset(Integer patchset);
+
+ public abstract CommentContextKey build();
+ }
+
+ public enum Serializer implements CacheSerializer<CommentContextKey> {
+ INSTANCE;
+
+ @Override
+ public byte[] serialize(CommentContextKey key) {
+ return Protos.toByteArray(
+ Cache.CommentContextKeyProto.newBuilder()
+ .setProject(key.project().get())
+ .setChangeId(key.changeId().toString())
+ .setPatchset(key.patchset())
+ .setPathHash(key.path())
+ .setCommentId(key.id())
+ .build());
+ }
+
+ @Override
+ public CommentContextKey deserialize(byte[] in) {
+ Cache.CommentContextKeyProto proto =
+ Protos.parseUnchecked(Cache.CommentContextKeyProto.parser(), in);
+ return CommentContextKey.builder()
+ .project(Project.NameKey.parse(proto.getProject()))
+ .changeId(Change.Id.tryParse(proto.getChangeId()).get())
+ .patchset(proto.getPatchset())
+ .id(proto.getCommentId())
+ .path(proto.getPathHash())
+ .build();
+ }
+ }
+}
diff --git a/java/com/google/gerrit/server/config/GerritGlobalModule.java b/java/com/google/gerrit/server/config/GerritGlobalModule.java
index 22d02d2..98a3179 100644
--- a/java/com/google/gerrit/server/config/GerritGlobalModule.java
+++ b/java/com/google/gerrit/server/config/GerritGlobalModule.java
@@ -114,6 +114,7 @@
import com.google.gerrit.server.change.MergeabilityCacheImpl;
import com.google.gerrit.server.change.ReviewerSuggestion;
import com.google.gerrit.server.change.RevisionJson;
+import com.google.gerrit.server.comment.CommentContextCacheImpl;
import com.google.gerrit.server.events.EventFactory;
import com.google.gerrit.server.events.EventListener;
import com.google.gerrit.server.events.EventsMetrics;
@@ -250,6 +251,7 @@
install(TagCache.module());
install(OAuthTokenCache.module());
install(PureRevertCache.module());
+ install(CommentContextCacheImpl.module());
install(new AccessControlModule());
install(new CmdLineParserModule());
diff --git a/java/com/google/gerrit/server/events/EventGsonProvider.java b/java/com/google/gerrit/server/events/EventGsonProvider.java
index 688507b..72cf7be3 100644
--- a/java/com/google/gerrit/server/events/EventGsonProvider.java
+++ b/java/com/google/gerrit/server/events/EventGsonProvider.java
@@ -15,9 +15,8 @@
package com.google.gerrit.server.events;
import com.google.common.base.Supplier;
-import com.google.gerrit.entities.Change;
+import com.google.gerrit.entities.EntitiesAdapterFactory;
import com.google.gerrit.entities.Project;
-import com.google.gerrit.server.change.ChangeKeyAdapter;
import com.google.gson.Gson;
import com.google.gson.GsonBuilder;
import com.google.inject.Provider;
@@ -29,8 +28,8 @@
.registerTypeAdapter(Event.class, new EventDeserializer())
.registerTypeAdapter(Supplier.class, new SupplierSerializer())
.registerTypeAdapter(Supplier.class, new SupplierDeserializer())
- .registerTypeAdapter(Change.Key.class, new ChangeKeyAdapter())
.registerTypeAdapter(Project.NameKey.class, new ProjectNameKeyAdapter())
+ .registerTypeAdapterFactory(EntitiesAdapterFactory.create())
.create();
}
}
diff --git a/java/com/google/gerrit/server/git/GroupCollector.java b/java/com/google/gerrit/server/git/GroupCollector.java
index 9e0f2ee..5bbe5e2 100644
--- a/java/com/google/gerrit/server/git/GroupCollector.java
+++ b/java/com/google/gerrit/server/git/GroupCollector.java
@@ -29,7 +29,6 @@
import com.google.common.flogger.FluentLogger;
import com.google.gerrit.entities.PatchSet;
import com.google.gerrit.entities.Project;
-import com.google.gerrit.entities.RefNames;
import com.google.gerrit.server.PatchSetUtil;
import com.google.gerrit.server.change.RevisionResource;
import com.google.gerrit.server.git.receive.ReceivePackRefCache;
@@ -43,7 +42,6 @@
import java.util.Set;
import java.util.TreeSet;
import org.eclipse.jgit.lib.ObjectId;
-import org.eclipse.jgit.lib.Ref;
import org.eclipse.jgit.revwalk.RevCommit;
/**
@@ -231,7 +229,7 @@
private boolean isGroupFromExistingPatchSet(RevCommit commit, String group) throws IOException {
ObjectId id = parseGroup(commit, group);
- return id != null && !receivePackRefCache.tipsFromObjectId(id, RefNames.REFS_CHANGES).isEmpty();
+ return id != null && !receivePackRefCache.patchSetIdsFromObjectId(id).isEmpty();
}
private Set<String> resolveGroups(ObjectId forCommit, Collection<String> candidates)
@@ -273,17 +271,13 @@
private Iterable<String> resolveGroup(ObjectId forCommit, String group) throws IOException {
ObjectId id = parseGroup(forCommit, group);
if (id != null) {
- Ref ref =
- Iterables.getFirst(receivePackRefCache.tipsFromObjectId(id, RefNames.REFS_CHANGES), null);
- if (ref != null) {
- PatchSet.Id psId = PatchSet.Id.fromRef(ref.getName());
- if (psId != null) {
- List<String> groups = groupLookup.lookup(psId);
- // Group for existing patch set may be missing, e.g. if group has not
- // been migrated yet.
- if (groups != null && !groups.isEmpty()) {
- return groups;
- }
+ PatchSet.Id psId = Iterables.getFirst(receivePackRefCache.patchSetIdsFromObjectId(id), null);
+ if (psId != null) {
+ List<String> groups = groupLookup.lookup(psId);
+ // Group for existing patch set may be missing, e.g. if group has not
+ // been migrated yet.
+ if (groups != null && !groups.isEmpty()) {
+ return groups;
}
}
}
diff --git a/java/com/google/gerrit/server/git/receive/ReceiveCommits.java b/java/com/google/gerrit/server/git/receive/ReceiveCommits.java
index 3c70bfb..21bcc77 100644
--- a/java/com/google/gerrit/server/git/receive/ReceiveCommits.java
+++ b/java/com/google/gerrit/server/git/receive/ReceiveCommits.java
@@ -344,7 +344,7 @@
private final RequestScopePropagator requestScopePropagator;
private final Sequences seq;
private final SetHashtagsOp.Factory hashtagsFactory;
- private final SubmissionListener superprojectUpdateSubmissionListener;
+ private final ImmutableList<SubmissionListener> superprojectUpdateSubmissionListeners;
private final TagCache tagCache;
private final ProjectConfig.Factory projectConfigFactory;
private final SetPrivateOp.Factory setPrivateOpFactory;
@@ -426,7 +426,8 @@
RequestScopePropagator requestScopePropagator,
Sequences seq,
SetHashtagsOp.Factory hashtagsFactory,
- @SuperprojectUpdateOnSubmission SubmissionListener superprojectUpdateSubmissionListener,
+ @SuperprojectUpdateOnSubmission
+ ImmutableList<SubmissionListener> superprojectUpdateSubmissionListeners,
TagCache tagCache,
SetPrivateOp.Factory setPrivateOpFactory,
ReplyAttentionSetUpdates replyAttentionSetUpdates,
@@ -474,7 +475,7 @@
this.retryHelper = retryHelper;
this.requestScopePropagator = requestScopePropagator;
this.seq = seq;
- this.superprojectUpdateSubmissionListener = superprojectUpdateSubmissionListener;
+ this.superprojectUpdateSubmissionListeners = superprojectUpdateSubmissionListeners;
this.tagCache = tagCache;
this.projectConfigFactory = projectConfigFactory;
this.setPrivateOpFactory = setPrivateOpFactory;
@@ -742,7 +743,7 @@
logger.atFine().log("Added %d additional ref updates", added);
SubmissionExecutor submissionExecutor =
- new SubmissionExecutor(false, superprojectUpdateSubmissionListener);
+ new SubmissionExecutor(false, superprojectUpdateSubmissionListeners);
submissionExecutor.execute(ImmutableList.of(bu));
@@ -2145,15 +2146,15 @@
receivePack.getRevWalk().parseBody(c);
String name = c.name();
groupCollector.visit(c);
- Collection<Ref> existingRefs =
- receivePackRefCache.tipsFromObjectId(c, RefNames.REFS_CHANGES);
+ Collection<PatchSet.Id> existingPatchSets =
+ receivePackRefCache.patchSetIdsFromObjectId(c);
if (rejectImplicitMerges) {
Collections.addAll(mergedParents, c.getParents());
mergedParents.remove(c);
}
- boolean commitAlreadyTracked = !existingRefs.isEmpty();
+ boolean commitAlreadyTracked = !existingPatchSets.isEmpty();
if (commitAlreadyTracked) {
alreadyTracked++;
// Corner cases where an existing commit might need a new group:
@@ -2169,9 +2170,7 @@
// A's group.
// C) Commit is a PatchSet of a pre-existing change uploaded with a
// different target branch.
- existingRefs.stream()
- .map(r -> PatchSet.Id.fromRef(r.getName()))
- .filter(Objects::nonNull)
+ existingPatchSets.stream()
.forEach(i -> updateGroups.add(new UpdateGroupsRequest(i, c)));
if (!(newChangeForAllNotInTarget || magicBranch.base != null)) {
continue;
@@ -2312,8 +2311,7 @@
// In case the change look up from the index failed,
// double check against the existing refs
- if (foundInExistingRef(
- receivePackRefCache.tipsFromObjectId(p.commit, RefNames.REFS_CHANGES))) {
+ if (foundInExistingPatchSets(receivePackRefCache.patchSetIdsFromObjectId(p.commit))) {
if (pending.size() == 1) {
reject(magicBranch.cmd, "commit(s) already exists (as current patchset)");
return Collections.emptyList();
@@ -2361,11 +2359,10 @@
}
}
- private boolean foundInExistingRef(Collection<Ref> existingRefs) {
- try (TraceTimer traceTimer = newTimer("foundInExistingRef")) {
- for (Ref ref : existingRefs) {
- ChangeNotes notes =
- notesFactory.create(project.getNameKey(), Change.Id.fromRef(ref.getName()));
+ private boolean foundInExistingPatchSets(Collection<PatchSet.Id> existingPatchSets) {
+ try (TraceTimer traceTimer = newTimer("foundInExistingPatchSet")) {
+ for (PatchSet.Id psId : existingPatchSets) {
+ ChangeNotes notes = notesFactory.create(project.getNameKey(), psId.changeId());
Change change = notes.getChange();
if (change.getDest().equals(magicBranch.dest)) {
logger.atFine().log("Found change %s from existing refs.", change.getKey());
@@ -2839,15 +2836,15 @@
return false;
}
- List<Ref> existingChangesWithSameCommit =
- receivePackRefCache.tipsFromObjectId(newCommit, RefNames.REFS_CHANGES);
- if (!existingChangesWithSameCommit.isEmpty()) {
+ List<PatchSet.Id> existingPatchSetsWithSameCommit =
+ receivePackRefCache.patchSetIdsFromObjectId(newCommit);
+ if (!existingPatchSetsWithSameCommit.isEmpty()) {
// TODO(hiesel, hanwen): Remove this check entirely when Gerrit requires change IDs
// without the option to turn that off.
reject(
inputCommand,
"commit already exists (in the project): "
- + existingChangesWithSameCommit.get(0).getName());
+ + existingPatchSetsWithSameCommit.get(0).toRefName());
return false;
}
@@ -3226,7 +3223,7 @@
"more than %d commits, and %s not set", limit, PUSH_OPTION_SKIP_VALIDATION));
return;
}
- if (!receivePackRefCache.tipsFromObjectId(c, RefNames.REFS_CHANGES).isEmpty()) {
+ if (!receivePackRefCache.patchSetIdsFromObjectId(c).isEmpty()) {
continue;
}
@@ -3295,12 +3292,8 @@
// Check if change refs point to this commit. Usually there are 0-1 change
// refs pointing to this commit.
- for (Ref ref :
- receivePackRefCache.tipsFromObjectId(c.copy(), RefNames.REFS_CHANGES)) {
- if (!PatchSet.isChangeRef(ref.getName())) {
- continue;
- }
- PatchSet.Id psId = PatchSet.Id.fromRef(ref.getName());
+ for (PatchSet.Id psId :
+ receivePackRefCache.patchSetIdsFromObjectId(c.copy())) {
Optional<ChangeNotes> notes = getChangeNotes(psId.changeId());
if (notes.isPresent() && notes.get().getChange().getDest().equals(branch)) {
if (submissionId == null) {
diff --git a/java/com/google/gerrit/server/git/receive/ReceivePackRefCache.java b/java/com/google/gerrit/server/git/receive/ReceivePackRefCache.java
index 376ab2d..8568810 100644
--- a/java/com/google/gerrit/server/git/receive/ReceivePackRefCache.java
+++ b/java/com/google/gerrit/server/git/receive/ReceivePackRefCache.java
@@ -21,9 +21,11 @@
import com.google.common.collect.ListMultimap;
import com.google.common.collect.MultimapBuilder;
import com.google.gerrit.entities.Change;
+import com.google.gerrit.entities.PatchSet;
import com.google.gerrit.entities.RefNames;
import java.io.IOException;
import java.util.Map;
+import java.util.Objects;
import org.eclipse.jgit.annotations.Nullable;
import org.eclipse.jgit.lib.ObjectId;
import org.eclipse.jgit.lib.Ref;
@@ -58,8 +60,8 @@
return new WithAdvertisedRefs(allRefsSupplier);
}
- /** Returns a list of refs whose name starts with {@code prefix} that point to {@code id}. */
- ImmutableList<Ref> tipsFromObjectId(ObjectId id, @Nullable String prefix) throws IOException;
+ /** Returns a list of {@link com.google.gerrit.entities.PatchSet.Id}s that point to {@code id}. */
+ ImmutableList<PatchSet.Id> patchSetIdsFromObjectId(ObjectId id) throws IOException;
/** Returns all refs whose name starts with {@code prefix}. */
ImmutableList<Ref> byPrefix(String prefix) throws IOException;
@@ -76,10 +78,10 @@
}
@Override
- public ImmutableList<Ref> tipsFromObjectId(ObjectId id, @Nullable String prefix)
- throws IOException {
+ public ImmutableList<PatchSet.Id> patchSetIdsFromObjectId(ObjectId id) throws IOException {
return delegate.getTipsWithSha1(id).stream()
- .filter(r -> prefix == null || r.getName().startsWith(prefix))
+ .map(r -> PatchSet.Id.fromRef(r.getName()))
+ .filter(Objects::nonNull)
.collect(toImmutableList());
}
@@ -113,10 +115,11 @@
}
@Override
- public ImmutableList<Ref> tipsFromObjectId(ObjectId id, String prefix) {
+ public ImmutableList<PatchSet.Id> patchSetIdsFromObjectId(ObjectId id) {
lazilyInitRefMaps();
return refsByObjectId.get(id).stream()
- .filter(r -> prefix == null || r.getName().startsWith(prefix))
+ .map(r -> PatchSet.Id.fromRef(r.getName()))
+ .filter(Objects::nonNull)
.collect(toImmutableList());
}
diff --git a/java/com/google/gerrit/server/index/IndexUtils.java b/java/com/google/gerrit/server/index/IndexUtils.java
index 9e3d91c..ee8dfc8 100644
--- a/java/com/google/gerrit/server/index/IndexUtils.java
+++ b/java/com/google/gerrit/server/index/IndexUtils.java
@@ -30,7 +30,7 @@
import com.google.gerrit.server.index.account.AccountField;
import com.google.gerrit.server.index.group.GroupField;
import com.google.gerrit.server.query.change.ChangeData;
-import com.google.gerrit.server.query.change.SingleGroupUser;
+import com.google.gerrit.server.query.change.GroupBackedUser;
import java.io.IOException;
import java.util.Set;
import org.eclipse.jgit.errors.ConfigInvalidException;
@@ -107,7 +107,7 @@
if (user.isIdentifiedUser()) {
return user.getAccountId().toString();
}
- if (user instanceof SingleGroupUser) {
+ if (user instanceof GroupBackedUser) {
return "group:" + user.getEffectiveGroups().getKnownGroups().iterator().next().toString();
}
return user.toString();
diff --git a/java/com/google/gerrit/server/logging/LoggingContext.java b/java/com/google/gerrit/server/logging/LoggingContext.java
index 671c224..a1b807d 100644
--- a/java/com/google/gerrit/server/logging/LoggingContext.java
+++ b/java/com/google/gerrit/server/logging/LoggingContext.java
@@ -42,11 +42,12 @@
private static final ThreadLocal<MutableTags> tags = new ThreadLocal<>();
private static final ThreadLocal<Boolean> forceLogging = new ThreadLocal<>();
private static final ThreadLocal<Boolean> performanceLogging = new ThreadLocal<>();
+ private static final ThreadLocal<Boolean> aclLogging = new ThreadLocal<>();
/**
- * When copying the logging context to a new thread we need to ensure that the performance log
- * records that are added in the new thread are added to the same {@link
- * MutablePerformanceLogRecords} instance (see {@link LoggingContextAwareRunnable} and {@link
+ * When copying the logging context to a new thread we need to ensure that the mutable log records
+ * (performance logs and ACL logs) that are added in the new thread are added to the same multable
+ * log records instance (see {@link LoggingContextAwareRunnable} and {@link
* LoggingContextAwareCallable}). This is important since performance log records are processed
* only at the end of the request and performance log records that are created in another thread
* should not get lost.
@@ -54,6 +55,8 @@
private static final ThreadLocal<MutablePerformanceLogRecords> performanceLogRecords =
new ThreadLocal<>();
+ private static final ThreadLocal<MutableAclLogRecords> aclLogRecords = new ThreadLocal<>();
+
private LoggingContext() {}
/** This method is expected to be called via reflection (and might otherwise be unused). */
@@ -67,7 +70,9 @@
}
return new LoggingContextAwareRunnable(
- runnable, getInstance().getMutablePerformanceLogRecords());
+ runnable,
+ getInstance().getMutablePerformanceLogRecords(),
+ getInstance().getMutableAclRecords());
}
public static <T> Callable<T> copy(Callable<T> callable) {
@@ -76,14 +81,18 @@
}
return new LoggingContextAwareCallable<>(
- callable, getInstance().getMutablePerformanceLogRecords());
+ callable,
+ getInstance().getMutablePerformanceLogRecords(),
+ getInstance().getMutableAclRecords());
}
public boolean isEmpty() {
return tags.get() == null
&& forceLogging.get() == null
&& performanceLogging.get() == null
- && performanceLogRecords.get() == null;
+ && performanceLogRecords.get() == null
+ && aclLogging.get() == null
+ && aclLogRecords.get() == null;
}
public void clear() {
@@ -91,6 +100,8 @@
forceLogging.remove();
performanceLogging.remove();
performanceLogRecords.remove();
+ aclLogging.remove();
+ aclLogRecords.remove();
}
@Override
@@ -250,6 +261,101 @@
return records;
}
+ public boolean isAclLogging() {
+ Boolean isAclLogging = aclLogging.get();
+ return isAclLogging != null ? isAclLogging : false;
+ }
+
+ /**
+ * Enables ACL logging.
+ *
+ * <p>It's important to enable ACL logging only in a context that ensures to consume the captured
+ * ACL log records. Otherwise captured ACL log records might leak into other requests that are
+ * executed by the same thread (if a thread pool is used to process requests).
+ *
+ * @param enable whether ACL logging should be enabled.
+ * @return whether ACL logging was be enabled before invoking this method (old value).
+ */
+ boolean aclLogging(boolean enable) {
+ Boolean oldValue = aclLogging.get();
+ if (enable) {
+ aclLogging.set(true);
+ } else {
+ aclLogging.remove();
+ }
+ return oldValue != null ? oldValue : false;
+ }
+
+ /**
+ * Adds an ACL log record.
+ *
+ * @param aclLogRecord ACL log record
+ */
+ public void addAclLogRecord(String aclLogRecord) {
+ if (!isAclLogging()) {
+ return;
+ }
+
+ getMutableAclRecords().add(aclLogRecord);
+ }
+
+ ImmutableList<String> getAclLogRecords() {
+ MutableAclLogRecords records = aclLogRecords.get();
+ if (records != null) {
+ return records.list();
+ }
+ return ImmutableList.of();
+ }
+
+ void clearAclLogEntries() {
+ aclLogRecords.remove();
+ }
+
+ /**
+ * Set the ACL log records in this logging context. Existing log records are overwritten.
+ *
+ * <p>This method makes a defensive copy of the passed in list.
+ *
+ * @param newAclLogRecords ACL log records that should be set
+ */
+ void setAclLogRecords(List<String> newAclLogRecords) {
+ if (newAclLogRecords.isEmpty()) {
+ aclLogRecords.remove();
+ return;
+ }
+
+ getMutableAclRecords().set(newAclLogRecords);
+ }
+
+ /**
+ * Sets a {@link MutableAclLogRecords} instance for storing ACL log records.
+ *
+ * <p><strong>Attention:</strong> The passed in {@link MutableAclLogRecords} instance is directly
+ * stored in the logging context.
+ *
+ * <p>This method is intended to be only used when the logging context is copied to a new thread
+ * to ensure that the ACL log records that are added in the new thread are added to the same
+ * {@link MutableAclLogRecords} instance (see {@link LoggingContextAwareRunnable} and {@link
+ * LoggingContextAwareCallable}). This is important since ACL log records are processed only at
+ * the end of the request and ACL log records that are created in another thread should not get
+ * lost.
+ *
+ * @param mutableAclLogRecords the {@link MutableAclLogRecords} instance in which ACL log records
+ * should be stored
+ */
+ void setMutableAclLogRecords(MutableAclLogRecords mutableAclLogRecords) {
+ aclLogRecords.set(requireNonNull(mutableAclLogRecords));
+ }
+
+ private MutableAclLogRecords getMutableAclRecords() {
+ MutableAclLogRecords records = aclLogRecords.get();
+ if (records == null) {
+ records = new MutableAclLogRecords();
+ aclLogRecords.set(records);
+ }
+ return records;
+ }
+
@Override
public String toString() {
return MoreObjects.toStringHelper(this)
@@ -257,6 +363,8 @@
.add("forceLogging", forceLogging.get())
.add("performanceLogging", performanceLogging.get())
.add("performanceLogRecords", performanceLogRecords.get())
+ .add("aclLogging", aclLogging.get())
+ .add("aclLogRecords", aclLogRecords.get())
.toString();
}
}
diff --git a/java/com/google/gerrit/server/logging/LoggingContextAwareCallable.java b/java/com/google/gerrit/server/logging/LoggingContextAwareCallable.java
index 1adee1b..ab5db02 100644
--- a/java/com/google/gerrit/server/logging/LoggingContextAwareCallable.java
+++ b/java/com/google/gerrit/server/logging/LoggingContextAwareCallable.java
@@ -40,6 +40,8 @@
private final boolean forceLogging;
private final boolean performanceLogging;
private final MutablePerformanceLogRecords mutablePerformanceLogRecords;
+ private final boolean aclLogging;
+ private final MutableAclLogRecords mutableAclLogRecords;
/**
* Creates a LoggingContextAwareCallable that wraps the given {@link Callable}.
@@ -47,15 +49,21 @@
* @param callable Callable that should be wrapped.
* @param mutablePerformanceLogRecords instance of {@link MutablePerformanceLogRecords} to which
* performance log records that are created from the runnable are added
+ * @param mutableAclLogRecords instance of {@link MutableAclLogRecords} to which ACL log records
+ * that are created from the runnable are added
*/
LoggingContextAwareCallable(
- Callable<T> callable, MutablePerformanceLogRecords mutablePerformanceLogRecords) {
+ Callable<T> callable,
+ MutablePerformanceLogRecords mutablePerformanceLogRecords,
+ MutableAclLogRecords mutableAclLogRecords) {
this.callable = callable;
this.callingThread = Thread.currentThread();
this.tags = LoggingContext.getInstance().getTagsAsMap();
this.forceLogging = LoggingContext.getInstance().isLoggingForced();
this.performanceLogging = LoggingContext.getInstance().isPerformanceLogging();
this.mutablePerformanceLogRecords = mutablePerformanceLogRecords;
+ this.aclLogging = LoggingContext.getInstance().isAclLogging();
+ this.mutableAclLogRecords = mutableAclLogRecords;
}
@Override
@@ -76,6 +84,8 @@
loggingCtx.forceLogging(forceLogging);
loggingCtx.performanceLogging(performanceLogging);
loggingCtx.setMutablePerformanceLogRecords(mutablePerformanceLogRecords);
+ loggingCtx.aclLogging(aclLogging);
+ loggingCtx.setMutableAclLogRecords(mutableAclLogRecords);
try {
return callable.call();
} finally {
diff --git a/java/com/google/gerrit/server/logging/LoggingContextAwareRunnable.java b/java/com/google/gerrit/server/logging/LoggingContextAwareRunnable.java
index d0559cc..3c4c563 100644
--- a/java/com/google/gerrit/server/logging/LoggingContextAwareRunnable.java
+++ b/java/com/google/gerrit/server/logging/LoggingContextAwareRunnable.java
@@ -58,6 +58,8 @@
private final boolean forceLogging;
private final boolean performanceLogging;
private final MutablePerformanceLogRecords mutablePerformanceLogRecords;
+ private final boolean aclLogging;
+ private final MutableAclLogRecords mutableAclLogRecords;
/**
* Creates a LoggingContextAwareRunnable that wraps the given {@link Runnable}.
@@ -65,15 +67,21 @@
* @param runnable Runnable that should be wrapped.
* @param mutablePerformanceLogRecords instance of {@link MutablePerformanceLogRecords} to which
* performance log records that are created from the runnable are added
+ * @param mutableAclLogRecords instance of {@link MutableAclLogRecords} to which ACL log records
+ * that are created from the runnable are added
*/
LoggingContextAwareRunnable(
- Runnable runnable, MutablePerformanceLogRecords mutablePerformanceLogRecords) {
+ Runnable runnable,
+ MutablePerformanceLogRecords mutablePerformanceLogRecords,
+ MutableAclLogRecords mutableAclLogRecords) {
this.runnable = runnable;
this.callingThread = Thread.currentThread();
this.tags = LoggingContext.getInstance().getTagsAsMap();
this.forceLogging = LoggingContext.getInstance().isLoggingForced();
this.performanceLogging = LoggingContext.getInstance().isPerformanceLogging();
this.mutablePerformanceLogRecords = mutablePerformanceLogRecords;
+ this.aclLogging = LoggingContext.getInstance().isAclLogging();
+ this.mutableAclLogRecords = mutableAclLogRecords;
}
public Runnable unwrap() {
@@ -99,6 +107,8 @@
loggingCtx.forceLogging(forceLogging);
loggingCtx.performanceLogging(performanceLogging);
loggingCtx.setMutablePerformanceLogRecords(mutablePerformanceLogRecords);
+ loggingCtx.aclLogging(aclLogging);
+ loggingCtx.setMutableAclLogRecords(mutableAclLogRecords);
try {
runnable.run();
} finally {
diff --git a/java/com/google/gerrit/server/logging/MutableAclLogRecords.java b/java/com/google/gerrit/server/logging/MutableAclLogRecords.java
new file mode 100644
index 0000000..baa9b1f
--- /dev/null
+++ b/java/com/google/gerrit/server/logging/MutableAclLogRecords.java
@@ -0,0 +1,52 @@
+// 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.logging;
+
+import com.google.common.base.MoreObjects;
+import com.google.common.collect.ImmutableList;
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * Thread-safe store for ACL log records.
+ *
+ * <p>This class is intended to keep track of user ACL records in {@link LoggingContext}. It needs
+ * to be thread-safe because it gets shared between threads when the logging context is copied to
+ * another thread (see {@link LoggingContextAwareRunnable} and {@link LoggingContextAwareCallable}.
+ * In this case the logging contexts of both threads share the same instance of this class. This is
+ * important since ACL log records are processed only at the end of a request and user ACL records
+ * that are created in another thread should not get lost.
+ */
+public class MutableAclLogRecords {
+ private final ArrayList<String> aclLogRecords = new ArrayList<>();
+
+ public synchronized void add(String record) {
+ aclLogRecords.add(record);
+ }
+
+ public synchronized void set(List<String> records) {
+ aclLogRecords.clear();
+ aclLogRecords.addAll(records);
+ }
+
+ public synchronized ImmutableList<String> list() {
+ return ImmutableList.copyOf(aclLogRecords);
+ }
+
+ @Override
+ public String toString() {
+ return MoreObjects.toStringHelper(this).add("aclLogRecords", aclLogRecords).toString();
+ }
+}
diff --git a/java/com/google/gerrit/server/logging/TraceContext.java b/java/com/google/gerrit/server/logging/TraceContext.java
index 21a4ce6..2fc19b5 100644
--- a/java/com/google/gerrit/server/logging/TraceContext.java
+++ b/java/com/google/gerrit/server/logging/TraceContext.java
@@ -19,6 +19,7 @@
import com.google.common.base.Stopwatch;
import com.google.common.base.Strings;
import com.google.common.collect.HashBasedTable;
+import com.google.common.collect.ImmutableList;
import com.google.common.collect.ImmutableMap;
import com.google.common.collect.Table;
import com.google.common.flogger.FluentLogger;
@@ -222,9 +223,17 @@
// Table<TAG_NAME, TAG_VALUE, REMOVE_ON_CLOSE>
private final Table<String, String, Boolean> tags = HashBasedTable.create();
- private boolean stopForceLoggingOnClose;
+ private final boolean oldAclLogging;
+ private final ImmutableList<String> oldAclLogRecords;
- private TraceContext() {}
+ private boolean stopForceLoggingOnClose;
+ private boolean stopAclLoggingOnClose;
+
+ private TraceContext() {
+ // Just in case remember the old state and reset ACL log entries.
+ this.oldAclLogging = LoggingContext.getInstance().isAclLogging();
+ this.oldAclLogRecords = LoggingContext.getInstance().getAclLogRecords();
+ }
public TraceContext addTag(RequestId.Type requestId, Object tagValue) {
return addTag(requireNonNull(requestId, "request ID is required").name(), tagValue);
@@ -265,6 +274,23 @@
.findFirst();
}
+ public TraceContext enableAclLogging() {
+ if (stopAclLoggingOnClose) {
+ return this;
+ }
+
+ stopAclLoggingOnClose = !LoggingContext.getInstance().aclLogging(true);
+ return this;
+ }
+
+ public boolean isAclLoggingEnabled() {
+ return LoggingContext.getInstance().isAclLogging();
+ }
+
+ public ImmutableList<String> getAclLogRecords() {
+ return LoggingContext.getInstance().getAclLogRecords();
+ }
+
@Override
public void close() {
for (Table.Cell<String, String, Boolean> cell : tags.cellSet()) {
@@ -275,5 +301,10 @@
if (stopForceLoggingOnClose) {
LoggingContext.getInstance().forceLogging(false);
}
+
+ if (stopAclLoggingOnClose) {
+ LoggingContext.getInstance().aclLogging(oldAclLogging);
+ LoggingContext.getInstance().setAclLogRecords(oldAclLogRecords);
+ }
}
}
diff --git a/java/com/google/gerrit/server/mail/EmailSettings.java b/java/com/google/gerrit/server/mail/EmailSettings.java
index c411af5..1ac6eb6 100644
--- a/java/com/google/gerrit/server/mail/EmailSettings.java
+++ b/java/com/google/gerrit/server/mail/EmailSettings.java
@@ -38,6 +38,7 @@
public final Encryption encryption;
public final long fetchInterval; // in milliseconds
public final boolean sendNewPatchsetEmails;
+ public final boolean isAttentionSetEnabled;
@Inject
EmailSettings(@GerritServerConfig Config cfg) {
@@ -60,5 +61,6 @@
TimeUnit.MILLISECONDS.convert(60, TimeUnit.SECONDS),
TimeUnit.MILLISECONDS);
sendNewPatchsetEmails = cfg.getBoolean("change", null, "sendNewPatchsetEmails", true);
+ isAttentionSetEnabled = cfg.getBoolean("change", null, "enableAttentionSet", false);
}
}
diff --git a/java/com/google/gerrit/server/mail/send/ChangeEmail.java b/java/com/google/gerrit/server/mail/send/ChangeEmail.java
index 7b2bf12..8d76e23 100644
--- a/java/com/google/gerrit/server/mail/send/ChangeEmail.java
+++ b/java/com/google/gerrit/server/mail/send/ChangeEmail.java
@@ -14,6 +14,7 @@
package com.google.gerrit.server.mail.send;
+import static com.google.common.collect.ImmutableSet.toImmutableSet;
import static com.google.gerrit.server.util.AttentionSetUtil.additionsOnly;
import com.google.common.base.Splitter;
@@ -33,9 +34,11 @@
import com.google.gerrit.exceptions.StorageException;
import com.google.gerrit.extensions.api.changes.NotifyHandling;
import com.google.gerrit.extensions.api.changes.RecipientType;
+import com.google.gerrit.extensions.client.GeneralPreferencesInfo.EmailStrategy;
import com.google.gerrit.extensions.restapi.AuthException;
import com.google.gerrit.mail.MailHeader;
import com.google.gerrit.server.StarredChangesUtil;
+import com.google.gerrit.server.account.AccountState;
import com.google.gerrit.server.mail.send.ProjectWatch.Watchers;
import com.google.gerrit.server.notedb.ReviewerStateInternal;
import com.google.gerrit.server.patch.PatchList;
@@ -56,6 +59,7 @@
import java.util.HashMap;
import java.util.HashSet;
import java.util.Map;
+import java.util.Optional;
import java.util.Set;
import java.util.TreeSet;
import java.util.stream.Collectors;
@@ -75,6 +79,7 @@
return ea.changeDataFactory.create(project, id);
}
+ private final Set<Account.Id> currentAttentionSet;
protected final Change change;
protected final ChangeData changeData;
protected ListMultimap<Account.Id, String> stars;
@@ -92,6 +97,7 @@
this.changeData = changeData;
this.change = changeData.change();
this.emailOnlyAuthors = false;
+ this.currentAttentionSet = getAttentionSet();
}
@Override
@@ -123,6 +129,10 @@
/** Format the message body by calling {@link #appendText(String)}. */
@Override
protected void format() throws EmailException {
+ if (useHtml()) {
+ appendHtml(soyHtmlTemplate("ChangeHeaderHtml"));
+ }
+ appendText(textTemplate("ChangeHeader"));
formatChange();
appendText(textTemplate("ChangeFooter"));
if (useHtml()) {
@@ -387,9 +397,19 @@
@Override
protected void add(RecipientType rt, Account.Id to) {
- if (!emailOnlyAuthors || authors.contains(to)) {
- super.add(rt, to);
+ Optional<AccountState> accountState = args.accountCache.get(to);
+ if (!accountState.isPresent()) {
+ return;
}
+ if (accountState.get().generalPreferences().getEmailStrategy()
+ == EmailStrategy.ATTENTION_SET_ONLY
+ && !currentAttentionSet.contains(to)) {
+ return;
+ }
+ if (emailOnlyAuthors && !authors.contains(to)) {
+ return;
+ }
+ super.add(rt, to);
}
@Override
@@ -487,8 +507,15 @@
for (String reviewer : getEmailsByState(ReviewerStateInternal.CC)) {
footers.add(MailHeader.CC.withDelimiter() + reviewer);
}
- for (String attentionUser : getAttentionSet()) {
- footers.add(MailHeader.ATTENTION.withDelimiter() + attentionUser);
+ for (Account.Id attentionUser : currentAttentionSet) {
+ footers.add(MailHeader.ATTENTION.withDelimiter() + getNameEmailFor(attentionUser));
+ }
+ // Since this would be user visible, only show it if attention set is enabled
+ if (args.settings.isAttentionSetEnabled && !currentAttentionSet.isEmpty()) {
+ // We need names rather than account ids / emails to make it user readable.
+ soyContext.put(
+ "attentionSet",
+ currentAttentionSet.stream().map(this::getNameFor).collect(toImmutableSet()));
}
}
@@ -515,12 +542,12 @@
return reviewers;
}
- private Set<String> getAttentionSet() {
- Set<String> attentionSet = new TreeSet<>();
+ private Set<Account.Id> getAttentionSet() {
+ Set<Account.Id> attentionSet = new TreeSet<>();
try {
attentionSet =
additionsOnly(changeData.attentionSet()).stream()
- .map(a -> getNameEmailFor(a.account()))
+ .map(a -> a.account())
.collect(Collectors.toSet());
} catch (StorageException e) {
logger.atWarning().withCause(e).log("Cannot get change attention set");
diff --git a/java/com/google/gerrit/server/mail/send/MailSoySauceProvider.java b/java/com/google/gerrit/server/mail/send/MailSoySauceProvider.java
index 623bdc2..1b58057 100644
--- a/java/com/google/gerrit/server/mail/send/MailSoySauceProvider.java
+++ b/java/com/google/gerrit/server/mail/send/MailSoySauceProvider.java
@@ -45,6 +45,8 @@
"AddToAttentionSetHtml.soy",
"ChangeFooter.soy",
"ChangeFooterHtml.soy",
+ "ChangeHeader.soy",
+ "ChangeHeaderHtml.soy",
"ChangeSubject.soy",
"Comment.soy",
"CommentHtml.soy",
@@ -60,7 +62,6 @@
"InboundEmailRejectionHtml.soy",
"Footer.soy",
"FooterHtml.soy",
- "HeaderHtml.soy",
"HttpPasswordUpdate.soy",
"HttpPasswordUpdateHtml.soy",
"Merged.soy",
diff --git a/java/com/google/gerrit/server/mail/send/OutgoingEmail.java b/java/com/google/gerrit/server/mail/send/OutgoingEmail.java
index 1eb274b..bef5317 100644
--- a/java/com/google/gerrit/server/mail/send/OutgoingEmail.java
+++ b/java/com/google/gerrit/server/mail/send/OutgoingEmail.java
@@ -116,9 +116,6 @@
if (messageId == null) {
throw new IllegalStateException("All emails must have a messageId");
}
- if (useHtml()) {
- appendHtml(soyHtmlTemplate("HeaderHtml"));
- }
format();
appendText(textTemplate("Footer"));
if (useHtml()) {
diff --git a/java/com/google/gerrit/server/mail/send/ProjectWatch.java b/java/com/google/gerrit/server/mail/send/ProjectWatch.java
index 0514337..173b121 100644
--- a/java/com/google/gerrit/server/mail/send/ProjectWatch.java
+++ b/java/com/google/gerrit/server/mail/send/ProjectWatch.java
@@ -33,7 +33,7 @@
import com.google.gerrit.server.project.ProjectState;
import com.google.gerrit.server.query.change.ChangeData;
import com.google.gerrit.server.query.change.ChangeQueryBuilder;
-import com.google.gerrit.server.query.change.SingleGroupUser;
+import com.google.gerrit.server.query.change.GroupBackedUser;
import java.util.ArrayList;
import java.util.HashSet;
import java.util.List;
@@ -150,7 +150,7 @@
throws QueryParseException {
logger.atFine().log("Checking watchers for notify config %s from project %s", nc, projectName);
for (GroupReference groupRef : nc.getGroups()) {
- CurrentUser user = new SingleGroupUser(groupRef.getUUID());
+ CurrentUser user = new GroupBackedUser(ImmutableSet.of(groupRef.getUUID()));
if (filterMatch(user, nc.getFilter())) {
deliverToMembers(matching.list(nc.getHeader()), groupRef.getUUID());
logger.atFine().log("Added watchers for group %s", groupRef);
diff --git a/java/com/google/gerrit/server/notedb/ChangeNotes.java b/java/com/google/gerrit/server/notedb/ChangeNotes.java
index e83d4fd..890a1b1 100644
--- a/java/com/google/gerrit/server/notedb/ChangeNotes.java
+++ b/java/com/google/gerrit/server/notedb/ChangeNotes.java
@@ -77,6 +77,9 @@
import org.eclipse.jgit.lib.Repository;
/** View of a single {@link Change} based on the log of its notes branch. */
+// TODO(paiking): This class should be refactored to get rid of potentially duplicate or unneeded
+// variables, such as allAttentionSetUpdates, reviewerUpdates, and others.
+
public class ChangeNotes extends AbstractChangeNotes<ChangeNotes> {
private static final FluentLogger logger = FluentLogger.forEnclosingClass();
@@ -391,6 +394,11 @@
return state.attentionSet();
}
+ /** Returns all updates for the attention set. */
+ public ImmutableList<AttentionSetUpdate> getAttentionSetUpdates() {
+ return state.allAttentionSetUpdates();
+ }
+
/**
* @return an ImmutableSet of Account.Ids of all users that have been assigned to this change. The
* order of the set is the order in which they were assigned.
diff --git a/java/com/google/gerrit/server/notedb/ChangeNotesCache.java b/java/com/google/gerrit/server/notedb/ChangeNotesCache.java
index 7fde297..1650421 100644
--- a/java/com/google/gerrit/server/notedb/ChangeNotesCache.java
+++ b/java/com/google/gerrit/server/notedb/ChangeNotesCache.java
@@ -168,6 +168,10 @@
+ P
+ list(state.assigneeUpdates(), 4 * O + K + K)
+ P
+ + set(state.attentionSet(), 4 * O + K + I + str(15))
+ + P
+ + list(state.allAttentionSetUpdates(), 4 * O + K + I + str(15))
+ + P
+ list(state.submitRecords(), P + list(2, str(4) + P + K) + P)
+ P
+ list(state.changeMessages(), changeMessage())
diff --git a/java/com/google/gerrit/server/notedb/ChangeNotesParser.java b/java/com/google/gerrit/server/notedb/ChangeNotesParser.java
index c92d236..fae29f8 100644
--- a/java/com/google/gerrit/server/notedb/ChangeNotesParser.java
+++ b/java/com/google/gerrit/server/notedb/ChangeNotesParser.java
@@ -118,6 +118,8 @@
private final List<ReviewerStatusUpdate> reviewerUpdates;
/** Holds only the most recent update per user. Older updates are discarded. */
private final Map<Account.Id, AttentionSetUpdate> latestAttentionStatus;
+ /** Holds all updates to attention set. */
+ private final List<AttentionSetUpdate> allAttentionSetUpdates;
private final List<AssigneeStatusUpdate> assigneeUpdates;
private final List<SubmitRecord> submitRecords;
@@ -175,6 +177,7 @@
allPastReviewers = new ArrayList<>();
reviewerUpdates = new ArrayList<>();
latestAttentionStatus = new HashMap<>();
+ allAttentionSetUpdates = new ArrayList<>();
assigneeUpdates = new ArrayList<>();
submitRecords = Lists.newArrayListWithExpectedSize(1);
allChangeMessages = new ArrayList<>();
@@ -246,6 +249,7 @@
allPastReviewers,
buildReviewerUpdates(),
ImmutableSet.copyOf(latestAttentionStatus.values()),
+ allAttentionSetUpdates,
assigneeUpdates,
submitRecords,
buildAllMessages(),
@@ -589,6 +593,9 @@
}
// Processing is in reverse chronological order. Keep only the latest update.
latestAttentionStatus.putIfAbsent(attentionStatus.get().account(), attentionStatus.get());
+
+ // Keep all updates as well.
+ allAttentionSetUpdates.add(attentionStatus.get());
}
}
diff --git a/java/com/google/gerrit/server/notedb/ChangeNotesState.java b/java/com/google/gerrit/server/notedb/ChangeNotesState.java
index 76c4678..fa32686 100644
--- a/java/com/google/gerrit/server/notedb/ChangeNotesState.java
+++ b/java/com/google/gerrit/server/notedb/ChangeNotesState.java
@@ -84,6 +84,9 @@
* <p>Note that {@link ChangeNotes} contains more than just a single {@code ChangeNoteState}, such
* as per-draft information, so that class is not cached directly.
*/
+// TODO(paiking): This class should be refactored to get rid of potentially duplicate or unneeded
+// variables, such as allAttentionSetUpdates, reviewerUpdates, and others.
+
@AutoValue
public abstract class ChangeNotesState {
@@ -120,6 +123,7 @@
List<Account.Id> allPastReviewers,
List<ReviewerStatusUpdate> reviewerUpdates,
Set<AttentionSetUpdate> attentionSetUpdates,
+ List<AttentionSetUpdate> allAttentionSetUpdates,
List<AssigneeStatusUpdate> assigneeUpdates,
List<SubmitRecord> submitRecords,
List<ChangeMessage> changeMessages,
@@ -171,6 +175,7 @@
.allPastReviewers(allPastReviewers)
.reviewerUpdates(reviewerUpdates)
.attentionSet(attentionSetUpdates)
+ .allAttentionSetUpdates(allAttentionSetUpdates)
.assigneeUpdates(assigneeUpdates)
.submitRecords(submitRecords)
.changeMessages(changeMessages)
@@ -305,9 +310,12 @@
abstract ImmutableList<ReviewerStatusUpdate> reviewerUpdates();
- /** Returns the most recent update (i.e. current status status) per user. */
+ /** Returns the most recent update (i.e. current status) per user. */
abstract ImmutableSet<AttentionSetUpdate> attentionSet();
+ /** Returns all attention set updates. */
+ abstract ImmutableList<AttentionSetUpdate> allAttentionSetUpdates();
+
abstract ImmutableList<AssigneeStatusUpdate> assigneeUpdates();
abstract ImmutableList<SubmitRecord> submitRecords();
@@ -386,6 +394,7 @@
.allPastReviewers(ImmutableList.of())
.reviewerUpdates(ImmutableList.of())
.attentionSet(ImmutableSet.of())
+ .allAttentionSetUpdates(ImmutableList.of())
.assigneeUpdates(ImmutableList.of())
.submitRecords(ImmutableList.of())
.changeMessages(ImmutableList.of())
@@ -421,6 +430,8 @@
abstract Builder attentionSet(Set<AttentionSetUpdate> attentionSetUpdates);
+ abstract Builder allAttentionSetUpdates(List<AttentionSetUpdate> attentionSetUpdates);
+
abstract Builder assigneeUpdates(List<AssigneeStatusUpdate> assigneeUpdates);
abstract Builder submitRecords(List<SubmitRecord> submitRecords);
@@ -489,6 +500,9 @@
object.allPastReviewers().forEach(a -> b.addPastReviewer(a.get()));
object.reviewerUpdates().forEach(u -> b.addReviewerUpdate(toReviewerStatusUpdateProto(u)));
object.attentionSet().forEach(u -> b.addAttentionSetUpdate(toAttentionSetUpdateProto(u)));
+ object
+ .allAttentionSetUpdates()
+ .forEach(u -> b.addAllAttentionSetUpdate(toAttentionSetUpdateProto(u)));
object.assigneeUpdates().forEach(u -> b.addAssigneeUpdate(toAssigneeStatusUpdateProto(u)));
object
.submitRecords()
@@ -623,6 +637,8 @@
proto.getPastReviewerList().stream().map(Account::id).collect(toImmutableList()))
.reviewerUpdates(toReviewerStatusUpdateList(proto.getReviewerUpdateList()))
.attentionSet(toAttentionSetUpdates(proto.getAttentionSetUpdateList()))
+ .allAttentionSetUpdates(
+ toAllAttentionSetUpdates(proto.getAllAttentionSetUpdateList()))
.assigneeUpdates(toAssigneeStatusUpdateList(proto.getAssigneeUpdateList()))
.submitRecords(
proto.getSubmitRecordList().stream()
@@ -735,6 +751,20 @@
return b.build();
}
+ private static ImmutableList<AttentionSetUpdate> toAllAttentionSetUpdates(
+ List<AttentionSetUpdateProto> protos) {
+ ImmutableList.Builder<AttentionSetUpdate> b = ImmutableList.builder();
+ for (AttentionSetUpdateProto proto : protos) {
+ b.add(
+ AttentionSetUpdate.createFromRead(
+ Instant.ofEpochMilli(proto.getTimestampMillis()),
+ Account.id(proto.getAccount()),
+ AttentionSetUpdate.Operation.valueOf(proto.getOperation()),
+ proto.getReason()));
+ }
+ return b.build();
+ }
+
private static ImmutableList<AssigneeStatusUpdate> toAssigneeStatusUpdateList(
List<AssigneeStatusUpdateProto> protos) {
ImmutableList.Builder<AssigneeStatusUpdate> b = ImmutableList.builder();
diff --git a/java/com/google/gerrit/server/permissions/DefaultPermissionBackend.java b/java/com/google/gerrit/server/permissions/DefaultPermissionBackend.java
index cf6a184..49df653 100644
--- a/java/com/google/gerrit/server/permissions/DefaultPermissionBackend.java
+++ b/java/com/google/gerrit/server/permissions/DefaultPermissionBackend.java
@@ -34,7 +34,6 @@
import com.google.gerrit.server.IdentifiedUser;
import com.google.gerrit.server.PeerDaemonUser;
import com.google.gerrit.server.account.CapabilityCollection;
-import com.google.gerrit.server.cache.PerThreadCache;
import com.google.gerrit.server.project.ProjectCache;
import com.google.gerrit.server.project.ProjectState;
import com.google.inject.Inject;
@@ -42,15 +41,12 @@
import com.google.inject.Singleton;
import java.util.Collection;
import java.util.List;
-import java.util.Optional;
import java.util.Set;
@Singleton
public class DefaultPermissionBackend extends PermissionBackend {
private static final FluentLogger logger = FluentLogger.forEnclosingClass();
- private static final CurrentUser.PropertyKey<Boolean> IS_ADMIN = CurrentUser.PropertyKey.create();
-
private final Provider<CurrentUser> currentUser;
private final ProjectCache projectCache;
private final ProjectControl.Factory projectControlFactory;
@@ -105,11 +101,7 @@
public ForProject project(Project.NameKey project) {
try {
ProjectState state = projectCache.get(project).orElseThrow(illegalState(project));
- ProjectControl control =
- PerThreadCache.getOrCompute(
- PerThreadCache.Key.create(ProjectControl.class, project, user.getCacheKey()),
- () -> projectControlFactory.create(user, state));
- return control.asForProject();
+ return projectControlFactory.create(user, state).asForProject();
} catch (Exception e) {
Throwable cause = e.getCause() != null ? e.getCause() : e;
return FailedPermissionBackend.project(
@@ -202,21 +194,13 @@
}
private Boolean computeAdmin() {
- Optional<Boolean> r = user.get(IS_ADMIN);
- if (r.isPresent()) {
- return r.get();
- }
-
- boolean isAdmin;
if (user.isImpersonating()) {
- isAdmin = false;
- } else if (user instanceof PeerDaemonUser) {
- isAdmin = true;
- } else {
- isAdmin = allow(capabilities().administrateServer);
+ return false;
}
- user.put(IS_ADMIN, isAdmin);
- return isAdmin;
+ if (user instanceof PeerDaemonUser) {
+ return true;
+ }
+ return allow(capabilities().administrateServer);
}
private boolean canEmailReviewers() {
diff --git a/java/com/google/gerrit/server/permissions/RefControl.java b/java/com/google/gerrit/server/permissions/RefControl.java
index bc802cc..e704a99 100644
--- a/java/com/google/gerrit/server/permissions/RefControl.java
+++ b/java/com/google/gerrit/server/permissions/RefControl.java
@@ -29,6 +29,7 @@
import com.google.gerrit.extensions.restapi.AuthException;
import com.google.gerrit.server.CurrentUser;
import com.google.gerrit.server.logging.CallerFinder;
+import com.google.gerrit.server.logging.LoggingContext;
import com.google.gerrit.server.notedb.ChangeNotes;
import com.google.gerrit.server.permissions.PermissionBackend.ForChange;
import com.google.gerrit.server.permissions.PermissionBackend.ForRef;
@@ -397,40 +398,52 @@
/** True if the user has this permission. */
private boolean canPerform(String permissionName, boolean isChangeOwner, boolean withForce) {
if (isBlocked(permissionName, isChangeOwner, withForce)) {
- logger.atFine().log(
- "'%s' cannot perform '%s' with force=%s on project '%s' for ref '%s'"
- + " because this permission is blocked (caller: %s)",
- getUser().getLoggableName(),
- permissionName,
- withForce,
- projectControl.getProject().getName(),
- refName,
- callerFinder.findCallerLazy());
+ if (logger.atFine().isEnabled() || LoggingContext.getInstance().isAclLogging()) {
+ String logMessage =
+ String.format(
+ "'%s' cannot perform '%s' with force=%s on project '%s' for ref '%s'"
+ + " because this permission is blocked",
+ getUser().getLoggableName(),
+ permissionName,
+ withForce,
+ projectControl.getProject().getName(),
+ refName);
+ LoggingContext.getInstance().addAclLogRecord(logMessage);
+ logger.atFine().log("%s (caller: %s)", logMessage, callerFinder.findCallerLazy());
+ }
return false;
}
for (PermissionRule pr : relevant.getAllowRules(permissionName)) {
if (isAllow(pr, withForce) && projectControl.match(pr, isChangeOwner)) {
- logger.atFine().log(
- "'%s' can perform '%s' with force=%s on project '%s' for ref '%s' (caller: %s)",
- getUser().getLoggableName(),
- permissionName,
- withForce,
- projectControl.getProject().getName(),
- refName,
- callerFinder.findCallerLazy());
+ if (logger.atFine().isEnabled() || LoggingContext.getInstance().isAclLogging()) {
+ String logMessage =
+ String.format(
+ "'%s' can perform '%s' with force=%s on project '%s' for ref '%s'",
+ getUser().getLoggableName(),
+ permissionName,
+ withForce,
+ projectControl.getProject().getName(),
+ refName);
+ LoggingContext.getInstance().addAclLogRecord(logMessage);
+ logger.atFine().log("%s (caller: %s)", logMessage, callerFinder.findCallerLazy());
+ }
return true;
}
}
- logger.atFine().log(
- "'%s' cannot perform '%s' with force=%s on project '%s' for ref '%s' (caller: %s)",
- getUser().getLoggableName(),
- permissionName,
- withForce,
- projectControl.getProject().getName(),
- refName,
- callerFinder.findCallerLazy());
+ if (logger.atFine().isEnabled() || LoggingContext.getInstance().isAclLogging()) {
+ String logMessage =
+ String.format(
+ "'%s' cannot perform '%s' with force=%s on project '%s' for ref '%s'",
+ getUser().getLoggableName(),
+ permissionName,
+ withForce,
+ projectControl.getProject().getName(),
+ refName);
+ LoggingContext.getInstance().addAclLogRecord(logMessage);
+ logger.atFine().log("%s (caller: %s)", logMessage, callerFinder.findCallerLazy());
+ }
return false;
}
diff --git a/java/com/google/gerrit/server/query/change/ChangeIsVisibleToPredicate.java b/java/com/google/gerrit/server/query/change/ChangeIsVisibleToPredicate.java
index c6bcd60..a66c43ae 100644
--- a/java/com/google/gerrit/server/query/change/ChangeIsVisibleToPredicate.java
+++ b/java/com/google/gerrit/server/query/change/ChangeIsVisibleToPredicate.java
@@ -88,7 +88,7 @@
? permissionBackend.absentUser(user.getAccountId())
: permissionBackend.user(
Optional.of(user)
- .filter(u -> u instanceof SingleGroupUser || u instanceof InternalUser)
+ .filter(u -> u instanceof GroupBackedUser || u instanceof InternalUser)
.orElseGet(anonymousUserProvider::get));
try {
withUser.change(cd).check(ChangePermission.READ);
diff --git a/java/com/google/gerrit/server/query/change/ChangeQueryBuilder.java b/java/com/google/gerrit/server/query/change/ChangeQueryBuilder.java
index 464ba81..6f4ccb7 100644
--- a/java/com/google/gerrit/server/query/change/ChangeQueryBuilder.java
+++ b/java/com/google/gerrit/server/query/change/ChangeQueryBuilder.java
@@ -1026,7 +1026,7 @@
for (GroupReference ref : suggestions) {
ids.add(ref.getUUID());
}
- return visibleto(new SingleGroupUser(ids));
+ return visibleto(new GroupBackedUser(ids));
}
throw error("No user or group matches \"" + who + "\".");
diff --git a/java/com/google/gerrit/server/query/change/GroupBackedUser.java b/java/com/google/gerrit/server/query/change/GroupBackedUser.java
new file mode 100644
index 0000000..dac555d
--- /dev/null
+++ b/java/com/google/gerrit/server/query/change/GroupBackedUser.java
@@ -0,0 +1,67 @@
+// Copyright (C) 2009 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.query.change;
+
+import com.google.gerrit.entities.AccountGroup;
+import com.google.gerrit.server.CurrentUser;
+import com.google.gerrit.server.account.GroupMembership;
+import com.google.gerrit.server.account.ListGroupMembership;
+import java.util.Set;
+
+/**
+ * Representation of a user that does not have a Gerrit account.
+ *
+ * <p>This user representation is intended to be used for two purposes:
+ *
+ * <ol>
+ * <li>Checking permissions for groups: There are occasions where we need to check if a resource -
+ * such as a change - is accessible by a group. Our entire {@link
+ * com.google.gerrit.server.permissions.PermissionBackend} works solely with {@link
+ * CurrentUser}. This class can be used to check permissions on a synthetic user with the
+ * given group memberships. Any real Gerrit user with the same group memberships would receive
+ * the same permission check results.
+ * <li>Checking permissions for an external user: In installations with external group systems,
+ * one might want to check what Gerrit permissions a user has, before or even without creating
+ * a Gerrit account. Such an external user has external group memberships only as well as
+ * internal groups that contain the user's external groups as subgroups. This class can be
+ * used to represent such an external user.
+ * </ol>
+ */
+public final class GroupBackedUser extends CurrentUser {
+ private final GroupMembership groups;
+
+ /**
+ * Creates a new instance
+ *
+ * @param groups this set has to include all parent groups the user is contained in through
+ * subgroup membership. Given a set of groups that contains the user directly, callers can use
+ * {@link
+ * com.google.gerrit.server.account.GroupIncludeCache#parentGroupsOf(AccountGroup.UUID)} to
+ * resolve parent groups.
+ */
+ public GroupBackedUser(Set<AccountGroup.UUID> groups) {
+ this.groups = new ListGroupMembership(groups);
+ }
+
+ @Override
+ public GroupMembership getEffectiveGroups() {
+ return groups;
+ }
+
+ @Override
+ public String getLoggableName() {
+ return "GroupBackedUser with memberships: " + groups.getKnownGroups();
+ }
+}
diff --git a/java/com/google/gerrit/server/query/change/SingleGroupUser.java b/java/com/google/gerrit/server/query/change/SingleGroupUser.java
deleted file mode 100644
index c451d46..0000000
--- a/java/com/google/gerrit/server/query/change/SingleGroupUser.java
+++ /dev/null
@@ -1,44 +0,0 @@
-// Copyright (C) 2009 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.query.change;
-
-import com.google.common.collect.ImmutableSet;
-import com.google.gerrit.entities.AccountGroup;
-import com.google.gerrit.server.CurrentUser;
-import com.google.gerrit.server.account.GroupMembership;
-import com.google.gerrit.server.account.ListGroupMembership;
-import java.util.Set;
-
-public final class SingleGroupUser extends CurrentUser {
- private final GroupMembership groups;
-
- public SingleGroupUser(AccountGroup.UUID groupId) {
- this(ImmutableSet.of(groupId));
- }
-
- public SingleGroupUser(Set<AccountGroup.UUID> groups) {
- this.groups = new ListGroupMembership(groups);
- }
-
- @Override
- public GroupMembership getEffectiveGroups() {
- return groups;
- }
-
- @Override
- public Object getCacheKey() {
- return groups.getKnownGroups();
- }
-}
diff --git a/java/com/google/gerrit/server/restapi/account/PostWatchedProjects.java b/java/com/google/gerrit/server/restapi/account/PostWatchedProjects.java
index c80bf57..5979b2a 100644
--- a/java/com/google/gerrit/server/restapi/account/PostWatchedProjects.java
+++ b/java/com/google/gerrit/server/restapi/account/PostWatchedProjects.java
@@ -91,7 +91,7 @@
throws RestApiException, IOException, PermissionBackendException {
Map<ProjectWatchKey, Set<NotifyType>> m = new HashMap<>();
for (ProjectWatchInfo info : input) {
- if (info.project == null) {
+ if (info.project == null || info.project.trim().isEmpty()) {
throw new BadRequestException("project name must be specified");
}
diff --git a/java/com/google/gerrit/server/restapi/change/CommentJson.java b/java/com/google/gerrit/server/restapi/change/CommentJson.java
index 4de9b63..cc8ad47 100644
--- a/java/com/google/gerrit/server/restapi/change/CommentJson.java
+++ b/java/com/google/gerrit/server/restapi/change/CommentJson.java
@@ -20,9 +20,12 @@
import com.google.common.base.Strings;
import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableMap;
import com.google.common.collect.Streams;
import com.google.gerrit.common.Nullable;
+import com.google.gerrit.entities.Change;
import com.google.gerrit.entities.Comment;
+import com.google.gerrit.entities.CommentContext;
import com.google.gerrit.entities.FixReplacement;
import com.google.gerrit.entities.FixSuggestion;
import com.google.gerrit.entities.HumanComment;
@@ -31,15 +34,18 @@
import com.google.gerrit.extensions.client.Comment.Range;
import com.google.gerrit.extensions.client.Side;
import com.google.gerrit.extensions.common.CommentInfo;
+import com.google.gerrit.extensions.common.ContextLineInfo;
import com.google.gerrit.extensions.common.FixReplacementInfo;
import com.google.gerrit.extensions.common.FixSuggestionInfo;
import com.google.gerrit.extensions.common.RobotCommentInfo;
import com.google.gerrit.extensions.restapi.Url;
-import com.google.gerrit.server.CommentContextLoader;
import com.google.gerrit.server.account.AccountLoader;
+import com.google.gerrit.server.comment.CommentContextCache;
+import com.google.gerrit.server.comment.CommentContextKey;
import com.google.gerrit.server.permissions.PermissionBackendException;
import com.google.inject.Inject;
import java.util.ArrayList;
+import java.util.Collection;
import java.util.List;
import java.util.Map;
import java.util.TreeMap;
@@ -47,18 +53,19 @@
public class CommentJson {
private final AccountLoader.Factory accountLoaderFactory;
+ private final CommentContextCache commentContextCache;
+
+ private Project.NameKey project;
+ private Change.Id changeId;
private boolean fillAccounts = true;
private boolean fillPatchSet;
- private CommentContextLoader.Factory commentContextLoaderFactory;
- private CommentContextLoader commentContextLoader;
+ private boolean fillCommentContext;
@Inject
- CommentJson(
- AccountLoader.Factory accountLoaderFactory,
- CommentContextLoader.Factory commentContextLoaderFactory) {
+ CommentJson(AccountLoader.Factory accountLoaderFactory, CommentContextCache commentContextCache) {
this.accountLoaderFactory = accountLoaderFactory;
- this.commentContextLoaderFactory = commentContextLoaderFactory;
+ this.commentContextCache = commentContextCache;
}
CommentJson setFillAccounts(boolean fillAccounts) {
@@ -71,10 +78,18 @@
return this;
}
- CommentJson setEnableContext(boolean enableContext, Project.NameKey project) {
- if (enableContext) {
- this.commentContextLoader = commentContextLoaderFactory.create(project);
- }
+ CommentJson setFillCommentContext(boolean fillCommentContext) {
+ this.fillCommentContext = fillCommentContext;
+ return this;
+ }
+
+ CommentJson setProjectKey(Project.NameKey project) {
+ this.project = project;
+ return this;
+ }
+
+ CommentJson setChangeId(Change.Id changeId) {
+ this.changeId = changeId;
return this;
}
@@ -93,9 +108,6 @@
if (loader != null) {
loader.fill();
}
- if (commentContextLoader != null) {
- commentContextLoader.fill();
- }
return info;
}
@@ -120,8 +132,10 @@
if (loader != null) {
loader.fill();
}
- if (commentContextLoader != null) {
- commentContextLoader.fill();
+
+ if (fillCommentContext) {
+ List<T> allComments = out.values().stream().flatMap(Collection::stream).collect(toList());
+ addCommentContext(allComments);
}
return out;
}
@@ -138,12 +152,41 @@
if (loader != null) {
loader.fill();
}
- if (commentContextLoader != null) {
- commentContextLoader.fill();
+
+ if (fillCommentContext) {
+ addCommentContext(out);
}
+
return out;
}
+ protected void addCommentContext(List<T> allComments) {
+ List<CommentContextKey> keys =
+ allComments.stream().map(this::createCommentContextKey).collect(toList());
+ ImmutableMap<CommentContextKey, CommentContext> allContext = commentContextCache.getAll(keys);
+ for (T c : allComments) {
+ c.contextLines = toContextLineInfoList(allContext.get(createCommentContextKey(c)));
+ }
+ }
+
+ protected List<ContextLineInfo> toContextLineInfoList(CommentContext commentContext) {
+ List<ContextLineInfo> result = new ArrayList<>();
+ for (Map.Entry<Integer, String> e : commentContext.lines().entrySet()) {
+ result.add(new ContextLineInfo(e.getKey(), e.getValue()));
+ }
+ return result;
+ }
+
+ protected CommentContextKey createCommentContextKey(T r) {
+ return CommentContextKey.builder()
+ .project(project)
+ .changeId(changeId)
+ .id(r.id)
+ .path(r.path)
+ .patchset(r.patchSet)
+ .build();
+ }
+
protected abstract T toInfo(F comment, AccountLoader loader);
protected void fillCommentInfo(Comment c, CommentInfo r, AccountLoader loader) {
@@ -170,9 +213,6 @@
r.author = loader.get(c.author.getId());
}
r.commitId = c.getCommitId().getName();
- if (commentContextLoader != null) {
- r.contextLines = commentContextLoader.getContext(r);
- }
}
protected Range toRange(Comment.Range commentRange) {
diff --git a/java/com/google/gerrit/server/restapi/change/CreateMergePatchSet.java b/java/com/google/gerrit/server/restapi/change/CreateMergePatchSet.java
index 8ac2140..af4bf69 100644
--- a/java/com/google/gerrit/server/restapi/change/CreateMergePatchSet.java
+++ b/java/com/google/gerrit/server/restapi/change/CreateMergePatchSet.java
@@ -55,6 +55,7 @@
import com.google.gerrit.server.permissions.ChangePermission;
import com.google.gerrit.server.permissions.PermissionBackend;
import com.google.gerrit.server.permissions.PermissionBackendException;
+import com.google.gerrit.server.permissions.RefPermission;
import com.google.gerrit.server.project.ProjectCache;
import com.google.gerrit.server.project.ProjectState;
import com.google.gerrit.server.restapi.project.CommitsCollection;
@@ -128,6 +129,13 @@
psUtil.checkPatchSetNotLocked(rsrc.getNotes());
rsrc.permissions().check(ChangePermission.ADD_PATCH_SET);
+ if (in.author != null) {
+ permissionBackend
+ .currentUser()
+ .project(rsrc.getProject())
+ .ref(rsrc.getChange().getDest().branch())
+ .check(RefPermission.FORGE_AUTHOR);
+ }
ProjectState projectState =
projectCache.get(rsrc.getProject()).orElseThrow(illegalState(rsrc.getProject()));
@@ -137,6 +145,10 @@
if (merge == null || Strings.isNullOrEmpty(merge.source)) {
throw new BadRequestException("merge.source must be non-empty");
}
+ if (in.author != null
+ && (Strings.isNullOrEmpty(in.author.email) || Strings.isNullOrEmpty(in.author.name))) {
+ throw new BadRequestException("Author must specify name and email");
+ }
in.baseChange = Strings.nullToEmpty(in.baseChange).trim();
PatchSet ps = psUtil.current(rsrc.getNotes());
@@ -166,7 +178,10 @@
Timestamp now = TimeUtil.nowTs();
IdentifiedUser me = user.get().asIdentifiedUser();
- PersonIdent author = me.newCommitterIdent(now, serverTimeZone);
+ PersonIdent author =
+ in.author == null
+ ? me.newCommitterIdent(now, serverTimeZone)
+ : new PersonIdent(in.author.name, in.author.email, now, serverTimeZone);
CodeReviewCommit newCommit =
createMergeCommit(
in,
diff --git a/java/com/google/gerrit/server/restapi/change/ListChangeComments.java b/java/com/google/gerrit/server/restapi/change/ListChangeComments.java
index e3b433c..fa7c1f5 100644
--- a/java/com/google/gerrit/server/restapi/change/ListChangeComments.java
+++ b/java/com/google/gerrit/server/restapi/change/ListChangeComments.java
@@ -19,7 +19,6 @@
import com.google.common.collect.ImmutableList;
import com.google.gerrit.entities.ChangeMessage;
import com.google.gerrit.entities.HumanComment;
-import com.google.gerrit.entities.Project;
import com.google.gerrit.extensions.common.CommentInfo;
import com.google.gerrit.extensions.common.ContextLineInfo;
import com.google.gerrit.extensions.restapi.AuthException;
@@ -84,8 +83,7 @@
private ImmutableList<CommentInfo> getAsList(Iterable<HumanComment> comments, ChangeResource rsrc)
throws PermissionBackendException {
- ImmutableList<CommentInfo> commentInfos =
- getCommentFormatter(rsrc.getProject()).formatAsList(comments);
+ ImmutableList<CommentInfo> commentInfos = getCommentFormatter(rsrc).formatAsList(comments);
List<ChangeMessage> changeMessages = changeMessagesUtil.byChange(rsrc.getNotes());
CommentsUtil.linkCommentsToChangeMessages(commentInfos, changeMessages, true);
return commentInfos;
@@ -93,8 +91,7 @@
private Map<String, List<CommentInfo>> getAsMap(
Iterable<HumanComment> comments, ChangeResource rsrc) throws PermissionBackendException {
- Map<String, List<CommentInfo>> commentInfosMap =
- getCommentFormatter(rsrc.getProject()).format(comments);
+ Map<String, List<CommentInfo>> commentInfosMap = getCommentFormatter(rsrc).format(comments);
List<CommentInfo> commentInfos =
commentInfosMap.values().stream().flatMap(List::stream).collect(toList());
List<ChangeMessage> changeMessages = changeMessagesUtil.byChange(rsrc.getNotes());
@@ -102,12 +99,14 @@
return commentInfosMap;
}
- private CommentJson.HumanCommentFormatter getCommentFormatter(Project.NameKey project) {
+ private CommentJson.HumanCommentFormatter getCommentFormatter(ChangeResource rsrc) {
return commentJson
.get()
.setFillAccounts(true)
.setFillPatchSet(true)
- .setEnableContext(includeContext, project)
+ .setFillCommentContext(includeContext)
+ .setProjectKey(rsrc.getProject())
+ .setChangeId(rsrc.getId())
.newHumanCommentFormatter();
}
}
diff --git a/java/com/google/gerrit/server/restapi/project/CheckAccess.java b/java/com/google/gerrit/server/restapi/project/CheckAccess.java
index 037a953..4ef724a 100644
--- a/java/com/google/gerrit/server/restapi/project/CheckAccess.java
+++ b/java/com/google/gerrit/server/restapi/project/CheckAccess.java
@@ -28,6 +28,7 @@
import com.google.gerrit.extensions.restapi.RestModifyView;
import com.google.gerrit.server.account.AccountResolver;
import com.google.gerrit.server.git.GitRepositoryManager;
+import com.google.gerrit.server.logging.TraceContext;
import com.google.gerrit.server.permissions.DefaultPermissionMappings;
import com.google.gerrit.server.permissions.GlobalPermission;
import com.google.gerrit.server.permissions.PermissionBackend;
@@ -73,60 +74,74 @@
throw new BadRequestException("input requires 'account'");
}
- Account.Id match = accountResolver.resolve(input.account).asUnique().account().id();
+ try (TraceContext traceContext = TraceContext.open()) {
+ traceContext.enableAclLogging();
- AccessCheckInfo info = new AccessCheckInfo();
- try {
- permissionBackend
- .absentUser(match)
- .project(rsrc.getNameKey())
- .check(ProjectPermission.ACCESS);
- } catch (AuthException e) {
- info.message = String.format("user %s cannot see project %s", match, rsrc.getName());
- info.status = HttpServletResponse.SC_FORBIDDEN;
- return Response.ok(info);
- }
+ Account.Id match = accountResolver.resolve(input.account).asUnique().account().id();
- RefPermission refPerm;
- if (!Strings.isNullOrEmpty(input.permission)) {
- if (Strings.isNullOrEmpty(input.ref)) {
- throw new BadRequestException("must set 'ref' when specifying 'permission'");
- }
- Optional<RefPermission> rp = DefaultPermissionMappings.refPermission(input.permission);
- if (!rp.isPresent()) {
- throw new BadRequestException(
- String.format("'%s' is not recognized as ref permission", input.permission));
- }
-
- refPerm = rp.get();
- } else {
- refPerm = RefPermission.READ;
- }
-
- if (!Strings.isNullOrEmpty(input.ref)) {
try {
permissionBackend
.absentUser(match)
- .ref(BranchNameKey.create(rsrc.getNameKey(), input.ref))
- .check(refPerm);
+ .project(rsrc.getNameKey())
+ .check(ProjectPermission.ACCESS);
} catch (AuthException e) {
- info.status = HttpServletResponse.SC_FORBIDDEN;
- info.message =
- String.format(
- "user %s lacks permission %s for %s in project %s",
- match, input.permission, input.ref, rsrc.getName());
- return Response.ok(info);
+ return Response.ok(
+ createInfo(
+ traceContext,
+ HttpServletResponse.SC_FORBIDDEN,
+ String.format("user %s cannot see project %s", match, rsrc.getName())));
}
- } else {
- // We say access is okay if there are no refs, but this warrants a warning,
- // as access denied looks the same as no branches to the user.
- try (Repository repo = gitRepositoryManager.openRepository(rsrc.getNameKey())) {
- if (repo.getRefDatabase().getRefsByPrefix(REFS_HEADS).isEmpty()) {
- info.message = "access is OK, but repository has no branches under refs/heads/";
+
+ RefPermission refPerm;
+ if (!Strings.isNullOrEmpty(input.permission)) {
+ if (Strings.isNullOrEmpty(input.ref)) {
+ throw new BadRequestException("must set 'ref' when specifying 'permission'");
+ }
+ Optional<RefPermission> rp = DefaultPermissionMappings.refPermission(input.permission);
+ if (!rp.isPresent()) {
+ throw new BadRequestException(
+ String.format("'%s' is not recognized as ref permission", input.permission));
+ }
+
+ refPerm = rp.get();
+ } else {
+ refPerm = RefPermission.READ;
+ }
+
+ String message = null;
+ if (!Strings.isNullOrEmpty(input.ref)) {
+ try {
+ permissionBackend
+ .absentUser(match)
+ .ref(BranchNameKey.create(rsrc.getNameKey(), input.ref))
+ .check(refPerm);
+ } catch (AuthException e) {
+ return Response.ok(
+ createInfo(
+ traceContext,
+ HttpServletResponse.SC_FORBIDDEN,
+ String.format(
+ "user %s lacks permission %s for %s in project %s",
+ match, input.permission, input.ref, rsrc.getName())));
+ }
+ } else {
+ // We say access is okay if there are no refs, but this warrants a warning,
+ // as access denied looks the same as no branches to the user.
+ try (Repository repo = gitRepositoryManager.openRepository(rsrc.getNameKey())) {
+ if (repo.getRefDatabase().getRefsByPrefix(REFS_HEADS).isEmpty()) {
+ message = "access is OK, but repository has no branches under refs/heads/";
+ }
}
}
+ return Response.ok(createInfo(traceContext, HttpServletResponse.SC_OK, message));
}
- info.status = HttpServletResponse.SC_OK;
- return Response.ok(info);
+ }
+
+ private AccessCheckInfo createInfo(TraceContext traceContext, int statusCode, String message) {
+ AccessCheckInfo info = new AccessCheckInfo();
+ info.status = statusCode;
+ info.message = message;
+ info.debugLogs = traceContext.getAclLogRecords();
+ return info;
}
}
diff --git a/java/com/google/gerrit/server/submit/MergeOp.java b/java/com/google/gerrit/server/submit/MergeOp.java
index 8a293a4..45e544d2 100644
--- a/java/com/google/gerrit/server/submit/MergeOp.java
+++ b/java/com/google/gerrit/server/submit/MergeOp.java
@@ -232,7 +232,7 @@
private final SubmitStrategyFactory submitStrategyFactory;
private final SubscriptionGraph.Factory subscriptionGraphFactory;
private final SubmoduleCommits.Factory submoduleCommitsFactory;
- private final SubmissionListener superprojectUpdateSubmissionListener;
+ private final ImmutableList<SubmissionListener> superprojectUpdateSubmissionListeners;
private final Provider<MergeOpRepoManager> ormProvider;
private final NotifyResolver notifyResolver;
private final RetryHelper retryHelper;
@@ -264,7 +264,8 @@
SubmitStrategyFactory submitStrategyFactory,
SubmoduleCommits.Factory submoduleCommitsFactory,
SubscriptionGraph.Factory subscriptionGraphFactory,
- @SuperprojectUpdateOnSubmission SubmissionListener superprojectUpdateSubmissionListener,
+ @SuperprojectUpdateOnSubmission
+ ImmutableList<SubmissionListener> superprojectUpdateSubmissionListeners,
Provider<MergeOpRepoManager> ormProvider,
NotifyResolver notifyResolver,
TopicMetrics topicMetrics,
@@ -279,7 +280,7 @@
this.submitStrategyFactory = submitStrategyFactory;
this.submoduleCommitsFactory = submoduleCommitsFactory;
this.subscriptionGraphFactory = subscriptionGraphFactory;
- this.superprojectUpdateSubmissionListener = superprojectUpdateSubmissionListener;
+ this.superprojectUpdateSubmissionListeners = superprojectUpdateSubmissionListeners;
this.ormProvider = ormProvider;
this.notifyResolver = notifyResolver;
this.retryHelper = retryHelper;
@@ -498,7 +499,7 @@
}
SubmissionExecutor submissionExecutor =
- new SubmissionExecutor(dryrun, superprojectUpdateSubmissionListener);
+ new SubmissionExecutor(dryrun, superprojectUpdateSubmissionListeners);
RetryTracker retryTracker = new RetryTracker();
retryHelper
.changeUpdate(
diff --git a/java/com/google/gerrit/server/submit/SubmitStrategyOp.java b/java/com/google/gerrit/server/submit/SubmitStrategyOp.java
index 3b77dd9..3430047 100644
--- a/java/com/google/gerrit/server/submit/SubmitStrategyOp.java
+++ b/java/com/google/gerrit/server/submit/SubmitStrategyOp.java
@@ -461,9 +461,12 @@
// If we naively execute postUpdate even if the change is already merged when updateChange
// being, then we are subject to a race where postUpdate steps are run twice if two submit
// processes run at the same time.
- logger.atFine().log("Skipping post-update steps for change %s", getId());
+ logger.atFine().log(
+ "Skipping post-update steps for change %s; submitter is %s", getId(), submitter);
return;
}
+ logger.atFine().log(
+ "Begin post-update steps for change %s; submitter is %s", getId(), submitter);
postUpdateImpl(ctx);
if (command != null) {
@@ -483,6 +486,9 @@
}
}
+ logger.atFine().log(
+ "Begin sending emails for submitting change %s; submitter is %s", getId(), submitter);
+
// Assume the change must have been merged at this point, otherwise we would
// have failed fast in one of the other steps.
try {
diff --git a/java/com/google/gerrit/server/update/SubmissionExecutor.java b/java/com/google/gerrit/server/update/SubmissionExecutor.java
index 5a3a789..39eda58 100644
--- a/java/com/google/gerrit/server/update/SubmissionExecutor.java
+++ b/java/com/google/gerrit/server/update/SubmissionExecutor.java
@@ -17,7 +17,6 @@
import com.google.common.collect.ImmutableList;
import com.google.gerrit.extensions.restapi.RestApiException;
import com.google.gerrit.server.submit.MergeOpRepoManager;
-import java.util.Arrays;
import java.util.Collection;
import java.util.Optional;
import java.util.stream.Collectors;
@@ -28,14 +27,9 @@
private final boolean dryrun;
private ImmutableList<BatchUpdateListener> additionalListeners = ImmutableList.of();
- public SubmissionExecutor(
- boolean dryrun, SubmissionListener listener, SubmissionListener... otherListeners) {
+ public SubmissionExecutor(boolean dryrun, ImmutableList<SubmissionListener> submissionListeners) {
this.dryrun = dryrun;
- this.submissionListeners =
- ImmutableList.<SubmissionListener>builder()
- .add(listener)
- .addAll(Arrays.asList(otherListeners))
- .build();
+ this.submissionListeners = submissionListeners;
if (dryrun) {
submissionListeners.forEach(SubmissionListener::setDryrun);
}
diff --git a/java/com/google/gerrit/server/update/SuperprojectUpdateSubmissionListener.java b/java/com/google/gerrit/server/update/SuperprojectUpdateSubmissionListener.java
index dffdff0..4c65c80 100644
--- a/java/com/google/gerrit/server/update/SuperprojectUpdateSubmissionListener.java
+++ b/java/com/google/gerrit/server/update/SuperprojectUpdateSubmissionListener.java
@@ -21,6 +21,7 @@
import com.google.gerrit.server.submit.MergeOpRepoManager;
import com.google.gerrit.server.submit.SubmoduleOp;
import com.google.inject.AbstractModule;
+import com.google.inject.Provides;
import java.util.Collection;
import java.util.HashMap;
import java.util.Map;
@@ -39,11 +40,11 @@
private boolean dryrun;
public static class Module extends AbstractModule {
- @Override
- protected void configure() {
- bind(SubmissionListener.class)
- .annotatedWith(SuperprojectUpdateOnSubmission.class)
- .to(SuperprojectUpdateSubmissionListener.class);
+ @Provides
+ @SuperprojectUpdateOnSubmission
+ ImmutableList<SubmissionListener> provideSubmissionListeners(
+ SuperprojectUpdateSubmissionListener listener) {
+ return ImmutableList.of(listener);
}
}
diff --git a/java/com/google/gerrit/sshd/AbstractGitCommand.java b/java/com/google/gerrit/sshd/AbstractGitCommand.java
index 8bf6cd5..9efcff2 100644
--- a/java/com/google/gerrit/sshd/AbstractGitCommand.java
+++ b/java/com/google/gerrit/sshd/AbstractGitCommand.java
@@ -52,6 +52,7 @@
@Override
public void start(ChannelSession channel, Environment env) {
+ enableGracefulStop();
String gitProtocol = env.getEnv().get(GIT_PROTOCOL);
if (gitProtocol != null) {
extraParameters = gitProtocol.split(":");
diff --git a/java/com/google/gerrit/sshd/BaseCommand.java b/java/com/google/gerrit/sshd/BaseCommand.java
index ab1f062..a027dd1 100644
--- a/java/com/google/gerrit/sshd/BaseCommand.java
+++ b/java/com/google/gerrit/sshd/BaseCommand.java
@@ -403,6 +403,10 @@
}
}
+ protected void enableGracefulStop() {
+ context.getSession().setGracefulStop(true);
+ }
+
protected String getTaskDescription() {
String[] ta = getTrimmedArguments();
if (ta != null) {
diff --git a/java/com/google/gerrit/sshd/SshDaemon.java b/java/com/google/gerrit/sshd/SshDaemon.java
index c43bf91..c14ebd8 100644
--- a/java/com/google/gerrit/sshd/SshDaemon.java
+++ b/java/com/google/gerrit/sshd/SshDaemon.java
@@ -90,6 +90,7 @@
import org.apache.sshd.common.random.Random;
import org.apache.sshd.common.random.SingletonRandomFactory;
import org.apache.sshd.common.session.Session;
+import org.apache.sshd.common.session.helpers.AbstractSession;
import org.apache.sshd.common.session.helpers.DefaultUnknownChannelReferenceHandler;
import org.apache.sshd.common.util.buffer.Buffer;
import org.apache.sshd.common.util.buffer.ByteArrayBuffer;
@@ -369,14 +370,24 @@
Collection<IoSession> ioSessions = daemonAcceptor.getManagedSessions().values();
CountDownLatch allSessionsClosed = new CountDownLatch(ioSessions.size());
for (IoSession io : ioSessions) {
- logger.atFine().log("Waiting for session %s to stop.", io.getId());
- io.addCloseFutureListener(
- new SshFutureListener<CloseFuture>() {
- @Override
- public void operationComplete(CloseFuture future) {
- allSessionsClosed.countDown();
- }
- });
+ AbstractSession serverSession = AbstractSession.getSession(io, true);
+ SshSession sshSession =
+ serverSession != null ? serverSession.getAttribute(SshSession.KEY) : null;
+ if (sshSession != null && sshSession.requiresGracefulStop()) {
+ logger.atFine().log("Waiting for session %s to stop.", io.getId());
+ io.addCloseFutureListener(
+ new SshFutureListener<CloseFuture>() {
+ @Override
+ public void operationComplete(CloseFuture future) {
+ logger.atFine().log("Session %s was stopped.", io.getId());
+ allSessionsClosed.countDown();
+ }
+ });
+ } else {
+ logger.atFine().log("Stopping session %s immediately.", io.getId());
+ io.close(true);
+ allSessionsClosed.countDown();
+ }
}
try {
if (!allSessionsClosed.await(gracefulStopTimeout, TimeUnit.SECONDS)) {
diff --git a/java/com/google/gerrit/sshd/SshSession.java b/java/com/google/gerrit/sshd/SshSession.java
index d6ecc73..b39eaed 100644
--- a/java/com/google/gerrit/sshd/SshSession.java
+++ b/java/com/google/gerrit/sshd/SshSession.java
@@ -35,6 +35,8 @@
private volatile String authError;
private volatile String peerAgent;
+ private volatile boolean gracefulStop = false;
+
SshSession(int sessionId, SocketAddress peer) {
this.sessionId = sessionId;
this.remoteAddress = peer;
@@ -58,6 +60,14 @@
return sessionId;
}
+ public boolean requiresGracefulStop() {
+ return gracefulStop;
+ }
+
+ public void setGracefulStop(boolean gracefulStop) {
+ this.gracefulStop = gracefulStop;
+ }
+
/** Identity of the authenticated user account on the socket. */
public CurrentUser getUser() {
return identity;
diff --git a/java/com/google/gerrit/sshd/commands/AproposCommand.java b/java/com/google/gerrit/sshd/commands/AproposCommand.java
index d3db70d..e7a88a1 100644
--- a/java/com/google/gerrit/sshd/commands/AproposCommand.java
+++ b/java/com/google/gerrit/sshd/commands/AproposCommand.java
@@ -39,6 +39,7 @@
@Override
public void run() throws Exception {
+ enableGracefulStop();
try {
List<QueryDocumentationExecutor.DocResult> res = searcher.doQuery(q);
for (DocResult docResult : res) {
diff --git a/java/com/google/gerrit/sshd/commands/BanCommitCommand.java b/java/com/google/gerrit/sshd/commands/BanCommitCommand.java
index ee6f635..134fb03 100644
--- a/java/com/google/gerrit/sshd/commands/BanCommitCommand.java
+++ b/java/com/google/gerrit/sshd/commands/BanCommitCommand.java
@@ -63,6 +63,7 @@
@Override
protected void run() throws Failure {
+ enableGracefulStop();
try {
BanCommitInput input =
BanCommitInput.fromCommits(Lists.transform(commitsToBan, ObjectId::getName));
diff --git a/java/com/google/gerrit/sshd/commands/BaseTestPrologCommand.java b/java/com/google/gerrit/sshd/commands/BaseTestPrologCommand.java
index d70c153..ad8e20d 100644
--- a/java/com/google/gerrit/sshd/commands/BaseTestPrologCommand.java
+++ b/java/com/google/gerrit/sshd/commands/BaseTestPrologCommand.java
@@ -59,6 +59,7 @@
@Override
protected final void run() throws UnloggedFailure {
+ enableGracefulStop();
try {
RevisionResource revision =
revisions.parse(
diff --git a/java/com/google/gerrit/sshd/commands/CloseConnection.java b/java/com/google/gerrit/sshd/commands/CloseConnection.java
index 093f647..e0b87f8 100644
--- a/java/com/google/gerrit/sshd/commands/CloseConnection.java
+++ b/java/com/google/gerrit/sshd/commands/CloseConnection.java
@@ -57,6 +57,7 @@
@Override
protected void run() throws Failure {
+ enableGracefulStop();
SshUtil.forEachSshSession(
sshDaemon,
(k, sshSession, abstractSession, ioSession) -> {
diff --git a/java/com/google/gerrit/sshd/commands/CreateAccountCommand.java b/java/com/google/gerrit/sshd/commands/CreateAccountCommand.java
index 004a0ba..4da55e2 100644
--- a/java/com/google/gerrit/sshd/commands/CreateAccountCommand.java
+++ b/java/com/google/gerrit/sshd/commands/CreateAccountCommand.java
@@ -72,6 +72,7 @@
@Override
protected void run()
throws IOException, ConfigInvalidException, UnloggedFailure, PermissionBackendException {
+ enableGracefulStop();
AccountInput input = new AccountInput();
input.username = username;
input.email = email;
diff --git a/java/com/google/gerrit/sshd/commands/CreateBranchCommand.java b/java/com/google/gerrit/sshd/commands/CreateBranchCommand.java
index aad96a1..a837ecd 100644
--- a/java/com/google/gerrit/sshd/commands/CreateBranchCommand.java
+++ b/java/com/google/gerrit/sshd/commands/CreateBranchCommand.java
@@ -44,6 +44,7 @@
@Override
protected void run() throws UnloggedFailure {
+ enableGracefulStop();
try {
BranchInput in = new BranchInput();
in.revision = revision;
diff --git a/java/com/google/gerrit/sshd/commands/CreateGroupCommand.java b/java/com/google/gerrit/sshd/commands/CreateGroupCommand.java
index 17f80c0..5fd2297 100644
--- a/java/com/google/gerrit/sshd/commands/CreateGroupCommand.java
+++ b/java/com/google/gerrit/sshd/commands/CreateGroupCommand.java
@@ -102,6 +102,7 @@
@Override
protected void run()
throws Failure, IOException, ConfigInvalidException, PermissionBackendException {
+ enableGracefulStop();
try {
GroupResource rsrc = createGroup();
diff --git a/java/com/google/gerrit/sshd/commands/CreateProjectCommand.java b/java/com/google/gerrit/sshd/commands/CreateProjectCommand.java
index fca7427..f2ab4e8 100644
--- a/java/com/google/gerrit/sshd/commands/CreateProjectCommand.java
+++ b/java/com/google/gerrit/sshd/commands/CreateProjectCommand.java
@@ -166,6 +166,7 @@
@Override
protected void run() throws Failure {
+ enableGracefulStop();
try {
if (!suggestParent) {
if (projectName == null) {
diff --git a/java/com/google/gerrit/sshd/commands/FlushCaches.java b/java/com/google/gerrit/sshd/commands/FlushCaches.java
index 2afc009..fe2a897 100644
--- a/java/com/google/gerrit/sshd/commands/FlushCaches.java
+++ b/java/com/google/gerrit/sshd/commands/FlushCaches.java
@@ -55,6 +55,7 @@
@Override
protected void run() throws Failure {
+ enableGracefulStop();
try {
if (list) {
if (all || !caches.isEmpty()) {
diff --git a/java/com/google/gerrit/sshd/commands/GarbageCollectionCommand.java b/java/com/google/gerrit/sshd/commands/GarbageCollectionCommand.java
index 2073087..28a7804 100644
--- a/java/com/google/gerrit/sshd/commands/GarbageCollectionCommand.java
+++ b/java/com/google/gerrit/sshd/commands/GarbageCollectionCommand.java
@@ -62,6 +62,7 @@
@Override
public void run() throws Exception {
+ enableGracefulStop();
verifyCommandLine();
runGC();
}
diff --git a/java/com/google/gerrit/sshd/commands/IndexActivateCommand.java b/java/com/google/gerrit/sshd/commands/IndexActivateCommand.java
index 0804d08..30dc5c4 100644
--- a/java/com/google/gerrit/sshd/commands/IndexActivateCommand.java
+++ b/java/com/google/gerrit/sshd/commands/IndexActivateCommand.java
@@ -34,6 +34,7 @@
@Override
protected void run() throws UnloggedFailure {
+ enableGracefulStop();
try {
if (versionManager.isKnownIndex(name)) {
if (versionManager.activateLatestIndex(name)) {
diff --git a/java/com/google/gerrit/sshd/commands/IndexChangesCommand.java b/java/com/google/gerrit/sshd/commands/IndexChangesCommand.java
index fb62b48..1fb0e13 100644
--- a/java/com/google/gerrit/sshd/commands/IndexChangesCommand.java
+++ b/java/com/google/gerrit/sshd/commands/IndexChangesCommand.java
@@ -52,6 +52,7 @@
@Override
protected void run() throws UnloggedFailure {
+ enableGracefulStop();
boolean ok = true;
for (ChangeResource rsrc : changes.values()) {
try {
diff --git a/java/com/google/gerrit/sshd/commands/IndexChangesInProjectCommand.java b/java/com/google/gerrit/sshd/commands/IndexChangesInProjectCommand.java
index 56b00a5..168dc19 100644
--- a/java/com/google/gerrit/sshd/commands/IndexChangesInProjectCommand.java
+++ b/java/com/google/gerrit/sshd/commands/IndexChangesInProjectCommand.java
@@ -43,6 +43,7 @@
@Override
protected void run() throws UnloggedFailure, Failure, Exception {
+ enableGracefulStop();
if (projects.isEmpty()) {
throw die("needs at least one project as command arguments");
}
diff --git a/java/com/google/gerrit/sshd/commands/IndexStartCommand.java b/java/com/google/gerrit/sshd/commands/IndexStartCommand.java
index f3d349c..5433b17 100644
--- a/java/com/google/gerrit/sshd/commands/IndexStartCommand.java
+++ b/java/com/google/gerrit/sshd/commands/IndexStartCommand.java
@@ -38,6 +38,7 @@
@Override
protected void run() throws UnloggedFailure {
+ enableGracefulStop();
try {
if (versionManager.isKnownIndex(name)) {
if (versionManager.startReindexer(name, force)) {
diff --git a/java/com/google/gerrit/sshd/commands/KillCommand.java b/java/com/google/gerrit/sshd/commands/KillCommand.java
index df74f86..a633a8a 100644
--- a/java/com/google/gerrit/sshd/commands/KillCommand.java
+++ b/java/com/google/gerrit/sshd/commands/KillCommand.java
@@ -47,6 +47,7 @@
@Override
protected void run() {
+ enableGracefulStop();
ConfigResource cfgRsrc = new ConfigResource();
for (String id : taskIds) {
try {
diff --git a/java/com/google/gerrit/sshd/commands/ListGroupsCommand.java b/java/com/google/gerrit/sshd/commands/ListGroupsCommand.java
index bdf5412..7bf42eb 100644
--- a/java/com/google/gerrit/sshd/commands/ListGroupsCommand.java
+++ b/java/com/google/gerrit/sshd/commands/ListGroupsCommand.java
@@ -52,6 +52,7 @@
@Override
public void run() throws Exception {
+ enableGracefulStop();
if (listGroups.getUser() != null && !listGroups.getProjects().isEmpty()) {
throw die("--user and --project options are not compatible.");
}
diff --git a/java/com/google/gerrit/sshd/commands/ListLoggingLevelCommand.java b/java/com/google/gerrit/sshd/commands/ListLoggingLevelCommand.java
index c8b8fa1..1a7be32 100644
--- a/java/com/google/gerrit/sshd/commands/ListLoggingLevelCommand.java
+++ b/java/com/google/gerrit/sshd/commands/ListLoggingLevelCommand.java
@@ -40,6 +40,7 @@
@SuppressWarnings("unchecked")
@Override
protected void run() {
+ enableGracefulStop();
Map<String, String> logs = new TreeMap<>();
for (Enumeration<Logger> logger = LogManager.getCurrentLoggers(); logger.hasMoreElements(); ) {
Logger log = logger.nextElement();
diff --git a/java/com/google/gerrit/sshd/commands/ListMembersCommand.java b/java/com/google/gerrit/sshd/commands/ListMembersCommand.java
index dc1bc6e..3269c2b 100644
--- a/java/com/google/gerrit/sshd/commands/ListMembersCommand.java
+++ b/java/com/google/gerrit/sshd/commands/ListMembersCommand.java
@@ -45,6 +45,7 @@
@Override
public void run() throws Exception {
+ enableGracefulStop();
impl.display(stdout);
}
diff --git a/java/com/google/gerrit/sshd/commands/ListProjectsCommand.java b/java/com/google/gerrit/sshd/commands/ListProjectsCommand.java
index 9f2ffa9..e711d57 100644
--- a/java/com/google/gerrit/sshd/commands/ListProjectsCommand.java
+++ b/java/com/google/gerrit/sshd/commands/ListProjectsCommand.java
@@ -32,6 +32,7 @@
@Override
public void run() throws Exception {
+ enableGracefulStop();
if (!impl.getFormat().isJson()) {
List<String> showBranch = impl.getShowBranch();
if (impl.isShowTree() && (showBranch != null) && !showBranch.isEmpty()) {
diff --git a/java/com/google/gerrit/sshd/commands/LsUserRefs.java b/java/com/google/gerrit/sshd/commands/LsUserRefs.java
index 80aee01..6eb045b 100644
--- a/java/com/google/gerrit/sshd/commands/LsUserRefs.java
+++ b/java/com/google/gerrit/sshd/commands/LsUserRefs.java
@@ -74,6 +74,7 @@
@Override
protected void run() throws Failure {
+ enableGracefulStop();
Account.Id userAccountId;
try {
userAccountId = accountResolver.resolve(userName).asUnique().account().id();
diff --git a/java/com/google/gerrit/sshd/commands/PluginAdminSshCommand.java b/java/com/google/gerrit/sshd/commands/PluginAdminSshCommand.java
index 7e32615..086081c 100644
--- a/java/com/google/gerrit/sshd/commands/PluginAdminSshCommand.java
+++ b/java/com/google/gerrit/sshd/commands/PluginAdminSshCommand.java
@@ -28,6 +28,7 @@
@Override
protected final void run() throws UnloggedFailure {
+ enableGracefulStop();
if (!loader.isRemoteAdminEnabled()) {
throw die("remote plugin administration is disabled");
}
diff --git a/java/com/google/gerrit/sshd/commands/PluginLsCommand.java b/java/com/google/gerrit/sshd/commands/PluginLsCommand.java
index 3a952f0..504b239 100644
--- a/java/com/google/gerrit/sshd/commands/PluginLsCommand.java
+++ b/java/com/google/gerrit/sshd/commands/PluginLsCommand.java
@@ -41,6 +41,7 @@
@Override
public void run() throws Exception {
+ enableGracefulStop();
Map<String, PluginInfo> output = list.apply(TopLevelResource.INSTANCE).value();
if (format.isJson()) {
diff --git a/java/com/google/gerrit/sshd/commands/Query.java b/java/com/google/gerrit/sshd/commands/Query.java
index 78485d3..772eabe 100644
--- a/java/com/google/gerrit/sshd/commands/Query.java
+++ b/java/com/google/gerrit/sshd/commands/Query.java
@@ -106,6 +106,7 @@
@Override
protected void run() throws Exception {
+ enableGracefulStop();
processor.query(join(query, " "));
}
diff --git a/java/com/google/gerrit/sshd/commands/ReloadConfig.java b/java/com/google/gerrit/sshd/commands/ReloadConfig.java
index cbe3c57..eeb48bb 100644
--- a/java/com/google/gerrit/sshd/commands/ReloadConfig.java
+++ b/java/com/google/gerrit/sshd/commands/ReloadConfig.java
@@ -38,6 +38,7 @@
@Override
protected void run() throws Failure {
+ enableGracefulStop();
Multimap<UpdateResult, ConfigUpdateEntry> updates = gerritServerConfigReloader.reloadConfig();
if (updates.isEmpty()) {
stdout.println("No config entries updated!");
diff --git a/java/com/google/gerrit/sshd/commands/RenameGroupCommand.java b/java/com/google/gerrit/sshd/commands/RenameGroupCommand.java
index 166ad68..976e7bd 100644
--- a/java/com/google/gerrit/sshd/commands/RenameGroupCommand.java
+++ b/java/com/google/gerrit/sshd/commands/RenameGroupCommand.java
@@ -46,6 +46,7 @@
@Override
protected void run() throws Failure {
+ enableGracefulStop();
try {
GroupResource rsrc = groups.parse(TopLevelResource.INSTANCE, IdString.fromDecoded(groupName));
NameInput input = new NameInput();
diff --git a/java/com/google/gerrit/sshd/commands/ReviewCommand.java b/java/com/google/gerrit/sshd/commands/ReviewCommand.java
index 78a7381..b58cc45 100644
--- a/java/com/google/gerrit/sshd/commands/ReviewCommand.java
+++ b/java/com/google/gerrit/sshd/commands/ReviewCommand.java
@@ -167,6 +167,7 @@
@Override
protected void run() throws UnloggedFailure {
+ enableGracefulStop();
if (abandonChange) {
if (restoreChange) {
throw die("abandon and restore actions are mutually exclusive");
diff --git a/java/com/google/gerrit/sshd/commands/SetAccountCommand.java b/java/com/google/gerrit/sshd/commands/SetAccountCommand.java
index df1e3ed..43a1670 100644
--- a/java/com/google/gerrit/sshd/commands/SetAccountCommand.java
+++ b/java/com/google/gerrit/sshd/commands/SetAccountCommand.java
@@ -154,6 +154,7 @@
@Override
public void run() throws Exception {
+ enableGracefulStop();
user = genericUserFactory.create(id);
validate();
diff --git a/java/com/google/gerrit/sshd/commands/SetHeadCommand.java b/java/com/google/gerrit/sshd/commands/SetHeadCommand.java
index fd7ef75..b6d283e 100644
--- a/java/com/google/gerrit/sshd/commands/SetHeadCommand.java
+++ b/java/com/google/gerrit/sshd/commands/SetHeadCommand.java
@@ -43,6 +43,7 @@
@Override
protected void run() throws Exception {
+ enableGracefulStop();
HeadInput input = new HeadInput();
input.ref = newHead;
try {
diff --git a/java/com/google/gerrit/sshd/commands/SetLoggingLevelCommand.java b/java/com/google/gerrit/sshd/commands/SetLoggingLevelCommand.java
index cfdd735..3faf598 100644
--- a/java/com/google/gerrit/sshd/commands/SetLoggingLevelCommand.java
+++ b/java/com/google/gerrit/sshd/commands/SetLoggingLevelCommand.java
@@ -61,6 +61,7 @@
@SuppressWarnings("unchecked")
@Override
protected void run() throws MalformedURLException {
+ enableGracefulStop();
if (level == LevelOption.RESET) {
reset();
} else {
diff --git a/java/com/google/gerrit/sshd/commands/SetMembersCommand.java b/java/com/google/gerrit/sshd/commands/SetMembersCommand.java
index 2511df4..db8e42a 100644
--- a/java/com/google/gerrit/sshd/commands/SetMembersCommand.java
+++ b/java/com/google/gerrit/sshd/commands/SetMembersCommand.java
@@ -102,6 +102,7 @@
@Override
protected void run() throws UnloggedFailure, Failure, Exception {
+ enableGracefulStop();
try {
for (AccountGroup.UUID groupUuid : groups) {
GroupResource resource =
diff --git a/java/com/google/gerrit/sshd/commands/SetParentCommand.java b/java/com/google/gerrit/sshd/commands/SetParentCommand.java
index 406949e..d23f7fa 100644
--- a/java/com/google/gerrit/sshd/commands/SetParentCommand.java
+++ b/java/com/google/gerrit/sshd/commands/SetParentCommand.java
@@ -90,6 +90,7 @@
@Override
protected void run() throws Failure {
+ enableGracefulStop();
if (oldParent == null && children.isEmpty()) {
throw die(
"child projects have to be specified as "
diff --git a/java/com/google/gerrit/sshd/commands/SetProjectCommand.java b/java/com/google/gerrit/sshd/commands/SetProjectCommand.java
index 8c9fc9f..9866c4e 100644
--- a/java/com/google/gerrit/sshd/commands/SetProjectCommand.java
+++ b/java/com/google/gerrit/sshd/commands/SetProjectCommand.java
@@ -132,6 +132,7 @@
@Override
protected void run() throws Failure {
+ enableGracefulStop();
ConfigInput configInput = new ConfigInput();
configInput.requireChangeId = requireChangeID;
configInput.submitType = submitType;
diff --git a/java/com/google/gerrit/sshd/commands/SetReviewersCommand.java b/java/com/google/gerrit/sshd/commands/SetReviewersCommand.java
index 5bc5537..95627e1 100644
--- a/java/com/google/gerrit/sshd/commands/SetReviewersCommand.java
+++ b/java/com/google/gerrit/sshd/commands/SetReviewersCommand.java
@@ -95,6 +95,7 @@
@Override
protected void run() throws UnloggedFailure {
+ enableGracefulStop();
boolean ok = true;
for (ChangeResource rsrc : changes.values()) {
try {
diff --git a/java/com/google/gerrit/sshd/commands/ShowCaches.java b/java/com/google/gerrit/sshd/commands/ShowCaches.java
index 1d756de..ba84179 100644
--- a/java/com/google/gerrit/sshd/commands/ShowCaches.java
+++ b/java/com/google/gerrit/sshd/commands/ShowCaches.java
@@ -112,6 +112,7 @@
@Override
protected void run() throws Failure {
+ enableGracefulStop();
nw = columns - 50;
Date now = new Date();
stdout.format(
diff --git a/java/com/google/gerrit/sshd/commands/ShowConnections.java b/java/com/google/gerrit/sshd/commands/ShowConnections.java
index decf5d5..d271364 100644
--- a/java/com/google/gerrit/sshd/commands/ShowConnections.java
+++ b/java/com/google/gerrit/sshd/commands/ShowConnections.java
@@ -86,6 +86,7 @@
@Override
protected void run() throws Failure {
+ enableGracefulStop();
final IoAcceptor acceptor = daemon.getIoAcceptor();
if (acceptor == null) {
throw new Failure(1, "fatal: sshd no longer running");
diff --git a/java/com/google/gerrit/sshd/commands/ShowQueue.java b/java/com/google/gerrit/sshd/commands/ShowQueue.java
index 2ec9e2d..779f2df 100644
--- a/java/com/google/gerrit/sshd/commands/ShowQueue.java
+++ b/java/com/google/gerrit/sshd/commands/ShowQueue.java
@@ -85,6 +85,7 @@
@Override
protected void run() throws Failure {
+ enableGracefulStop();
maxCommandWidth = wide ? Integer.MAX_VALUE : columns - 8 - 12 - 12 - 4 - 4;
stdout.print(
String.format(
diff --git a/java/com/google/gerrit/sshd/commands/VersionCommand.java b/java/com/google/gerrit/sshd/commands/VersionCommand.java
index 8fac979..f8771fb 100644
--- a/java/com/google/gerrit/sshd/commands/VersionCommand.java
+++ b/java/com/google/gerrit/sshd/commands/VersionCommand.java
@@ -25,6 +25,7 @@
@Override
protected void run() throws Failure {
+ enableGracefulStop();
String v = Version.getVersion();
if (v == null) {
throw new Failure(1, "fatal: version unavailable");
diff --git a/javatests/com/google/gerrit/acceptance/api/change/ChangeIT.java b/javatests/com/google/gerrit/acceptance/api/change/ChangeIT.java
index d4affb7..410cb41 100644
--- a/javatests/com/google/gerrit/acceptance/api/change/ChangeIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/change/ChangeIT.java
@@ -47,7 +47,6 @@
import static com.google.gerrit.extensions.client.ReviewerState.CC;
import static com.google.gerrit.extensions.client.ReviewerState.REMOVED;
import static com.google.gerrit.extensions.client.ReviewerState.REVIEWER;
-import static com.google.gerrit.git.ObjectIds.abbreviateName;
import static com.google.gerrit.server.StarredChangesUtil.DEFAULT_LABEL;
import static com.google.gerrit.server.group.SystemGroupBackend.ANONYMOUS_USERS;
import static com.google.gerrit.server.group.SystemGroupBackend.CHANGE_OWNER;
@@ -142,14 +141,11 @@
import com.google.gerrit.extensions.common.CommitInfo;
import com.google.gerrit.extensions.common.GitPerson;
import com.google.gerrit.extensions.common.LabelInfo;
-import com.google.gerrit.extensions.common.MergeInput;
-import com.google.gerrit.extensions.common.MergePatchSetInput;
import com.google.gerrit.extensions.common.RevisionInfo;
import com.google.gerrit.extensions.common.TrackingIdInfo;
import com.google.gerrit.extensions.events.WorkInProgressStateChangedListener;
import com.google.gerrit.extensions.restapi.AuthException;
import com.google.gerrit.extensions.restapi.BadRequestException;
-import com.google.gerrit.extensions.restapi.BinaryResult;
import com.google.gerrit.extensions.restapi.MethodNotAllowedException;
import com.google.gerrit.extensions.restapi.ResourceConflictException;
import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
@@ -183,7 +179,6 @@
import com.google.inject.AbstractModule;
import com.google.inject.Inject;
import com.google.inject.name.Named;
-import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.sql.Timestamp;
import java.util.ArrayList;
@@ -3189,407 +3184,6 @@
}
@Test
- public void createMergePatchSet() throws Exception {
- RevCommit initialHead = projectOperations.project(project).getHead("master");
- createBranch("dev");
-
- // create a change for master
- String changeId = createChange().getChangeId();
-
- testRepo.reset(initialHead);
- PushOneCommit.Result currentMaster = pushTo("refs/heads/master");
- currentMaster.assertOkStatus();
- String parent = currentMaster.getCommit().getName();
-
- // push a commit into dev branch
- testRepo.reset(initialHead);
- PushOneCommit.Result changeA =
- pushFactory
- .create(user.newIdent(), testRepo, "change A", "A.txt", "A content")
- .to("refs/heads/dev");
- changeA.assertOkStatus();
- MergeInput mergeInput = new MergeInput();
- mergeInput.source = "dev";
- MergePatchSetInput in = new MergePatchSetInput();
- in.merge = mergeInput;
- String subject = "update change by merge ps2";
- in.subject = subject;
-
- TestWorkInProgressStateChangedListener wipStateChangedListener =
- new TestWorkInProgressStateChangedListener();
- try (Registration registration =
- extensionRegistry.newRegistration().add(wipStateChangedListener)) {
- ChangeInfo changeInfo = gApi.changes().id(changeId).createMergePatchSet(in);
- assertThat(changeInfo.subject).isEqualTo(in.subject);
- assertThat(changeInfo.containsGitConflicts).isNull();
- assertThat(changeInfo.workInProgress).isNull();
- }
- assertThat(wipStateChangedListener.invoked).isFalse();
-
- // To get the revisions, we must retrieve the change with more change options.
- ChangeInfo changeInfo =
- gApi.changes().id(changeId).get(ALL_REVISIONS, CURRENT_COMMIT, CURRENT_REVISION);
- assertThat(changeInfo.revisions).hasSize(2);
- assertThat(changeInfo.revisions.get(changeInfo.currentRevision).commit.parents.get(0).commit)
- .isEqualTo(parent);
-
- // Verify the message that has been posted on the change.
- List<ChangeMessageInfo> messages = gApi.changes().id(changeId).messages();
- assertThat(messages).hasSize(2);
- assertThat(Iterables.getLast(messages).message).isEqualTo("Uploaded patch set 2.");
-
- assertThat(changeInfo.revisions.get(changeInfo.currentRevision).commit.message)
- .contains(subject);
- }
-
- @Test
- public void createMergePatchSet_SubjectCarriesOverByDefault() throws Exception {
- RevCommit initialHead = projectOperations.project(project).getHead("master");
- createBranch("dev");
-
- // create a change for master
- PushOneCommit.Result result = createChange();
- String changeId = result.getChangeId();
- String subject = result.getChange().change().getSubject();
-
- // push a commit into dev branch
- testRepo.reset(initialHead);
- PushOneCommit.Result pushResult =
- pushFactory.create(user.newIdent(), testRepo).to("refs/heads/dev");
- pushResult.assertOkStatus();
- MergeInput mergeInput = new MergeInput();
- mergeInput.source = "dev";
- MergePatchSetInput in = new MergePatchSetInput();
- in.merge = mergeInput;
- in.subject = null;
-
- // Ensure subject carries over
- gApi.changes().id(changeId).createMergePatchSet(in);
- ChangeInfo changeInfo = gApi.changes().id(changeId).get();
- assertThat(changeInfo.subject).isEqualTo(subject);
- }
-
- @Test
- public void createMergePatchSet_Conflict() throws Exception {
- RevCommit initialHead = projectOperations.project(project).getHead("master");
- createBranch("dev");
-
- // create a change for master
- String changeId = createChange().getChangeId();
-
- String fileName = "shared.txt";
- testRepo.reset(initialHead);
- PushOneCommit.Result currentMaster =
- pushFactory
- .create(admin.newIdent(), testRepo, "change 1", fileName, "content 1")
- .to("refs/heads/master");
- currentMaster.assertOkStatus();
-
- // push a commit into dev branch
- testRepo.reset(initialHead);
- PushOneCommit.Result changeA =
- pushFactory
- .create(user.newIdent(), testRepo, "change 2", fileName, "content 2")
- .to("refs/heads/dev");
- changeA.assertOkStatus();
- MergeInput mergeInput = new MergeInput();
- mergeInput.source = "dev";
- MergePatchSetInput in = new MergePatchSetInput();
- in.merge = mergeInput;
- in.subject = "update change by merge ps2";
- ResourceConflictException thrown =
- assertThrows(
- ResourceConflictException.class,
- () -> gApi.changes().id(changeId).createMergePatchSet(in));
- assertThat(thrown).hasMessageThat().isEqualTo("merge conflict(s):\n" + fileName);
- }
-
- @Test
- public void createMergePatchSet_ConflictAllowed() throws Exception {
- RevCommit initialHead = projectOperations.project(project).getHead("master");
- createBranch("dev");
-
- // create a change for master
- String changeId = createChange().getChangeId();
-
- String fileName = "shared.txt";
- String sourceSubject = "source change";
- String sourceContent = "source content";
- String targetSubject = "target change";
- String targetContent = "target content";
- testRepo.reset(initialHead);
- PushOneCommit.Result currentMaster =
- pushFactory
- .create(admin.newIdent(), testRepo, targetSubject, fileName, targetContent)
- .to("refs/heads/master");
- currentMaster.assertOkStatus();
- String parent = currentMaster.getCommit().getName();
-
- // push a commit into dev branch
- testRepo.reset(initialHead);
- PushOneCommit.Result changeA =
- pushFactory
- .create(user.newIdent(), testRepo, sourceSubject, fileName, sourceContent)
- .to("refs/heads/dev");
- changeA.assertOkStatus();
- MergeInput mergeInput = new MergeInput();
- mergeInput.source = "dev";
- mergeInput.allowConflicts = true;
- MergePatchSetInput in = new MergePatchSetInput();
- in.merge = mergeInput;
- in.subject = "update change by merge ps2";
-
- TestWorkInProgressStateChangedListener wipStateChangedListener =
- new TestWorkInProgressStateChangedListener();
- try (Registration registration =
- extensionRegistry.newRegistration().add(wipStateChangedListener)) {
- ChangeInfo changeInfo = gApi.changes().id(changeId).createMergePatchSet(in);
- assertThat(changeInfo.subject).isEqualTo(in.subject);
- assertThat(changeInfo.containsGitConflicts).isTrue();
- assertThat(changeInfo.workInProgress).isTrue();
- }
- assertThat(wipStateChangedListener.invoked).isTrue();
- assertThat(wipStateChangedListener.wip).isTrue();
-
- // To get the revisions, we must retrieve the change with more change options.
- ChangeInfo changeInfo =
- gApi.changes().id(changeId).get(ALL_REVISIONS, CURRENT_COMMIT, CURRENT_REVISION);
- assertThat(changeInfo.revisions).hasSize(2);
- assertThat(changeInfo.revisions.get(changeInfo.currentRevision).commit.parents.get(0).commit)
- .isEqualTo(parent);
-
- // Verify that the file content in the created patch set is correct.
- // We expect that it has conflict markers to indicate the conflict.
- BinaryResult bin = gApi.changes().id(changeId).current().file(fileName).content();
- ByteArrayOutputStream os = new ByteArrayOutputStream();
- bin.writeTo(os);
- String fileContent = new String(os.toByteArray(), UTF_8);
- String sourceSha1 = abbreviateName(changeA.getCommit(), 6);
- String targetSha1 = abbreviateName(currentMaster.getCommit(), 6);
- assertThat(fileContent)
- .isEqualTo(
- "<<<<<<< TARGET BRANCH ("
- + targetSha1
- + " "
- + targetSubject
- + ")\n"
- + targetContent
- + "\n"
- + "=======\n"
- + sourceContent
- + "\n"
- + ">>>>>>> SOURCE BRANCH ("
- + sourceSha1
- + " "
- + sourceSubject
- + ")\n");
-
- // Verify the message that has been posted on the change.
- List<ChangeMessageInfo> messages = gApi.changes().id(changeId).messages();
- assertThat(messages).hasSize(2);
- assertThat(Iterables.getLast(messages).message)
- .isEqualTo(
- "Uploaded patch set 2.\n\n"
- + "The following files contain Git conflicts:\n"
- + "* "
- + fileName
- + "\n");
- }
-
- @Test
- public void createMergePatchSet_ConflictAllowedNotSupportedByMergeStrategy() throws Exception {
- RevCommit initialHead = projectOperations.project(project).getHead("master");
- createBranch("dev");
-
- // create a change for master
- String changeId = createChange().getChangeId();
-
- String fileName = "shared.txt";
- String sourceSubject = "source change";
- String sourceContent = "source content";
- String targetSubject = "target change";
- String targetContent = "target content";
- testRepo.reset(initialHead);
- PushOneCommit.Result currentMaster =
- pushFactory
- .create(admin.newIdent(), testRepo, targetSubject, fileName, targetContent)
- .to("refs/heads/master");
- currentMaster.assertOkStatus();
-
- // push a commit into dev branch
- testRepo.reset(initialHead);
- PushOneCommit.Result changeA =
- pushFactory
- .create(user.newIdent(), testRepo, sourceSubject, fileName, sourceContent)
- .to("refs/heads/dev");
- changeA.assertOkStatus();
- MergeInput mergeInput = new MergeInput();
- mergeInput.source = "dev";
- mergeInput.allowConflicts = true;
- mergeInput.strategy = "simple-two-way-in-core";
- MergePatchSetInput in = new MergePatchSetInput();
- in.merge = mergeInput;
- in.subject = "update change by merge ps2";
-
- BadRequestException ex =
- assertThrows(
- BadRequestException.class, () -> gApi.changes().id(changeId).createMergePatchSet(in));
- assertThat(ex)
- .hasMessageThat()
- .isEqualTo(
- "merge with conflicts is not supported with merge strategy: " + mergeInput.strategy);
- }
-
- @Test
- public void createMergePatchSetInheritParent() throws Exception {
- RevCommit initialHead = projectOperations.project(project).getHead("master");
- createBranch("dev");
-
- // create a change for master
- PushOneCommit.Result r = createChange();
- String changeId = r.getChangeId();
- String parent = r.getCommit().getParent(0).getName();
-
- // advance master branch
- testRepo.reset(initialHead);
- PushOneCommit.Result currentMaster = pushTo("refs/heads/master");
- currentMaster.assertOkStatus();
-
- // push a commit into dev branch
- testRepo.reset(initialHead);
- PushOneCommit.Result changeA =
- pushFactory
- .create(user.newIdent(), testRepo, "change A", "A.txt", "A content")
- .to("refs/heads/dev");
- changeA.assertOkStatus();
- MergeInput mergeInput = new MergeInput();
- mergeInput.source = "dev";
- MergePatchSetInput in = new MergePatchSetInput();
- in.merge = mergeInput;
- in.subject = "update change by merge ps2 inherit parent of ps1";
- in.inheritParent = true;
- gApi.changes().id(changeId).createMergePatchSet(in);
- ChangeInfo changeInfo =
- gApi.changes().id(changeId).get(ALL_REVISIONS, CURRENT_COMMIT, CURRENT_REVISION);
-
- assertThat(changeInfo.revisions).hasSize(2);
- assertThat(changeInfo.subject).isEqualTo(in.subject);
- assertThat(changeInfo.revisions.get(changeInfo.currentRevision).commit.parents.get(0).commit)
- .isEqualTo(parent);
- assertThat(changeInfo.revisions.get(changeInfo.currentRevision).commit.parents.get(0).commit)
- .isNotEqualTo(currentMaster.getCommit().getName());
- }
-
- @Test
- public void createMergePatchSetCannotBaseOnInvisibleChange() throws Exception {
- RevCommit initialHead = projectOperations.project(project).getHead("master");
- createBranch("foo");
- createBranch("bar");
-
- // Create a merged commit on 'foo' branch.
- merge(createChange("refs/for/foo"));
-
- // Create the base change on 'bar' branch.
- testRepo.reset(initialHead);
- String baseChange = createChange("refs/for/bar").getChangeId();
- gApi.changes().id(baseChange).setPrivate(true, "set private");
-
- // Create the destination change on 'master' branch.
- requestScopeOperations.setApiUser(user.id());
- testRepo.reset(initialHead);
- String changeId = createChange().getChangeId();
-
- UnprocessableEntityException thrown =
- assertThrows(
- UnprocessableEntityException.class,
- () ->
- gApi.changes()
- .id(changeId)
- .createMergePatchSet(createMergePatchSetInput(baseChange)));
- assertThat(thrown).hasMessageThat().contains("Read not permitted for " + baseChange);
- }
-
- @Test
- public void createMergePatchSetBaseOnChange() throws Exception {
- RevCommit initialHead = projectOperations.project(project).getHead("master");
- createBranch("foo");
- createBranch("bar");
-
- // Create a merged commit on 'foo' branch.
- merge(createChange("refs/for/foo"));
-
- // Create the base change on 'bar' branch.
- testRepo.reset(initialHead);
- PushOneCommit.Result result = createChange("refs/for/bar");
- String baseChange = result.getChangeId();
- String expectedParent = result.getCommit().getName();
-
- // Create the destination change on 'master' branch.
- testRepo.reset(initialHead);
- String changeId = createChange().getChangeId();
-
- gApi.changes().id(changeId).createMergePatchSet(createMergePatchSetInput(baseChange));
-
- ChangeInfo changeInfo =
- gApi.changes().id(changeId).get(ALL_REVISIONS, CURRENT_COMMIT, CURRENT_REVISION);
- assertThat(changeInfo.revisions).hasSize(2);
- assertThat(changeInfo.subject).isEqualTo("create ps2");
- assertThat(changeInfo.revisions.get(changeInfo.currentRevision).commit.parents.get(0).commit)
- .isEqualTo(expectedParent);
- }
-
- @Test
- public void createMergePatchSetWithUnupportedMergeStrategy() throws Exception {
- RevCommit initialHead = projectOperations.project(project).getHead("master");
- createBranch("dev");
-
- // create a change for master
- String changeId = createChange().getChangeId();
-
- String fileName = "shared.txt";
- String sourceSubject = "source change";
- String sourceContent = "source content";
- String targetSubject = "target change";
- String targetContent = "target content";
- testRepo.reset(initialHead);
- PushOneCommit.Result currentMaster =
- pushFactory
- .create(admin.newIdent(), testRepo, targetSubject, fileName, targetContent)
- .to("refs/heads/master");
- currentMaster.assertOkStatus();
-
- // push a commit into dev branch
- testRepo.reset(initialHead);
- PushOneCommit.Result changeA =
- pushFactory
- .create(user.newIdent(), testRepo, sourceSubject, fileName, sourceContent)
- .to("refs/heads/dev");
- changeA.assertOkStatus();
- MergeInput mergeInput = new MergeInput();
- mergeInput.source = "dev";
- mergeInput.strategy = "unsupported-strategy";
- MergePatchSetInput in = new MergePatchSetInput();
- in.merge = mergeInput;
- in.subject = "update change by merge ps2";
-
- BadRequestException ex =
- assertThrows(
- BadRequestException.class, () -> gApi.changes().id(changeId).createMergePatchSet(in));
- assertThat(ex).hasMessageThat().isEqualTo("invalid merge strategy: " + mergeInput.strategy);
- }
-
- private MergePatchSetInput createMergePatchSetInput(String baseChange) {
- MergeInput mergeInput = new MergeInput();
- mergeInput.source = "foo";
- MergePatchSetInput in = new MergePatchSetInput();
- in.merge = mergeInput;
- in.subject = "create ps2";
- in.inheritParent = false;
- in.baseChange = baseChange;
- return in;
- }
-
- @Test
public void checkLabelsForUnsubmittedChange() throws Exception {
PushOneCommit.Result r = createChange();
ChangeInfo change = gApi.changes().id(r.getChangeId()).get();
diff --git a/javatests/com/google/gerrit/acceptance/api/change/CreateMergePatchSetIT.java b/javatests/com/google/gerrit/acceptance/api/change/CreateMergePatchSetIT.java
new file mode 100644
index 0000000..8b77d013
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/api/change/CreateMergePatchSetIT.java
@@ -0,0 +1,644 @@
+package com.google.gerrit.acceptance.api.change;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.allow;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.block;
+import static com.google.gerrit.extensions.client.ListChangesOption.ALL_REVISIONS;
+import static com.google.gerrit.extensions.client.ListChangesOption.CURRENT_COMMIT;
+import static com.google.gerrit.extensions.client.ListChangesOption.CURRENT_REVISION;
+import static com.google.gerrit.git.ObjectIds.abbreviateName;
+import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
+import static com.google.gerrit.testing.GerritJUnit.assertThrows;
+import static java.nio.charset.StandardCharsets.UTF_8;
+
+import com.google.common.collect.Iterables;
+import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.acceptance.ExtensionRegistry;
+import com.google.gerrit.acceptance.PushOneCommit;
+import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
+import com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate;
+import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
+import com.google.gerrit.entities.BranchNameKey;
+import com.google.gerrit.entities.Permission;
+import com.google.gerrit.extensions.api.accounts.AccountInput;
+import com.google.gerrit.extensions.common.ChangeInfo;
+import com.google.gerrit.extensions.common.ChangeMessageInfo;
+import com.google.gerrit.extensions.common.CommitInfo;
+import com.google.gerrit.extensions.common.MergeInput;
+import com.google.gerrit.extensions.common.MergePatchSetInput;
+import com.google.gerrit.extensions.events.WorkInProgressStateChangedListener;
+import com.google.gerrit.extensions.restapi.AuthException;
+import com.google.gerrit.extensions.restapi.BadRequestException;
+import com.google.gerrit.extensions.restapi.BinaryResult;
+import com.google.gerrit.extensions.restapi.ResourceConflictException;
+import com.google.gerrit.extensions.restapi.UnprocessableEntityException;
+import com.google.inject.Inject;
+import java.io.ByteArrayOutputStream;
+import java.util.List;
+import org.eclipse.jgit.revwalk.RevCommit;
+import org.junit.Before;
+import org.junit.Test;
+
+public class CreateMergePatchSetIT extends AbstractDaemonTest {
+
+ @Inject private ProjectOperations projectOperations;
+ @Inject private RequestScopeOperations requestScopeOperations;
+ @Inject private ExtensionRegistry extensionRegistry;
+
+ @Before
+ public void setUp() {
+ projectOperations
+ .project(project)
+ .forUpdate()
+ .add(allow(Permission.FORGE_AUTHOR).ref("refs/*").group(REGISTERED_USERS))
+ .update();
+ }
+
+ @Test
+ public void createMergePatchSet() throws Exception {
+ RevCommit initialHead = projectOperations.project(project).getHead("master");
+ createBranch(BranchNameKey.create(project, "dev"));
+
+ // create a change for master
+ String changeId = createChange().getChangeId();
+
+ testRepo.reset(initialHead);
+ PushOneCommit.Result currentMaster = pushTo("refs/heads/master");
+ currentMaster.assertOkStatus();
+ String parent = currentMaster.getCommit().getName();
+
+ // push a commit into dev branch
+ testRepo.reset(initialHead);
+ PushOneCommit.Result changeA =
+ pushFactory
+ .create(user.newIdent(), testRepo, "change A", "A.txt", "A content")
+ .to("refs/heads/dev");
+ changeA.assertOkStatus();
+ MergeInput mergeInput = new MergeInput();
+ mergeInput.source = "dev";
+ MergePatchSetInput in = new MergePatchSetInput();
+ in.merge = mergeInput;
+ String subject = "update change by merge ps2";
+ in.subject = subject;
+
+ TestWorkInProgressStateChangedListener wipStateChangedListener =
+ new TestWorkInProgressStateChangedListener();
+ try (ExtensionRegistry.Registration registration =
+ extensionRegistry.newRegistration().add(wipStateChangedListener)) {
+ ChangeInfo changeInfo = gApi.changes().id(changeId).createMergePatchSet(in);
+ assertThat(changeInfo.subject).isEqualTo(in.subject);
+ assertThat(changeInfo.containsGitConflicts).isNull();
+ assertThat(changeInfo.workInProgress).isNull();
+ }
+ assertThat(wipStateChangedListener.invoked).isFalse();
+
+ // To get the revisions, we must retrieve the change with more change options.
+ ChangeInfo changeInfo =
+ gApi.changes().id(changeId).get(ALL_REVISIONS, CURRENT_COMMIT, CURRENT_REVISION);
+ assertThat(changeInfo.revisions).hasSize(2);
+ assertThat(changeInfo.revisions.get(changeInfo.currentRevision).commit.parents.get(0).commit)
+ .isEqualTo(parent);
+
+ // Verify the message that has been posted on the change.
+ List<ChangeMessageInfo> messages = gApi.changes().id(changeId).messages();
+ assertThat(messages).hasSize(2);
+ assertThat(Iterables.getLast(messages).message).isEqualTo("Uploaded patch set 2.");
+
+ assertThat(changeInfo.revisions.get(changeInfo.currentRevision).commit.message)
+ .contains(subject);
+ }
+
+ @Test
+ public void createMergePatchSet_SubjectCarriesOverByDefault() throws Exception {
+ RevCommit initialHead = projectOperations.project(project).getHead("master");
+ createBranch(BranchNameKey.create(project, "dev"));
+
+ // create a change for master
+ PushOneCommit.Result result = createChange();
+ String changeId = result.getChangeId();
+ String subject = result.getChange().change().getSubject();
+
+ // push a commit into dev branch
+ testRepo.reset(initialHead);
+ PushOneCommit.Result pushResult =
+ pushFactory.create(user.newIdent(), testRepo).to("refs/heads/dev");
+ pushResult.assertOkStatus();
+ MergeInput mergeInput = new MergeInput();
+ mergeInput.source = "dev";
+ MergePatchSetInput in = new MergePatchSetInput();
+ in.merge = mergeInput;
+ in.subject = null;
+
+ // Ensure subject carries over
+ gApi.changes().id(changeId).createMergePatchSet(in);
+ ChangeInfo changeInfo = gApi.changes().id(changeId).get();
+ assertThat(changeInfo.subject).isEqualTo(subject);
+ }
+
+ @Test
+ public void createMergePatchSet_Conflict() throws Exception {
+ RevCommit initialHead = projectOperations.project(project).getHead("master");
+ createBranch(BranchNameKey.create(project, "dev"));
+
+ // create a change for master
+ String changeId = createChange().getChangeId();
+
+ String fileName = "shared.txt";
+ testRepo.reset(initialHead);
+ PushOneCommit.Result currentMaster =
+ pushFactory
+ .create(admin.newIdent(), testRepo, "change 1", fileName, "content 1")
+ .to("refs/heads/master");
+ currentMaster.assertOkStatus();
+
+ // push a commit into dev branch
+ testRepo.reset(initialHead);
+ PushOneCommit.Result changeA =
+ pushFactory
+ .create(user.newIdent(), testRepo, "change 2", fileName, "content 2")
+ .to("refs/heads/dev");
+ changeA.assertOkStatus();
+ MergeInput mergeInput = new MergeInput();
+ mergeInput.source = "dev";
+ MergePatchSetInput in = new MergePatchSetInput();
+ in.merge = mergeInput;
+ in.subject = "update change by merge ps2";
+ ResourceConflictException thrown =
+ assertThrows(
+ ResourceConflictException.class,
+ () -> gApi.changes().id(changeId).createMergePatchSet(in));
+ assertThat(thrown).hasMessageThat().isEqualTo("merge conflict(s):\n" + fileName);
+ }
+
+ @Test
+ public void createMergePatchSet_ConflictAllowed() throws Exception {
+ RevCommit initialHead = projectOperations.project(project).getHead("master");
+ createBranch(BranchNameKey.create(project, "dev"));
+
+ // create a change for master
+ String changeId = createChange().getChangeId();
+
+ String fileName = "shared.txt";
+ String sourceSubject = "source change";
+ String sourceContent = "source content";
+ String targetSubject = "target change";
+ String targetContent = "target content";
+ testRepo.reset(initialHead);
+ PushOneCommit.Result currentMaster =
+ pushFactory
+ .create(admin.newIdent(), testRepo, targetSubject, fileName, targetContent)
+ .to("refs/heads/master");
+ currentMaster.assertOkStatus();
+ String parent = currentMaster.getCommit().getName();
+
+ // push a commit into dev branch
+ testRepo.reset(initialHead);
+ PushOneCommit.Result changeA =
+ pushFactory
+ .create(user.newIdent(), testRepo, sourceSubject, fileName, sourceContent)
+ .to("refs/heads/dev");
+ changeA.assertOkStatus();
+ MergeInput mergeInput = new MergeInput();
+ mergeInput.source = "dev";
+ mergeInput.allowConflicts = true;
+ MergePatchSetInput in = new MergePatchSetInput();
+ in.merge = mergeInput;
+ in.subject = "update change by merge ps2";
+
+ TestWorkInProgressStateChangedListener wipStateChangedListener =
+ new TestWorkInProgressStateChangedListener();
+ try (ExtensionRegistry.Registration registration =
+ extensionRegistry.newRegistration().add(wipStateChangedListener)) {
+ ChangeInfo changeInfo = gApi.changes().id(changeId).createMergePatchSet(in);
+ assertThat(changeInfo.subject).isEqualTo(in.subject);
+ assertThat(changeInfo.containsGitConflicts).isTrue();
+ assertThat(changeInfo.workInProgress).isTrue();
+ }
+ assertThat(wipStateChangedListener.invoked).isTrue();
+ assertThat(wipStateChangedListener.wip).isTrue();
+
+ // To get the revisions, we must retrieve the change with more change options.
+ ChangeInfo changeInfo =
+ gApi.changes().id(changeId).get(ALL_REVISIONS, CURRENT_COMMIT, CURRENT_REVISION);
+ assertThat(changeInfo.revisions).hasSize(2);
+ assertThat(changeInfo.revisions.get(changeInfo.currentRevision).commit.parents.get(0).commit)
+ .isEqualTo(parent);
+
+ // Verify that the file content in the created patch set is correct.
+ // We expect that it has conflict markers to indicate the conflict.
+ BinaryResult bin = gApi.changes().id(changeId).current().file(fileName).content();
+ ByteArrayOutputStream os = new ByteArrayOutputStream();
+ bin.writeTo(os);
+ String fileContent = new String(os.toByteArray(), UTF_8);
+ String sourceSha1 = abbreviateName(changeA.getCommit(), 6);
+ String targetSha1 = abbreviateName(currentMaster.getCommit(), 6);
+ assertThat(fileContent)
+ .isEqualTo(
+ "<<<<<<< TARGET BRANCH ("
+ + targetSha1
+ + " "
+ + targetSubject
+ + ")\n"
+ + targetContent
+ + "\n"
+ + "=======\n"
+ + sourceContent
+ + "\n"
+ + ">>>>>>> SOURCE BRANCH ("
+ + sourceSha1
+ + " "
+ + sourceSubject
+ + ")\n");
+
+ // Verify the message that has been posted on the change.
+ List<ChangeMessageInfo> messages = gApi.changes().id(changeId).messages();
+ assertThat(messages).hasSize(2);
+ assertThat(Iterables.getLast(messages).message)
+ .isEqualTo(
+ "Uploaded patch set 2.\n\n"
+ + "The following files contain Git conflicts:\n"
+ + "* "
+ + fileName
+ + "\n");
+ }
+
+ @Test
+ public void createMergePatchSet_ConflictAllowedNotSupportedByMergeStrategy() throws Exception {
+ RevCommit initialHead = projectOperations.project(project).getHead("master");
+ createBranch(BranchNameKey.create(project, "dev"));
+
+ // create a change for master
+ String changeId = createChange().getChangeId();
+
+ String fileName = "shared.txt";
+ String sourceSubject = "source change";
+ String sourceContent = "source content";
+ String targetSubject = "target change";
+ String targetContent = "target content";
+ testRepo.reset(initialHead);
+ PushOneCommit.Result currentMaster =
+ pushFactory
+ .create(admin.newIdent(), testRepo, targetSubject, fileName, targetContent)
+ .to("refs/heads/master");
+ currentMaster.assertOkStatus();
+
+ // push a commit into dev branch
+ testRepo.reset(initialHead);
+ PushOneCommit.Result changeA =
+ pushFactory
+ .create(user.newIdent(), testRepo, sourceSubject, fileName, sourceContent)
+ .to("refs/heads/dev");
+ changeA.assertOkStatus();
+ MergeInput mergeInput = new MergeInput();
+ mergeInput.source = "dev";
+ mergeInput.allowConflicts = true;
+ mergeInput.strategy = "simple-two-way-in-core";
+ MergePatchSetInput in = new MergePatchSetInput();
+ in.merge = mergeInput;
+ in.subject = "update change by merge ps2";
+
+ BadRequestException ex =
+ assertThrows(
+ BadRequestException.class, () -> gApi.changes().id(changeId).createMergePatchSet(in));
+ assertThat(ex)
+ .hasMessageThat()
+ .isEqualTo(
+ "merge with conflicts is not supported with merge strategy: " + mergeInput.strategy);
+ }
+
+ @Test
+ public void createMergePatchSetInheritParent() throws Exception {
+ RevCommit initialHead = projectOperations.project(project).getHead("master");
+ createBranch(BranchNameKey.create(project, "dev"));
+
+ // create a change for master
+ PushOneCommit.Result r = createChange();
+ String changeId = r.getChangeId();
+ String parent = r.getCommit().getParent(0).getName();
+
+ // advance master branch
+ testRepo.reset(initialHead);
+ PushOneCommit.Result currentMaster = pushTo("refs/heads/master");
+ currentMaster.assertOkStatus();
+
+ // push a commit into dev branch
+ testRepo.reset(initialHead);
+ PushOneCommit.Result changeA =
+ pushFactory
+ .create(user.newIdent(), testRepo, "change A", "A.txt", "A content")
+ .to("refs/heads/dev");
+ changeA.assertOkStatus();
+ MergeInput mergeInput = new MergeInput();
+ mergeInput.source = "dev";
+ MergePatchSetInput in = new MergePatchSetInput();
+ in.merge = mergeInput;
+ in.subject = "update change by merge ps2 inherit parent of ps1";
+ in.inheritParent = true;
+ gApi.changes().id(changeId).createMergePatchSet(in);
+ ChangeInfo changeInfo =
+ gApi.changes().id(changeId).get(ALL_REVISIONS, CURRENT_COMMIT, CURRENT_REVISION);
+
+ assertThat(changeInfo.revisions).hasSize(2);
+ assertThat(changeInfo.subject).isEqualTo(in.subject);
+ assertThat(changeInfo.revisions.get(changeInfo.currentRevision).commit.parents.get(0).commit)
+ .isEqualTo(parent);
+ assertThat(changeInfo.revisions.get(changeInfo.currentRevision).commit.parents.get(0).commit)
+ .isNotEqualTo(currentMaster.getCommit().getName());
+ }
+
+ @Test
+ public void createMergePatchSetCannotBaseOnInvisibleChange() throws Exception {
+ RevCommit initialHead = projectOperations.project(project).getHead("master");
+ createBranch(BranchNameKey.create(project, "foo"));
+ createBranch(BranchNameKey.create(project, "bar"));
+
+ // Create a merged commit on 'foo' branch.
+ merge(createChange("refs/for/foo"));
+
+ // Create the base change on 'bar' branch.
+ testRepo.reset(initialHead);
+ String baseChange = createChange("refs/for/bar").getChangeId();
+ gApi.changes().id(baseChange).setPrivate(true, "set private");
+
+ // Create the destination change on 'master' branch.
+ requestScopeOperations.setApiUser(user.id());
+ testRepo.reset(initialHead);
+ String changeId = createChange().getChangeId();
+
+ UnprocessableEntityException thrown =
+ assertThrows(
+ UnprocessableEntityException.class,
+ () ->
+ gApi.changes()
+ .id(changeId)
+ .createMergePatchSet(createMergePatchSetInput(baseChange)));
+ assertThat(thrown).hasMessageThat().contains("Read not permitted for " + baseChange);
+ }
+
+ @Test
+ public void createMergePatchSetBaseOnChange() throws Exception {
+ RevCommit initialHead = projectOperations.project(project).getHead("master");
+ createBranch(BranchNameKey.create(project, "foo"));
+ createBranch(BranchNameKey.create(project, "bar"));
+
+ // Create a merged commit on 'foo' branch.
+ merge(createChange("refs/for/foo"));
+
+ // Create the base change on 'bar' branch.
+ testRepo.reset(initialHead);
+ PushOneCommit.Result result = createChange("refs/for/bar");
+ String baseChange = result.getChangeId();
+ String expectedParent = result.getCommit().getName();
+
+ // Create the destination change on 'master' branch.
+ testRepo.reset(initialHead);
+ String changeId = createChange().getChangeId();
+
+ gApi.changes().id(changeId).createMergePatchSet(createMergePatchSetInput(baseChange));
+
+ ChangeInfo changeInfo =
+ gApi.changes().id(changeId).get(ALL_REVISIONS, CURRENT_COMMIT, CURRENT_REVISION);
+ assertThat(changeInfo.revisions).hasSize(2);
+ assertThat(changeInfo.subject).isEqualTo("create ps2");
+ assertThat(changeInfo.revisions.get(changeInfo.currentRevision).commit.parents.get(0).commit)
+ .isEqualTo(expectedParent);
+ }
+
+ @Test
+ public void createMergePatchSetWithUnupportedMergeStrategy() throws Exception {
+ RevCommit initialHead = projectOperations.project(project).getHead("master");
+ createBranch(BranchNameKey.create(project, "dev"));
+
+ // create a change for master
+ String changeId = createChange().getChangeId();
+
+ String fileName = "shared.txt";
+ String sourceSubject = "source change";
+ String sourceContent = "source content";
+ String targetSubject = "target change";
+ String targetContent = "target content";
+ testRepo.reset(initialHead);
+ PushOneCommit.Result currentMaster =
+ pushFactory
+ .create(admin.newIdent(), testRepo, targetSubject, fileName, targetContent)
+ .to("refs/heads/master");
+ currentMaster.assertOkStatus();
+
+ // push a commit into dev branch
+ testRepo.reset(initialHead);
+ PushOneCommit.Result changeA =
+ pushFactory
+ .create(user.newIdent(), testRepo, sourceSubject, fileName, sourceContent)
+ .to("refs/heads/dev");
+ changeA.assertOkStatus();
+ MergeInput mergeInput = new MergeInput();
+ mergeInput.source = "dev";
+ mergeInput.strategy = "unsupported-strategy";
+ MergePatchSetInput in = new MergePatchSetInput();
+ in.merge = mergeInput;
+ in.subject = "update change by merge ps2";
+
+ BadRequestException ex =
+ assertThrows(
+ BadRequestException.class, () -> gApi.changes().id(changeId).createMergePatchSet(in));
+ assertThat(ex).hasMessageThat().isEqualTo("invalid merge strategy: " + mergeInput.strategy);
+ }
+
+ @Test
+ public void createMergePatchSetWithOtherAuthor() throws Exception {
+ RevCommit initialHead = projectOperations.project(project).getHead("master");
+ createBranch(BranchNameKey.create(project, "dev"));
+
+ // create a change for master
+ String changeId = createChange().getChangeId();
+
+ testRepo.reset(initialHead);
+ PushOneCommit.Result currentMaster = pushTo("refs/heads/master");
+ currentMaster.assertOkStatus();
+ String parent = currentMaster.getCommit().getName();
+
+ // push a commit into dev branch
+ testRepo.reset(initialHead);
+ PushOneCommit.Result changeA =
+ pushFactory
+ .create(user.newIdent(), testRepo, "change A", "A.txt", "A content")
+ .to("refs/heads/dev");
+ changeA.assertOkStatus();
+ MergeInput mergeInput = new MergeInput();
+ mergeInput.source = "dev";
+ MergePatchSetInput in = new MergePatchSetInput();
+ in.merge = mergeInput;
+ String subject = "update change by merge ps2";
+ in.subject = subject;
+ in.author = new AccountInput();
+ in.author.name = "Other Author";
+ in.author.email = "otherauthor@example.com";
+ gApi.changes().id(changeId).createMergePatchSet(in);
+
+ // To get the revisions, we must retrieve the change with more change options.
+ ChangeInfo changeInfo =
+ gApi.changes().id(changeId).get(ALL_REVISIONS, CURRENT_COMMIT, CURRENT_REVISION);
+ assertThat(changeInfo.revisions).hasSize(2);
+ assertThat(changeInfo.revisions.get(changeInfo.currentRevision).commit.parents.get(0).commit)
+ .isEqualTo(parent);
+
+ // Verify the message that has been posted on the change.
+ List<ChangeMessageInfo> messages = gApi.changes().id(changeId).messages();
+ assertThat(messages).hasSize(2);
+ assertThat(Iterables.getLast(messages).message).isEqualTo("Uploaded patch set 2.");
+
+ CommitInfo commitInfo = changeInfo.revisions.get(changeInfo.currentRevision).commit;
+ assertThat(commitInfo.message).contains(subject);
+ assertThat(commitInfo.author.name).isEqualTo("Other Author");
+ assertThat(commitInfo.author.email).isEqualTo("otherauthor@example.com");
+ }
+
+ @Test
+ public void createMergePatchSetWithSpecificAuthorButNoForgeAuthorPermission() throws Exception {
+ RevCommit initialHead = projectOperations.project(project).getHead("master");
+ createBranch(BranchNameKey.create(project, "dev"));
+
+ // create a change for master
+ String changeId = createChange().getChangeId();
+
+ testRepo.reset(initialHead);
+ PushOneCommit.Result currentMaster = pushTo("refs/heads/master");
+ currentMaster.assertOkStatus();
+ String parent = currentMaster.getCommit().getName();
+
+ // push a commit into dev branch
+ testRepo.reset(initialHead);
+ PushOneCommit.Result changeA =
+ pushFactory
+ .create(user.newIdent(), testRepo, "change A", "A.txt", "A content")
+ .to("refs/heads/dev");
+ changeA.assertOkStatus();
+ MergeInput mergeInput = new MergeInput();
+ mergeInput.source = "dev";
+ MergePatchSetInput in = new MergePatchSetInput();
+ in.merge = mergeInput;
+ String subject = "update change by merge ps2";
+ in.subject = subject;
+ in.author = new AccountInput();
+ in.author.name = "Foo";
+ in.author.email = "foo@example.com";
+
+ projectOperations
+ .project(project)
+ .forUpdate()
+ .remove(
+ TestProjectUpdate.permissionKey(Permission.FORGE_AUTHOR)
+ .ref("refs/*")
+ .group(REGISTERED_USERS))
+ .add(block(Permission.FORGE_AUTHOR).ref("refs/*").group(REGISTERED_USERS))
+ .update();
+ AuthException ex =
+ assertThrows(
+ AuthException.class, () -> gApi.changes().id(changeId).createMergePatchSet(in));
+ assertThat(ex).hasMessageThat().isEqualTo("not permitted: forge author on refs/heads/master");
+ }
+
+ @Test
+ public void createMergePatchSetWithMissingNameFailsWithBadRequestException() throws Exception {
+ RevCommit initialHead = projectOperations.project(project).getHead("master");
+ createBranch(BranchNameKey.create(project, "dev"));
+
+ // create a change for master
+ String changeId = createChange().getChangeId();
+
+ testRepo.reset(initialHead);
+ PushOneCommit.Result currentMaster = pushTo("refs/heads/master");
+ currentMaster.assertOkStatus();
+ String parent = currentMaster.getCommit().getName();
+
+ // push a commit into dev branch
+ testRepo.reset(initialHead);
+ PushOneCommit.Result changeA =
+ pushFactory
+ .create(user.newIdent(), testRepo, "change A", "A.txt", "A content")
+ .to("refs/heads/dev");
+ changeA.assertOkStatus();
+ MergeInput mergeInput = new MergeInput();
+ mergeInput.source = "dev";
+ MergePatchSetInput in = new MergePatchSetInput();
+ in.merge = mergeInput;
+ String subject = "update change by merge ps2";
+ in.subject = subject;
+ in.author = new AccountInput();
+ in.author.name = "Foo";
+
+ projectOperations
+ .project(project)
+ .forUpdate()
+ .add(block(Permission.FORGE_AUTHOR).ref("refs/*").group(REGISTERED_USERS))
+ .update();
+ BadRequestException ex =
+ assertThrows(
+ BadRequestException.class, () -> gApi.changes().id(changeId).createMergePatchSet(in));
+ assertThat(ex).hasMessageThat().isEqualTo("Author must specify name and email");
+ }
+
+ @Test
+ public void createMergePatchSetWithMissingEmailFailsWithBadRequestException() throws Exception {
+ RevCommit initialHead = projectOperations.project(project).getHead("master");
+ createBranch(BranchNameKey.create(project, "dev"));
+
+ // create a change for master
+ String changeId = createChange().getChangeId();
+
+ testRepo.reset(initialHead);
+ PushOneCommit.Result currentMaster = pushTo("refs/heads/master");
+ currentMaster.assertOkStatus();
+ String parent = currentMaster.getCommit().getName();
+
+ // push a commit into dev branch
+ testRepo.reset(initialHead);
+ PushOneCommit.Result changeA =
+ pushFactory
+ .create(user.newIdent(), testRepo, "change A", "A.txt", "A content")
+ .to("refs/heads/dev");
+ changeA.assertOkStatus();
+ MergeInput mergeInput = new MergeInput();
+ mergeInput.source = "dev";
+ MergePatchSetInput in = new MergePatchSetInput();
+ in.merge = mergeInput;
+ String subject = "update change by merge ps2";
+ in.subject = subject;
+ in.author = new AccountInput();
+ in.author.email = "Foo";
+
+ projectOperations
+ .project(project)
+ .forUpdate()
+ .add(block(Permission.FORGE_AUTHOR).ref("refs/*").group(REGISTERED_USERS))
+ .update();
+ BadRequestException ex =
+ assertThrows(
+ BadRequestException.class, () -> gApi.changes().id(changeId).createMergePatchSet(in));
+ assertThat(ex).hasMessageThat().isEqualTo("Author must specify name and email");
+ }
+
+ private MergePatchSetInput createMergePatchSetInput(String baseChange) {
+ MergeInput mergeInput = new MergeInput();
+ mergeInput.source = "foo";
+ MergePatchSetInput in = new MergePatchSetInput();
+ in.merge = mergeInput;
+ in.subject = "create ps2";
+ in.inheritParent = false;
+ in.baseChange = baseChange;
+ return in;
+ }
+
+ private static class TestWorkInProgressStateChangedListener
+ implements WorkInProgressStateChangedListener {
+ boolean invoked;
+ Boolean wip;
+
+ @Override
+ public void onWorkInProgressStateChanged(Event event) {
+ this.invoked = true;
+ this.wip =
+ event.getChange().workInProgress != null ? event.getChange().workInProgress : false;
+ }
+ }
+}
diff --git a/javatests/com/google/gerrit/acceptance/api/project/CheckAccessIT.java b/javatests/com/google/gerrit/acceptance/api/project/CheckAccessIT.java
index f1d537f..59493be 100644
--- a/javatests/com/google/gerrit/acceptance/api/project/CheckAccessIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/project/CheckAccessIT.java
@@ -162,28 +162,37 @@
String project;
String permission;
int want;
+ List<String> expectedDebugLogs;
- static TestCase project(String mail, String project, int want) {
+ static TestCase project(String mail, String project, int want, List<String> expectedDebugLogs) {
TestCase t = new TestCase();
t.input = new AccessCheckInput();
t.input.account = mail;
t.project = project;
t.want = want;
+ t.expectedDebugLogs = expectedDebugLogs;
return t;
}
- static TestCase projectRef(String mail, String project, String ref, int want) {
+ static TestCase projectRef(
+ String mail, String project, String ref, int want, List<String> expectedDebugLogs) {
TestCase t = new TestCase();
t.input = new AccessCheckInput();
t.input.account = mail;
t.input.ref = ref;
t.project = project;
t.want = want;
+ t.expectedDebugLogs = expectedDebugLogs;
return t;
}
static TestCase projectRefPerm(
- String mail, String project, String ref, String permission, int want) {
+ String mail,
+ String project,
+ String ref,
+ String permission,
+ int want,
+ List<String> expectedDebugLogs) {
TestCase t = new TestCase();
t.input = new AccessCheckInput();
t.input.account = mail;
@@ -191,6 +200,7 @@
t.input.permission = permission;
t.project = project;
t.want = want;
+ t.expectedDebugLogs = expectedDebugLogs;
return t;
}
}
@@ -217,27 +227,98 @@
normalProject.get(),
"refs/heads/master",
Permission.VIEW_PRIVATE_CHANGES,
- 403),
- TestCase.project(user.email(), normalProject.get(), 200),
- TestCase.project(user.email(), secretProject.get(), 403),
+ 403,
+ ImmutableList.of(
+ "'user' can perform 'read' with force=false on project '"
+ + normalProject.get()
+ + "' for ref 'refs/*'",
+ "'user' cannot perform 'viewPrivateChanges' with force=false on project '"
+ + normalProject.get()
+ + "' for ref 'refs/heads/master'")),
+ TestCase.project(
+ user.email(),
+ normalProject.get(),
+ 200,
+ ImmutableList.of(
+ "'user' can perform 'read' with force=false on project '"
+ + normalProject.get()
+ + "' for ref 'refs/*'")),
+ TestCase.project(
+ user.email(),
+ secretProject.get(),
+ 403,
+ ImmutableList.of(
+ "'user' cannot perform 'read' with force=false on project '"
+ + secretProject.get()
+ + "' for ref 'refs/*' because this permission is blocked")),
TestCase.projectRef(
- user.email(), secretRefProject.get(), "refs/heads/secret/master", 403),
+ user.email(),
+ secretRefProject.get(),
+ "refs/heads/secret/master",
+ 403,
+ ImmutableList.of(
+ "'user' can perform 'read' with force=false on project '"
+ + secretRefProject.get()
+ + "' for ref 'refs/heads/*'",
+ "'user' cannot perform 'read' with force=false on project '"
+ + secretRefProject.get()
+ + "' for ref 'refs/heads/secret/master' because this permission is blocked")),
TestCase.projectRef(
- privilegedUser.email(), secretRefProject.get(), "refs/heads/secret/master", 200),
- TestCase.projectRef(privilegedUser.email(), normalProject.get(), null, 200),
- TestCase.projectRef(privilegedUser.email(), secretProject.get(), null, 200),
+ privilegedUser.email(),
+ secretRefProject.get(),
+ "refs/heads/secret/master",
+ 200,
+ ImmutableList.of(
+ "'privilegedUser' can perform 'read' with force=false on project '"
+ + secretRefProject.get()
+ + "' for ref 'refs/heads/*'",
+ "'privilegedUser' can perform 'read' with force=false on project '"
+ + secretRefProject.get()
+ + "' for ref 'refs/heads/secret/master'")),
+ TestCase.projectRef(
+ privilegedUser.email(),
+ normalProject.get(),
+ null,
+ 200,
+ ImmutableList.of(
+ "'privilegedUser' can perform 'read' with force=false on project '"
+ + normalProject.get()
+ + "' for ref 'refs/*'")),
+ TestCase.projectRef(
+ privilegedUser.email(),
+ secretProject.get(),
+ null,
+ 200,
+ ImmutableList.of(
+ "'privilegedUser' can perform 'read' with force=false on project '"
+ + secretProject.get()
+ + "' for ref 'refs/*'")),
TestCase.projectRefPerm(
privilegedUser.email(),
normalProject.get(),
"refs/heads/master",
Permission.VIEW_PRIVATE_CHANGES,
- 200),
+ 200,
+ ImmutableList.of(
+ "'privilegedUser' can perform 'read' with force=false on project '"
+ + normalProject.get()
+ + "' for ref 'refs/*'",
+ "'privilegedUser' can perform 'viewPrivateChanges' with force=false on project '"
+ + normalProject.get()
+ + "' for ref 'refs/heads/master'")),
TestCase.projectRefPerm(
privilegedUser.email(),
normalProject.get(),
"refs/heads/master",
Permission.FORGE_SERVER,
- 200));
+ 200,
+ ImmutableList.of(
+ "'privilegedUser' can perform 'read' with force=false on project '"
+ + normalProject.get()
+ + "' for ref 'refs/*'",
+ "'privilegedUser' can perform 'forgeServerAsCommitter' with force=false on project '"
+ + normalProject.get()
+ + "' for ref 'refs/heads/master'")));
for (TestCase tc : inputs) {
String in = newGson().toJson(tc.input);
@@ -273,6 +354,14 @@
default:
assertWithMessage(String.format("unknown code %d", want)).fail();
}
+
+ if (!info.debugLogs.equals(tc.expectedDebugLogs)) {
+ assertWithMessage(
+ String.format(
+ "check.access(%s, %s) = %s, want %s",
+ tc.project, in, info.debugLogs, tc.expectedDebugLogs))
+ .fail();
+ }
}
}
diff --git a/javatests/com/google/gerrit/acceptance/git/PushPermissionsIT.java b/javatests/com/google/gerrit/acceptance/git/PushPermissionsIT.java
index 1a2ae7c..78be4ab 100644
--- a/javatests/com/google/gerrit/acceptance/git/PushPermissionsIT.java
+++ b/javatests/com/google/gerrit/acceptance/git/PushPermissionsIT.java
@@ -364,7 +364,7 @@
.update();
String project2 = name("project2");
- gApi.projects().create(project2);
+ projectOperations.newProject().name(project2).create();
ObjectId oldId = forceFetch("refs/meta/config");
diff --git a/javatests/com/google/gerrit/acceptance/rest/TraceIT.java b/javatests/com/google/gerrit/acceptance/rest/TraceIT.java
index f5d9e3a..cb34bdb 100644
--- a/javatests/com/google/gerrit/acceptance/rest/TraceIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/TraceIT.java
@@ -54,9 +54,9 @@
import com.google.inject.Inject;
import java.util.ArrayList;
import java.util.List;
+import java.util.Map;
import java.util.Optional;
-import java.util.SortedMap;
-import java.util.SortedSet;
+import java.util.Set;
import org.apache.http.message.BasicHeader;
import org.junit.Rule;
import org.junit.Test;
@@ -337,7 +337,7 @@
assertThat(LoggingContext.getInstance().getTags().isEmpty()).isTrue();
assertForceLogging(false);
try (TraceContext traceContext = TraceContext.open().forceLogging().addTag("foo", "bar")) {
- SortedMap<String, SortedSet<Object>> tagMap = LoggingContext.getInstance().getTags().asMap();
+ Map<String, ? extends Set<Object>> tagMap = LoggingContext.getInstance().getTags().asMap();
assertThat(tagMap.keySet()).containsExactly("foo");
assertThat(tagMap.get("foo")).containsExactly("bar");
assertForceLogging(true);
@@ -348,7 +348,7 @@
() -> {
// Verify that the tags and force logging flag have been propagated to the new
// thread.
- SortedMap<String, SortedSet<Object>> threadTagMap =
+ Map<String, ? extends Set<Object>> threadTagMap =
LoggingContext.getInstance().getTags().asMap();
expect.that(threadTagMap.keySet()).containsExactly("foo");
expect.that(threadTagMap.get("foo")).containsExactly("bar");
diff --git a/javatests/com/google/gerrit/acceptance/rest/account/WatchedProjectsIT.java b/javatests/com/google/gerrit/acceptance/rest/account/WatchedProjectsIT.java
index 2c9107c..b70cab8 100644
--- a/javatests/com/google/gerrit/acceptance/rest/account/WatchedProjectsIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/account/WatchedProjectsIT.java
@@ -249,4 +249,30 @@
public void postWithoutBody() throws Exception {
adminRestSession.post("/accounts/" + admin.username() + "/watched.projects").assertOK();
}
+
+ @Test
+ public void nullProjectThrowsBadRequestException() {
+ List<ProjectWatchInfo> projectsToWatch = new ArrayList<>();
+ ProjectWatchInfo pwi = new ProjectWatchInfo();
+ pwi.project = null;
+ projectsToWatch.add(pwi);
+ Throwable t =
+ assertThrows(
+ BadRequestException.class,
+ () -> gApi.accounts().self().setWatchedProjects(projectsToWatch));
+ assertThat(t.getMessage()).isEqualTo("project name must be specified");
+ }
+
+ @Test
+ public void emptyProjectThrowsBadRequestException() {
+ List<ProjectWatchInfo> projectsToWatch = new ArrayList<>();
+ ProjectWatchInfo pwi = new ProjectWatchInfo();
+ pwi.project = " ";
+ projectsToWatch.add(pwi);
+ Throwable t =
+ assertThrows(
+ BadRequestException.class,
+ () -> gApi.accounts().self().setWatchedProjects(projectsToWatch));
+ assertThat(t.getMessage()).isEqualTo("project name must be specified");
+ }
}
diff --git a/javatests/com/google/gerrit/acceptance/rest/change/AttentionSetIT.java b/javatests/com/google/gerrit/acceptance/rest/change/AttentionSetIT.java
index 976be96..61eef63 100644
--- a/javatests/com/google/gerrit/acceptance/rest/change/AttentionSetIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/change/AttentionSetIT.java
@@ -28,6 +28,7 @@
import com.google.gerrit.acceptance.PushOneCommit;
import com.google.gerrit.acceptance.TestAccount;
import com.google.gerrit.acceptance.UseClockStep;
+import com.google.gerrit.acceptance.config.GerritConfig;
import com.google.gerrit.acceptance.testsuite.account.AccountOperations;
import com.google.gerrit.acceptance.testsuite.change.ChangeOperations;
import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
@@ -41,6 +42,8 @@
import com.google.gerrit.extensions.api.changes.DeleteReviewerInput;
import com.google.gerrit.extensions.api.changes.HashtagsInput;
import com.google.gerrit.extensions.api.changes.ReviewInput;
+import com.google.gerrit.extensions.client.GeneralPreferencesInfo;
+import com.google.gerrit.extensions.client.GeneralPreferencesInfo.EmailStrategy;
import com.google.gerrit.extensions.client.ReviewerState;
import com.google.gerrit.extensions.client.Side;
import com.google.gerrit.extensions.restapi.BadRequestException;
@@ -1360,6 +1363,118 @@
sender.clear();
}
+ @Test
+ @GerritConfig(name = "change.enableAttentionSet", value = "true")
+ public void attentionSetEmailHeader() throws Exception {
+ PushOneCommit.Result r = createChange();
+ TestAccount user2 = accountCreator.user2();
+ // Add user and user2 to the attention set.
+ change(r)
+ .current()
+ .review(
+ ReviewInput.create().reviewer(user.email()).reviewer(accountCreator.user2().email()));
+ assertThat(Iterables.getOnlyElement(sender.getMessages()).body())
+ .contains(
+ "Attention is currently required from: "
+ + user2.fullName()
+ + ", "
+ + user.fullName()
+ + ".");
+ assertThat(Iterables.getOnlyElement(sender.getMessages()).htmlBody())
+ .contains(
+ "Attention is currently required from: "
+ + user2.fullName()
+ + ", "
+ + user.fullName()
+ + ".");
+ sender.clear();
+
+ // Irrelevant reply, User and User2 are still in the attention set.
+ change(r).current().review(ReviewInput.approve());
+ assertThat(Iterables.getOnlyElement(sender.getMessages()).body())
+ .contains(
+ "Attention is currently required from: "
+ + user2.fullName()
+ + ", "
+ + user.fullName()
+ + ".");
+ assertThat(Iterables.getOnlyElement(sender.getMessages()).htmlBody())
+ .contains(
+ "Attention is currently required from: "
+ + user2.fullName()
+ + ", "
+ + user.fullName()
+ + ".");
+ sender.clear();
+
+ // Abandon the change which removes user from attention set; there is an email but without the
+ // attention footer.
+ change(r).abandon();
+ assertThat(Iterables.getOnlyElement(sender.getMessages()).body())
+ .doesNotContain("Attention is currently required");
+ assertThat(Iterables.getOnlyElement(sender.getMessages()).htmlBody())
+ .doesNotContain("Attention is currently required");
+ sender.clear();
+ }
+
+ @Test
+ @GerritConfig(name = "change.enableAttentionSet", value = "false")
+ public void noReferenceToAttentionSetInEmailsWhenDisabled() throws Exception {
+ PushOneCommit.Result r = createChange();
+ // Add user and to the attention set.
+ change(r).addReviewer(user.id().toString());
+
+ // Attention set is not referenced.
+ assertThat(Iterables.getOnlyElement(sender.getMessages()).body())
+ .doesNotContain("Attention is currently required");
+ assertThat(Iterables.getOnlyElement(sender.getMessages()).htmlBody())
+ .doesNotContain("Attention is currently required");
+ sender.clear();
+ }
+
+ @Test
+ public void attentionSetWithEmailFilter() throws Exception {
+ PushOneCommit.Result r = createChange();
+
+ // Add preference for the user such that they only receive an email on changes that require
+ // their attention.
+ requestScopeOperations.setApiUser(user.id());
+ GeneralPreferencesInfo prefs = gApi.accounts().self().getPreferences();
+ prefs.emailStrategy = EmailStrategy.ATTENTION_SET_ONLY;
+ gApi.accounts().self().setPreferences(prefs);
+ requestScopeOperations.setApiUser(admin.id());
+
+ // Add user to attention set. They receive an email since they are in the attention set.
+ change(r).addReviewer(user.id().toString());
+ assertThat(sender.getMessages()).isNotEmpty();
+ sender.clear();
+
+ // Irrelevant reply, User is still in the attention set, thus got another email.
+ change(r).current().review(ReviewInput.approve());
+ assertThat(sender.getMessages()).isNotEmpty();
+ sender.clear();
+
+ // Abandon the change which removes user from attention set; the user doesn't receive an email
+ // since they are not in the attention set.
+ change(r).abandon();
+ assertThat(sender.getMessages()).isEmpty();
+ }
+
+ @Test
+ public void attentionSetWithEmailFilterImpactingOnlyChangeEmails() throws Exception {
+ // Add preference for the user such that they only receive an email on changes that require
+ // their attention.
+ requestScopeOperations.setApiUser(user.id());
+ GeneralPreferencesInfo prefs = gApi.accounts().self().getPreferences();
+ prefs.emailStrategy = EmailStrategy.ATTENTION_SET_ONLY;
+ gApi.accounts().self().setPreferences(prefs);
+ requestScopeOperations.setApiUser(admin.id());
+
+ // Ensure emails that don't relate to changes are still sent.
+ gApi.accounts().id(user.id().get()).generateHttpPassword();
+ assertThat(sender.getMessages()).isNotEmpty();
+ }
+
private List<AttentionSetUpdate> getAttentionSetUpdatesForUser(
PushOneCommit.Result r, TestAccount account) {
return getAttentionSetUpdates(r.getChange().getId()).stream()
diff --git a/javatests/com/google/gerrit/acceptance/server/change/CommentsIT.java b/javatests/com/google/gerrit/acceptance/server/change/CommentsIT.java
index b4dd4b3..2c42d0a 100644
--- a/javatests/com/google/gerrit/acceptance/server/change/CommentsIT.java
+++ b/javatests/com/google/gerrit/acceptance/server/change/CommentsIT.java
@@ -1090,6 +1090,76 @@
contextLines("2", "line_2", "3", "line_3", "4", "line_4", "5", "line_5"));
}
+ @Test
+ public void commentContextForCommentsOnDifferentPatchsets() throws Exception {
+ PushOneCommit.Result r1 = createChange();
+
+ ImmutableList.Builder<String> content = ImmutableList.builder();
+ for (int i = 1; i <= 10; i++) {
+ content.add("line_" + i);
+ }
+
+ PushOneCommit.Result r2 =
+ pushFactory
+ .create(
+ admin.newIdent(),
+ testRepo,
+ SUBJECT,
+ FILE_NAME,
+ String.join("\n", content.build()),
+ r1.getChangeId())
+ .to("refs/for/master");
+
+ PushOneCommit.Result r3 =
+ pushFactory
+ .create(
+ admin.newIdent(),
+ testRepo,
+ SUBJECT,
+ FILE_NAME,
+ content.build().stream().collect(Collectors.joining("\n")),
+ r1.getChangeId())
+ .to("refs/for/master");
+
+ addCommentOnLine(r2, "r2: please fix", 1);
+ addCommentOnRange(r2, "r2: looks good", commentRangeInLines(2, 3));
+ addCommentOnLine(r3, "r3: please fix", 6);
+ addCommentOnRange(r3, "r3: looks good", commentRangeInLines(7, 8));
+
+ List<CommentInfo> comments =
+ gApi.changes().id(r2.getChangeId()).commentsRequest().withContext(true).getAsList();
+
+ assertThat(comments).hasSize(4);
+
+ assertThat(
+ comments.stream()
+ .filter(c -> c.message.equals("r2: please fix"))
+ .collect(MoreCollectors.onlyElement())
+ .contextLines)
+ .containsExactlyElementsIn(contextLines("1", "line_1"));
+
+ assertThat(
+ comments.stream()
+ .filter(c -> c.message.equals("r2: looks good"))
+ .collect(MoreCollectors.onlyElement())
+ .contextLines)
+ .containsExactlyElementsIn(contextLines("2", "line_2", "3", "line_3"));
+
+ assertThat(
+ comments.stream()
+ .filter(c -> c.message.equals("r3: please fix"))
+ .collect(MoreCollectors.onlyElement())
+ .contextLines)
+ .containsExactlyElementsIn(contextLines("6", "line_6"));
+
+ assertThat(
+ comments.stream()
+ .filter(c -> c.message.equals("r3: looks good"))
+ .collect(MoreCollectors.onlyElement())
+ .contextLines)
+ .containsExactlyElementsIn(contextLines("7", "line_7", "8", "line_8"));
+ }
+
private List<ContextLineInfo> contextLines(String... args) {
List<ContextLineInfo> result = new ArrayList<>();
for (int i = 0; i < args.length; i += 2) {
diff --git a/javatests/com/google/gerrit/acceptance/ssh/SshDaemonIT.java b/javatests/com/google/gerrit/acceptance/ssh/SshDaemonIT.java
new file mode 100644
index 0000000..827c192
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/ssh/SshDaemonIT.java
@@ -0,0 +1,100 @@
+// 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.ssh;
+
+import com.google.common.flogger.FluentLogger;
+import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.acceptance.NoHttpd;
+import com.google.gerrit.acceptance.Sandboxed;
+import com.google.gerrit.acceptance.UseSsh;
+import com.google.gerrit.server.config.SitePaths;
+import com.google.gerrit.server.restapi.config.ListTasks;
+import com.google.gerrit.testing.ConfigSuite;
+import com.google.inject.Inject;
+import com.google.inject.Module;
+import java.time.LocalDateTime;
+import java.util.concurrent.Callable;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+import java.util.concurrent.Future;
+import java.util.concurrent.TimeUnit;
+import org.eclipse.jgit.lib.Config;
+import org.junit.Assert;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+
+@NoHttpd
+@UseSsh
+@Sandboxed
+@RunWith(ConfigSuite.class)
+@SuppressWarnings("unused")
+public class SshDaemonIT extends AbstractDaemonTest {
+ private static final FluentLogger logger = FluentLogger.forEnclosingClass();
+
+ @Inject private ListTasks listTasks;
+ @Inject private SitePaths gerritSitePath;
+
+ @ConfigSuite.Parameter protected Config config;
+
+ @ConfigSuite.Config
+ public static Config gracefulConfig() {
+ Config config = new Config();
+ config.setString("sshd", null, "gracefulStopTimeout", "10s");
+ return config;
+ }
+
+ @Override
+ public Module createSshModule() {
+ return new TestSshCommandModule();
+ }
+
+ public Future<Integer> startCommand(String command) throws Exception {
+ Callable<Integer> gracefulSession =
+ () -> {
+ int returnCode = -1;
+ logger.atFine().log("Before Command");
+ returnCode = userSshSession.execAndReturnStatus(command);
+ logger.atFine().log("After Command");
+ return returnCode;
+ };
+
+ ExecutorService executor = Executors.newFixedThreadPool(1);
+ Future<Integer> future = executor.submit(gracefulSession);
+
+ LocalDateTime timeout = LocalDateTime.now().plusSeconds(10);
+
+ TestCommand.syncPoint.await();
+
+ return future;
+ }
+
+ @Test
+ public void NonGracefulCommandIsStoppedImmediately() throws Exception {
+ Future<Integer> future = startCommand("non-graceful -d 5");
+ restart();
+ Assert.assertTrue(future.get() == -1);
+ }
+
+ @Test
+ public void GracefulCommandIsStoppedGracefully() throws Exception {
+ Future<Integer> future = startCommand("graceful -d 5");
+ restart();
+ if (cfg.getTimeUnit("sshd", null, "gracefulStopTimeout", 0, TimeUnit.SECONDS) == 0) {
+ Assert.assertTrue(future.get() == -1);
+ } else {
+ Assert.assertTrue(future.get() == 0);
+ }
+ }
+}
diff --git a/javatests/com/google/gerrit/acceptance/testsuite/project/ProjectOperationsImplTest.java b/javatests/com/google/gerrit/acceptance/testsuite/project/ProjectOperationsImplTest.java
index 62dfc63..b888102 100644
--- a/javatests/com/google/gerrit/acceptance/testsuite/project/ProjectOperationsImplTest.java
+++ b/javatests/com/google/gerrit/acceptance/testsuite/project/ProjectOperationsImplTest.java
@@ -41,6 +41,7 @@
import com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.TestPermission;
import com.google.gerrit.entities.Permission;
import com.google.gerrit.entities.Project;
+import com.google.gerrit.entities.RefNames;
import com.google.gerrit.extensions.api.projects.BranchInfo;
import com.google.gerrit.extensions.api.projects.ConfigInput;
import com.google.gerrit.server.project.ProjectConfig;
@@ -114,6 +115,13 @@
}
@Test
+ public void permissionOnly() throws Exception {
+ Project.NameKey key = projectOperations.newProject().permissionOnly(true).create();
+ String head = gApi.projects().name(key.get()).head();
+ assertThat(head).isEqualTo(RefNames.REFS_CONFIG);
+ }
+
+ @Test
public void getProjectConfig() throws Exception {
Project.NameKey key = projectOperations.newProject().create();
assertThat(projectOperations.project(key).getProjectConfig().getProject().getDescription())
diff --git a/javatests/com/google/gerrit/acceptance/testsuite/request/RequestScopeOperationsImplTest.java b/javatests/com/google/gerrit/acceptance/testsuite/request/RequestScopeOperationsImplTest.java
index f6421a5..48fd38c 100644
--- a/javatests/com/google/gerrit/acceptance/testsuite/request/RequestScopeOperationsImplTest.java
+++ b/javatests/com/google/gerrit/acceptance/testsuite/request/RequestScopeOperationsImplTest.java
@@ -16,7 +16,6 @@
import static com.google.common.truth.Truth.assertThat;
import static com.google.common.truth.Truth.assertWithMessage;
-import static com.google.common.truth.Truth8.assertThat;
import static com.google.gerrit.testing.GerritJUnit.assertThrows;
import com.google.common.collect.ImmutableSet;
@@ -29,7 +28,6 @@
import com.google.gerrit.extensions.common.ChangeInput;
import com.google.gerrit.server.AnonymousUser;
import com.google.gerrit.server.CurrentUser;
-import com.google.gerrit.server.CurrentUser.PropertyKey;
import com.google.gerrit.server.notedb.Sequences;
import com.google.inject.Inject;
import com.google.inject.Provider;
@@ -75,19 +73,6 @@
}
@Test
- public void resetCurrentApiUserClearsCachedState() throws Exception {
- requestScopeOperations.setApiUser(user.id());
- PropertyKey<String> key = PropertyKey.create();
- atrScope.get().getUser().put(key, "foo");
- assertThat(atrScope.get().getUser().get(key)).hasValue("foo");
-
- AcceptanceTestRequestScope.Context oldCtx = requestScopeOperations.resetCurrentApiUser();
- checkCurrentUser(user.id());
- assertThat(atrScope.get().getUser().get(key)).isEmpty();
- assertThat(oldCtx.getUser().get(key)).hasValue("foo");
- }
-
- @Test
public void setApiUserAnonymousSetsAnonymousUser() throws Exception {
fastCheckCurrentUser(admin.id());
requestScopeOperations.setApiUserAnonymous();
diff --git a/javatests/com/google/gerrit/server/cache/PerThreadCacheTest.java b/javatests/com/google/gerrit/server/cache/PerThreadCacheTest.java
deleted file mode 100644
index 5d420d3..0000000
--- a/javatests/com/google/gerrit/server/cache/PerThreadCacheTest.java
+++ /dev/null
@@ -1,103 +0,0 @@
-// 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.
-
-package com.google.gerrit.server.cache;
-
-import static com.google.common.truth.Truth.assertThat;
-import static com.google.gerrit.testing.GerritJUnit.assertThrows;
-
-import java.util.function.Supplier;
-import org.junit.Test;
-
-public class PerThreadCacheTest {
-
- @SuppressWarnings("TruthIncompatibleType")
- @Test
- public void key_respectsClass() {
- assertThat(PerThreadCache.Key.create(String.class))
- .isEqualTo(PerThreadCache.Key.create(String.class));
- assertThat(PerThreadCache.Key.create(String.class))
- .isNotEqualTo(
- /* expected: Key<String>, actual: Key<Integer> */ PerThreadCache.Key.create(
- Integer.class));
- }
-
- @Test
- public void key_respectsIdentifiers() {
- assertThat(PerThreadCache.Key.create(String.class, "id1"))
- .isEqualTo(PerThreadCache.Key.create(String.class, "id1"));
- assertThat(PerThreadCache.Key.create(String.class, "id1"))
- .isNotEqualTo(PerThreadCache.Key.create(String.class, "id2"));
- }
-
- @Test
- public void endToEndCache() {
- try (PerThreadCache ignored = PerThreadCache.create()) {
- PerThreadCache cache = PerThreadCache.get();
- PerThreadCache.Key<String> key1 = PerThreadCache.Key.create(String.class);
-
- String value1 = cache.get(key1, () -> "value1");
- assertThat(value1).isEqualTo("value1");
-
- Supplier<String> neverCalled =
- () -> {
- throw new IllegalStateException("this method must not be called");
- };
- assertThat(cache.get(key1, neverCalled)).isEqualTo("value1");
- }
- }
-
- @Test
- public void cleanUp() {
- PerThreadCache.Key<String> key = PerThreadCache.Key.create(String.class);
- try (PerThreadCache ignored = PerThreadCache.create()) {
- PerThreadCache cache = PerThreadCache.get();
- String value1 = cache.get(key, () -> "value1");
- assertThat(value1).isEqualTo("value1");
- }
-
- // Create a second cache and assert that it is not connected to the first one.
- // This ensures that the cleanup is actually working.
- try (PerThreadCache ignored = PerThreadCache.create()) {
- PerThreadCache cache = PerThreadCache.get();
- String value1 = cache.get(key, () -> "value2");
- assertThat(value1).isEqualTo("value2");
- }
- }
-
- @Test
- public void doubleInstantiationFails() {
- try (PerThreadCache ignored = PerThreadCache.create()) {
- IllegalStateException thrown =
- assertThrows(IllegalStateException.class, () -> PerThreadCache.create());
- assertThat(thrown).hasMessageThat().contains("called create() twice on the same request");
- }
- }
-
- @Test
- public void enforceMaxSize() {
- try (PerThreadCache cache = PerThreadCache.create()) {
- // Fill the cache
- for (int i = 0; i < 50; i++) {
- PerThreadCache.Key<String> key = PerThreadCache.Key.create(String.class, i);
- cache.get(key, () -> "cached value");
- }
- // Assert that the value was not persisted
- PerThreadCache.Key<String> key = PerThreadCache.Key.create(String.class, 1000);
- cache.get(key, () -> "new value");
- String value = cache.get(key, () -> "directly served");
- assertThat(value).isEqualTo("directly served");
- }
- }
-}
diff --git a/javatests/com/google/gerrit/server/cache/serialize/BUILD b/javatests/com/google/gerrit/server/cache/serialize/BUILD
index fa6a717..6976d19 100644
--- a/javatests/com/google/gerrit/server/cache/serialize/BUILD
+++ b/javatests/com/google/gerrit/server/cache/serialize/BUILD
@@ -5,6 +5,7 @@
srcs = glob(["*.java"]),
deps = [
"//java/com/google/gerrit/entities",
+ "//java/com/google/gerrit/extensions:api",
"//java/com/google/gerrit/server",
"//java/com/google/gerrit/server/cache/serialize",
"//java/com/google/gerrit/server/cache/testing",
diff --git a/javatests/com/google/gerrit/server/cache/serialize/CommentContextSerializerTest.java b/javatests/com/google/gerrit/server/cache/serialize/CommentContextSerializerTest.java
new file mode 100644
index 0000000..84f290c
--- /dev/null
+++ b/javatests/com/google/gerrit/server/cache/serialize/CommentContextSerializerTest.java
@@ -0,0 +1,41 @@
+package com.google.gerrit.server.cache.serialize;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.server.comment.CommentContextCacheImpl.CommentContextSerializer.INSTANCE;
+
+import com.google.common.collect.ImmutableMap;
+import com.google.gerrit.entities.Change;
+import com.google.gerrit.entities.CommentContext;
+import com.google.gerrit.entities.Project;
+import com.google.gerrit.server.comment.CommentContextKey;
+import org.junit.Test;
+
+public class CommentContextSerializerTest {
+ @Test
+ public void roundTripValue() {
+ CommentContext commentContext =
+ CommentContext.create(ImmutableMap.of(1, "line_1", 2, "line_2"));
+
+ byte[] serialized = INSTANCE.serialize(commentContext);
+ CommentContext deserialized = INSTANCE.deserialize(serialized);
+
+ assertThat(commentContext).isEqualTo(deserialized);
+ }
+
+ @Test
+ public void roundTripKey() {
+ Project.NameKey proj = Project.NameKey.parse("project");
+ Change.Id changeId = Change.Id.tryParse("1234").get();
+
+ CommentContextKey k =
+ CommentContextKey.builder()
+ .project(proj)
+ .changeId(changeId)
+ .id("commentId")
+ .path("pathHash")
+ .patchset(1)
+ .build();
+ byte[] serialized = CommentContextKey.Serializer.INSTANCE.serialize(k);
+ assertThat(k).isEqualTo(CommentContextKey.Serializer.INSTANCE.deserialize(serialized));
+ }
+}
diff --git a/javatests/com/google/gerrit/server/extensions/webui/UiActionsTest.java b/javatests/com/google/gerrit/server/extensions/webui/UiActionsTest.java
index de23ef4..1cdca1b 100644
--- a/javatests/com/google/gerrit/server/extensions/webui/UiActionsTest.java
+++ b/javatests/com/google/gerrit/server/extensions/webui/UiActionsTest.java
@@ -101,11 +101,6 @@
}
@Override
- public Object getCacheKey() {
- return new Object();
- }
-
- @Override
public boolean isIdentifiedUser() {
return true;
}
diff --git a/javatests/com/google/gerrit/server/git/receive/ReceivePackRefCacheTest.java b/javatests/com/google/gerrit/server/git/receive/ReceivePackRefCacheTest.java
index 698acd8..7eb6bc7 100644
--- a/javatests/com/google/gerrit/server/git/receive/ReceivePackRefCacheTest.java
+++ b/javatests/com/google/gerrit/server/git/receive/ReceivePackRefCacheTest.java
@@ -24,6 +24,7 @@
import com.google.common.collect.ImmutableMap;
import com.google.common.collect.ImmutableSet;
import com.google.gerrit.entities.Change;
+import com.google.gerrit.entities.PatchSet;
import com.google.gerrit.entities.RefNames;
import java.util.Map;
import org.eclipse.jgit.lib.ObjectId;
@@ -60,17 +61,18 @@
}
@Test
- public void noCache_tipsFromObjectIdDelegatesToRefDbAndFiltersByPrefix() throws Exception {
+ public void noCache_tipsFromObjectIdDelegatesToRefDb() throws Exception {
Ref refBla = newRef("refs/bla", "badc0feebadc0feebadc0feebadc0feebadc0fee");
- Ref refheads = newRef(RefNames.REFS_HEADS, "deadbeefdeadbeefdeadbeefdeadbeefdeadbeef");
+ String patchSetRef = RefNames.REFS_CHANGES + "01/1/1";
+ Ref patchSet = newRef(patchSetRef, "deadbeefdeadbeefdeadbeefdeadbeefdeadbeef");
RefDatabase mockRefDb = mock(RefDatabase.class);
ReceivePackRefCache cache = ReceivePackRefCache.noCache(mockRefDb);
when(mockRefDb.getTipsWithSha1(ObjectId.zeroId()))
- .thenReturn(ImmutableSet.of(refBla, refheads));
+ .thenReturn(ImmutableSet.of(refBla, patchSet));
- assertThat(cache.tipsFromObjectId(ObjectId.zeroId(), RefNames.REFS_HEADS))
- .containsExactly(refheads);
+ assertThat(cache.patchSetIdsFromObjectId(ObjectId.zeroId()))
+ .containsExactly(PatchSet.Id.fromRef(patchSetRef));
verify(mockRefDb).getTipsWithSha1(ObjectId.zeroId());
verifyNoMoreInteractions(mockRefDb);
}
@@ -107,25 +109,14 @@
}
@Test
- public void advertisedRefs_tipsFromObjectIdWithNoPrefix() throws Exception {
+ public void advertisedRefs_patchSetIdsFromObjectId() throws Exception {
Map<String, Ref> refs = setupTwoChanges();
ReceivePackRefCache cache = ReceivePackRefCache.withAdvertisedRefs(() -> refs);
assertThat(
- cache.tipsFromObjectId(
- ObjectId.fromString("badc0feebadc0feebadc0feebadc0feebadc0fee"), null))
- .containsExactly(refs.get("refs/changes/01/1/1"));
- }
-
- @Test
- public void advertisedRefs_tipsFromObjectIdWithPrefix() throws Exception {
- Map<String, Ref> refs = setupTwoChanges();
- ReceivePackRefCache cache = ReceivePackRefCache.withAdvertisedRefs(() -> refs);
-
- assertThat(
- cache.tipsFromObjectId(
- ObjectId.fromString("badc0feebadc0feebadc0feebadc0feebadc0fee"), "/refs/some"))
- .isEmpty();
+ cache.patchSetIdsFromObjectId(
+ ObjectId.fromString("badc0feebadc0feebadc0feebadc0feebadc0fee")))
+ .containsExactly(PatchSet.Id.fromRef("refs/changes/01/1/1"));
}
private static Ref newRef(String name, String sha1) {
diff --git a/javatests/com/google/gerrit/server/logging/LoggingContextAwareExecutorServiceTest.java b/javatests/com/google/gerrit/server/logging/LoggingContextAwareExecutorServiceTest.java
index 733d784..8d019f3 100644
--- a/javatests/com/google/gerrit/server/logging/LoggingContextAwareExecutorServiceTest.java
+++ b/javatests/com/google/gerrit/server/logging/LoggingContextAwareExecutorServiceTest.java
@@ -24,8 +24,8 @@
import com.google.inject.Guice;
import com.google.inject.Inject;
import com.google.inject.Injector;
-import java.util.SortedMap;
-import java.util.SortedSet;
+import java.util.Map;
+import java.util.Set;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import org.eclipse.jgit.lib.Config;
@@ -76,7 +76,7 @@
// Create a performance log record.
TraceContext.newTimer("test").close();
- SortedMap<String, SortedSet<Object>> tagMap = LoggingContext.getInstance().getTags().asMap();
+ Map<String, ? extends Set<Object>> tagMap = LoggingContext.getInstance().getTags().asMap();
assertThat(tagMap.keySet()).containsExactly("foo");
assertThat(tagMap.get("foo")).containsExactly("bar");
assertForceLogging(true);
@@ -90,7 +90,7 @@
() -> {
// Verify that the tags and force logging flag have been propagated to the new
// thread.
- SortedMap<String, SortedSet<Object>> threadTagMap =
+ Map<String, ? extends Set<Object>> threadTagMap =
LoggingContext.getInstance().getTags().asMap();
expect.that(threadTagMap.keySet()).containsExactly("foo");
expect.that(threadTagMap.get("foo")).containsExactly("bar");
diff --git a/javatests/com/google/gerrit/server/logging/MutableTagsTest.java b/javatests/com/google/gerrit/server/logging/MutableTagsTest.java
index f6f3b46..200c49d 100644
--- a/javatests/com/google/gerrit/server/logging/MutableTagsTest.java
+++ b/javatests/com/google/gerrit/server/logging/MutableTagsTest.java
@@ -21,8 +21,7 @@
import com.google.common.collect.ImmutableSet;
import com.google.common.collect.ImmutableSetMultimap;
import java.util.Map;
-import java.util.SortedMap;
-import java.util.SortedSet;
+import java.util.Set;
import org.junit.Before;
import org.junit.Test;
@@ -157,7 +156,7 @@
}
private void assertTags(ImmutableMap<String, ImmutableSet<String>> expectedTagMap) {
- SortedMap<String, SortedSet<Object>> actualTagMap = tags.getTags().asMap();
+ Map<String, ? extends Set<Object>> actualTagMap = tags.getTags().asMap();
assertThat(actualTagMap.keySet()).containsExactlyElementsIn(expectedTagMap.keySet());
for (Map.Entry<String, ImmutableSet<String>> expectedEntry : expectedTagMap.entrySet()) {
assertThat(actualTagMap.get(expectedEntry.getKey()))
diff --git a/javatests/com/google/gerrit/server/logging/TraceContextTest.java b/javatests/com/google/gerrit/server/logging/TraceContextTest.java
index 13f2035..6a3632d 100644
--- a/javatests/com/google/gerrit/server/logging/TraceContextTest.java
+++ b/javatests/com/google/gerrit/server/logging/TraceContextTest.java
@@ -21,8 +21,7 @@
import com.google.common.collect.ImmutableSet;
import com.google.gerrit.server.logging.TraceContext.TraceIdConsumer;
import java.util.Map;
-import java.util.SortedMap;
-import java.util.SortedSet;
+import java.util.Set;
import org.junit.After;
import org.junit.Test;
@@ -254,7 +253,7 @@
}
private void assertTags(ImmutableMap<String, ImmutableSet<String>> expectedTagMap) {
- SortedMap<String, SortedSet<Object>> actualTagMap =
+ Map<String, ? extends Set<Object>> actualTagMap =
LoggingContext.getInstance().getTags().asMap();
assertThat(actualTagMap.keySet()).containsExactlyElementsIn(expectedTagMap.keySet());
for (Map.Entry<String, ImmutableSet<String>> expectedEntry : expectedTagMap.entrySet()) {
diff --git a/javatests/com/google/gerrit/server/notedb/ChangeNotesStateTest.java b/javatests/com/google/gerrit/server/notedb/ChangeNotesStateTest.java
index dd3238f..321e4da 100644
--- a/javatests/com/google/gerrit/server/notedb/ChangeNotesStateTest.java
+++ b/javatests/com/google/gerrit/server/notedb/ChangeNotesStateTest.java
@@ -634,6 +634,39 @@
}
@Test
+ public void serializeAllAttentionSetUpdates() throws Exception {
+ assertRoundTrip(
+ newBuilder()
+ .allAttentionSetUpdates(
+ ImmutableList.of(
+ AttentionSetUpdate.createFromRead(
+ Instant.EPOCH.plusSeconds(23),
+ Account.id(1000),
+ AttentionSetUpdate.Operation.ADD,
+ "reason 1"),
+ AttentionSetUpdate.createFromRead(
+ Instant.EPOCH.plusSeconds(42),
+ Account.id(2000),
+ AttentionSetUpdate.Operation.REMOVE,
+ "reason 2")))
+ .build(),
+ newProtoBuilder()
+ .addAllAttentionSetUpdate(
+ AttentionSetUpdateProto.newBuilder()
+ .setTimestampMillis(23_000) // epoch millis
+ .setAccount(1000)
+ .setOperation("ADD")
+ .setReason("reason 1"))
+ .addAllAttentionSetUpdate(
+ AttentionSetUpdateProto.newBuilder()
+ .setTimestampMillis(42_000) // epoch millis
+ .setAccount(2000)
+ .setOperation("REMOVE")
+ .setReason("reason 2"))
+ .build());
+ }
+
+ @Test
public void serializeAssigneeUpdates() throws Exception {
assertRoundTrip(
newBuilder()
@@ -793,6 +826,9 @@
"attentionSet",
new TypeLiteral<ImmutableSet<AttentionSetUpdate>>() {}.getType())
.put(
+ "allAttentionSetUpdates",
+ new TypeLiteral<ImmutableList<AttentionSetUpdate>>() {}.getType())
+ .put(
"assigneeUpdates",
new TypeLiteral<ImmutableList<AssigneeStatusUpdate>>() {}.getType())
.put("submitRecords", new TypeLiteral<ImmutableList<SubmitRecord>>() {}.getType())
diff --git a/javatests/com/google/gerrit/server/notedb/ChangeNotesTest.java b/javatests/com/google/gerrit/server/notedb/ChangeNotesTest.java
index 938fffc..44dd831 100644
--- a/javatests/com/google/gerrit/server/notedb/ChangeNotesTest.java
+++ b/javatests/com/google/gerrit/server/notedb/ChangeNotesTest.java
@@ -699,6 +699,13 @@
}
@Test
+ public void defaultAttentionSetUpdatesIsEmpty() throws Exception {
+ Change c = newChange();
+ ChangeNotes notes = newNotes(c);
+ assertThat(notes.getAttentionSetUpdates()).isEmpty();
+ }
+
+ @Test
public void addAttentionStatus() throws Exception {
Change c = newChange();
ChangeUpdate update = newUpdate(c, changeOwner);
@@ -712,6 +719,19 @@
}
@Test
+ public void addAllAttentionUpdates() throws Exception {
+ Change c = newChange();
+ ChangeUpdate update = newUpdate(c, changeOwner);
+ AttentionSetUpdate attentionSetUpdate =
+ AttentionSetUpdate.createForWrite(changeOwner.getAccountId(), Operation.ADD, "test");
+ update.addToPlannedAttentionSetUpdates(ImmutableSet.of(attentionSetUpdate));
+ update.commit();
+
+ ChangeNotes notes = newNotes(c);
+ assertThat(notes.getAttentionSetUpdates()).containsExactly(addTimestamp(attentionSetUpdate, c));
+ }
+
+ @Test
public void filterLatestAttentionStatus() throws Exception {
Change c = newChange();
ChangeUpdate update = newUpdate(c, changeOwner);
@@ -730,6 +750,28 @@
}
@Test
+ public void DoesNotFilterLatestAttentionSetUpdates() throws Exception {
+ Change c = newChange();
+ ChangeUpdate update = newUpdate(c, changeOwner);
+ AttentionSetUpdate firstAttentionSetUpdate =
+ AttentionSetUpdate.createForWrite(changeOwner.getAccountId(), Operation.ADD, "test");
+ update.addToPlannedAttentionSetUpdates(ImmutableSet.of(firstAttentionSetUpdate));
+ update.commit();
+ update = newUpdate(c, changeOwner);
+ firstAttentionSetUpdate = addTimestamp(firstAttentionSetUpdate, c);
+
+ AttentionSetUpdate secondAttentionSetUpdate =
+ AttentionSetUpdate.createForWrite(changeOwner.getAccountId(), Operation.REMOVE, "test");
+ update.addToPlannedAttentionSetUpdates(ImmutableSet.of(secondAttentionSetUpdate));
+ update.commit();
+ secondAttentionSetUpdate = addTimestamp(secondAttentionSetUpdate, c);
+
+ ChangeNotes notes = newNotes(c);
+ assertThat(notes.getAttentionSetUpdates())
+ .containsExactly(secondAttentionSetUpdate, firstAttentionSetUpdate);
+ }
+
+ @Test
public void addAttentionStatus_rejectTimestamp() throws Exception {
Change c = newChange();
ChangeUpdate update = newUpdate(c, changeOwner);
diff --git a/javatests/com/google/gerrit/server/permissions/RefControlTest.java b/javatests/com/google/gerrit/server/permissions/RefControlTest.java
index 81cb732..64f9392 100644
--- a/javatests/com/google/gerrit/server/permissions/RefControlTest.java
+++ b/javatests/com/google/gerrit/server/permissions/RefControlTest.java
@@ -1198,11 +1198,6 @@
}
@Override
- public Object getCacheKey() {
- return new Object();
- }
-
- @Override
public Optional<String> getUserName() {
return Optional.ofNullable(username);
}
diff --git a/lib/auto/BUILD b/lib/auto/BUILD
index 1da7f50..18b9b91 100644
--- a/lib/auto/BUILD
+++ b/lib/auto/BUILD
@@ -27,6 +27,21 @@
],
)
+java_plugin(
+ name = "auto-value-gson-plugin",
+ processor_class = "com.ryanharter.auto.value.gson.factory.AutoValueGsonAdapterFactoryProcessor",
+ deps = [
+ "@auto-value-annotations//jar",
+ "@auto-value-gson-extension//jar",
+ "@auto-value-gson-factory//jar",
+ "@auto-value-gson-runtime//jar",
+ "@auto-value//jar",
+ "@autotransient//jar",
+ "@gson//jar",
+ "@javapoet//jar",
+ ],
+)
+
java_library(
name = "auto-value",
data = ["//lib:LICENSE-Apache2.0"],
@@ -50,3 +65,17 @@
visibility = ["//visibility:public"],
exports = ["@auto-value-annotations//jar"],
)
+
+java_library(
+ name = "auto-value-gson",
+ data = ["//lib:LICENSE-Apache2.0"],
+ exported_plugins = [
+ ":auto-value-gson-plugin",
+ ],
+ visibility = ["//visibility:public"],
+ exports = [
+ "@auto-value-gson-extension//jar",
+ "@auto-value-gson-factory//jar",
+ "@auto-value-gson-runtime//jar",
+ ],
+)
diff --git a/modules/jgit b/modules/jgit
index 9fe5406..dd16976 160000
--- a/modules/jgit
+++ b/modules/jgit
@@ -1 +1 @@
-Subproject commit 9fe54061197c42faedc9417bdc70797681aa06d6
+Subproject commit dd169769bf42115e1dee749efeecab84544b28c4
diff --git a/plugins/replication b/plugins/replication
index 293ee35..63fb2b4 160000
--- a/plugins/replication
+++ b/plugins/replication
@@ -1 +1 @@
-Subproject commit 293ee3590d9fb57f6882e11126d8c97532cce968
+Subproject commit 63fb2b4ba85380d798acbdc076e8673353507569
diff --git a/polygerrit-ui/app/.eslintrc.js b/polygerrit-ui/app/.eslintrc.js
index 226cc4c..399ae6e 100644
--- a/polygerrit-ui/app/.eslintrc.js
+++ b/polygerrit-ui/app/.eslintrc.js
@@ -277,6 +277,12 @@
"@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",
+ // Disable no-invalid-this for ts files, because it incorrectly reports
+ // errors in some cases (see https://github.com/typescript-eslint/typescript-eslint/issues/491)
+ // At the same time, we are using typescript in a strict mode and
+ // it catches almost all errors related to invalid usage of this.
+ "no-invalid-this": "off",
+
"jsdoc/no-types": 2,
},
"parserOptions": {
diff --git a/polygerrit-ui/app/constants/constants.ts b/polygerrit-ui/app/constants/constants.ts
index 2db076f..8e8eaf3 100644
--- a/polygerrit-ui/app/constants/constants.ts
+++ b/polygerrit-ui/app/constants/constants.ts
@@ -292,10 +292,21 @@
export enum EmailStrategy {
ENABLED = 'ENABLED',
CC_ON_OWN_COMMENTS = 'CC_ON_OWN_COMMENTS',
+ ATTENTION_SET_ONLY = 'ATTENTION_SET_ONLY',
DISABLED = 'DISABLED',
}
/**
+ * The type of email format to use.
+ * Doesn't mentioned in doc, but exists in Java class GeneralPreferencesInfo.
+ */
+
+export enum EmailFormat {
+ PLAINTEXT = 'PLAINTEXT',
+ HTML_PLAINTEXT = 'HTML_PLAINTEXT',
+}
+
+/**
* The base which should be pre-selected in the 'Diff Against' drop-down list when the change screen is opened for a merge commit
* https://gerrit-review.googlesource.com/Documentation/rest-api-accounts.html#preferences-input
*/
@@ -330,3 +341,20 @@
OWNER_REVIEWERS = 'OWNER_REVIEWERS',
ALL = 'ALL',
}
+
+/**
+ * The authentication type that is configured on the server.
+ * https://gerrit-review.googlesource.com/Documentation/rest-api-config.html#auth-info
+ */
+export enum AuthType {
+ OPENID = 'OPENID',
+ OPENID_SSO = 'OPENID_SSO',
+ OAUTH = 'OAUTH',
+ HTTP = 'HTTP',
+ HTTP_LDAP = 'HTTP_LDAP',
+ CLIENT_SSL_CERT_LDAP = 'CLIENT_SSL_CERT_LDAP',
+ LDAP = 'LDAP',
+ LDAP_BIND = 'LDAP_BIND',
+ CUSTOM_EXTENSION = 'CUSTOM_EXTENSION',
+ DEVELOPMENT_BECOME_ANY_ACCOUNT = 'DEVELOPMENT_BECOME_ANY_ACCOUNT',
+}
diff --git a/polygerrit-ui/app/constants/messages.ts b/polygerrit-ui/app/constants/messages.ts
index 15692bd..5b4a534 100644
--- a/polygerrit-ui/app/constants/messages.ts
+++ b/polygerrit-ui/app/constants/messages.ts
@@ -15,10 +15,6 @@
* limitations under the License.
*/
-/** @desc Default message shown when no threads in gr-thread-list */
-export const NO_THREADS_MSG =
- 'There are no inline comment threads on any diff for this change.';
-
/** @desc Message shown when no threads in gr-thread-list for robot comments */
export const NO_ROBOT_COMMENTS_THREADS_MSG =
'There are no findings for this patchset.';
diff --git a/polygerrit-ui/app/elements/admin/gr-admin-group-list/gr-admin-group-list.ts b/polygerrit-ui/app/elements/admin/gr-admin-group-list/gr-admin-group-list.ts
index b9039ae..9d40e28 100644
--- a/polygerrit-ui/app/elements/admin/gr-admin-group-list/gr-admin-group-list.ts
+++ b/polygerrit-ui/app/elements/admin/gr-admin-group-list/gr-admin-group-list.ts
@@ -31,7 +31,7 @@
import {customElement, property, observe, computed} from '@polymer/decorators';
import {AppElementAdminParams} from '../../gr-app-types';
import {GrOverlay} from '../../shared/gr-overlay/gr-overlay';
-import {GroupId, GroupInfo} from '../../../types/common';
+import {GroupId, GroupInfo, GroupName} from '../../../types/common';
import {RestApiService} from '../../../services/services/gr-rest-api/gr-rest-api';
import {GrCreateGroupDialog} from '../gr-create-group-dialog/gr-create-group-dialog';
@@ -160,7 +160,7 @@
}
this._groups = Object.keys(groups).map(key => {
const group = groups[key];
- group.name = key;
+ group.name = key as GroupName;
return group;
});
this._loading = false;
diff --git a/polygerrit-ui/app/elements/admin/gr-admin-view/gr-admin-view.js b/polygerrit-ui/app/elements/admin/gr-admin-view/gr-admin-view.js
deleted file mode 100644
index d861681..0000000
--- a/polygerrit-ui/app/elements/admin/gr-admin-view/gr-admin-view.js
+++ /dev/null
@@ -1,313 +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 '../../../styles/gr-menu-page-styles.js';
-import '../../../styles/gr-page-nav-styles.js';
-import '../../../styles/shared-styles.js';
-import '../../shared/gr-dropdown-list/gr-dropdown-list.js';
-import '../../shared/gr-icons/gr-icons.js';
-import '../../shared/gr-js-api-interface/gr-js-api-interface.js';
-import '../../shared/gr-page-nav/gr-page-nav.js';
-import '../../shared/gr-rest-api-interface/gr-rest-api-interface.js';
-import '../gr-admin-group-list/gr-admin-group-list.js';
-import '../gr-group/gr-group.js';
-import '../gr-group-audit-log/gr-group-audit-log.js';
-import '../gr-group-members/gr-group-members.js';
-import '../gr-plugin-list/gr-plugin-list.js';
-import '../gr-repo/gr-repo.js';
-import '../gr-repo-access/gr-repo-access.js';
-import '../gr-repo-commands/gr-repo-commands.js';
-import '../gr-repo-dashboards/gr-repo-dashboards.js';
-import '../gr-repo-detail-list/gr-repo-detail-list.js';
-import '../gr-repo-list/gr-repo-list.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-admin-view_html.js';
-import {getBaseUrl} from '../../../utils/url-util.js';
-import {GerritNav} from '../../core/gr-navigation/gr-navigation.js';
-import {getPluginLoader} from '../../shared/gr-js-api-interface/gr-plugin-loader.js';
-import {getAdminLinks} from '../../../utils/admin-nav-util.js';
-
-const INTERNAL_GROUP_REGEX = /^[\da-f]{40}$/;
-
-/**
- * @extends PolymerElement
- */
-class GrAdminView extends GestureEventListeners(
- LegacyElementMixin(
- PolymerElement)) {
- static get template() { return htmlTemplate; }
-
- static get is() { return 'gr-admin-view'; }
-
- static get properties() {
- return {
- /** @type {?} */
- params: Object,
- path: String,
- adminView: String,
-
- _breadcrumbParentName: String,
- _repoName: String,
- _groupId: {
- type: Number,
- observer: '_computeGroupName',
- },
- _groupIsInternal: Boolean,
- _groupName: String,
- _groupOwner: {
- type: Boolean,
- value: false,
- },
- _subsectionLinks: Array,
- _filteredLinks: Array,
- _showDownload: {
- type: Boolean,
- value: false,
- },
- _isAdmin: {
- type: Boolean,
- value: false,
- },
- _showGroup: Boolean,
- _showGroupAuditLog: Boolean,
- _showGroupList: Boolean,
- _showGroupMembers: Boolean,
- _showRepoAccess: Boolean,
- _showRepoCommands: Boolean,
- _showRepoDashboards: Boolean,
- _showRepoDetailList: Boolean,
- _showRepoMain: Boolean,
- _showRepoList: Boolean,
- _showPluginList: Boolean,
- };
- }
-
- static get observers() {
- return [
- '_paramsChanged(params)',
- ];
- }
-
- /** @override */
- attached() {
- super.attached();
- this.reload();
- }
-
- reload() {
- const promises = [
- this.$.restAPI.getAccount(),
- getPluginLoader().awaitPluginsLoaded(),
- ];
- return Promise.all(promises).then(result => {
- this._account = result[0];
- let options;
- if (this._repoName) {
- options = {repoName: this._repoName};
- } else if (this._groupId) {
- options = {
- groupId: this._groupId,
- groupName: this._groupName,
- groupIsInternal: this._groupIsInternal,
- isAdmin: this._isAdmin,
- groupOwner: this._groupOwner,
- };
- }
-
- return getAdminLinks(this._account,
- params => this.$.restAPI.getAccountCapabilities(params),
- () => this.$.jsAPI.getAdminMenuLinks(),
- options)
- .then(res => {
- this._filteredLinks = res.links;
- this._breadcrumbParentName = res.expandedSection ?
- res.expandedSection.name : '';
-
- if (!res.expandedSection) {
- this._subsectionLinks = [];
- return;
- }
- this._subsectionLinks = [res.expandedSection]
- .concat(res.expandedSection.children).map(section => {
- return {
- text: !section.detailType ? 'Home' : section.name,
- value: section.view + (section.detailType || ''),
- view: section.view,
- url: section.url,
- detailType: section.detailType,
- parent: this._groupId || this._repoName || '',
- };
- });
- });
- });
- }
-
- _computeSelectValue(params) {
- if (!params || !params.view) { return; }
- return params.view + (params.detail || '');
- }
-
- _selectedIsCurrentPage(selected) {
- return (selected.parent === (this._repoName || this._groupId) &&
- selected.view === this.params.view &&
- selected.detailType === this.params.detail);
- }
-
- _handleSubsectionChange(e) {
- const selected = this._subsectionLinks
- .find(section => section.value === e.detail.value);
-
- // This is when it gets set initially.
- if (this._selectedIsCurrentPage(selected)) {
- return;
- }
- GerritNav.navigateToRelativeUrl(selected.url);
- }
-
- _paramsChanged(params) {
- const isGroupView = params.view === GerritNav.View.GROUP;
- const isRepoView = params.view === GerritNav.View.REPO;
- const isAdminView = params.view === GerritNav.View.ADMIN;
-
- this.set('_showGroup', isGroupView && !params.detail);
- this.set('_showGroupAuditLog', isGroupView &&
- params.detail === GerritNav.GroupDetailView.LOG);
- this.set('_showGroupMembers', isGroupView &&
- params.detail === GerritNav.GroupDetailView.MEMBERS);
-
- this.set('_showGroupList', isAdminView &&
- params.adminView === 'gr-admin-group-list');
-
- this.set('_showRepoAccess', isRepoView &&
- params.detail === GerritNav.RepoDetailView.ACCESS);
- this.set('_showRepoCommands', isRepoView &&
- params.detail === GerritNav.RepoDetailView.COMMANDS);
- this.set('_showRepoDetailList', isRepoView &&
- (params.detail === GerritNav.RepoDetailView.BRANCHES ||
- params.detail === GerritNav.RepoDetailView.TAGS));
- this.set('_showRepoDashboards', isRepoView &&
- params.detail === GerritNav.RepoDetailView.DASHBOARDS);
- this.set('_showRepoMain', isRepoView && !params.detail);
-
- this.set('_showRepoList', isAdminView &&
- params.adminView === 'gr-repo-list');
-
- this.set('_showPluginList', isAdminView &&
- params.adminView === 'gr-plugin-list');
-
- let needsReload = false;
- if (params.repo !== this._repoName) {
- this._repoName = params.repo || '';
- // Reloads the admin menu.
- needsReload = true;
- }
- if (params.groupId !== this._groupId) {
- this._groupId = params.groupId || '';
- // Reloads the admin menu.
- needsReload = true;
- }
- if (this._breadcrumbParentName && !params.groupId && !params.repo) {
- needsReload = true;
- }
- if (!needsReload) { return; }
- this.reload();
- }
-
- // TODO (beckysiegel): Update these functions after router abstraction is
- // updated. They are currently copied from gr-dropdown (and should be
- // updated there as well once complete).
- _computeURLHelper(host, path) {
- return '//' + host + getBaseUrl() + path;
- }
-
- _computeRelativeURL(path) {
- const host = window.location.host;
- return this._computeURLHelper(host, path);
- }
-
- _computeLinkURL(link) {
- if (!link || typeof link.url === 'undefined') { return ''; }
- if (link.target || !link.noBaseUrl) {
- return link.url;
- }
- return this._computeRelativeURL(link.url);
- }
-
- /**
- * @param {string} itemView
- * @param {Object} params
- * @param {string=} opt_detailType
- */
- _computeSelectedClass(itemView, params, opt_detailType) {
- if (!params) return '';
- // Group params are structured differently from admin params. Compute
- // selected differently for groups.
- // TODO(wyatta): Simplify this when all routes work like group params.
- if (params.view === GerritNav.View.GROUP &&
- itemView === GerritNav.View.GROUP) {
- if (!params.detail && !opt_detailType) { return 'selected'; }
- if (params.detail === opt_detailType) { return 'selected'; }
- return '';
- }
-
- if (params.view === GerritNav.View.REPO &&
- itemView === GerritNav.View.REPO) {
- if (!params.detail && !opt_detailType) { return 'selected'; }
- if (params.detail === opt_detailType) { return 'selected'; }
- return '';
- }
-
- if (params.detailType && params.detailType !== opt_detailType) {
- return '';
- }
- return itemView === params.adminView ? 'selected' : '';
- }
-
- _computeGroupName(groupId) {
- if (!groupId) { return ''; }
-
- const promises = [];
- this.$.restAPI.getGroupConfig(groupId).then(group => {
- if (!group || !group.name) { return; }
-
- this._groupName = group.name;
- this._groupIsInternal = !!group.id.match(INTERNAL_GROUP_REGEX);
- this.reload();
-
- promises.push(this.$.restAPI.getIsAdmin().then(isAdmin => {
- this._isAdmin = isAdmin;
- }));
-
- promises.push(this.$.restAPI.getIsGroupOwner(group.name).then(
- isOwner => {
- this._groupOwner = isOwner;
- }));
-
- return Promise.all(promises).then(() => {
- this.reload();
- });
- });
- }
-
- _updateGroupName(e) {
- this._groupName = e.detail.name;
- this.reload();
- }
-}
-
-customElements.define(GrAdminView.is, GrAdminView);
diff --git a/polygerrit-ui/app/elements/admin/gr-admin-view/gr-admin-view.ts b/polygerrit-ui/app/elements/admin/gr-admin-view/gr-admin-view.ts
new file mode 100644
index 0000000..7fb713f
--- /dev/null
+++ b/polygerrit-ui/app/elements/admin/gr-admin-view/gr-admin-view.ts
@@ -0,0 +1,463 @@
+/**
+ * @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 '../../../styles/gr-menu-page-styles';
+import '../../../styles/gr-page-nav-styles';
+import '../../../styles/shared-styles';
+import '../../shared/gr-dropdown-list/gr-dropdown-list';
+import '../../shared/gr-icons/gr-icons';
+import '../../shared/gr-js-api-interface/gr-js-api-interface';
+import '../../shared/gr-page-nav/gr-page-nav';
+import '../../shared/gr-rest-api-interface/gr-rest-api-interface';
+import '../gr-admin-group-list/gr-admin-group-list';
+import '../gr-group/gr-group';
+import '../gr-group-audit-log/gr-group-audit-log';
+import '../gr-group-members/gr-group-members';
+import '../gr-plugin-list/gr-plugin-list';
+import '../gr-repo/gr-repo';
+import '../gr-repo-access/gr-repo-access';
+import '../gr-repo-commands/gr-repo-commands';
+import '../gr-repo-dashboards/gr-repo-dashboards';
+import '../gr-repo-detail-list/gr-repo-detail-list';
+import '../gr-repo-list/gr-repo-list';
+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-admin-view_html';
+import {getBaseUrl} from '../../../utils/url-util';
+import {
+ GerritNav,
+ GerritView,
+ GroupDetailView,
+ RepoDetailView,
+} from '../../core/gr-navigation/gr-navigation';
+import {getPluginLoader} from '../../shared/gr-js-api-interface/gr-plugin-loader';
+import {
+ AdminNavLinksOption,
+ getAdminLinks,
+ NavLink,
+ SubsectionInterface,
+} from '../../../utils/admin-nav-util';
+import {customElement, observe, property} from '@polymer/decorators';
+import {RestApiService} from '../../../services/services/gr-rest-api/gr-rest-api';
+import {
+ AppElementAdminParams,
+ AppElementGroupParams,
+ AppElementRepoParams,
+} from '../../gr-app-types';
+import {
+ AccountDetailInfo,
+ GroupId,
+ GroupName,
+ RepoName,
+} from '../../../types/common';
+import {GroupNameChangedDetail} from '../gr-group/gr-group';
+import {ValueChangeDetail} from '../../shared/gr-dropdown-list/gr-dropdown-list';
+import {GrJsApiInterface} from '../../shared/gr-js-api-interface/gr-js-api-interface-element';
+
+const INTERNAL_GROUP_REGEX = /^[\da-f]{40}$/;
+
+export interface GrAdminView {
+ $: {
+ restAPI: RestApiService & Element;
+ jsAPI: GrJsApiInterface;
+ };
+}
+
+interface AdminSubsectionLink {
+ text: string;
+ value: string;
+ view: GerritView;
+ url: string;
+ detailType?: GroupDetailView | RepoDetailView;
+ parent?: GroupId | RepoName;
+}
+
+// The type is matched to the _showAdminView function from the gr-app-element
+type AdminViewParams =
+ | AppElementAdminParams
+ | AppElementGroupParams
+ | AppElementRepoParams;
+
+function getAdminViewParamsDetail(
+ params: AdminViewParams
+): GroupDetailView | RepoDetailView | undefined {
+ if (params.view !== GerritView.ADMIN) {
+ return params.detail;
+ }
+ return undefined;
+}
+
+@customElement('gr-admin-view')
+export class GrAdminView extends GestureEventListeners(
+ LegacyElementMixin(PolymerElement)
+) {
+ static get template() {
+ return htmlTemplate;
+ }
+
+ private _account?: AccountDetailInfo;
+
+ @property({type: Object})
+ params?: AdminViewParams;
+
+ @property({type: String})
+ path?: string;
+
+ @property({type: String})
+ adminView?: string;
+
+ @property({type: String})
+ _breadcrumbParentName?: string;
+
+ @property({type: String})
+ _repoName?: RepoName;
+
+ @property({type: String, observer: '_computeGroupName'})
+ _groupId?: GroupId;
+
+ @property({type: Boolean})
+ _groupIsInternal?: boolean;
+
+ @property({type: String})
+ _groupName?: GroupName;
+
+ @property({type: Boolean})
+ _groupOwner = false;
+
+ @property({type: Array})
+ _subsectionLinks?: AdminSubsectionLink[];
+
+ @property({type: Array})
+ _filteredLinks?: NavLink[];
+
+ @property({type: Boolean})
+ _showDownload = false;
+
+ @property({type: Boolean})
+ _isAdmin = false;
+
+ @property({type: Boolean})
+ _showGroup?: boolean;
+
+ @property({type: Boolean})
+ _showGroupAuditLog?: boolean;
+
+ @property({type: Boolean})
+ _showGroupList?: boolean;
+
+ @property({type: Boolean})
+ _showGroupMembers?: boolean;
+
+ @property({type: Boolean})
+ _showRepoAccess?: boolean;
+
+ @property({type: Boolean})
+ _showRepoCommands?: boolean;
+
+ @property({type: Boolean})
+ _showRepoDashboards?: boolean;
+
+ @property({type: Boolean})
+ _showRepoDetailList?: boolean;
+
+ @property({type: Boolean})
+ _showRepoMain?: boolean;
+
+ @property({type: Boolean})
+ _showRepoList?: boolean;
+
+ @property({type: Boolean})
+ _showPluginList?: boolean;
+
+ /** @override */
+ attached() {
+ super.attached();
+ this.reload();
+ }
+
+ reload() {
+ const promises: [Promise<AccountDetailInfo | undefined>, Promise<void>] = [
+ this.$.restAPI.getAccount(),
+ getPluginLoader().awaitPluginsLoaded(),
+ ];
+ return Promise.all(promises).then(result => {
+ this._account = result[0];
+ let options: AdminNavLinksOption | undefined = undefined;
+ if (this._repoName) {
+ options = {repoName: this._repoName};
+ } else if (this._groupId) {
+ options = {
+ groupId: this._groupId,
+ groupName: this._groupName,
+ groupIsInternal: this._groupIsInternal,
+ isAdmin: this._isAdmin,
+ groupOwner: this._groupOwner,
+ };
+ }
+
+ return getAdminLinks(
+ this._account,
+ () =>
+ this.$.restAPI.getAccountCapabilities().then(capabilities => {
+ if (!capabilities) {
+ throw new Error('getAccountCapabilities returns undefined');
+ }
+ return capabilities;
+ }),
+ () => this.$.jsAPI.getAdminMenuLinks(),
+ options
+ ).then(res => {
+ this._filteredLinks = res.links;
+ this._breadcrumbParentName = res.expandedSection
+ ? res.expandedSection.name
+ : '';
+
+ if (!res.expandedSection) {
+ this._subsectionLinks = [];
+ return;
+ }
+ this._subsectionLinks = [res.expandedSection]
+ .concat(res.expandedSection.children ?? [])
+ .map(section => {
+ return {
+ text: !section.detailType ? 'Home' : section.name,
+ value: section.view + (section.detailType ?? ''),
+ view: section.view,
+ url: section.url,
+ detailType: section.detailType,
+ parent: this._groupId ?? this._repoName,
+ };
+ });
+ });
+ });
+ }
+
+ _computeSelectValue(params: AdminViewParams) {
+ if (!params || !params.view) return;
+ return `${params.view}${getAdminViewParamsDetail(params) ?? ''}`;
+ }
+
+ _selectedIsCurrentPage(selected: AdminSubsectionLink) {
+ if (!this.params) return false;
+
+ return (
+ selected.parent === (this._repoName ?? this._groupId) &&
+ selected.view === this.params.view &&
+ selected.detailType === getAdminViewParamsDetail(this.params)
+ );
+ }
+
+ _handleSubsectionChange(e: CustomEvent<ValueChangeDetail>) {
+ if (!this._subsectionLinks) return;
+
+ // The GrDropdownList items are _subsectionLinks, so find(...) always return
+ // an item _subsectionLinks and never returns undefined
+ const selected = this._subsectionLinks.find(
+ section => section.value === e.detail.value
+ )!;
+
+ // This is when it gets set initially.
+ if (this._selectedIsCurrentPage(selected)) return;
+ GerritNav.navigateToRelativeUrl(selected.url);
+ }
+
+ @observe('params')
+ _paramsChanged(params: AdminViewParams) {
+ this.set('_showGroup', params.view === GerritView.GROUP && !params.detail);
+ this.set(
+ '_showGroupAuditLog',
+ params.view === GerritView.GROUP && params.detail === GroupDetailView.LOG
+ );
+ this.set(
+ '_showGroupMembers',
+ params.view === GerritView.GROUP &&
+ params.detail === GroupDetailView.MEMBERS
+ );
+
+ this.set(
+ '_showGroupList',
+ params.view === GerritView.ADMIN &&
+ params.adminView === 'gr-admin-group-list'
+ );
+
+ this.set(
+ '_showRepoAccess',
+ params.view === GerritView.REPO && params.detail === RepoDetailView.ACCESS
+ );
+ this.set(
+ '_showRepoCommands',
+ params.view === GerritView.REPO &&
+ params.detail === RepoDetailView.COMMANDS
+ );
+ this.set(
+ '_showRepoDetailList',
+ params.view === GerritView.REPO &&
+ (params.detail === RepoDetailView.BRANCHES ||
+ params.detail === RepoDetailView.TAGS)
+ );
+ this.set(
+ '_showRepoDashboards',
+ params.view === GerritView.REPO &&
+ params.detail === RepoDetailView.DASHBOARDS
+ );
+ this.set(
+ '_showRepoMain',
+ params.view === GerritView.REPO && !params.detail
+ );
+
+ this.set(
+ '_showRepoList',
+ params.view === GerritView.ADMIN && params.adminView === 'gr-repo-list'
+ );
+
+ this.set(
+ '_showPluginList',
+ params.view === GerritView.ADMIN && params.adminView === 'gr-plugin-list'
+ );
+
+ let needsReload = false;
+ const newRepoName =
+ params.view === GerritView.REPO ? params.repo : undefined;
+ if (newRepoName !== this._repoName) {
+ this._repoName = newRepoName;
+ // Reloads the admin menu.
+ needsReload = true;
+ }
+ const newGroupId =
+ params.view === GerritView.GROUP ? params.groupId : undefined;
+ if (newGroupId !== this._groupId) {
+ this._groupId = newGroupId;
+ // Reloads the admin menu.
+ needsReload = true;
+ }
+ if (
+ this._breadcrumbParentName &&
+ (params.view !== GerritView.GROUP || !params.groupId) &&
+ (params.view !== GerritView.REPO || !params.repo)
+ ) {
+ needsReload = true;
+ }
+ if (!needsReload) {
+ return;
+ }
+ this.reload();
+ }
+
+ // TODO (beckysiegel): Update these functions after router abstraction is
+ // updated. They are currently copied from gr-dropdown (and should be
+ // updated there as well once complete).
+ _computeURLHelper(host: string, path: string) {
+ return '//' + host + getBaseUrl() + path;
+ }
+
+ _computeRelativeURL(path: string) {
+ const host = window.location.host;
+ return this._computeURLHelper(host, path);
+ }
+
+ _computeLinkURL(link: NavLink | SubsectionInterface) {
+ if (!link || typeof link.url === 'undefined') return '';
+
+ if ((link as NavLink).target || !(link as NavLink).noBaseUrl) {
+ return link.url;
+ }
+ return this._computeRelativeURL(link.url);
+ }
+
+ _computeSelectedClass(
+ itemView?: GerritView,
+ params?: AdminViewParams,
+ detailType?: GroupDetailView | RepoDetailView
+ ) {
+ if (!params) return '';
+ // Group params are structured differently from admin params. Compute
+ // selected differently for groups.
+ // TODO(wyatta): Simplify this when all routes work like group params.
+ if (params.view === GerritView.GROUP && itemView === GerritView.GROUP) {
+ if (!params.detail && !detailType) {
+ return 'selected';
+ }
+ if (params.detail === detailType) {
+ return 'selected';
+ }
+ return '';
+ }
+
+ if (params.view === GerritView.REPO && itemView === GerritView.REPO) {
+ if (!params.detail && !detailType) {
+ return 'selected';
+ }
+ if (params.detail === detailType) {
+ return 'selected';
+ }
+ return '';
+ }
+ // TODO(TS): The following condtion seems always false, because params
+ // never has detailType property. Remove it.
+ if (
+ ((params as unknown) as AdminSubsectionLink).detailType &&
+ ((params as unknown) as AdminSubsectionLink).detailType !== detailType
+ ) {
+ return '';
+ }
+ return params.view === GerritView.ADMIN && itemView === params.adminView
+ ? 'selected'
+ : '';
+ }
+
+ _computeGroupName(groupId?: GroupId) {
+ if (!groupId) return;
+
+ const promises: Array<Promise<void>> = [];
+ this.$.restAPI.getGroupConfig(groupId).then(group => {
+ if (!group || !group.name) {
+ return;
+ }
+
+ this._groupName = group.name;
+ this._groupIsInternal = !!group.id.match(INTERNAL_GROUP_REGEX);
+ this.reload();
+
+ promises.push(
+ this.$.restAPI.getIsAdmin().then(isAdmin => {
+ this._isAdmin = !!isAdmin;
+ })
+ );
+
+ promises.push(
+ this.$.restAPI.getIsGroupOwner(group.name).then(isOwner => {
+ this._groupOwner = isOwner;
+ })
+ );
+
+ return Promise.all(promises).then(() => {
+ this.reload();
+ });
+ });
+ }
+
+ _updateGroupName(e: CustomEvent<GroupNameChangedDetail>) {
+ this._groupName = e.detail.name;
+ this.reload();
+ }
+}
+
+declare global {
+ interface HTMLElementTagNameMap {
+ 'gr-admin-view': GrAdminView;
+ }
+}
diff --git a/polygerrit-ui/app/elements/admin/gr-admin-view/gr-admin-view_test.js b/polygerrit-ui/app/elements/admin/gr-admin-view/gr-admin-view_test.js
index 5fe479a..44fd4d6 100644
--- a/polygerrit-ui/app/elements/admin/gr-admin-view/gr-admin-view_test.js
+++ b/polygerrit-ui/app/elements/admin/gr-admin-view/gr-admin-view_test.js
@@ -18,7 +18,7 @@
import '../../../test/common-test-setup-karma.js';
import './gr-admin-view.js';
import {dom} from '@polymer/polymer/lib/legacy/polymer.dom.js';
-import {GerritNav} from '../../core/gr-navigation/gr-navigation.js';
+import {GerritNav, GerritView} from '../../core/gr-navigation/gr-navigation.js';
import {getPluginLoader} from '../../shared/gr-js-api-interface/gr-plugin-loader.js';
import {stubBaseUrl} from '../../../test/test-utils.js';
@@ -149,7 +149,7 @@
return element.reload().then(() => {
assert.equal(element._filteredLinks.length, 3);
assert.deepEqual(element._filteredLinks[1], {
- capability: null,
+ capability: undefined,
url: '/internal/link/url',
name: 'internal link text',
noBaseUrl: true,
@@ -158,7 +158,7 @@
target: null,
});
assert.deepEqual(element._filteredLinks[2], {
- capability: null,
+ capability: undefined,
url: 'http://external/link/url',
name: 'external link text',
noBaseUrl: false,
@@ -244,10 +244,10 @@
'getAccount')
.callsFake(() => Promise.resolve({_id: 1}));
sinon.stub(element, 'reload');
- element.params = {repo: 'Test Repo', adminView: 'gr-repo'};
+ element.params = {repo: 'Test Repo', view: GerritView.REPO};
assert.equal(element.reload.callCount, 1);
element.params = {repo: 'Test Repo 2',
- adminView: 'gr-repo'};
+ view: GerritView.REPO};
assert.equal(element.reload.callCount, 2);
});
@@ -266,7 +266,7 @@
'getAccount')
.callsFake(() => Promise.resolve({_id: 1}));
sinon.stub(element, 'reload');
- element.params = {groupId: '1', adminView: 'gr-group'};
+ element.params = {groupId: '1', view: GerritView.GROUP};
assert.equal(element.reload.callCount, 1);
});
diff --git a/polygerrit-ui/app/elements/admin/gr-create-group-dialog/gr-create-group-dialog.ts b/polygerrit-ui/app/elements/admin/gr-create-group-dialog/gr-create-group-dialog.ts
index e8b5d78..1d8b7db 100644
--- a/polygerrit-ui/app/elements/admin/gr-create-group-dialog/gr-create-group-dialog.ts
+++ b/polygerrit-ui/app/elements/admin/gr-create-group-dialog/gr-create-group-dialog.ts
@@ -26,7 +26,7 @@
import {page} from '../../../utils/page-wrapper-utils';
import {customElement, property, observe} from '@polymer/decorators';
import {RestApiService} from '../../../services/services/gr-rest-api/gr-rest-api';
-import {GroupId} from '../../../types/common';
+import {GroupName} from '../../../types/common';
export interface GrCreateGroupDialog {
$: {
@@ -46,7 +46,7 @@
hasNewGroupName = false;
@property({type: String})
- _name = '';
+ _name: GroupName | '' = '';
@property({type: Boolean})
_groupCreated = false;
@@ -61,20 +61,17 @@
}
handleCreateGroup() {
- return this.$.restAPI
- .createGroup({name: this._name})
- .then(groupRegistered => {
- if (groupRegistered.status !== 201) {
- return;
- }
- this._groupCreated = true;
- return this.$.restAPI
- .getGroupConfig(this._name as GroupId)
- .then(group => {
- // TODO(TS): should group always defined ?
- page.show(this._computeGroupUrl(group!.group_id!));
- });
+ const name = this._name as GroupName;
+ return this.$.restAPI.createGroup({name}).then(groupRegistered => {
+ if (groupRegistered.status !== 201) {
+ return;
+ }
+ this._groupCreated = true;
+ return this.$.restAPI.getGroupConfig(name).then(group => {
+ // TODO(TS): should group always defined ?
+ page.show(this._computeGroupUrl(group!.group_id!));
});
+ });
}
}
diff --git a/polygerrit-ui/app/elements/admin/gr-group-members/gr-group-members.ts b/polygerrit-ui/app/elements/admin/gr-group-members/gr-group-members.ts
index f8f1fee..ae10c03 100644
--- a/polygerrit-ui/app/elements/admin/gr-group-members/gr-group-members.ts
+++ b/polygerrit-ui/app/elements/admin/gr-group-members/gr-group-members.ts
@@ -41,6 +41,7 @@
AccountId,
AccountInfo,
GroupInfo,
+ GroupName,
} from '../../../types/common';
import {AutocompleteQuery} from '../../shared/gr-autocomplete/gr-autocomplete';
import {PolymerDomRepeatEvent} from '../../../types/types';
@@ -85,7 +86,7 @@
_loading = true;
@property({type: String})
- _groupName?: GroupId;
+ _groupName?: GroupName;
@property({type: Object})
_groupMembers?: AccountInfo[];
@@ -155,7 +156,7 @@
return Promise.resolve();
}
- this._groupName = config.name as GroupId;
+ this._groupName = config.name;
promises.push(
this.$.restAPI.getIsAdmin().then(isAdmin => {
diff --git a/polygerrit-ui/app/elements/admin/gr-group/gr-group.ts b/polygerrit-ui/app/elements/admin/gr-group/gr-group.ts
index 7bc0312..511bf5c 100644
--- a/polygerrit-ui/app/elements/admin/gr-group/gr-group.ts
+++ b/polygerrit-ui/app/elements/admin/gr-group/gr-group.ts
@@ -32,7 +32,7 @@
AutocompleteSuggestion,
AutocompleteQuery,
} from '../../shared/gr-autocomplete/gr-autocomplete';
-import {GroupId, GroupInfo} from '../../../types/common';
+import {GroupId, GroupInfo, GroupName} from '../../../types/common';
import {
ErrorCallback,
RestApiService,
@@ -59,6 +59,11 @@
};
}
+export interface GroupNameChangedDetail {
+ name: GroupName;
+ external: boolean;
+}
+
declare global {
interface HTMLElementTagNameMap {
'gr-group': GrGroup;
@@ -164,7 +169,7 @@
);
promises.push(
- this.$.restAPI.getIsGroupOwner(config.name as GroupId).then(isOwner => {
+ this.$.restAPI.getIsGroupOwner(config.name).then(isOwner => {
this._groupOwner = !!isOwner;
})
);
@@ -205,17 +210,19 @@
if (!this.groupId || !groupConfig || !groupConfig.name) {
return Promise.reject(new Error('invalid groupId or config name'));
}
+ const groupName = groupConfig.name;
return this.$.restAPI
- .saveGroupName(this.groupId, groupConfig.name)
+ .saveGroupName(this.groupId, groupName)
.then(config => {
if (config.status === 200) {
- this._groupName = groupConfig.name;
+ this._groupName = groupName;
+ const detail: GroupNameChangedDetail = {
+ name: groupName,
+ external: !this._groupIsInternal,
+ };
this.dispatchEvent(
new CustomEvent('name-changed', {
- detail: {
- name: groupConfig.name,
- external: !this._groupIsInternal,
- },
+ detail,
composed: true,
bubbles: true,
})
diff --git a/polygerrit-ui/app/elements/change-list/gr-change-list-item/gr-change-list-item.ts b/polygerrit-ui/app/elements/change-list/gr-change-list-item/gr-change-list-item.ts
index 20cc207..b5d2d53 100644
--- a/polygerrit-ui/app/elements/change-list/gr-change-list-item/gr-change-list-item.ts
+++ b/polygerrit-ui/app/elements/change-list/gr-change-list-item/gr-change-list-item.ts
@@ -46,6 +46,7 @@
ServerInfo,
AccountInfo,
QuickLabelInfo,
+ Timestamp,
} from '../../../types/common';
import {hasOwnProperty} from '../../../utils/common-util';
@@ -67,6 +68,11 @@
REJECTED = 'REJECTED',
}
+export interface ChangeListToggleReviewedDetail {
+ change: ChangeInfo;
+ reviewed: boolean;
+}
+
// How many reviewers should be shown with an account-label?
const PRIMARY_REVIEWERS_COUNT = 2;
@@ -383,14 +389,27 @@
}
}
+ _computeSince(
+ account?: AccountInfo,
+ change?: ChangeInfo
+ ): Timestamp | undefined {
+ if (!account?._account_id || !change?.attention_set) return undefined;
+ return change?.attention_set[account._account_id]?.last_update;
+ }
+
toggleReviewed() {
+ if (!this.change) return;
const newVal = !this.change?.reviewed;
this.set('change.reviewed', newVal);
+ const detail: ChangeListToggleReviewedDetail = {
+ change: this.change,
+ reviewed: newVal,
+ };
this.dispatchEvent(
new CustomEvent('toggle-reviewed', {
bubbles: true,
composed: true,
- detail: {change: this.change, reviewed: newVal},
+ detail,
})
);
}
@@ -408,6 +427,16 @@
isOwner: selfId === ownerId,
});
}
+
+ _computeCommaHidden(index?: number, change?: ChangeInfo) {
+ if (index === undefined) return false;
+ if (change === undefined) return false;
+
+ const additionalCount = this._computeAdditionalReviewersCount(change);
+ const primaryCount = this._computePrimaryReviewers(change).length;
+ const isLast = index === primaryCount - 1;
+ return isLast && additionalCount === 0;
+ }
}
declare global {
diff --git a/polygerrit-ui/app/elements/change-list/gr-change-list-item/gr-change-list-item_html.ts b/polygerrit-ui/app/elements/change-list/gr-change-list-item/gr-change-list-item_html.ts
index 5316fe5..2ae6c37 100644
--- a/polygerrit-ui/app/elements/change-list/gr-change-list-item/gr-change-list-item_html.ts
+++ b/polygerrit-ui/app/elements/change-list/gr-change-list-item/gr-change-list-item_html.ts
@@ -32,9 +32,6 @@
font-weight: var(--font-weight-bold);
color: var(--primary-text-color);
}
- :host([highlight]) {
- background-color: var(--assignee-highlight-color);
- }
.container {
position: relative;
}
@@ -113,9 +110,6 @@
.cell.label iron-icon {
vertical-align: top;
}
- .lastChildHidden:last-of-type {
- display: none;
- }
@media only screen and (max-width: 50em) {
:host {
display: flex;
@@ -197,6 +191,7 @@
is="dom-repeat"
items="[[_computePrimaryReviewers(change)]]"
as="reviewer"
+ indexAs="index"
>
<gr-account-link
hide-avatar=""
@@ -206,7 +201,11 @@
change="[[change]]"
account="[[reviewer]]"
></gr-account-link
- ><span class="lastChildHidden" aria-hidden="true">, </span>
+ ><span
+ hidden$="[[_computeCommaHidden(index, change)]]"
+ aria-hidden="true"
+ >,
+ </span>
</template>
<template is="dom-if" if="[[_computeAdditionalReviewersCount(change)]]">
<span title="[[_computeAdditionalReviewersTitle(change, config)]]">
@@ -266,6 +265,26 @@
></gr-date-formatter>
</td>
<td
+ class="cell submitted"
+ hidden$="[[isColumnHidden('Submitted', visibleChangeTableColumns)]]"
+ >
+ <gr-date-formatter
+ has-tooltip=""
+ date-str="[[change.submitted]]"
+ ></gr-date-formatter>
+ </td>
+ <td
+ class="cell since"
+ hidden$="[[isColumnHidden('Since', visibleChangeTableColumns)]]"
+ >
+ <gr-date-formatter
+ has-tooltip=""
+ force-relative=""
+ relative-option-no-ago=""
+ date-str="[[_computeSince(account, change)]]"
+ ></gr-date-formatter>
+ </td>
+ <td
class="cell size"
hidden$="[[isColumnHidden('Size', visibleChangeTableColumns)]]"
>
diff --git a/polygerrit-ui/app/elements/change-list/gr-change-list-view/gr-change-list-view.js b/polygerrit-ui/app/elements/change-list/gr-change-list-view/gr-change-list-view.js
deleted file mode 100644
index b6bc03c..0000000
--- a/polygerrit-ui/app/elements/change-list/gr-change-list-view/gr-change-list-view.js
+++ /dev/null
@@ -1,306 +0,0 @@
-/**
- * @license
- * Copyright (C) 2015 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-import '../../shared/gr-icons/gr-icons.js';
-import '../../shared/gr-rest-api-interface/gr-rest-api-interface.js';
-import '../gr-change-list/gr-change-list.js';
-import '../gr-repo-header/gr-repo-header.js';
-import '../gr-user-header/gr-user-header.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-change-list-view_html.js';
-import {page} from '../../../utils/page-wrapper-utils.js';
-import {GerritNav} from '../../core/gr-navigation/gr-navigation.js';
-
-const LookupQueryPatterns = {
- CHANGE_ID: /^\s*i?[0-9a-f]{7,40}\s*$/i,
- CHANGE_NUM: /^\s*[1-9][0-9]*\s*$/g,
- COMMIT: /[0-9a-f]{40}/,
-};
-
-const USER_QUERY_PATTERN = /^owner:\s?("[^"]+"|[^ ]+)$/;
-
-const REPO_QUERY_PATTERN =
- /^project:\s?("[^"]+"|[^ ]+)(\sstatus\s?:(open|"open"))?$/;
-
-const LIMIT_OPERATOR_PATTERN = /\blimit:(\d+)/i;
-
-/**
- * @extends PolymerElement
- */
-class GrChangeListView extends GestureEventListeners(
- LegacyElementMixin(
- PolymerElement)) {
- static get template() { return htmlTemplate; }
-
- static get is() { return 'gr-change-list-view'; }
- /**
- * Fired when the title of the page should change.
- *
- * @event title-change
- */
-
- static get properties() {
- return {
- /**
- * URL params passed from the router.
- */
- params: {
- type: Object,
- observer: '_paramsChanged',
- },
-
- /**
- * True when user is logged in.
- */
- _loggedIn: {
- type: Boolean,
- computed: '_computeLoggedIn(account)',
- },
-
- account: {
- type: Object,
- value: null,
- },
-
- /**
- * State persisted across restamps of the element.
- *
- * Need sub-property declaration since it is used in template before
- * assignment.
- *
- * @type {{ selectedChangeIndex: (number|undefined) }}
- *
- */
- viewState: {
- type: Object,
- notify: true,
- value() { return {}; },
- },
-
- preferences: Object,
-
- _changesPerPage: Number,
-
- /**
- * Currently active query.
- */
- _query: {
- type: String,
- value: '',
- },
-
- /**
- * Offset of currently visible query results.
- */
- _offset: Number,
-
- /**
- * Change objects loaded from the server.
- */
- _changes: {
- type: Array,
- observer: '_changesChanged',
- },
-
- /**
- * For showing a "loading..." string during ajax requests.
- */
- _loading: {
- type: Boolean,
- value: true,
- },
-
- /** @type {?string} */
- _userId: {
- type: String,
- value: null,
- },
-
- /** @type {?string} */
- _repo: {
- type: String,
- value: null,
- },
- };
- }
-
- /** @override */
- created() {
- super.created();
- this.addEventListener('next-page',
- () => this._handleNextPage());
- this.addEventListener('previous-page',
- () => this._handlePreviousPage());
- }
-
- /** @override */
- attached() {
- super.attached();
- this._loadPreferences();
- }
-
- _paramsChanged(value) {
- if (value.view !== GerritNav.View.SEARCH) { return; }
-
- this._loading = true;
- this._query = value.query;
- this._offset = value.offset || 0;
- if (this.viewState.query != this._query ||
- this.viewState.offset != this._offset) {
- this.set('viewState.selectedChangeIndex', 0);
- this.set('viewState.query', this._query);
- this.set('viewState.offset', this._offset);
- }
-
- // NOTE: This method may be called before attachment. Fire title-change
- // in an async so that attachment to the DOM can take place first.
- this.async(() => this.dispatchEvent(new CustomEvent('title-change', {
- detail: {title: this._query},
- composed: true, bubbles: true,
- })));
-
- this._getPreferences()
- .then(prefs => {
- this._changesPerPage = prefs.changes_per_page;
- return this._getChanges();
- })
- .then(changes => {
- changes = changes || [];
- if (this._query && changes.length === 1) {
- for (const query in LookupQueryPatterns) {
- if (LookupQueryPatterns.hasOwnProperty(query) &&
- this._query.match(LookupQueryPatterns[query])) {
- // "Back"/"Forward" buttons work correctly only with
- // opt_redirect options
- GerritNav.navigateToChange(changes[0], null, null, null, true);
- return;
- }
- }
- }
- this._changes = changes;
- this._loading = false;
- });
- }
-
- _loadPreferences() {
- return this.$.restAPI.getLoggedIn().then(loggedIn => {
- if (loggedIn) {
- this._getPreferences().then(preferences => {
- this.preferences = preferences;
- });
- } else {
- this.preferences = {};
- }
- });
- }
-
- _getChanges() {
- return this.$.restAPI.getChanges(this._changesPerPage, this._query,
- this._offset);
- }
-
- _getPreferences() {
- return this.$.restAPI.getPreferences();
- }
-
- _limitFor(query, defaultLimit) {
- const match = query.match(LIMIT_OPERATOR_PATTERN);
- if (!match) {
- return defaultLimit;
- }
- return parseInt(match[1], 10);
- }
-
- _computeNavLink(query, offset, direction, changesPerPage) {
- // Offset could be a string when passed from the router.
- offset = +(offset || 0);
- const limit = this._limitFor(query, changesPerPage);
- const newOffset = Math.max(0, offset + (limit * direction));
- return GerritNav.getUrlForSearchQuery(query, newOffset);
- }
-
- _computePrevArrowClass(offset) {
- return offset === 0 ? 'hide' : '';
- }
-
- _computeNextArrowClass(changes) {
- const more = changes.length && changes[changes.length - 1]._more_changes;
- return more ? '' : 'hide';
- }
-
- _computeNavClass(loading) {
- return loading || !this._changes || !this._changes.length ? 'hide' : '';
- }
-
- _handleNextPage() {
- if (this.$.nextArrow.hidden) { return; }
- page.show(this._computeNavLink(
- this._query, this._offset, 1, this._changesPerPage));
- }
-
- _handlePreviousPage() {
- if (this.$.prevArrow.hidden) { return; }
- page.show(this._computeNavLink(
- this._query, this._offset, -1, this._changesPerPage));
- }
-
- _changesChanged(changes) {
- this._userId = null;
- this._repo = null;
- if (!changes || !changes.length) {
- return;
- }
- if (USER_QUERY_PATTERN.test(this._query)) {
- const owner = changes[0].owner;
- const userId = owner._account_id ? owner._account_id : owner.email;
- if (userId) {
- this._userId = userId;
- return;
- }
- }
- if (REPO_QUERY_PATTERN.test(this._query)) {
- this._repo = changes[0].project;
- }
- }
-
- _computeHeaderClass(id) {
- return id ? '' : 'hide';
- }
-
- _computePage(offset, changesPerPage) {
- return offset / changesPerPage + 1;
- }
-
- _computeLoggedIn(account) {
- return !!(account && Object.keys(account).length > 0);
- }
-
- _handleToggleStar(e) {
- this.$.restAPI.saveChangeStarred(e.detail.change._number,
- e.detail.starred);
- }
-
- _handleToggleReviewed(e) {
- this.$.restAPI.saveChangeReviewed(e.detail.change._number,
- e.detail.reviewed);
- }
-}
-
-customElements.define(GrChangeListView.is, GrChangeListView);
diff --git a/polygerrit-ui/app/elements/change-list/gr-change-list-view/gr-change-list-view.ts b/polygerrit-ui/app/elements/change-list/gr-change-list-view/gr-change-list-view.ts
new file mode 100644
index 0000000..63bbaee
--- /dev/null
+++ b/polygerrit-ui/app/elements/change-list/gr-change-list-view/gr-change-list-view.ts
@@ -0,0 +1,322 @@
+/**
+ * @license
+ * Copyright (C) 2015 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import '../../shared/gr-icons/gr-icons';
+import '../../shared/gr-rest-api-interface/gr-rest-api-interface';
+import '../gr-change-list/gr-change-list';
+import '../gr-repo-header/gr-repo-header';
+import '../gr-user-header/gr-user-header';
+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-change-list-view_html';
+import {page} from '../../../utils/page-wrapper-utils';
+import {GerritNav, GerritView} from '../../core/gr-navigation/gr-navigation';
+import {customElement, property} from '@polymer/decorators';
+import {AppElementParams} from '../../gr-app-types';
+import {
+ AccountDetailInfo,
+ AccountId,
+ ChangeId,
+ ChangeInfo,
+ EmailAddress,
+ PatchRange,
+ PreferencesInput,
+} from '../../../types/common';
+import {RestApiService} from '../../../services/services/gr-rest-api/gr-rest-api';
+import {ChangeListToggleReviewedDetail} from '../gr-change-list-item/gr-change-list-item';
+import {ChangeStarToggleStarDetail} from '../../shared/gr-change-star/gr-change-star';
+import {hasOwnProperty} from '../../../utils/common-util';
+import {DiffViewMode} from '../../../constants/constants';
+
+const LookupQueryPatterns = {
+ CHANGE_ID: /^\s*i?[0-9a-f]{7,40}\s*$/i,
+ CHANGE_NUM: /^\s*[1-9][0-9]*\s*$/g,
+ COMMIT: /[0-9a-f]{40}/,
+};
+
+const USER_QUERY_PATTERN = /^owner:\s?("[^"]+"|[^ ]+)$/;
+
+const REPO_QUERY_PATTERN = /^project:\s?("[^"]+"|[^ ]+)(\sstatus\s?:(open|"open"))?$/;
+
+const LIMIT_OPERATOR_PATTERN = /\blimit:(\d+)/i;
+
+export interface ChangeListViewState {
+ changeNum?: ChangeId;
+ patchRange?: PatchRange;
+ selectedFileIndex?: number;
+ showReplyDialog?: boolean;
+ showDownloadDialog?: boolean;
+ diffMode?: DiffViewMode;
+ numFilesShown?: number;
+ scrollTop?: number;
+ query?: string;
+ offset?: number;
+}
+
+export interface GrChangeListView {
+ $: {
+ restAPI: RestApiService & Element;
+ prevArrow: HTMLAnchorElement;
+ nextArrow: HTMLAnchorElement;
+ };
+}
+
+@customElement('gr-change-list-view')
+export class GrChangeListView extends GestureEventListeners(
+ LegacyElementMixin(PolymerElement)
+) {
+ static get template() {
+ return htmlTemplate;
+ }
+
+ /**
+ * Fired when the title of the page should change.
+ *
+ * @event title-change
+ */
+
+ @property({type: Object, observer: '_paramsChanged'})
+ params?: AppElementParams;
+
+ @property({type: Boolean, computed: '_computeLoggedIn(account)'})
+ _loggedIn?: boolean;
+
+ @property({type: Object})
+ account: AccountDetailInfo | null = null;
+
+ @property({type: Object, notify: true})
+ viewState: ChangeListViewState = {};
+
+ @property({type: Object})
+ preferences?: PreferencesInput;
+
+ @property({type: Number})
+ _changesPerPage?: number;
+
+ @property({type: String})
+ _query = '';
+
+ @property({type: Number})
+ _offset?: number;
+
+ @property({type: Array, observer: '_changesChanged'})
+ _changes?: ChangeInfo[];
+
+ @property({type: Boolean})
+ _loading = true;
+
+ @property({type: String})
+ _userId: AccountId | EmailAddress | null = null;
+
+ @property({type: String})
+ _repo: string | null = null;
+
+ /** @override */
+ created() {
+ super.created();
+ this.addEventListener('next-page', () => this._handleNextPage());
+ this.addEventListener('previous-page', () => this._handlePreviousPage());
+ }
+
+ /** @override */
+ attached() {
+ super.attached();
+ this._loadPreferences();
+ }
+
+ _paramsChanged(value: AppElementParams) {
+ if (value.view !== GerritView.SEARCH) return;
+
+ this._loading = true;
+ this._query = value.query;
+ const offset = Number(value.offset);
+ this._offset = isNaN(offset) ? 0 : offset;
+ if (
+ this.viewState.query !== this._query ||
+ this.viewState.offset !== this._offset
+ ) {
+ this.set('viewState.selectedChangeIndex', 0);
+ this.set('viewState.query', this._query);
+ this.set('viewState.offset', this._offset);
+ }
+
+ // NOTE: This method may be called before attachment. Fire title-change
+ // in an async so that attachment to the DOM can take place first.
+ this.async(() =>
+ this.dispatchEvent(
+ new CustomEvent('title-change', {
+ detail: {title: this._query},
+ composed: true,
+ bubbles: true,
+ })
+ )
+ );
+
+ this.$.restAPI
+ .getPreferences()
+ .then(prefs => {
+ if (!prefs) {
+ throw new Error('getPreferences returned undefined');
+ }
+ this._changesPerPage = prefs.changes_per_page;
+ return this._getChanges();
+ })
+ .then(changes => {
+ changes = changes || [];
+ if (this._query && changes.length === 1) {
+ let query: keyof typeof LookupQueryPatterns;
+ for (query in LookupQueryPatterns) {
+ if (
+ hasOwnProperty(LookupQueryPatterns, query) &&
+ this._query.match(LookupQueryPatterns[query])
+ ) {
+ // "Back"/"Forward" buttons work correctly only with
+ // opt_redirect options
+ GerritNav.navigateToChange(
+ changes[0],
+ undefined,
+ undefined,
+ undefined,
+ true
+ );
+ return;
+ }
+ }
+ }
+ this._changes = changes;
+ this._loading = false;
+ });
+ }
+
+ _loadPreferences() {
+ return this.$.restAPI.getLoggedIn().then(loggedIn => {
+ if (loggedIn) {
+ this.$.restAPI.getPreferences().then(preferences => {
+ this.preferences = preferences;
+ });
+ } else {
+ this.preferences = {};
+ }
+ });
+ }
+
+ _getChanges() {
+ return this.$.restAPI.getChanges(
+ this._changesPerPage,
+ this._query,
+ this._offset
+ );
+ }
+
+ _limitFor(query: string, defaultLimit: number) {
+ const match = query.match(LIMIT_OPERATOR_PATTERN);
+ if (!match) {
+ return defaultLimit;
+ }
+ return parseInt(match[1], 10);
+ }
+
+ _computeNavLink(
+ query: string,
+ offset: number | undefined,
+ direction: number,
+ changesPerPage: number
+ ) {
+ offset = offset ?? 0;
+ const limit = this._limitFor(query, changesPerPage);
+ const newOffset = Math.max(0, offset + limit * direction);
+ return GerritNav.getUrlForSearchQuery(query, newOffset);
+ }
+
+ _computePrevArrowClass(offset?: number) {
+ return offset === 0 ? 'hide' : '';
+ }
+
+ _computeNextArrowClass(changes?: ChangeInfo[]) {
+ const more = changes?.length && changes[changes.length - 1]._more_changes;
+ return more ? '' : 'hide';
+ }
+
+ _computeNavClass(loading?: boolean) {
+ return loading || !this._changes || !this._changes.length ? 'hide' : '';
+ }
+
+ _handleNextPage() {
+ if (this.$.nextArrow.hidden || !this._changesPerPage) return;
+ page.show(
+ this._computeNavLink(this._query, this._offset, 1, this._changesPerPage)
+ );
+ }
+
+ _handlePreviousPage() {
+ if (this.$.prevArrow.hidden || !this._changesPerPage) return;
+ page.show(
+ this._computeNavLink(this._query, this._offset, -1, this._changesPerPage)
+ );
+ }
+
+ _changesChanged(changes?: ChangeInfo[]) {
+ this._userId = null;
+ this._repo = null;
+ if (!changes || !changes.length) {
+ return;
+ }
+ if (USER_QUERY_PATTERN.test(this._query)) {
+ const owner = changes[0].owner;
+ const userId = owner._account_id ? owner._account_id : owner.email;
+ if (userId) {
+ this._userId = userId;
+ return;
+ }
+ }
+ if (REPO_QUERY_PATTERN.test(this._query)) {
+ this._repo = changes[0].project;
+ }
+ }
+
+ _computeHeaderClass(id?: string) {
+ return id ? '' : 'hide';
+ }
+
+ _computePage(offset?: number, changesPerPage?: number) {
+ if (offset === undefined || changesPerPage === undefined) return;
+ return offset / changesPerPage + 1;
+ }
+
+ _computeLoggedIn(account?: AccountDetailInfo) {
+ return !!(account && Object.keys(account).length > 0);
+ }
+
+ _handleToggleStar(e: CustomEvent<ChangeStarToggleStarDetail>) {
+ this.$.restAPI.saveChangeStarred(e.detail.change._number, e.detail.starred);
+ }
+
+ _handleToggleReviewed(e: CustomEvent<ChangeListToggleReviewedDetail>) {
+ this.$.restAPI.saveChangeReviewed(
+ e.detail.change._number,
+ e.detail.reviewed
+ );
+ }
+}
+
+declare global {
+ interface HTMLElementTagNameMap {
+ 'gr-change-list-view': GrChangeListView;
+ }
+}
diff --git a/polygerrit-ui/app/elements/change-list/gr-change-list-view/gr-change-list-view_test.js b/polygerrit-ui/app/elements/change-list/gr-change-list-view/gr-change-list-view_test.js
index 4c52ce9..af3acd8 100644
--- a/polygerrit-ui/app/elements/change-list/gr-change-list-view/gr-change-list-view_test.js
+++ b/polygerrit-ui/app/elements/change-list/gr-change-list-view/gr-change-list-view_test.js
@@ -109,6 +109,7 @@
test('_handleNextPage', () => {
const showStub = sinon.stub(page, 'show');
+ element._changesPerPage = 10;
element.$.nextArrow.hidden = true;
element._handleNextPage();
assert.isFalse(showStub.called);
@@ -119,6 +120,7 @@
test('_handlePreviousPage', () => {
const showStub = sinon.stub(page, 'show');
+ element._changesPerPage = 10;
element.$.prevArrow.hidden = true;
element._handlePreviousPage();
assert.isFalse(showStub.called);
diff --git a/polygerrit-ui/app/elements/change-list/gr-change-list/gr-change-list.ts b/polygerrit-ui/app/elements/change-list/gr-change-list/gr-change-list.ts
index a05ccf4..f486435 100644
--- a/polygerrit-ui/app/elements/change-list/gr-change-list/gr-change-list.ts
+++ b/polygerrit-ui/app/elements/change-list/gr-change-list/gr-change-list.ts
@@ -37,10 +37,12 @@
import {
GerritNav,
DashboardSection,
+ YOUR_TURN,
+ CLOSED,
} from '../../core/gr-navigation/gr-navigation';
import {getPluginEndpoints} from '../../shared/gr-js-api-interface/gr-plugin-endpoints';
import {getPluginLoader} from '../../shared/gr-js-api-interface/gr-plugin-loader';
-import {changeIsOpen} from '../../../utils/change-util';
+import {changeIsOpen, isOwner} from '../../../utils/change-util';
import {customElement, property, observe} from '@polymer/decorators';
import {RestApiService} from '../../../services/services/gr-rest-api/gr-rest-api';
import {GrCursorManager} from '../../shared/gr-cursor-manager/gr-cursor-manager';
@@ -50,6 +52,10 @@
ServerInfo,
PreferencesInput,
} from '../../../types/common';
+import {
+ hasAttention,
+ isAttentionSetEnabled,
+} from '../../../utils/attention-set-util';
const NUMBER_FIXED_COLUMNS = 3;
const CLOSED_STATUS = ['MERGED', 'ABANDONED'];
@@ -57,6 +63,8 @@
const MAX_SHORTCUT_CHARS = 5;
export interface ChangeListSection {
+ name?: string;
+ query?: string;
results: ChangeInfo[];
}
export interface GrChangeList {
@@ -229,9 +237,35 @@
}
}
- _computeColspan(changeTableColumns: string[], labelNames: string[]) {
- if (!changeTableColumns || !labelNames) return;
- return changeTableColumns.length + labelNames.length + NUMBER_FIXED_COLUMNS;
+ /**
+ * This methods allows us to customize the columns per section.
+ *
+ * @param visibleColumns are the columns according to configs and user prefs
+ */
+ _computeColumns(
+ section?: ChangeListSection,
+ visibleColumns?: string[]
+ ): string[] {
+ if (!section || !visibleColumns) return [];
+ const cols = [...visibleColumns];
+ const updatedIndex = cols.indexOf('Updated');
+ if (section.name === YOUR_TURN.name && updatedIndex !== -1) {
+ cols[updatedIndex] = 'Since';
+ }
+ if (section.name === CLOSED.name && updatedIndex !== -1) {
+ cols[updatedIndex] = 'Submitted';
+ }
+ return cols;
+ }
+
+ _computeColspan(
+ section?: ChangeListSection,
+ visibleColumns?: string[],
+ labelNames?: string[]
+ ) {
+ const cols = this._computeColumns(section, visibleColumns);
+ if (!cols || !labelNames) return 1;
+ return cols.length + labelNames.length + NUMBER_FIXED_COLUMNS;
}
_computeLabelNames(sections: ChangeListSection[]) {
@@ -327,10 +361,8 @@
showReviewedState: boolean,
config?: ServerInfo
) {
- const isAttentionSetEnabled =
- !!config && !!config.change && config.change.enable_attention_set;
return (
- !isAttentionSetEnabled &&
+ !isAttentionSetEnabled(config) &&
showReviewedState &&
!change.reviewed &&
!change.work_in_progress &&
@@ -339,17 +371,19 @@
);
}
- _computeItemHighlight(account?: AccountInfo, change?: ChangeInfo) {
- // Do not show the assignee highlight if the change is not open.
- if (
- !change ||
- !change.assignee ||
- !account ||
- CLOSED_STATUS.indexOf(change.status) !== -1
- ) {
- return false;
- }
- return account._account_id === change.assignee._account_id;
+ _computeItemHighlight(
+ account?: AccountInfo,
+ change?: ChangeInfo,
+ config?: ServerInfo,
+ sectionName?: string
+ ) {
+ if (!change || !account) return false;
+ if (CLOSED_STATUS.indexOf(change.status) !== -1) return false;
+ return isAttentionSetEnabled(config)
+ ? hasAttention(config, account, change) &&
+ !isOwner(change, account) &&
+ sectionName === YOUR_TURN.name
+ : account._account_id === change.assignee?._account_id;
}
_nextChange(e: CustomKeyboardEvent) {
@@ -492,7 +526,7 @@
_getSpecialEmptySlot(section: DashboardSection) {
if (section.isOutgoing) return 'empty-outgoing';
- if (section.name === 'Your Turn') return 'empty-your-turn';
+ if (section.name === YOUR_TURN.name) return 'empty-your-turn';
return '';
}
diff --git a/polygerrit-ui/app/elements/change-list/gr-change-list/gr-change-list_html.ts b/polygerrit-ui/app/elements/change-list/gr-change-list/gr-change-list_html.ts
index f223fbf..6a66472 100644
--- a/polygerrit-ui/app/elements/change-list/gr-change-list/gr-change-list_html.ts
+++ b/polygerrit-ui/app/elements/change-list/gr-change-list/gr-change-list_html.ts
@@ -61,7 +61,7 @@
></td>
<td
class="cell"
- colspan$="[[_computeColspan(changeTableColumns, labelNames)]]"
+ colspan$="[[_computeColspan(changeSection, visibleChangeTableColumns, labelNames)]]"
>
<a
href$="[[_sectionHref(changeSection.query)]]"
@@ -83,7 +83,7 @@
<td aria-hidden="true" class="star" hidden></td>
<td
class="cell"
- colspan$="[[_computeColspan(changeTableColumns, labelNames)]]"
+ colspan$="[[_computeColspan(changeSection, visibleChangeTableColumns, labelNames)]]"
>
<template
is="dom-if"
@@ -110,11 +110,12 @@
hidden=""
></td>
<td class="number" hidden$="[[!showNumber]]" hidden="">#</td>
- <template is="dom-repeat" items="[[changeTableColumns]]" as="item">
- <td
- class$="[[_lowerCase(item)]]"
- hidden$="[[isColumnHidden(item, visibleChangeTableColumns)]]"
- >
+ <template
+ is="dom-repeat"
+ items="[[_computeColumns(changeSection, visibleChangeTableColumns)]]"
+ as="item"
+ >
+ <td class$="[[_lowerCase(item)]]">
[[item]]
</td>
</template>
@@ -139,12 +140,12 @@
<gr-change-list-item
account="[[account]]"
selected$="[[_computeItemSelected(sectionIndex, index, selectedIndex)]]"
- highlight$="[[_computeItemHighlight(account, change)]]"
+ highlight$="[[_computeItemHighlight(account, change, _config, changeSection.name)]]"
needs-review$="[[_computeItemNeedsReview(account, change, showReviewedState, _config)]]"
change="[[change]]"
config="[[_config]]"
section-name="[[changeSection.name]]"
- visible-change-table-columns="[[visibleChangeTableColumns]]"
+ visible-change-table-columns="[[_computeColumns(changeSection, visibleChangeTableColumns)]]"
show-number="[[showNumber]]"
show-star="[[showStar]]"
tabindex$="[[_computeTabIndex(sectionIndex, index, selectedIndex)]]"
diff --git a/polygerrit-ui/app/elements/change-list/gr-change-list/gr-change-list_test.js b/polygerrit-ui/app/elements/change-list/gr-change-list/gr-change-list_test.js
index 68ab5e5..494d05a 100644
--- a/polygerrit-ui/app/elements/change-list/gr-change-list/gr-change-list_test.js
+++ b/polygerrit-ui/app/elements/change-list/gr-change-list/gr-change-list_test.js
@@ -21,6 +21,7 @@
import {GerritNav} from '../../core/gr-navigation/gr-navigation.js';
import {TestKeyboardShortcutBinder} from '../../../test/test-utils.js';
import {Shortcut} from '../../../mixins/keyboard-shortcut-mixin/keyboard-shortcut-mixin.js';
+import {YOUR_TURN} from '../../core/gr-navigation/gr-navigation.js';
const basicFixture = fixtureFromElement('gr-change-list');
@@ -145,7 +146,7 @@
const changeTableColumns = [];
const labelNames = [];
assert.equal(tdItemCount, element._computeColspan(
- changeTableColumns, labelNames));
+ {}, changeTableColumns, labelNames));
});
test('keyboard shortcuts', done => {
@@ -303,7 +304,7 @@
});
test('shown on empty outgoing sections', () => {
- const section = {results: [], name: 'Your Turn'};
+ const section = {results: [], name: YOUR_TURN.name};
assert.isTrue(element._isEmpty(section));
assert.equal(element._getSpecialEmptySlot(section), 'empty-your-turn');
});
@@ -416,11 +417,9 @@
for (const column of element.changeTableColumns) {
const elementClass = '.' + column.toLowerCase();
if (column === 'Repo') {
- assert.isTrue(element.shadowRoot
- .querySelector(elementClass).hidden);
+ assert.isNotOk(element.shadowRoot.querySelector(elementClass));
} else {
- assert.isFalse(element.shadowRoot
- .querySelector(elementClass).hidden);
+ assert.isOk(element.shadowRoot.querySelector(elementClass));
}
}
});
diff --git a/polygerrit-ui/app/elements/change-list/gr-create-destination-dialog/gr-create-destination-dialog.ts b/polygerrit-ui/app/elements/change-list/gr-create-destination-dialog/gr-create-destination-dialog.ts
index 27866ee..e53f68b 100644
--- a/polygerrit-ui/app/elements/change-list/gr-create-destination-dialog/gr-create-destination-dialog.ts
+++ b/polygerrit-ui/app/elements/change-list/gr-create-destination-dialog/gr-create-destination-dialog.ts
@@ -26,6 +26,11 @@
import {GrOverlay} from '../../shared/gr-overlay/gr-overlay';
import {RepoName, BranchName} from '../../../types/common';
+export interface CreateDestinationConfirmDetail {
+ repo?: RepoName;
+ branch?: BranchName;
+}
+
/**
* Fired when a destination has been picked. Event details contain the repo
* name and the branch name.
@@ -70,7 +75,10 @@
_pickerConfirm(e: Event) {
this.$.createOverlay.close();
- const detail = {repo: this._repo, branch: this._branch};
+ const detail: CreateDestinationConfirmDetail = {
+ repo: this._repo,
+ branch: this._branch,
+ };
// e is a 'confirm' event from gr-dialog. We want to fire a more detailed
// 'confirm' event here, so let's stop propagation of the bare event.
e.preventDefault();
diff --git a/polygerrit-ui/app/elements/change-list/gr-dashboard-view/gr-dashboard-view.js b/polygerrit-ui/app/elements/change-list/gr-dashboard-view/gr-dashboard-view.js
deleted file mode 100644
index 0658e16..0000000
--- a/polygerrit-ui/app/elements/change-list/gr-dashboard-view/gr-dashboard-view.js
+++ /dev/null
@@ -1,352 +0,0 @@
-/**
- * @license
- * Copyright (C) 2015 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import '../../../styles/shared-styles.js';
-import '../gr-change-list/gr-change-list.js';
-import '../../shared/gr-button/gr-button.js';
-import '../../shared/gr-dialog/gr-dialog.js';
-import '../../shared/gr-overlay/gr-overlay.js';
-import '../../shared/gr-rest-api-interface/gr-rest-api-interface.js';
-import '../gr-create-commands-dialog/gr-create-commands-dialog.js';
-import '../gr-create-change-help/gr-create-change-help.js';
-import '../gr-create-destination-dialog/gr-create-destination-dialog.js';
-import '../gr-user-header/gr-user-header.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-dashboard-view_html.js';
-import {GerritNav} from '../../core/gr-navigation/gr-navigation.js';
-import {appContext} from '../../../services/app-context.js';
-import {changeIsOpen} from '../../../utils/change-util.js';
-
-const PROJECT_PLACEHOLDER_PATTERN = /\$\{project\}/g;
-
-/**
- * @extends PolymerElement
- */
-class GrDashboardView extends GestureEventListeners(
- LegacyElementMixin(PolymerElement)) {
- static get template() { return htmlTemplate; }
-
- static get is() { return 'gr-dashboard-view'; }
- /**
- * Fired when the title of the page should change.
- *
- * @event title-change
- */
-
- static get properties() {
- return {
- account: {
- type: Object,
- value: null,
- },
- preferences: Object,
- /** @type {{ selectedChangeIndex: number }} */
- viewState: Object,
-
- /** @type {{ project: string, user: string }} */
- params: {
- type: Object,
- },
-
- createChangeTap: {
- type: Function,
- value() {
- return e => this._createChangeTap(e);
- },
- },
-
- _results: Array,
-
- /**
- * For showing a "loading..." string during ajax requests.
- */
- _loading: {
- type: Boolean,
- value: true,
- },
-
- _showDraftsBanner: {
- type: Boolean,
- value: false,
- },
-
- _showNewUserHelp: {
- type: Boolean,
- value: false,
- },
- };
- }
-
- constructor() {
- super();
- this.reporting = appContext.reportingService;
- }
-
- static get observers() {
- return [
- '_paramsChanged(params.*)',
- ];
- }
-
- /** @override */
- attached() {
- super.attached();
- this._loadPreferences();
- this.addEventListener('reload', e => {
- e.stopPropagation();
- this._reload();
- });
- }
-
- _loadPreferences() {
- return this.$.restAPI.getLoggedIn().then(loggedIn => {
- if (loggedIn) {
- this.$.restAPI.getPreferences().then(preferences => {
- this.preferences = preferences;
- });
- } else {
- this.preferences = {};
- }
- });
- }
-
- _getProjectDashboard(project, dashboard) {
- const errFn = response => {
- this.dispatchEvent(new CustomEvent('page-error', {
- detail: {response},
- composed: true, bubbles: true,
- }));
- };
- return this.$.restAPI.getDashboard(
- project, dashboard, errFn).then(response => {
- if (!response) {
- return;
- }
- return {
- title: response.title,
- sections: response.sections.map(section => {
- const suffix = response.foreach ? ' ' + response.foreach : '';
- return {
- name: section.name,
- query: (section.query + suffix).replace(
- PROJECT_PLACEHOLDER_PATTERN, project),
- };
- }),
- };
- });
- }
-
- _computeTitle(user) {
- if (!user || user === 'self') {
- return 'My Reviews';
- }
- return 'Dashboard for ' + user;
- }
-
- _isViewActive(params) {
- return params.view === GerritNav.View.DASHBOARD;
- }
-
- _paramsChanged(paramsChangeRecord) {
- const params = paramsChangeRecord.base;
-
- if (!this._isViewActive(params)) {
- return Promise.resolve();
- }
-
- return this._reload();
- }
-
- /**
- * Reloads the element.
- *
- * @return {Promise<!Object>}
- */
- _reload() {
- this._loading = true;
- const {project, dashboard, title, user, sections} = this.params;
- const dashboardPromise = project ?
- this._getProjectDashboard(project, dashboard) :
- this.$.restAPI.getConfig().then(
- config => Promise.resolve(GerritNav.getUserDashboard(
- user,
- sections,
- title || this._computeTitle(user),
- config
- ))
- );
-
- const checkForNewUser = !project && user === 'self';
- return dashboardPromise
- .then(res => {
- if (res && res.title) {
- this.dispatchEvent(new CustomEvent('title-change', {
- detail: {title: res.title},
- composed: true, bubbles: true,
- }));
- }
- return this._fetchDashboardChanges(res, checkForNewUser);
- })
- .then(() => {
- this._maybeShowDraftsBanner();
- this.reporting.dashboardDisplayed();
- })
- .catch(err => {
- this.dispatchEvent(new CustomEvent('title-change', {
- detail: {
- title: title || this._computeTitle(user),
- },
- composed: true, bubbles: true,
- }));
- console.warn(err);
- })
- .then(() => { this._loading = false; });
- }
-
- /**
- * Fetches the changes for each dashboard section and sets this._results
- * with the response.
- *
- * @param {!Object} res
- * @param {boolean} checkForNewUser
- * @return {Promise}
- */
- _fetchDashboardChanges(res, checkForNewUser) {
- if (!res) { return Promise.resolve(); }
-
- let queries;
-
- if (window.PRELOADED_QUERIES
- && window.PRELOADED_QUERIES.dashboardQuery) {
- queries = window.PRELOADED_QUERIES.dashboardQuery;
- // we use preloaded query from index only on first page load
- window.PRELOADED_QUERIES.dashboardQuery = undefined;
- } else {
- queries = res.sections
- .map(section => (section.suffixForDashboard ?
- section.query + ' ' + section.suffixForDashboard :
- section.query));
-
- if (checkForNewUser) {
- queries.push('owner:self limit:1');
- }
- }
-
- return this.$.restAPI.getChanges(null, queries)
- .then(changes => {
- if (checkForNewUser) {
- // Last set of results is not meant for dashboard display.
- const lastResultSet = changes.pop();
- this._showNewUserHelp = lastResultSet.length == 0;
- }
- this._results = changes.map((results, i) => {
- return {
- name: res.sections[i].name,
- countLabel: this._computeSectionCountLabel(results),
- query: res.sections[i].query,
- results,
- isOutgoing: res.sections[i].isOutgoing,
- };
- }).filter((section, i) => i < res.sections.length && (
- !res.sections[i].hideIfEmpty ||
- section.results.length));
- });
- }
-
- _computeSectionCountLabel(changes) {
- if (!changes || !changes.length || changes.length == 0) {
- return '';
- }
- const more = changes[changes.length - 1]._more_changes;
- const numChanges = changes.length;
- const andMore = more ? ' and more' : '';
- return `(${numChanges}${andMore})`;
- }
-
- _computeUserHeaderClass(params) {
- if (!params || !!params.project || !params.user ||
- params.user === 'self') {
- return 'hide';
- }
- return '';
- }
-
- _handleToggleStar(e) {
- this.$.restAPI.saveChangeStarred(e.detail.change._number,
- e.detail.starred);
- }
-
- _handleToggleReviewed(e) {
- this.$.restAPI.saveChangeReviewed(e.detail.change._number,
- e.detail.reviewed);
- }
-
- /**
- * Banner is shown if a user is on their own dashboard and they have draft
- * comments on closed changes.
- */
- _maybeShowDraftsBanner() {
- this._showDraftsBanner = false;
- if (!(this.params.user === 'self')) { return; }
-
- const draftSection = this._results
- .find(section => section.query === 'has:draft');
- if (!draftSection || !draftSection.results.length) { return; }
-
- const closedChanges = draftSection.results
- .filter(change => !changeIsOpen(change));
- if (!closedChanges.length) { return; }
-
- this._showDraftsBanner = true;
- }
-
- _computeBannerClass(show) {
- return show ? '' : 'hide';
- }
-
- _handleOpenDeleteDialog() {
- this.$.confirmDeleteOverlay.open();
- }
-
- _handleConfirmDelete() {
- this.$.confirmDeleteDialog.disabled = true;
- return this.$.restAPI.deleteDraftComments('-is:open').then(() => {
- this._closeConfirmDeleteOverlay();
- this._reload();
- });
- }
-
- _closeConfirmDeleteOverlay() {
- this.$.confirmDeleteOverlay.close();
- }
-
- _computeDraftsLink() {
- return GerritNav.getUrlForSearchQuery('has:draft -is:open');
- }
-
- _createChangeTap(e) {
- this.$.destinationDialog.open();
- }
-
- _handleDestinationConfirm(e) {
- this.$.commandsDialog.branch = e.detail.branch;
- this.$.commandsDialog.open();
- }
-}
-
-customElements.define(GrDashboardView.is, GrDashboardView);
diff --git a/polygerrit-ui/app/elements/change-list/gr-dashboard-view/gr-dashboard-view.ts b/polygerrit-ui/app/elements/change-list/gr-dashboard-view/gr-dashboard-view.ts
new file mode 100644
index 0000000..7ca62d9
--- /dev/null
+++ b/polygerrit-ui/app/elements/change-list/gr-dashboard-view/gr-dashboard-view.ts
@@ -0,0 +1,453 @@
+/**
+ * @license
+ * Copyright (C) 2015 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import '../../../styles/shared-styles';
+import '../gr-change-list/gr-change-list';
+import '../../shared/gr-button/gr-button';
+import '../../shared/gr-dialog/gr-dialog';
+import '../../shared/gr-overlay/gr-overlay';
+import '../../shared/gr-rest-api-interface/gr-rest-api-interface';
+import '../gr-create-commands-dialog/gr-create-commands-dialog';
+import '../gr-create-change-help/gr-create-change-help';
+import '../gr-create-destination-dialog/gr-create-destination-dialog';
+import '../gr-user-header/gr-user-header';
+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-dashboard-view_html';
+import {
+ GerritNav,
+ GerritView,
+ UserDashboard,
+ YOUR_TURN,
+} from '../../core/gr-navigation/gr-navigation';
+import {appContext} from '../../../services/app-context';
+import {changeIsOpen} from '../../../utils/change-util';
+import {parseDate} from '../../../utils/date-util';
+import {customElement, observe, property} from '@polymer/decorators';
+import {
+ AccountDetailInfo,
+ ChangeInfo,
+ DashboardId,
+ ElementPropertyDeepChange,
+ PreferencesInput,
+ RepoName,
+} from '../../../types/common';
+import {AppElementDashboardParams, AppElementParams} from '../../gr-app-types';
+import {RestApiService} from '../../../services/services/gr-rest-api/gr-rest-api';
+import {GrDialog} from '../../shared/gr-dialog/gr-dialog';
+import {GrCreateCommandsDialog} from '../gr-create-commands-dialog/gr-create-commands-dialog';
+import {
+ CreateDestinationConfirmDetail,
+ GrCreateDestinationDialog,
+} from '../gr-create-destination-dialog/gr-create-destination-dialog';
+import {GrOverlay} from '../../shared/gr-overlay/gr-overlay';
+import {ChangeListToggleReviewedDetail} from '../gr-change-list-item/gr-change-list-item';
+import {ChangeStarToggleStarDetail} from '../../shared/gr-change-star/gr-change-star';
+
+const PROJECT_PLACEHOLDER_PATTERN = /\$\{project\}/g;
+
+export interface DashboardViewState {
+ selectedChangeIndex: number;
+}
+
+export interface GrDashboardView {
+ $: {
+ restAPI: RestApiService & Element;
+ confirmDeleteDialog: GrDialog;
+ commandsDialog: GrCreateCommandsDialog;
+ destinationDialog: GrCreateDestinationDialog;
+ confirmDeleteOverlay: GrOverlay;
+ };
+}
+
+interface DashboardChange {
+ name: string;
+ countLabel: string;
+ query: string;
+ results: ChangeInfo[];
+ isOutgoing?: boolean;
+}
+
+@customElement('gr-dashboard-view')
+export class GrDashboardView extends GestureEventListeners(
+ LegacyElementMixin(PolymerElement)
+) {
+ static get template() {
+ return htmlTemplate;
+ }
+
+ /**
+ * Fired when the title of the page should change.
+ *
+ * @event title-change
+ */
+
+ @property({type: Object})
+ account: AccountDetailInfo | null = null;
+
+ @property({type: Object})
+ preferences?: PreferencesInput;
+
+ @property({type: Object})
+ viewState?: DashboardViewState;
+
+ @property({type: Object})
+ params?: AppElementParams;
+
+ @property({type: Array})
+ _results?: DashboardChange[];
+
+ @property({type: Boolean})
+ _loading = true;
+
+ @property({type: Boolean})
+ _showDraftsBanner = false;
+
+ @property({type: Boolean})
+ _showNewUserHelp = false;
+
+ private reporting = appContext.reportingService;
+
+ constructor() {
+ super();
+ }
+
+ /** @override */
+ attached() {
+ super.attached();
+ this._loadPreferences();
+ this.addEventListener('reload', e => {
+ e.stopPropagation();
+ this._reload();
+ });
+ }
+
+ _loadPreferences() {
+ return this.$.restAPI.getLoggedIn().then(loggedIn => {
+ if (loggedIn) {
+ this.$.restAPI.getPreferences().then(preferences => {
+ this.preferences = preferences;
+ });
+ } else {
+ this.preferences = {};
+ }
+ });
+ }
+
+ _getProjectDashboard(
+ project: RepoName,
+ dashboard: DashboardId
+ ): Promise<UserDashboard | undefined> {
+ const errFn = (response?: Response | null) => {
+ this.dispatchEvent(
+ new CustomEvent('page-error', {
+ detail: {response},
+ composed: true,
+ bubbles: true,
+ })
+ );
+ };
+ return this.$.restAPI
+ .getDashboard(project, dashboard, errFn)
+ .then(response => {
+ if (!response) {
+ return;
+ }
+ return {
+ title: response.title,
+ sections: response.sections.map(section => {
+ const suffix = response.foreach ? ' ' + response.foreach : '';
+ return {
+ name: section.name,
+ query: (section.query + suffix).replace(
+ PROJECT_PLACEHOLDER_PATTERN,
+ project
+ ),
+ };
+ }),
+ };
+ });
+ }
+
+ _computeTitle(user?: string) {
+ if (!user || user === 'self') {
+ return 'My Reviews';
+ }
+ return 'Dashboard for ' + user;
+ }
+
+ _isViewActive(params: AppElementParams): params is AppElementDashboardParams {
+ return params.view === GerritView.DASHBOARD;
+ }
+
+ @observe('params.*')
+ _paramsChanged(
+ paramsChangeRecord: ElementPropertyDeepChange<GrDashboardView, 'params'>
+ ) {
+ const params = paramsChangeRecord.base;
+
+ return this._reload(params);
+ }
+
+ /**
+ * Reloads the element.
+ */
+ _reload(params?: AppElementParams) {
+ if (!params || !this._isViewActive(params)) {
+ return Promise.resolve();
+ }
+ this._loading = true;
+ const {project, dashboard, title, user, sections} = params;
+ const dashboardPromise: Promise<UserDashboard | undefined> = project
+ ? this._getProjectDashboard(project, dashboard)
+ : this.$.restAPI
+ .getConfig()
+ .then(config =>
+ Promise.resolve(
+ GerritNav.getUserDashboard(
+ user,
+ sections,
+ title || this._computeTitle(user),
+ config
+ )
+ )
+ );
+
+ const checkForNewUser = !project && user === 'self';
+ return dashboardPromise
+ .then(res => {
+ if (res && res.title) {
+ this.dispatchEvent(
+ new CustomEvent('title-change', {
+ detail: {title: res.title},
+ composed: true,
+ bubbles: true,
+ })
+ );
+ }
+ return this._fetchDashboardChanges(res, checkForNewUser);
+ })
+ .then(() => {
+ this._maybeShowDraftsBanner(params);
+ this.reporting.dashboardDisplayed();
+ })
+ .catch(err => {
+ this.dispatchEvent(
+ new CustomEvent('title-change', {
+ detail: {
+ title: title || this._computeTitle(user),
+ },
+ composed: true,
+ bubbles: true,
+ })
+ );
+ console.warn(err);
+ })
+ .then(() => {
+ this._loading = false;
+ });
+ }
+
+ /**
+ * Fetches the changes for each dashboard section and sets this._results
+ * with the response.
+ */
+ _fetchDashboardChanges(
+ res: UserDashboard | undefined,
+ checkForNewUser: boolean
+ ): Promise<void> {
+ if (!res) {
+ return Promise.resolve();
+ }
+
+ let queries: string[];
+
+ if (window.PRELOADED_QUERIES && window.PRELOADED_QUERIES.dashboardQuery) {
+ queries = window.PRELOADED_QUERIES.dashboardQuery;
+ // we use preloaded query from index only on first page load
+ window.PRELOADED_QUERIES.dashboardQuery = undefined;
+ } else {
+ queries = res.sections.map(section =>
+ section.suffixForDashboard
+ ? section.query + ' ' + section.suffixForDashboard
+ : section.query
+ );
+
+ if (checkForNewUser) {
+ queries.push('owner:self limit:1');
+ }
+ }
+
+ return this.$.restAPI.getChanges(undefined, queries).then(changes => {
+ if (!changes) {
+ throw new Error('getChanges returns undefined');
+ }
+ if (checkForNewUser) {
+ // Last set of results is not meant for dashboard display.
+ const lastResultSet = changes.pop();
+ this._showNewUserHelp = lastResultSet!.length === 0;
+ }
+ this._results = changes
+ .map((results, i) => {
+ return {
+ name: res.sections[i].name,
+ countLabel: this._computeSectionCountLabel(results),
+ query: res.sections[i].query,
+ results: this._maybeSortResults(res.sections[i].name, results),
+ isOutgoing: res.sections[i].isOutgoing,
+ };
+ })
+ .filter(
+ (section, i) =>
+ i < res.sections.length &&
+ (!res.sections[i].hideIfEmpty || section.results.length)
+ );
+ });
+ }
+
+ /**
+ * Usually we really want to stick to the sorting that the backend provides,
+ * but for the "Your Turn" section it is important to put the changes at the
+ * top where the current user is a reviewer. Owned changes are less important.
+ * And then we want to emphasize the changes where the waiting time is larger.
+ */
+ _maybeSortResults(name: string, results: ChangeInfo[]) {
+ const userId = this.account && this.account._account_id;
+ const sortedResults = [...results];
+ if (name === YOUR_TURN.name && userId) {
+ sortedResults.sort((c1, c2) => {
+ const c1Owner = c1.owner._account_id === userId;
+ const c2Owner = c2.owner._account_id === userId;
+ if (c1Owner !== c2Owner) return c1Owner ? 1 : -1;
+ // Should never happen, because the change is in the 'Your Turn'
+ // section, so the userId should be found in the attention set of both.
+ if (!c1.attention_set || !c1.attention_set[userId]) return 0;
+ if (!c2.attention_set || !c2.attention_set[userId]) return 0;
+ const c1Update = c1.attention_set[userId].last_update;
+ const c2Update = c2.attention_set[userId].last_update;
+ // Should never happen that an attention set entry has no update.
+ if (!c1Update || !c2Update) return c1Update ? 1 : -1;
+ return parseDate(c1Update).valueOf() - parseDate(c2Update).valueOf();
+ });
+ }
+ return sortedResults;
+ }
+
+ _computeSectionCountLabel(changes: ChangeInfo[]) {
+ if (!changes || !changes.length || changes.length === 0) {
+ return '';
+ }
+ const more = changes[changes.length - 1]._more_changes;
+ const numChanges = changes.length;
+ const andMore = more ? ' and more' : '';
+ return `(${numChanges}${andMore})`;
+ }
+
+ _computeUserHeaderClass(params: AppElementParams) {
+ if (
+ !params ||
+ params.view !== GerritView.DASHBOARD ||
+ !!params.project ||
+ !params.user ||
+ params.user === 'self'
+ ) {
+ return 'hide';
+ }
+ return '';
+ }
+
+ _handleToggleStar(e: CustomEvent<ChangeStarToggleStarDetail>) {
+ this.$.restAPI.saveChangeStarred(e.detail.change._number, e.detail.starred);
+ }
+
+ _handleToggleReviewed(e: CustomEvent<ChangeListToggleReviewedDetail>) {
+ this.$.restAPI.saveChangeReviewed(
+ e.detail.change._number,
+ e.detail.reviewed
+ );
+ }
+
+ /**
+ * Banner is shown if a user is on their own dashboard and they have draft
+ * comments on closed changes.
+ */
+ _maybeShowDraftsBanner(params: AppElementDashboardParams) {
+ this._showDraftsBanner = false;
+ if (!(params.user === 'self')) {
+ return;
+ }
+
+ if (!this._results) {
+ throw new Error('this._results must be set. restAPI returned undefined');
+ }
+
+ const draftSection = this._results.find(
+ section => section.query === 'has:draft'
+ );
+ if (!draftSection || !draftSection.results.length) {
+ return;
+ }
+
+ const closedChanges = draftSection.results.filter(
+ change => !changeIsOpen(change)
+ );
+ if (!closedChanges.length) {
+ return;
+ }
+
+ this._showDraftsBanner = true;
+ }
+
+ _computeBannerClass(show: boolean) {
+ return show ? '' : 'hide';
+ }
+
+ _handleOpenDeleteDialog() {
+ this.$.confirmDeleteOverlay.open();
+ }
+
+ _handleConfirmDelete() {
+ this.$.confirmDeleteDialog.disabled = true;
+ return this.$.restAPI.deleteDraftComments('-is:open').then(() => {
+ this._closeConfirmDeleteOverlay();
+ this._reload(this.params);
+ });
+ }
+
+ _closeConfirmDeleteOverlay() {
+ this.$.confirmDeleteOverlay.close();
+ }
+
+ _computeDraftsLink() {
+ return GerritNav.getUrlForSearchQuery('has:draft -is:open');
+ }
+
+ _handleCreateChangeTap() {
+ this.$.destinationDialog.open();
+ }
+
+ _handleDestinationConfirm(e: CustomEvent<CreateDestinationConfirmDetail>) {
+ this.$.commandsDialog.branch = e.detail.branch;
+ this.$.commandsDialog.open();
+ }
+}
+
+declare global {
+ interface HTMLElementTagNameMap {
+ 'gr-dashboard-view': GrDashboardView;
+ }
+}
diff --git a/polygerrit-ui/app/elements/change-list/gr-dashboard-view/gr-dashboard-view_html.ts b/polygerrit-ui/app/elements/change-list/gr-dashboard-view/gr-dashboard-view_html.ts
index f8a0167..5739d4d 100644
--- a/polygerrit-ui/app/elements/change-list/gr-dashboard-view/gr-dashboard-view_html.ts
+++ b/polygerrit-ui/app/elements/change-list/gr-dashboard-view/gr-dashboard-view_html.ts
@@ -91,7 +91,7 @@
<div id="emptyOutgoing" slot="empty-outgoing">
<template is="dom-if" if="[[_showNewUserHelp]]">
<gr-create-change-help
- on-create-tap="createChangeTap"
+ on-create-tap="_handleCreateChangeTap"
></gr-create-change-help>
</template>
<template is="dom-if" if="[[!_showNewUserHelp]]">
diff --git a/polygerrit-ui/app/elements/change-list/gr-dashboard-view/gr-dashboard-view_test.js b/polygerrit-ui/app/elements/change-list/gr-dashboard-view/gr-dashboard-view_test.js
index 063fbb2..44f203d 100644
--- a/polygerrit-ui/app/elements/change-list/gr-dashboard-view/gr-dashboard-view_test.js
+++ b/polygerrit-ui/app/elements/change-list/gr-dashboard-view/gr-dashboard-view_test.js
@@ -18,7 +18,7 @@
import '../../../test/common-test-setup-karma.js';
import './gr-dashboard-view.js';
import {isHidden} from '../../../test/test-utils.js';
-import {GerritNav} from '../../core/gr-navigation/gr-navigation.js';
+import {GerritNav, GerritView} from '../../core/gr-navigation/gr-navigation.js';
import {changeIsOpen} from '../../../utils/change-util.js';
import {ChangeStatus} from '../../../constants/constants.js';
@@ -54,32 +54,40 @@
suite('drafts banner functionality', () => {
suite('_maybeShowDraftsBanner', () => {
test('not dashboard/self', () => {
- element.params = {user: 'notself'};
- element._maybeShowDraftsBanner();
+ element._maybeShowDraftsBanner({
+ view: GerritView.DASHBOARD,
+ user: 'notself',
+ });
assert.isFalse(element._showDraftsBanner);
});
test('no drafts at all', () => {
- element.params = {user: 'self'};
element._results = [];
- element._maybeShowDraftsBanner();
+ element._maybeShowDraftsBanner({
+ view: GerritView.DASHBOARD,
+ user: 'self',
+ });
assert.isFalse(element._showDraftsBanner);
});
test('no drafts on open changes', () => {
- element.params = {user: 'self'};
const openChange = {status: ChangeStatus.NEW};
element._results = [{query: 'has:draft', results: [openChange]}];
- element._maybeShowDraftsBanner();
+ element._maybeShowDraftsBanner({
+ view: GerritView.DASHBOARD,
+ user: 'self',
+ });
assert.isFalse(element._showDraftsBanner);
});
test('no drafts on not open changes', () => {
- element.params = {user: 'self'};
const notOpenChange = {status: '_'};
element._results = [{query: 'has:draft', results: [notOpenChange]}];
assert.isFalse(changeIsOpen(element._results[0].results[0]));
- element._maybeShowDraftsBanner();
+ element._maybeShowDraftsBanner({
+ view: GerritView.DASHBOARD,
+ user: 'self',
+ });
assert.isTrue(element._showDraftsBanner);
});
});
@@ -194,7 +202,8 @@
};
return paramsChangedPromise.then(() => {
assert.isTrue(
- getChangesStub.calledWith(null, ['1', '2', 'owner:self limit:1']));
+ getChangesStub.calledWith(undefined,
+ ['1', '2', 'owner:self limit:1']));
});
});
@@ -208,7 +217,7 @@
user: 'user',
};
return paramsChangedPromise.then(() => {
- assert.isTrue(getChangesStub.calledWith(null, ['1']));
+ assert.isTrue(getChangesStub.calledWith(undefined, ['1']));
});
});
});
@@ -224,7 +233,7 @@
return paramsChangedPromise.then(() => {
assert.isTrue(getChangesStub.calledOnce);
assert.deepEqual(
- getChangesStub.firstCall.args, [null, ['1', '2 suffix']]);
+ getChangesStub.firstCall.args, [undefined, ['1', '2 suffix']]);
});
});
@@ -329,10 +338,23 @@
assert.equal(element._computeUserHeaderClass(undefined), 'hide');
assert.equal(element._computeUserHeaderClass({}), 'hide');
assert.equal(element._computeUserHeaderClass({user: 'self'}), 'hide');
- assert.equal(element._computeUserHeaderClass({user: 'user'}), '');
+ assert.equal(element._computeUserHeaderClass({user: 'user'}), 'hide');
+ assert.equal(
+ element._computeUserHeaderClass({
+ view: GerritView.DASHBOARD,
+ user: 'user',
+ }),
+ '');
assert.equal(
element._computeUserHeaderClass({project: 'p', user: 'user'}),
'hide');
+ assert.equal(
+ element._computeUserHeaderClass({
+ view: GerritView.DASHBOARD,
+ project: 'p',
+ user: 'user',
+ }),
+ 'hide');
});
test('404 page', done => {
diff --git a/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions.js b/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions.js
deleted file mode 100644
index fba4b53..0000000
--- a/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions.js
+++ /dev/null
@@ -1,1724 +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 '../../admin/gr-create-change-dialog/gr-create-change-dialog.js';
-import '../../shared/gr-button/gr-button.js';
-import '../../shared/gr-dialog/gr-dialog.js';
-import '../../shared/gr-dropdown/gr-dropdown.js';
-import '../../shared/gr-icons/gr-icons.js';
-import '../../shared/gr-js-api-interface/gr-js-api-interface.js';
-import '../../shared/gr-overlay/gr-overlay.js';
-import '../../shared/gr-rest-api-interface/gr-rest-api-interface.js';
-import '../gr-confirm-abandon-dialog/gr-confirm-abandon-dialog.js';
-import '../gr-confirm-cherrypick-dialog/gr-confirm-cherrypick-dialog.js';
-import '../gr-confirm-cherrypick-conflict-dialog/gr-confirm-cherrypick-conflict-dialog.js';
-import '../gr-confirm-move-dialog/gr-confirm-move-dialog.js';
-import '../gr-confirm-rebase-dialog/gr-confirm-rebase-dialog.js';
-import '../gr-confirm-revert-dialog/gr-confirm-revert-dialog.js';
-import '../gr-confirm-revert-submission-dialog/gr-confirm-revert-submission-dialog.js';
-import '../gr-confirm-submit-dialog/gr-confirm-submit-dialog.js';
-import '../../../styles/shared-styles.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-change-actions_html.js';
-import {GerritNav} from '../../core/gr-navigation/gr-navigation.js';
-import {getPluginLoader} from '../../shared/gr-js-api-interface/gr-plugin-loader.js';
-import {appContext} from '../../../services/app-context.js';
-import {
- fetchChangeUpdates,
- patchNumEquals,
-} from '../../../utils/patch-set-util.js';
-import {
- changeIsOpen,
- ListChangesOption,
- listChangesOptionsToHex,
-} from '../../../utils/change-util.js';
-import {NotifyType} from '../../../constants/constants.js';
-import {TargetElement, EventType} from '../../plugins/gr-plugin-types.js';
-
-const ERR_BRANCH_EMPTY = 'The destination branch can’t be empty.';
-const ERR_COMMIT_EMPTY = 'The commit message can’t be empty.';
-const ERR_REVISION_ACTIONS = 'Couldn’t load revision actions.';
-/**
- * @enum {string}
- */
-const LabelStatus = {
- /**
- * This label provides what is necessary for submission.
- */
- OK: 'OK',
- /**
- * This label prevents the change from being submitted.
- */
- REJECT: 'REJECT',
- /**
- * The label may be set, but it's neither necessary for submission
- * nor does it block submission if set.
- */
- MAY: 'MAY',
- /**
- * The label is required for submission, but has not been satisfied.
- */
- NEED: 'NEED',
- /**
- * The label is required for submission, but is impossible to complete.
- * The likely cause is access has not been granted correctly by the
- * project owner or site administrator.
- */
- IMPOSSIBLE: 'IMPOSSIBLE',
- OPTIONAL: 'OPTIONAL',
-};
-
-const ChangeActions = {
- ABANDON: 'abandon',
- DELETE: '/',
- DELETE_EDIT: 'deleteEdit',
- EDIT: 'edit',
- FOLLOW_UP: 'followup',
- IGNORE: 'ignore',
- MOVE: 'move',
- PRIVATE: 'private',
- PRIVATE_DELETE: 'private.delete',
- PUBLISH_EDIT: 'publishEdit',
- REBASE: 'rebase',
- REBASE_EDIT: 'rebaseEdit',
- READY: 'ready',
- RESTORE: 'restore',
- REVERT: 'revert',
- REVERT_SUBMISSION: 'revert_submission',
- REVIEWED: 'reviewed',
- STOP_EDIT: 'stopEdit',
- SUBMIT: 'submit',
- UNIGNORE: 'unignore',
- UNREVIEWED: 'unreviewed',
- WIP: 'wip',
-};
-
-const RevisionActions = {
- CHERRYPICK: 'cherrypick',
- REBASE: 'rebase',
- SUBMIT: 'submit',
- DOWNLOAD: 'download',
-};
-
-const ActionLoadingLabels = {
- abandon: 'Abandoning...',
- cherrypick: 'Cherry-picking...',
- delete: 'Deleting...',
- move: 'Moving..',
- rebase: 'Rebasing...',
- restore: 'Restoring...',
- revert: 'Reverting...',
- revert_submission: 'Reverting Submission...',
- submit: 'Submitting...',
-};
-
-const ActionType = {
- CHANGE: 'change',
- REVISION: 'revision',
-};
-
-const ADDITIONAL_ACTION_KEY_PREFIX = '__additionalAction_';
-
-const QUICK_APPROVE_ACTION = {
- __key: 'review',
- __type: 'change',
- enabled: true,
- key: 'review',
- label: 'Quick approve',
- method: 'POST',
-};
-
-const ActionPriority = {
- CHANGE: 2,
- DEFAULT: 0,
- PRIMARY: 3,
- REVIEW: -3,
- REVISION: 1,
-};
-
-const DOWNLOAD_ACTION = {
- enabled: true,
- label: 'Download patch',
- title: 'Open download dialog',
- __key: 'download',
- __primary: false,
- __type: 'revision',
-};
-
-const REBASE_EDIT = {
- enabled: true,
- label: 'Rebase edit',
- title: 'Rebase change edit',
- __key: 'rebaseEdit',
- __primary: false,
- __type: 'change',
- method: 'POST',
-};
-
-const PUBLISH_EDIT = {
- enabled: true,
- label: 'Publish edit',
- title: 'Publish change edit',
- __key: 'publishEdit',
- __primary: false,
- __type: 'change',
- method: 'POST',
-};
-
-const DELETE_EDIT = {
- enabled: true,
- label: 'Delete edit',
- title: 'Delete change edit',
- __key: 'deleteEdit',
- __primary: false,
- __type: 'change',
- method: 'DELETE',
-};
-
-const EDIT = {
- enabled: true,
- label: 'Edit',
- title: 'Edit this change',
- __key: 'edit',
- __primary: false,
- __type: 'change',
-};
-
-const STOP_EDIT = {
- enabled: true,
- label: 'Stop editing',
- title: 'Stop editing this change',
- __key: 'stopEdit',
- __primary: false,
- __type: 'change',
-};
-
-// Set of keys that have icons. As more icons are added to gr-icons.html, this
-// set should be expanded.
-const ACTIONS_WITH_ICONS = new Set([
- ChangeActions.ABANDON,
- ChangeActions.DELETE_EDIT,
- ChangeActions.EDIT,
- ChangeActions.PUBLISH_EDIT,
- ChangeActions.READY,
- ChangeActions.REBASE_EDIT,
- ChangeActions.RESTORE,
- ChangeActions.REVERT,
- ChangeActions.REVERT_SUBMISSION,
- ChangeActions.STOP_EDIT,
- QUICK_APPROVE_ACTION.key,
- RevisionActions.REBASE,
- RevisionActions.SUBMIT,
-]);
-
-const AWAIT_CHANGE_ATTEMPTS = 5;
-const AWAIT_CHANGE_TIMEOUT_MS = 1000;
-
-const REVERT_TYPES = {
- REVERT_SINGLE_CHANGE: 1,
- REVERT_SUBMISSION: 2,
-};
-
-/* Revert submission is skipped as the normal revert dialog will now show
-the user a choice between reverting single change or an entire submission.
-Hence, a second button is not needed.
-*/
-const SKIP_ACTION_KEYS = [ChangeActions.REVERT_SUBMISSION];
-
-const SKIP_ACTION_KEYS_ATTENTION_SET = [
- ChangeActions.REVIEWED,
- ChangeActions.UNREVIEWED,
-];
-
-/**
- * @extends PolymerElement
- */
-class GrChangeActions extends GestureEventListeners(
- LegacyElementMixin(PolymerElement)) {
- static get template() { return htmlTemplate; }
-
- static get is() { return 'gr-change-actions'; }
- /**
- * Fired when the change should be reloaded.
- *
- * @event reload
- */
-
- /**
- * Fired when an action is tapped.
- *
- * @event custom-tap - naming pattern: <action key>-tap
- */
-
- /**
- * Fires to show an alert when a send is attempted on the non-latest patch.
- *
- * @event show-alert
- */
-
- /**
- * Fires when a change action fails.
- *
- * @event show-error
- */
-
- constructor() {
- super();
- this.ActionType = ActionType;
- this.ChangeActions = ChangeActions;
- this.RevisionActions = RevisionActions;
- this.reporting = appContext.reportingService;
- }
-
- static get properties() {
- return {
- /**
- * @type {{
- * _number: number,
- * branch: string,
- * id: string,
- * project: string,
- * subject: string,
- * }}
- */
- change: Object,
- actions: {
- type: Object,
- value() { return {}; },
- },
- primaryActionKeys: {
- type: Array,
- value() {
- return [
- ChangeActions.READY,
- RevisionActions.SUBMIT,
- ];
- },
- },
- disableEdit: {
- type: Boolean,
- value: false,
- },
- _hasKnownChainState: {
- type: Boolean,
- value: false,
- },
- _hideQuickApproveAction: {
- type: Boolean,
- value: false,
- },
- changeNum: String,
- changeStatus: String,
- commitNum: String,
- hasParent: {
- type: Boolean,
- observer: '_computeChainState',
- },
- latestPatchNum: String,
- commitMessage: {
- type: String,
- value: '',
- },
- /** @type {?} */
- revisionActions: {
- type: Object,
- notify: true,
- value() { return {}; },
- },
- // If property binds directly to [[revisionActions.submit]] it is not
- // updated when revisionActions doesn't contain submit action.
- /** @type {?} */
- _revisionSubmitAction: {
- type: Object,
- computed: '_getSubmitAction(revisionActions)',
- },
- // If property binds directly to [[revisionActions.rebase]] it is not
- // updated when revisionActions doesn't contain rebase action.
- /** @type {?} */
- _revisionRebaseAction: {
- type: Object,
- computed: '_getRebaseAction(revisionActions)',
- },
- privateByDefault: String,
-
- _loading: {
- type: Boolean,
- value: true,
- },
- _actionLoadingMessage: {
- type: String,
- value: '',
- },
- _allActionValues: {
- type: Array,
- computed: '_computeAllActions(actions.*, revisionActions.*,' +
- 'primaryActionKeys.*, _additionalActions.*, change, ' +
- '_config, _actionPriorityOverrides.*)',
- },
- _topLevelActions: {
- type: Array,
- computed: '_computeTopLevelActions(_allActionValues.*, ' +
- '_hiddenActions.*, _overflowActions.*)',
- observer: '_filterPrimaryActions',
- },
- _topLevelPrimaryActions: Array,
- _topLevelSecondaryActions: Array,
- _menuActions: {
- type: Array,
- computed: '_computeMenuActions(_allActionValues.*, ' +
- '_hiddenActions.*, _overflowActions.*)',
- },
- _overflowActions: {
- type: Array,
- value() {
- const value = [
- {
- type: ActionType.CHANGE,
- key: ChangeActions.WIP,
- },
- {
- type: ActionType.CHANGE,
- key: ChangeActions.DELETE,
- },
- {
- type: ActionType.REVISION,
- key: RevisionActions.CHERRYPICK,
- },
- {
- type: ActionType.CHANGE,
- key: ChangeActions.MOVE,
- },
- {
- type: ActionType.REVISION,
- key: RevisionActions.DOWNLOAD,
- },
- {
- type: ActionType.CHANGE,
- key: ChangeActions.IGNORE,
- },
- {
- type: ActionType.CHANGE,
- key: ChangeActions.UNIGNORE,
- },
- {
- type: ActionType.CHANGE,
- key: ChangeActions.REVIEWED,
- },
- {
- type: ActionType.CHANGE,
- key: ChangeActions.UNREVIEWED,
- },
- {
- type: ActionType.CHANGE,
- key: ChangeActions.PRIVATE,
- },
- {
- type: ActionType.CHANGE,
- key: ChangeActions.PRIVATE_DELETE,
- },
- {
- type: ActionType.CHANGE,
- key: ChangeActions.FOLLOW_UP,
- },
- ];
- return value;
- },
- },
- _actionPriorityOverrides: {
- type: Array,
- value() { return []; },
- },
- _additionalActions: {
- type: Array,
- value() { return []; },
- },
- _hiddenActions: {
- type: Array,
- value() { return []; },
- },
- _disabledMenuActions: {
- type: Array,
- value() { return []; },
- },
- // editPatchsetLoaded == "does the current selected patch range have
- // 'edit' as one of either basePatchNum or patchNum".
- editPatchsetLoaded: {
- type: Boolean,
- value: false,
- },
- // editMode == "is edit mode enabled in the file list".
- editMode: {
- type: Boolean,
- value: false,
- },
- editBasedOnCurrentPatchSet: {
- type: Boolean,
- value: true,
- },
- _config: Object,
- };
- }
-
- static get observers() {
- return [
- '_actionsChanged(actions.*, revisionActions.*, _additionalActions.*)',
- '_changeChanged(change)',
- '_editStatusChanged(editMode, editPatchsetLoaded, ' +
- 'editBasedOnCurrentPatchSet, disableEdit, actions.*, change.*)',
- ];
- }
-
- /** @override */
- created() {
- super.created();
- this.addEventListener('fullscreen-overlay-opened',
- () => this._handleHideBackgroundContent());
- this.addEventListener('fullscreen-overlay-closed',
- () => this._handleShowBackgroundContent());
- }
-
- /** @override */
- ready() {
- super.ready();
- this.$.jsAPI.addElement(TargetElement.CHANGE_ACTIONS, this);
- this.$.restAPI.getConfig().then(config => {
- this._config = config;
- });
- this._handleLoadingComplete();
- }
-
- _getSubmitAction(revisionActions) {
- return this._getRevisionAction(revisionActions, 'submit', null);
- }
-
- _getRebaseAction(revisionActions) {
- return this._getRevisionAction(revisionActions, 'rebase', null);
- }
-
- _getRevisionAction(revisionActions, actionName, emptyActionValue) {
- if (!revisionActions) {
- return undefined;
- }
- if (revisionActions[actionName] === undefined) {
- // Return null to fire an event when reveisionActions was loaded
- // but doesn't contain actionName. undefined doesn't fire an event
- return emptyActionValue;
- }
- return revisionActions[actionName];
- }
-
- reload() {
- if (!this.changeNum || !this.latestPatchNum) {
- return Promise.resolve();
- }
-
- this._loading = true;
- return this._getRevisionActions()
- .then(revisionActions => {
- if (!revisionActions) { return; }
-
- this.revisionActions = revisionActions;
- this._sendShowRevisionActions({
- change: this.change,
- revisionActions,
- });
- this._handleLoadingComplete();
- })
- .catch(err => {
- this.dispatchEvent(new CustomEvent('show-alert', {
- detail: {message: ERR_REVISION_ACTIONS},
- composed: true, bubbles: true,
- }));
- this._loading = false;
- throw err;
- });
- }
-
- _handleLoadingComplete() {
- getPluginLoader().awaitPluginsLoaded()
- .then(() => this._loading = false);
- }
-
- _sendShowRevisionActions(detail) {
- this.$.jsAPI.handleEvent(
- EventType.SHOW_REVISION_ACTIONS,
- detail
- );
- }
-
- _changeChanged() {
- this.reload();
- }
-
- addActionButton(type, label) {
- if (type !== ActionType.CHANGE && type !== ActionType.REVISION) {
- throw Error(`Invalid action type: ${type}`);
- }
- const action = {
- enabled: true,
- label,
- __type: type,
- __key: ADDITIONAL_ACTION_KEY_PREFIX +
- Math.random().toString(36)
- .substr(2),
- };
- this.push('_additionalActions', action);
- return action.__key;
- }
-
- removeActionButton(key) {
- const idx = this._indexOfActionButtonWithKey(key);
- if (idx === -1) {
- return;
- }
- this.splice('_additionalActions', idx, 1);
- }
-
- setActionButtonProp(key, prop, value) {
- this.set([
- '_additionalActions',
- this._indexOfActionButtonWithKey(key),
- prop,
- ], value);
- }
-
- setActionOverflow(type, key, overflow) {
- if (type !== ActionType.CHANGE && type !== ActionType.REVISION) {
- throw Error(`Invalid action type given: ${type}`);
- }
- const index = this._getActionOverflowIndex(type, key);
- const action = {
- type,
- key,
- overflow,
- };
- if (!overflow && index !== -1) {
- this.splice('_overflowActions', index, 1);
- } else if (overflow) {
- this.push('_overflowActions', action);
- }
- }
-
- setActionPriority(type, key, priority) {
- if (type !== ActionType.CHANGE && type !== ActionType.REVISION) {
- throw Error(`Invalid action type given: ${type}`);
- }
- const index = this._actionPriorityOverrides
- .findIndex(action => action.type === type && action.key === key);
- const action = {
- type,
- key,
- priority,
- };
- if (index !== -1) {
- this.set('_actionPriorityOverrides', index, action);
- } else {
- this.push('_actionPriorityOverrides', action);
- }
- }
-
- setActionHidden(type, key, hidden) {
- if (type !== ActionType.CHANGE && type !== ActionType.REVISION) {
- throw Error(`Invalid action type given: ${type}`);
- }
-
- const idx = this._hiddenActions.indexOf(key);
- if (hidden && idx === -1) {
- this.push('_hiddenActions', key);
- } else if (!hidden && idx !== -1) {
- this.splice('_hiddenActions', idx, 1);
- }
- }
-
- getActionDetails(action) {
- if (this.revisionActions[action]) {
- return this.revisionActions[action];
- } else if (this.actions[action]) {
- return this.actions[action];
- }
- }
-
- _indexOfActionButtonWithKey(key) {
- for (let i = 0; i < this._additionalActions.length; i++) {
- if (this._additionalActions[i].__key === key) {
- return i;
- }
- }
- return -1;
- }
-
- _getRevisionActions() {
- return this.$.restAPI.getChangeRevisionActions(this.changeNum,
- this.latestPatchNum);
- }
-
- _shouldHideActions(actions, loading) {
- return loading || !actions || !actions.base || !actions.base.length;
- }
-
- _keyCount(changeRecord) {
- return Object.keys((changeRecord && changeRecord.base) || {}).length;
- }
-
- _actionsChanged(actionsChangeRecord, revisionActionsChangeRecord,
- additionalActionsChangeRecord) {
- // Polymer 2: check for undefined
- if ([
- actionsChangeRecord,
- revisionActionsChangeRecord,
- additionalActionsChangeRecord,
- ].includes(undefined)) {
- return;
- }
-
- const additionalActions = (additionalActionsChangeRecord &&
- additionalActionsChangeRecord.base) || [];
- this.hidden = this._keyCount(actionsChangeRecord) === 0 &&
- this._keyCount(revisionActionsChangeRecord) === 0 &&
- additionalActions.length === 0;
- this._actionLoadingMessage = '';
- this._disabledMenuActions = [];
-
- const revisionActions = revisionActionsChangeRecord.base || {};
- if (Object.keys(revisionActions).length !== 0) {
- if (!revisionActions.download) {
- this.set('revisionActions.download', DOWNLOAD_ACTION);
- }
- }
- }
-
- /**
- * @param {string=} actionName
- */
- _deleteAndNotify(actionName) {
- if (this.actions && this.actions[actionName]) {
- delete this.actions[actionName];
- // We assign a fake value of 'false' to support Polymer 2
- // see https://github.com/Polymer/polymer/issues/2631
- this.notifyPath('actions.' + actionName, false);
- }
- }
-
- _editStatusChanged(editMode, editPatchsetLoaded,
- editBasedOnCurrentPatchSet, disableEdit) {
- // Polymer 2: check for undefined
- if ([
- editMode,
- editBasedOnCurrentPatchSet,
- disableEdit,
- ].includes(undefined)) {
- return;
- }
-
- if (disableEdit) {
- this._deleteAndNotify('publishEdit');
- this._deleteAndNotify('rebaseEdit');
- this._deleteAndNotify('deleteEdit');
- this._deleteAndNotify('stopEdit');
- this._deleteAndNotify('edit');
- return;
- }
- if (this.actions && editPatchsetLoaded) {
- // Only show actions that mutate an edit if an actual edit patch set
- // is loaded.
- if (changeIsOpen(this.change)) {
- if (editBasedOnCurrentPatchSet) {
- if (!this.actions.publishEdit) {
- this.set('actions.publishEdit', PUBLISH_EDIT);
- }
- this._deleteAndNotify('rebaseEdit');
- } else {
- if (!this.actions.rebaseEdit) {
- this.set('actions.rebaseEdit', REBASE_EDIT);
- }
- this._deleteAndNotify('publishEdit');
- }
- }
- if (!this.actions.deleteEdit) {
- this.set('actions.deleteEdit', DELETE_EDIT);
- }
- } else {
- this._deleteAndNotify('publishEdit');
- this._deleteAndNotify('rebaseEdit');
- this._deleteAndNotify('deleteEdit');
- }
-
- if (this.actions && changeIsOpen(this.change)) {
- // Only show edit button if there is no edit patchset loaded and the
- // file list is not in edit mode.
- if (editPatchsetLoaded || editMode) {
- this._deleteAndNotify('edit');
- } else {
- if (!this.actions.edit) { this.set('actions.edit', EDIT); }
- }
- // Only show STOP_EDIT if edit mode is enabled, but no edit patch set
- // is loaded.
- if (editMode && !editPatchsetLoaded) {
- if (!this.actions.stopEdit) {
- this.set('actions.stopEdit', STOP_EDIT);
- }
- } else {
- this._deleteAndNotify('stopEdit');
- }
- } else {
- // Remove edit button.
- this._deleteAndNotify('edit');
- }
- }
-
- _getValuesFor(obj) {
- return Object.keys(obj).map(key => obj[key]);
- }
-
- _getLabelStatus(label) {
- if (label.approved) {
- return LabelStatus.OK;
- } else if (label.rejected) {
- return LabelStatus.REJECT;
- } else if (label.optional) {
- return LabelStatus.OPTIONAL;
- } else {
- return LabelStatus.NEED;
- }
- }
-
- /**
- * Get highest score for last missing permitted label for current change.
- * Returns null if no labels permitted or more than one label missing.
- *
- * @return {{label: string, score: string}|null}
- */
- _getTopMissingApproval() {
- if (!this.change ||
- !this.change.labels ||
- !this.change.permitted_labels) {
- return null;
- }
- let result;
- for (const label in this.change.labels) {
- if (!(label in this.change.permitted_labels)) {
- continue;
- }
- if (this.change.permitted_labels[label].length === 0) {
- continue;
- }
- const status = this._getLabelStatus(this.change.labels[label]);
- if (status === LabelStatus.NEED) {
- if (result) {
- // More than one label is missing, so it's unclear which to quick
- // approve, return null;
- return null;
- }
- result = label;
- } else if (status === LabelStatus.REJECT ||
- status === LabelStatus.IMPOSSIBLE) {
- return null;
- }
- }
- if (result) {
- const score = this.change.permitted_labels[result].slice(-1)[0];
- const maxScore =
- Object.keys(this.change.labels[result].values).slice(-1)[0];
- if (score === maxScore) {
- // Allow quick approve only for maximal score.
- return {
- label: result,
- score,
- };
- }
- }
- return null;
- }
-
- hideQuickApproveAction() {
- this._topLevelSecondaryActions =
- this._topLevelSecondaryActions
- .filter(sa => sa.key !== QUICK_APPROVE_ACTION.key);
- this._hideQuickApproveAction = true;
- }
-
- _getQuickApproveAction() {
- if (this._hideQuickApproveAction) {
- return null;
- }
- const approval = this._getTopMissingApproval();
- if (!approval) {
- return null;
- }
- const action = {...QUICK_APPROVE_ACTION};
- action.label = approval.label + approval.score;
- const review = {
- drafts: 'PUBLISH_ALL_REVISIONS',
- labels: {},
- };
- review.labels[approval.label] = approval.score;
- action.payload = review;
- return action;
- }
-
- _getActionValues(actionsChangeRecord, primariesChangeRecord,
- additionalActionsChangeRecord, type) {
- if (!actionsChangeRecord || !primariesChangeRecord) { return []; }
-
- const actions = actionsChangeRecord.base || {};
- const primaryActionKeys = primariesChangeRecord.base || [];
- const result = [];
- const values = this._getValuesFor(
- type === ActionType.CHANGE ? ChangeActions : RevisionActions);
- const pluginActions = [];
- Object.keys(actions).forEach(a => {
- actions[a].__key = a;
- actions[a].__type = type;
- actions[a].__primary = primaryActionKeys.includes(a);
- // Plugin actions always contain ~ in the key.
- if (a.indexOf('~') !== -1) {
- this._populateActionUrl(actions[a]);
- pluginActions.push(actions[a]);
- // Add server-side provided plugin actions to overflow menu.
- this._overflowActions.push({
- type,
- key: a,
- });
- return;
- } else if (!values.includes(a)) {
- return;
- }
- actions[a].label = this._getActionLabel(actions[a]);
-
- // Triggers a re-render by ensuring object inequality.
- result.push({...actions[a]});
- });
-
- let additionalActions = (additionalActionsChangeRecord &&
- additionalActionsChangeRecord.base) || [];
- additionalActions = additionalActions
- .filter(a => a.__type === type)
- .map(a => {
- a.__primary = primaryActionKeys.includes(a.__key);
- // Triggers a re-render by ensuring object inequality.
- return {...a};
- });
- return result.concat(additionalActions).concat(pluginActions);
- }
-
- _populateActionUrl(action) {
- const patchNum =
- action.__type === ActionType.REVISION ? this.latestPatchNum : null;
- this.$.restAPI.getChangeActionURL(
- this.changeNum, patchNum, '/' + action.__key)
- .then(url => action.__url = url);
- }
-
- /**
- * Given a change action, return a display label that uses the appropriate
- * casing or includes explanatory details.
- */
- _getActionLabel(action) {
- if (action.label === 'Delete') {
- // This label is common within change and revision actions. Make it more
- // explicit to the user.
- return 'Delete change';
- } else if (action.label === 'WIP') {
- return 'Mark as work in progress';
- }
- // Otherwise, just map the name to sentence case.
- return this._toSentenceCase(action.label);
- }
-
- /**
- * Capitalize the first letter and lowecase all others.
- *
- * @param {string} s
- * @return {string}
- */
- _toSentenceCase(s) {
- if (!s.length) { return ''; }
- return s[0].toUpperCase() + s.slice(1).toLowerCase();
- }
-
- _computeLoadingLabel(action) {
- return ActionLoadingLabels[action] || 'Working...';
- }
-
- _canSubmitChange() {
- return this.$.jsAPI.canSubmitChange(this.change,
- this._getRevision(this.change, this.latestPatchNum));
- }
-
- _getRevision(change, patchNum) {
- for (const rev of Object.values(change.revisions)) {
- if (patchNumEquals(rev._number, patchNum)) {
- return rev;
- }
- }
- return null;
- }
-
- showRevertDialog() {
- // The search is still broken if there is a " in the topic.
- const query = `submissionid: "${this.change.submission_id}"`;
- /* A chromium plugin expects that the modifyRevertMsg hook will only
- be called after the revert button is pressed, hence we populate the
- revert dialog after revert button is pressed. */
- this.$.restAPI.getChanges('', query)
- .then(changes => {
- this.$.confirmRevertDialog.populate(this.change,
- this.commitMessage, changes);
- this._showActionDialog(this.$.confirmRevertDialog);
- });
- }
-
- showRevertSubmissionDialog() {
- const query = 'submissionid:' + this.change.submission_id;
- this.$.restAPI.getChanges('', query)
- .then(changes => {
- this.$.confirmRevertSubmissionDialog.
- _populateRevertSubmissionMessage(this.change, changes);
- this._showActionDialog(this.$.confirmRevertSubmissionDialog);
- });
- }
-
- _handleActionTap(e) {
- e.preventDefault();
- let el = dom(e).localTarget;
- while (el.tagName.toLowerCase() !== 'gr-button') {
- if (!el.parentElement) { return; }
- el = el.parentElement;
- }
-
- const key = el.getAttribute('data-action-key');
- if (key.startsWith(ADDITIONAL_ACTION_KEY_PREFIX) ||
- key.indexOf('~') !== -1) {
- this.dispatchEvent(new CustomEvent(`${key}-tap`, {
- detail: {node: el},
- composed: true, bubbles: true,
- }));
- return;
- }
- const type = el.getAttribute('data-action-type');
- this._handleAction(type, key);
- }
-
- _handleOverflowItemTap(e) {
- e.preventDefault();
- const el = dom(e).localTarget;
- const key = e.detail.action.__key;
- if (key.startsWith(ADDITIONAL_ACTION_KEY_PREFIX) ||
- key.indexOf('~') !== -1) {
- this.dispatchEvent(new CustomEvent(`${key}-tap`, {
- detail: {node: el},
- composed: true, bubbles: true,
- }));
- return;
- }
- this._handleAction(e.detail.action.__type, e.detail.action.__key);
- }
-
- _handleAction(type, key) {
- this.reporting.reportInteraction(`${type}-${key}`);
- switch (type) {
- case ActionType.REVISION:
- this._handleRevisionAction(key);
- break;
- case ActionType.CHANGE:
- this._handleChangeAction(key);
- break;
- default:
- this._fireAction(this._prependSlash(key), this.actions[key], false);
- }
- }
-
- _handleChangeAction(key) {
- let action;
- switch (key) {
- case ChangeActions.REVERT:
- this.showRevertDialog();
- break;
- case ChangeActions.REVERT_SUBMISSION:
- this.showRevertSubmissionDialog();
- break;
- case ChangeActions.ABANDON:
- this._showActionDialog(this.$.confirmAbandonDialog);
- break;
- case QUICK_APPROVE_ACTION.key:
- action = this._allActionValues.find(o => o.key === key);
- this._fireAction(
- this._prependSlash(key), action, true, action.payload);
- break;
- case ChangeActions.EDIT:
- this._handleEditTap();
- break;
- case ChangeActions.STOP_EDIT:
- this._handleStopEditTap();
- break;
- case ChangeActions.DELETE:
- this._handleDeleteTap();
- break;
- case ChangeActions.DELETE_EDIT:
- this._handleDeleteEditTap();
- break;
- case ChangeActions.FOLLOW_UP:
- this._handleFollowUpTap();
- break;
- case ChangeActions.WIP:
- this._handleWipTap();
- break;
- case ChangeActions.MOVE:
- this._handleMoveTap();
- break;
- case ChangeActions.PUBLISH_EDIT:
- this._handlePublishEditTap();
- break;
- case ChangeActions.REBASE_EDIT:
- this._handleRebaseEditTap();
- break;
- default:
- this._fireAction(this._prependSlash(key), this.actions[key], false);
- }
- }
-
- _handleRevisionAction(key) {
- switch (key) {
- case RevisionActions.REBASE:
- this._showActionDialog(this.$.confirmRebase);
- this.$.confirmRebase.fetchRecentChanges();
- break;
- case RevisionActions.CHERRYPICK:
- this._handleCherrypickTap();
- break;
- case RevisionActions.DOWNLOAD:
- this._handleDownloadTap();
- break;
- case RevisionActions.SUBMIT:
- if (!this._canSubmitChange()) { return; }
- this._showActionDialog(this.$.confirmSubmitDialog);
- break;
- default:
- this._fireAction(this._prependSlash(key),
- this.revisionActions[key], true);
- }
- }
-
- _prependSlash(key) {
- return key === '/' ? key : `/${key}`;
- }
-
- /**
- * _hasKnownChainState set to true true if hasParent is defined (can be
- * either true or false). set to false otherwise.
- */
- _computeChainState(hasParent) {
- this._hasKnownChainState = true;
- }
-
- _calculateDisabled(action, hasKnownChainState) {
- if (action.__key === 'rebase') {
- // Rebase button is only disabled when change has no parent(s).
- return hasKnownChainState === false;
- }
- return !action.enabled;
- }
-
- _handleConfirmDialogCancel() {
- this._hideAllDialogs();
- }
-
- _hideAllDialogs() {
- const dialogEls =
- this.root.querySelectorAll('.confirmDialog');
- for (const dialogEl of dialogEls) { dialogEl.hidden = true; }
- this.$.overlay.close();
- }
-
- _handleRebaseConfirm(e) {
- const el = this.$.confirmRebase;
- const payload = {base: e.detail.base};
- this.$.overlay.close();
- el.hidden = true;
- this._fireAction('/rebase', this.revisionActions.rebase, true, payload);
- }
-
- _handleCherrypickConfirm() {
- this._handleCherryPickRestApi(false);
- }
-
- _handleCherrypickConflictConfirm() {
- this._handleCherryPickRestApi(true);
- }
-
- _handleCherryPickRestApi(conflicts) {
- const el = this.$.confirmCherrypick;
- if (!el.branch) {
- this.dispatchEvent(new CustomEvent('show-alert', {
- detail: {message: ERR_BRANCH_EMPTY},
- composed: true, bubbles: true,
- }));
- return;
- }
- if (!el.message) {
- this.dispatchEvent(new CustomEvent('show-alert', {
- detail: {message: ERR_COMMIT_EMPTY},
- composed: true, bubbles: true,
- }));
- return;
- }
- this.$.overlay.close();
- el.hidden = true;
- this._fireAction(
- '/cherrypick',
- this.revisionActions.cherrypick,
- true,
- {
- destination: el.branch,
- base: el.baseCommit ? el.baseCommit : null,
- message: el.message,
- allow_conflicts: conflicts,
- }
- );
- }
-
- _handleMoveConfirm() {
- const el = this.$.confirmMove;
- if (!el.branch) {
- this.dispatchEvent(new CustomEvent('show-alert', {
- detail: {message: ERR_BRANCH_EMPTY},
- composed: true, bubbles: true,
- }));
- return;
- }
- this.$.overlay.close();
- el.hidden = true;
- this._fireAction(
- '/move',
- this.actions.move,
- false,
- {
- destination_branch: el.branch,
- message: el.message,
- }
- );
- }
-
- _handleRevertDialogConfirm(e) {
- const revertType = e.detail.revertType;
- const message = e.detail.message;
- const el = this.$.confirmRevertDialog;
- this.$.overlay.close();
- el.hidden = true;
- switch (revertType) {
- case REVERT_TYPES.REVERT_SINGLE_CHANGE:
- this._fireAction('/revert', this.actions.revert, false,
- {message});
- break;
- case REVERT_TYPES.REVERT_SUBMISSION:
- this._fireAction('/revert_submission', this.actions.revert_submission,
- false, {message});
- break;
- default:
- console.error('invalid revert type');
- }
- }
-
- _handleRevertSubmissionDialogConfirm() {
- const el = this.$.confirmRevertSubmissionDialog;
- this.$.overlay.close();
- el.hidden = true;
- this._fireAction('/revert_submission', this.actions.revert_submission,
- false, {message: el.message});
- }
-
- _handleAbandonDialogConfirm() {
- const el = this.$.confirmAbandonDialog;
- this.$.overlay.close();
- el.hidden = true;
- this._fireAction('/abandon', this.actions.abandon, false,
- {message: el.message});
- }
-
- _handleCreateFollowUpChange() {
- this.$.createFollowUpChange.handleCreateChange();
- this._handleCloseCreateFollowUpChange();
- }
-
- _handleCloseCreateFollowUpChange() {
- this.$.overlay.close();
- }
-
- _handleDeleteConfirm() {
- this._fireAction('/', this.actions[ChangeActions.DELETE], false);
- }
-
- _handleDeleteEditConfirm() {
- this._hideAllDialogs();
-
- this._fireAction('/edit', this.actions.deleteEdit, false);
- }
-
- _handleSubmitConfirm() {
- if (!this._canSubmitChange()) { return; }
- this._hideAllDialogs();
- this._fireAction('/submit', this.revisionActions.submit, true);
- }
-
- _getActionOverflowIndex(type, key) {
- return this._overflowActions
- .findIndex(action => action.type === type && action.key === key);
- }
-
- _setLoadingOnButtonWithKey(type, key) {
- this._actionLoadingMessage = this._computeLoadingLabel(key);
- let buttonKey = key;
- // TODO(dhruvsri): clean this up later
- // If key is revert-submission, then button key should be 'revert'
- if (buttonKey === ChangeActions.REVERT_SUBMISSION) {
- // Revert submission button no longer exists
- buttonKey = ChangeActions.REVERT;
- }
-
- // If the action appears in the overflow menu.
- if (this._getActionOverflowIndex(type, buttonKey) !== -1) {
- this.push('_disabledMenuActions', buttonKey === '/' ? 'delete' :
- buttonKey);
- return () => {
- this._actionLoadingMessage = '';
- this._disabledMenuActions = [];
- };
- }
-
- // Otherwise it's a top-level action.
- const buttonEl = this.shadowRoot
- .querySelector(`[data-action-key="${buttonKey}"]`);
- buttonEl.setAttribute('loading', true);
- buttonEl.disabled = true;
- return () => {
- this._actionLoadingMessage = '';
- buttonEl.removeAttribute('loading');
- buttonEl.disabled = false;
- };
- }
-
- /**
- * @param {string} endpoint
- * @param {!Object|undefined} action
- * @param {boolean} revAction
- * @param {!Object|string=} opt_payload
- */
- _fireAction(endpoint, action, revAction, opt_payload) {
- const cleanupFn =
- this._setLoadingOnButtonWithKey(action.__type, action.__key);
-
- this._send(action.method, opt_payload, endpoint, revAction, cleanupFn,
- action).then(res => this._handleResponse(action, res));
- }
-
- _showActionDialog(dialog) {
- this._hideAllDialogs();
-
- dialog.hidden = false;
- this.$.overlay.open().then(() => {
- if (dialog.resetFocus) {
- dialog.resetFocus();
- }
- });
- }
-
- // TODO(rmistry): Redo this after
- // https://bugs.chromium.org/p/gerrit/issues/detail?id=4671 is resolved.
- _setLabelValuesOnRevert(newChangeId) {
- const labels = this.$.jsAPI.getLabelValuesPostRevert(this.change);
- if (!labels) { return Promise.resolve(); }
- return this.$.restAPI.saveChangeReview(newChangeId, 'current', {labels});
- }
-
- _handleResponse(action, response) {
- if (!response) { return; }
- return this.$.restAPI.getResponseObject(response).then(obj => {
- switch (action.__key) {
- case ChangeActions.REVERT:
- this._waitForChangeReachable(obj._number)
- .then(() => this._setLabelValuesOnRevert(obj._number))
- .then(() => {
- GerritNav.navigateToChange(obj);
- });
- break;
- case RevisionActions.CHERRYPICK:
- this._waitForChangeReachable(obj._number).then(() => {
- GerritNav.navigateToChange(obj);
- });
- break;
- case ChangeActions.DELETE:
- if (action.__type === ActionType.CHANGE) {
- GerritNav.navigateToRelativeUrl(GerritNav.getUrlForRoot());
- }
- break;
- case ChangeActions.WIP:
- case ChangeActions.DELETE_EDIT:
- case ChangeActions.PUBLISH_EDIT:
- case ChangeActions.REBASE_EDIT:
- case ChangeActions.REBASE:
- case ChangeActions.SUBMIT:
- this.dispatchEvent(new CustomEvent('reload',
- {
- detail: {clearPatchset: true},
- bubbles: false,
- composed: true,
- }));
- break;
- case ChangeActions.REVERT_SUBMISSION:
- if (!obj.revert_changes || !obj.revert_changes.length) return;
- /* If there is only 1 change then gerrit will automatically
- redirect to that change */
- GerritNav.navigateToSearchQuery('topic: ' +
- obj.revert_changes[0].topic);
- break;
- default:
- this.dispatchEvent(new CustomEvent('reload',
- {
- detail: {action: action.__key, clearPatchset: true},
- bubbles: false,
- composed: true,
- }));
- break;
- }
- });
- }
-
- _handleShowRevertSubmissionChangesConfirm() {
- this._hideAllDialogs();
- }
-
- _handleResponseError(action, response, body) {
- if (action && action.__key === RevisionActions.CHERRYPICK) {
- if (response && response.status === 409 &&
- body && !body.allow_conflicts) {
- return this._showActionDialog(
- this.$.confirmCherrypickConflict);
- }
- }
- return response.text().then(errText => {
- this.dispatchEvent(new CustomEvent('show-error', {
- detail: {message: `Could not perform action: ${errText}`},
- composed: true, bubbles: true,
- }));
- if (!errText.startsWith('Change is already up to date')) {
- throw Error(errText);
- }
- });
- }
-
- /**
- * @param {string} method
- * @param {string|!Object|undefined} payload
- * @param {string} actionEndpoint
- * @param {boolean} revisionAction
- * @param {?Function} cleanupFn
- * @param {!Object|undefined} action
- */
- _send(method, payload, actionEndpoint, revisionAction, cleanupFn, action) {
- const handleError = response => {
- cleanupFn.call(this);
- this._handleResponseError(action, response, payload);
- };
- return fetchChangeUpdates(this.change, this.$.restAPI)
- .then(result => {
- if (!result.isLatest) {
- this.dispatchEvent(new CustomEvent('show-alert', {
- detail: {
- message: 'Cannot set label: a newer patch has been ' +
- 'uploaded to this change.',
- action: 'Reload',
- callback: () => {
- this.dispatchEvent(new CustomEvent('reload',
- {
- detail: {clearPatchset: true},
- bubbles: false,
- composed: true,
- }));
- },
- },
- composed: true, bubbles: true,
- }));
-
- // Because this is not a network error, call the cleanup function
- // but not the error handler.
- cleanupFn();
-
- return Promise.resolve();
- }
- const patchNum = revisionAction ? this.latestPatchNum : null;
- return this.$.restAPI.executeChangeAction(this.changeNum, method,
- actionEndpoint, patchNum, payload, handleError)
- .then(response => {
- cleanupFn.call(this);
- return response;
- });
- });
- }
-
- _handleAbandonTap() {
- this._showActionDialog(this.$.confirmAbandonDialog);
- }
-
- _handleCherrypickTap() {
- this.$.confirmCherrypick.branch = '';
- const query = `topic: "${this.change.topic}"`;
- const options =
- listChangesOptionsToHex(ListChangesOption.MESSAGES,
- ListChangesOption.ALL_REVISIONS);
- this.$.restAPI.getChanges('', query, undefined, options)
- .then(changes => {
- this.$.confirmCherrypick.updateChanges(changes);
- this._showActionDialog(this.$.confirmCherrypick);
- });
- }
-
- _handleMoveTap() {
- this.$.confirmMove.branch = '';
- this.$.confirmMove.message = '';
- this._showActionDialog(this.$.confirmMove);
- }
-
- _handleDownloadTap() {
- this.dispatchEvent(new CustomEvent('download-tap', {
- composed: true, bubbles: false,
- }));
- }
-
- _handleDeleteTap() {
- this._showActionDialog(this.$.confirmDeleteDialog);
- }
-
- _handleDeleteEditTap() {
- this._showActionDialog(this.$.confirmDeleteEditDialog);
- }
-
- _handleFollowUpTap() {
- this._showActionDialog(this.$.createFollowUpDialog);
- }
-
- _handleWipTap() {
- this._fireAction('/wip', this.actions.wip, false);
- }
-
- _handlePublishEditTap() {
- // Type of payload is PublishChangeEditInput.
- const payload = {notify: NotifyType.NONE};
- this._fireAction('/edit:publish', this.actions.publishEdit, false, payload);
- }
-
- _handleRebaseEditTap() {
- this._fireAction('/edit:rebase', this.actions.rebaseEdit, false);
- }
-
- _handleHideBackgroundContent() {
- this.$.mainContent.classList.add('overlayOpen');
- }
-
- _handleShowBackgroundContent() {
- this.$.mainContent.classList.remove('overlayOpen');
- }
-
- /**
- * Merge sources of change actions into a single ordered array of action
- * values.
- *
- * @param {!Array} changeActionsRecord
- * @param {!Array} revisionActionsRecord
- * @param {!Array} primariesRecord
- * @param {!Array} additionalActionsRecord
- * @param {!Object} change The change object.
- * @param {!Object} config server configuration info
- * @return {!Array}
- */
- _computeAllActions(changeActionsRecord, revisionActionsRecord,
- primariesRecord, additionalActionsRecord, change, config) {
- // Polymer 2: check for undefined
- if ([
- changeActionsRecord,
- revisionActionsRecord,
- primariesRecord,
- additionalActionsRecord,
- change,
- ].includes(undefined)) {
- return [];
- }
-
- const revisionActionValues = this._getActionValues(revisionActionsRecord,
- primariesRecord, additionalActionsRecord, ActionType.REVISION);
- const changeActionValues = this._getActionValues(changeActionsRecord,
- primariesRecord, additionalActionsRecord, ActionType.CHANGE);
- const quickApprove = this._getQuickApproveAction();
- if (quickApprove) {
- changeActionValues.unshift(quickApprove);
- }
-
- return revisionActionValues
- .concat(changeActionValues)
- .sort((a, b) => this._actionComparator(a, b))
- .map(action => {
- if (ACTIONS_WITH_ICONS.has(action.__key)) {
- action.icon = action.__key;
- }
- // TODO(brohlfs): Temporary hack until change 269573 is live in all
- // backends.
- if (action.__key === ChangeActions.READY) {
- action.label = 'Mark as Active';
- }
- // End of hack
- return action;
- })
- .filter(action => !this._shouldSkipAction(action, config));
- }
-
- _getActionPriority(action) {
- if (action.__type && action.__key) {
- const overrideAction = this._actionPriorityOverrides
- .find(i => i.type === action.__type && i.key === action.__key);
-
- if (overrideAction !== undefined) {
- return overrideAction.priority;
- }
- }
- if (action.__key === 'review') {
- return ActionPriority.REVIEW;
- } else if (action.__primary) {
- return ActionPriority.PRIMARY;
- } else if (action.__type === ActionType.CHANGE) {
- return ActionPriority.CHANGE;
- } else if (action.__type === ActionType.REVISION) {
- return ActionPriority.REVISION;
- }
- return ActionPriority.DEFAULT;
- }
-
- /**
- * Sort comparator to define the order of change actions.
- */
- _actionComparator(actionA, actionB) {
- const priorityDelta = this._getActionPriority(actionA) -
- this._getActionPriority(actionB);
- // Sort by the button label if same priority.
- if (priorityDelta === 0) {
- return actionA.label > actionB.label ? 1 : -1;
- } else {
- return priorityDelta;
- }
- }
-
- _shouldSkipAction(action, config) {
- const skipActionKeys = [...SKIP_ACTION_KEYS];
- const isAttentionSetEnabled = !!config && !!config.change
- && config.change.enable_attention_set;
- if (isAttentionSetEnabled) {
- skipActionKeys.push(...SKIP_ACTION_KEYS_ATTENTION_SET);
- }
- return skipActionKeys.includes(action.__key);
- }
-
- _computeTopLevelActions(actionRecord, hiddenActionsRecord) {
- const hiddenActions = hiddenActionsRecord.base || [];
- return actionRecord.base.filter(a => {
- const overflow = this._getActionOverflowIndex(a.__type, a.__key) !== -1;
- return !(overflow || hiddenActions.includes(a.__key));
- });
- }
-
- _filterPrimaryActions(_topLevelActions) {
- this._topLevelPrimaryActions = _topLevelActions.filter(action =>
- action.__primary);
- this._topLevelSecondaryActions = _topLevelActions.filter(action =>
- !action.__primary);
- }
-
- _computeMenuActions(actionRecord, hiddenActionsRecord) {
- const hiddenActions = hiddenActionsRecord.base || [];
- return actionRecord.base.filter(a => {
- const overflow = this._getActionOverflowIndex(a.__type, a.__key) !== -1;
- return overflow && !hiddenActions.includes(a.__key);
- }).map(action => {
- let key = action.__key;
- if (key === '/') { key = 'delete'; }
- return {
- name: action.label,
- id: `${key}-${action.__type}`,
- action,
- tooltip: action.title,
- };
- });
- }
-
- _computeRebaseOnCurrent(revisionRebaseAction) {
- if (revisionRebaseAction) {
- return !!revisionRebaseAction.enabled;
- }
- return null;
- }
-
- /**
- * Occasionally, a change created by a change action is not yet knwon to the
- * API for a brief time. Wait for the given change number to be recognized.
- *
- * Returns a promise that resolves with true if a request is recognized, or
- * false if the change was never recognized after all attempts.
- *
- * @param {number} changeNum
- * @return {Promise<boolean>}
- */
- _waitForChangeReachable(changeNum) {
- let attempsRemaining = AWAIT_CHANGE_ATTEMPTS;
- return new Promise(resolve => {
- const check = () => {
- attempsRemaining--;
- // Pass a no-op error handler to avoid the "not found" error toast.
- this.$.restAPI.getChange(changeNum, () => {}).then(response => {
- // If the response is 404, the response will be undefined.
- if (response) {
- resolve(true);
- return;
- }
-
- if (attempsRemaining) {
- this.async(check, AWAIT_CHANGE_TIMEOUT_MS);
- } else {
- resolve(false);
- }
- });
- };
- check();
- });
- }
-
- _handleEditTap() {
- this.dispatchEvent(new CustomEvent('edit-tap', {bubbles: false}));
- }
-
- _handleStopEditTap() {
- this.dispatchEvent(new CustomEvent('stop-edit-tap', {bubbles: false}));
- }
-
- _computeHasTooltip(title) {
- return !!title;
- }
-
- _computeHasIcon(action) {
- return action.icon ? '' : 'hidden';
- }
-}
-
-customElements.define(GrChangeActions.is, GrChangeActions);
diff --git a/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions.ts b/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions.ts
new file mode 100644
index 0000000..a9024ef
--- /dev/null
+++ b/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions.ts
@@ -0,0 +1,2094 @@
+/**
+ * @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 '../../admin/gr-create-change-dialog/gr-create-change-dialog';
+import '../../shared/gr-button/gr-button';
+import '../../shared/gr-dialog/gr-dialog';
+import '../../shared/gr-dropdown/gr-dropdown';
+import '../../shared/gr-icons/gr-icons';
+import '../../shared/gr-js-api-interface/gr-js-api-interface';
+import '../../shared/gr-overlay/gr-overlay';
+import '../../shared/gr-rest-api-interface/gr-rest-api-interface';
+import '../gr-confirm-abandon-dialog/gr-confirm-abandon-dialog';
+import '../gr-confirm-cherrypick-dialog/gr-confirm-cherrypick-dialog';
+import '../gr-confirm-cherrypick-conflict-dialog/gr-confirm-cherrypick-conflict-dialog';
+import '../gr-confirm-move-dialog/gr-confirm-move-dialog';
+import '../gr-confirm-rebase-dialog/gr-confirm-rebase-dialog';
+import '../gr-confirm-revert-dialog/gr-confirm-revert-dialog';
+import '../gr-confirm-revert-submission-dialog/gr-confirm-revert-submission-dialog';
+import '../gr-confirm-submit-dialog/gr-confirm-submit-dialog';
+import '../../../styles/shared-styles';
+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-change-actions_html';
+import {GerritNav} from '../../core/gr-navigation/gr-navigation';
+import {getPluginLoader} from '../../shared/gr-js-api-interface/gr-plugin-loader';
+import {appContext} from '../../../services/app-context';
+import {
+ fetchChangeUpdates,
+ patchNumEquals,
+} from '../../../utils/patch-set-util';
+import {
+ changeIsOpen,
+ ListChangesOption,
+ listChangesOptionsToHex,
+} from '../../../utils/change-util';
+import {
+ ChangeStatus,
+ DraftsAction,
+ HttpMethod,
+ NotifyType,
+} from '../../../constants/constants';
+import {EventType, TargetElement} from '../../plugins/gr-plugin-types';
+import {customElement, observe, property} from '@polymer/decorators';
+import {GrJsApiInterface} from '../../shared/gr-js-api-interface/gr-js-api-interface-element';
+import {
+ ActionPriority,
+ ActionType,
+ ErrorCallback,
+ RestApiService,
+} from '../../../services/services/gr-rest-api/gr-rest-api';
+import {
+ ActionInfo,
+ ActionNameToActionInfoMap,
+ BranchName,
+ ChangeInfo,
+ ChangeViewChangeInfo,
+ CherryPickInput,
+ CommitId,
+ InheritedBooleanInfo,
+ isDetailedLabelInfo,
+ isQuickLabelInfo,
+ LabelInfo,
+ NumericChangeId,
+ PatchSetNum,
+ PropertyType,
+ RequestPayload,
+ RevertSubmissionInfo,
+ ReviewInput,
+ ServerInfo,
+} from '../../../types/common';
+import {GrConfirmAbandonDialog} from '../gr-confirm-abandon-dialog/gr-confirm-abandon-dialog';
+import {GrOverlay} from '../../shared/gr-overlay/gr-overlay';
+import {GrDialog} from '../../shared/gr-dialog/gr-dialog';
+import {GrCreateChangeDialog} from '../../admin/gr-create-change-dialog/gr-create-change-dialog';
+import {GrConfirmSubmitDialog} from '../gr-confirm-submit-dialog/gr-confirm-submit-dialog';
+import {GrConfirmRevertSubmissionDialog} from '../gr-confirm-revert-submission-dialog/gr-confirm-revert-submission-dialog';
+import {
+ ConfirmRevertEventDetail,
+ GrConfirmRevertDialog,
+ RevertType,
+} from '../gr-confirm-revert-dialog/gr-confirm-revert-dialog';
+import {GrConfirmMoveDialog} from '../gr-confirm-move-dialog/gr-confirm-move-dialog';
+import {GrConfirmCherrypickDialog} from '../gr-confirm-cherrypick-dialog/gr-confirm-cherrypick-dialog';
+import {GrConfirmCherrypickConflictDialog} from '../gr-confirm-cherrypick-conflict-dialog/gr-confirm-cherrypick-conflict-dialog';
+import {
+ ConfirmRebaseEventDetail,
+ GrConfirmRebaseDialog,
+} from '../gr-confirm-rebase-dialog/gr-confirm-rebase-dialog';
+import {PolymerDeepPropertyChange} from '@polymer/polymer/interfaces';
+import {GrButton} from '../../shared/gr-button/gr-button';
+import {
+ ChangeActions,
+ GrChangeActionsElement,
+ PrimaryActionKey,
+ RevisionActions,
+ UIActionInfo,
+} from '../../shared/gr-js-api-interface/gr-change-actions-js-api';
+
+const ERR_BRANCH_EMPTY = 'The destination branch can’t be empty.';
+const ERR_COMMIT_EMPTY = 'The commit message can’t be empty.';
+const ERR_REVISION_ACTIONS = 'Couldn’t load revision actions.';
+
+enum LabelStatus {
+ /**
+ * This label provides what is necessary for submission.
+ */
+ OK = 'OK',
+ /**
+ * This label prevents the change from being submitted.
+ */
+ REJECT = 'REJECT',
+ /**
+ * The label may be set, but it's neither necessary for submission
+ * nor does it block submission if set.
+ */
+ MAY = 'MAY',
+ /**
+ * The label is required for submission, but has not been satisfied.
+ */
+ NEED = 'NEED',
+ /**
+ * The label is required for submission, but is impossible to complete.
+ * The likely cause is access has not been granted correctly by the
+ * project owner or site administrator.
+ */
+ IMPOSSIBLE = 'IMPOSSIBLE',
+ OPTIONAL = 'OPTIONAL',
+}
+
+const ActionLoadingLabels: {[actionKey: string]: string} = {
+ abandon: 'Abandoning...',
+ cherrypick: 'Cherry-picking...',
+ delete: 'Deleting...',
+ move: 'Moving..',
+ rebase: 'Rebasing...',
+ restore: 'Restoring...',
+ revert: 'Reverting...',
+ revert_submission: 'Reverting Submission...',
+ submit: 'Submitting...',
+};
+
+const ADDITIONAL_ACTION_KEY_PREFIX = '__additionalAction_';
+
+interface QuickApproveUIActionInfo extends UIActionInfo {
+ key: string;
+ payload?: RequestPayload;
+}
+
+const QUICK_APPROVE_ACTION: QuickApproveUIActionInfo = {
+ __key: 'review',
+ __type: ActionType.CHANGE,
+ enabled: true,
+ key: 'review',
+ label: 'Quick approve',
+ method: HttpMethod.POST,
+};
+
+function isQuckApproveAction(
+ action: UIActionInfo
+): action is QuickApproveUIActionInfo {
+ return (action as QuickApproveUIActionInfo).key === QUICK_APPROVE_ACTION.key;
+}
+
+const DOWNLOAD_ACTION: UIActionInfo = {
+ enabled: true,
+ label: 'Download patch',
+ title: 'Open download dialog',
+ __key: 'download',
+ __primary: false,
+ __type: ActionType.REVISION,
+};
+
+const REBASE_EDIT: UIActionInfo = {
+ enabled: true,
+ label: 'Rebase edit',
+ title: 'Rebase change edit',
+ __key: 'rebaseEdit',
+ __primary: false,
+ __type: ActionType.CHANGE,
+ method: HttpMethod.POST,
+};
+
+const PUBLISH_EDIT: UIActionInfo = {
+ enabled: true,
+ label: 'Publish edit',
+ title: 'Publish change edit',
+ __key: 'publishEdit',
+ __primary: false,
+ __type: ActionType.CHANGE,
+ method: HttpMethod.POST,
+};
+
+const DELETE_EDIT: UIActionInfo = {
+ enabled: true,
+ label: 'Delete edit',
+ title: 'Delete change edit',
+ __key: 'deleteEdit',
+ __primary: false,
+ __type: ActionType.CHANGE,
+ method: HttpMethod.DELETE,
+};
+
+const EDIT: UIActionInfo = {
+ enabled: true,
+ label: 'Edit',
+ title: 'Edit this change',
+ __key: 'edit',
+ __primary: false,
+ __type: ActionType.CHANGE,
+};
+
+const STOP_EDIT: UIActionInfo = {
+ enabled: true,
+ label: 'Stop editing',
+ title: 'Stop editing this change',
+ __key: 'stopEdit',
+ __primary: false,
+ __type: ActionType.CHANGE,
+};
+
+// Set of keys that have icons. As more icons are added to gr-icons.html, this
+// set should be expanded.
+const ACTIONS_WITH_ICONS = new Set([
+ ChangeActions.ABANDON,
+ ChangeActions.DELETE_EDIT,
+ ChangeActions.EDIT,
+ ChangeActions.PUBLISH_EDIT,
+ ChangeActions.READY,
+ ChangeActions.REBASE_EDIT,
+ ChangeActions.RESTORE,
+ ChangeActions.REVERT,
+ ChangeActions.REVERT_SUBMISSION,
+ ChangeActions.STOP_EDIT,
+ QUICK_APPROVE_ACTION.key,
+ RevisionActions.REBASE,
+ RevisionActions.SUBMIT,
+]);
+
+const AWAIT_CHANGE_ATTEMPTS = 5;
+const AWAIT_CHANGE_TIMEOUT_MS = 1000;
+
+/* Revert submission is skipped as the normal revert dialog will now show
+the user a choice between reverting single change or an entire submission.
+Hence, a second button is not needed.
+*/
+const SKIP_ACTION_KEYS = [ChangeActions.REVERT_SUBMISSION];
+
+const SKIP_ACTION_KEYS_ATTENTION_SET = [
+ ChangeActions.REVIEWED,
+ ChangeActions.UNREVIEWED,
+];
+
+function assertUIActionInfo(action?: ActionInfo): UIActionInfo {
+ // TODO(TS): Remove this function. The gr-change-actions adds properties
+ // to existing ActionInfo objects instead of creating a new objects. This
+ // function checks, that 'action' has all property required by UIActionInfo.
+ // In the future, we should avoid updates of an existing ActionInfos and
+ // instead create a new object to make code cleaner. However, at the current
+ // state this is unsafe, because other code can expect these properties to be
+ // set in ActionInfo.
+ if (!action) {
+ throw new Error('action is undefined');
+ }
+ const result = action as UIActionInfo;
+ if (result.__key === undefined || result.__type === undefined) {
+ throw new Error('action is not an UIActionInfo');
+ }
+ return result;
+}
+
+interface MenuAction {
+ name: string;
+ id: string;
+ action: UIActionInfo;
+ tooltip?: string;
+}
+
+interface OverflowAction {
+ type: ActionType;
+ key: string;
+ overflow?: boolean;
+}
+
+interface ActionPriorityOverride {
+ type: ActionType.CHANGE | ActionType.REVISION;
+ key: string;
+ priority: ActionPriority;
+}
+
+interface ChangeActionDialog extends HTMLElement {
+ resetFocus?(): void;
+}
+
+export interface GrChangeActions {
+ $: {
+ jsAPI: GrJsApiInterface;
+ restAPI: RestApiService & Element;
+ mainContent: Element;
+ overlay: GrOverlay;
+ confirmRebase: GrConfirmRebaseDialog;
+ confirmCherrypick: GrConfirmCherrypickDialog;
+ confirmCherrypickConflict: GrConfirmCherrypickConflictDialog;
+ confirmMove: GrConfirmMoveDialog;
+ confirmRevertDialog: GrConfirmRevertDialog;
+ confirmRevertSubmissionDialog: GrConfirmRevertSubmissionDialog;
+ confirmAbandonDialog: GrConfirmAbandonDialog;
+ confirmSubmitDialog: GrConfirmSubmitDialog;
+ createFollowUpDialog: GrDialog;
+ createFollowUpChange: GrCreateChangeDialog;
+ confirmDeleteDialog: GrDialog;
+ confirmDeleteEditDialog: GrDialog;
+ };
+}
+
+@customElement('gr-change-actions')
+export class GrChangeActions
+ extends GestureEventListeners(LegacyElementMixin(PolymerElement))
+ implements GrChangeActionsElement {
+ static get template() {
+ return htmlTemplate;
+ }
+
+ /**
+ * Fired when the change should be reloaded.
+ *
+ * @event reload
+ */
+
+ /**
+ * Fired when an action is tapped.
+ *
+ * @event custom-tap - naming pattern: <action key>-tap
+ */
+
+ /**
+ * Fires to show an alert when a send is attempted on the non-latest patch.
+ *
+ * @event show-alert
+ */
+
+ /**
+ * Fires when a change action fails.
+ *
+ * @event show-error
+ */
+
+ // TODO(TS): Ensure that ActionType, ChangeActions and RevisionActions
+ // properties are replaced with enums everywhere and remove them from
+ // the GrChangeActions class
+ ActionType = ActionType;
+
+ ChangeActions = ChangeActions;
+
+ RevisionActions = RevisionActions;
+
+ reporting = appContext.reportingService;
+
+ @property({type: Object})
+ change?: ChangeViewChangeInfo;
+
+ @property({type: Object})
+ actions: ActionNameToActionInfoMap = {};
+
+ @property({type: Array})
+ primaryActionKeys: PrimaryActionKey[] = [
+ ChangeActions.READY,
+ RevisionActions.SUBMIT,
+ ];
+
+ @property({type: Boolean})
+ disableEdit = false;
+
+ @property({type: Boolean})
+ _hasKnownChainState = false;
+
+ @property({type: Boolean})
+ _hideQuickApproveAction = false;
+
+ @property({type: String})
+ changeNum?: NumericChangeId;
+
+ @property({type: String})
+ changeStatus?: ChangeStatus;
+
+ @property({type: String})
+ commitNum?: CommitId;
+
+ @property({type: Boolean, observer: '_computeChainState'})
+ hasParent?: boolean;
+
+ @property({type: String})
+ latestPatchNum?: PatchSetNum;
+
+ @property({type: String})
+ commitMessage = '';
+
+ @property({type: Object, notify: true})
+ revisionActions: ActionNameToActionInfoMap = {};
+
+ @property({type: Object, computed: '_getSubmitAction(revisionActions)'})
+ _revisionSubmitAction?: ActionInfo | null;
+
+ @property({type: Object, computed: '_getRebaseAction(revisionActions)'})
+ _revisionRebaseAction?: ActionInfo | null;
+
+ @property({type: String})
+ privateByDefault?: InheritedBooleanInfo;
+
+ @property({type: Boolean})
+ _loading = true;
+
+ @property({type: String})
+ _actionLoadingMessage = '';
+
+ @property({
+ type: Array,
+ computed:
+ '_computeAllActions(actions.*, revisionActions.*,' +
+ 'primaryActionKeys.*, _additionalActions.*, change, ' +
+ '_config, _actionPriorityOverrides.*)',
+ })
+ _allActionValues: UIActionInfo[] = []; // _computeAllActions always returns an array
+
+ @property({
+ type: Array,
+ computed:
+ '_computeTopLevelActions(_allActionValues.*, ' +
+ '_hiddenActions.*, _overflowActions.*)',
+ observer: '_filterPrimaryActions',
+ })
+ _topLevelActions?: UIActionInfo[];
+
+ @property({type: Array})
+ _topLevelPrimaryActions?: UIActionInfo[];
+
+ @property({type: Array})
+ _topLevelSecondaryActions?: UIActionInfo[];
+
+ @property({
+ type: Array,
+ computed:
+ '_computeMenuActions(_allActionValues.*, ' +
+ '_hiddenActions.*, _overflowActions.*)',
+ })
+ _menuActions?: MenuAction[];
+
+ @property({type: Array})
+ _overflowActions: OverflowAction[] = [
+ {
+ type: ActionType.CHANGE,
+ key: ChangeActions.WIP,
+ },
+ {
+ type: ActionType.CHANGE,
+ key: ChangeActions.DELETE,
+ },
+ {
+ type: ActionType.REVISION,
+ key: RevisionActions.CHERRYPICK,
+ },
+ {
+ type: ActionType.CHANGE,
+ key: ChangeActions.MOVE,
+ },
+ {
+ type: ActionType.REVISION,
+ key: RevisionActions.DOWNLOAD,
+ },
+ {
+ type: ActionType.CHANGE,
+ key: ChangeActions.IGNORE,
+ },
+ {
+ type: ActionType.CHANGE,
+ key: ChangeActions.UNIGNORE,
+ },
+ {
+ type: ActionType.CHANGE,
+ key: ChangeActions.REVIEWED,
+ },
+ {
+ type: ActionType.CHANGE,
+ key: ChangeActions.UNREVIEWED,
+ },
+ {
+ type: ActionType.CHANGE,
+ key: ChangeActions.PRIVATE,
+ },
+ {
+ type: ActionType.CHANGE,
+ key: ChangeActions.PRIVATE_DELETE,
+ },
+ {
+ type: ActionType.CHANGE,
+ key: ChangeActions.FOLLOW_UP,
+ },
+ ];
+
+ @property({type: Array})
+ _actionPriorityOverrides: ActionPriorityOverride[] = [];
+
+ @property({type: Array})
+ _additionalActions: UIActionInfo[] = [];
+
+ @property({type: Array})
+ _hiddenActions: string[] = [];
+
+ @property({type: Array})
+ _disabledMenuActions: string[] = [];
+
+ @property({type: Boolean})
+ editPatchsetLoaded = false;
+
+ @property({type: Boolean})
+ editMode = false;
+
+ @property({type: Boolean})
+ editBasedOnCurrentPatchSet = true;
+
+ @property({type: Object})
+ _config?: ServerInfo;
+
+ /** @override */
+ created() {
+ super.created();
+ this.addEventListener('fullscreen-overlay-opened', () =>
+ this._handleHideBackgroundContent()
+ );
+ this.addEventListener('fullscreen-overlay-closed', () =>
+ this._handleShowBackgroundContent()
+ );
+ }
+
+ /** @override */
+ ready() {
+ super.ready();
+ this.$.jsAPI.addElement(TargetElement.CHANGE_ACTIONS, this);
+ this.$.restAPI.getConfig().then(config => {
+ this._config = config;
+ });
+ this._handleLoadingComplete();
+ }
+
+ _getSubmitAction(revisionActions: ActionNameToActionInfoMap) {
+ return this._getRevisionAction(revisionActions, 'submit');
+ }
+
+ _getRebaseAction(revisionActions: ActionNameToActionInfoMap) {
+ return this._getRevisionAction(revisionActions, 'rebase');
+ }
+
+ _getRevisionAction(
+ revisionActions: ActionNameToActionInfoMap,
+ actionName: string
+ ) {
+ if (!revisionActions) {
+ return undefined;
+ }
+ if (revisionActions[actionName] === undefined) {
+ // Return null to fire an event when reveisionActions was loaded
+ // but doesn't contain actionName. undefined doesn't fire an event
+ return null;
+ }
+ return revisionActions[actionName];
+ }
+
+ reload() {
+ if (!this.changeNum || !this.latestPatchNum || !this.change) {
+ return Promise.resolve();
+ }
+ const change = this.change;
+
+ this._loading = true;
+ return this.$.restAPI
+ .getChangeRevisionActions(this.changeNum, this.latestPatchNum)
+ .then(revisionActions => {
+ if (!revisionActions) {
+ return;
+ }
+
+ this.revisionActions = revisionActions;
+ this._sendShowRevisionActions({
+ change,
+ revisionActions,
+ });
+ this._handleLoadingComplete();
+ })
+ .catch(err => {
+ this.dispatchEvent(
+ new CustomEvent('show-alert', {
+ detail: {message: ERR_REVISION_ACTIONS},
+ composed: true,
+ bubbles: true,
+ })
+ );
+ this._loading = false;
+ throw err;
+ });
+ }
+
+ _handleLoadingComplete() {
+ getPluginLoader()
+ .awaitPluginsLoaded()
+ .then(() => (this._loading = false));
+ }
+
+ _sendShowRevisionActions(detail: {
+ change: ChangeInfo;
+ revisionActions: ActionNameToActionInfoMap;
+ }) {
+ this.$.jsAPI.handleEvent(EventType.SHOW_REVISION_ACTIONS, detail);
+ }
+
+ @observe('change')
+ _changeChanged() {
+ this.reload();
+ }
+
+ addActionButton(type: ActionType, label: string) {
+ if (type !== ActionType.CHANGE && type !== ActionType.REVISION) {
+ throw Error(`Invalid action type: ${type}`);
+ }
+ const action: UIActionInfo = {
+ enabled: true,
+ label,
+ __type: type,
+ __key:
+ ADDITIONAL_ACTION_KEY_PREFIX + Math.random().toString(36).substr(2),
+ };
+ this.push('_additionalActions', action);
+ return action.__key;
+ }
+
+ removeActionButton(key: string) {
+ const idx = this._indexOfActionButtonWithKey(key);
+ if (idx === -1) {
+ return;
+ }
+ this.splice('_additionalActions', idx, 1);
+ }
+
+ setActionButtonProp<T extends keyof UIActionInfo>(
+ key: string,
+ prop: T,
+ value: UIActionInfo[T]
+ ) {
+ this.set(
+ ['_additionalActions', this._indexOfActionButtonWithKey(key), prop],
+ value
+ );
+ }
+
+ setActionOverflow(type: ActionType, key: string, overflow: boolean) {
+ if (type !== ActionType.CHANGE && type !== ActionType.REVISION) {
+ throw Error(`Invalid action type given: ${type}`);
+ }
+ const index = this._getActionOverflowIndex(type, key);
+ const action: OverflowAction = {
+ type,
+ key,
+ overflow,
+ };
+ if (!overflow && index !== -1) {
+ this.splice('_overflowActions', index, 1);
+ } else if (overflow) {
+ this.push('_overflowActions', action);
+ }
+ }
+
+ setActionPriority(
+ type: ActionType.CHANGE | ActionType.REVISION,
+ key: string,
+ priority: ActionPriority
+ ) {
+ if (type !== ActionType.CHANGE && type !== ActionType.REVISION) {
+ throw Error(`Invalid action type given: ${type}`);
+ }
+ const index = this._actionPriorityOverrides.findIndex(
+ action => action.type === type && action.key === key
+ );
+ const action: ActionPriorityOverride = {
+ type,
+ key,
+ priority,
+ };
+ if (index !== -1) {
+ this.set('_actionPriorityOverrides', index, action);
+ } else {
+ this.push('_actionPriorityOverrides', action);
+ }
+ }
+
+ setActionHidden(
+ type: ActionType.CHANGE | ActionType.REVISION,
+ key: string,
+ hidden: boolean
+ ) {
+ if (type !== ActionType.CHANGE && type !== ActionType.REVISION) {
+ throw Error(`Invalid action type given: ${type}`);
+ }
+
+ const idx = this._hiddenActions.indexOf(key);
+ if (hidden && idx === -1) {
+ this.push('_hiddenActions', key);
+ } else if (!hidden && idx !== -1) {
+ this.splice('_hiddenActions', idx, 1);
+ }
+ }
+
+ getActionDetails(actionName: string) {
+ if (this.revisionActions[actionName]) {
+ return this.revisionActions[actionName];
+ } else if (this.actions[actionName]) {
+ return this.actions[actionName];
+ } else {
+ return undefined;
+ }
+ }
+
+ _indexOfActionButtonWithKey(key: string) {
+ for (let i = 0; i < this._additionalActions.length; i++) {
+ if (this._additionalActions[i].__key === key) {
+ return i;
+ }
+ }
+ return -1;
+ }
+
+ _shouldHideActions(
+ actions?: PolymerDeepPropertyChange<UIActionInfo[], UIActionInfo[]>,
+ loading?: boolean
+ ) {
+ return loading || !actions || !actions.base || !actions.base.length;
+ }
+
+ _keyCount(
+ changeRecord?: PolymerDeepPropertyChange<
+ ActionNameToActionInfoMap,
+ ActionNameToActionInfoMap
+ >
+ ) {
+ return Object.keys(changeRecord?.base || {}).length;
+ }
+
+ @observe('actions.*', 'revisionActions.*', '_additionalActions.*')
+ _actionsChanged(
+ actionsChangeRecord?: PolymerDeepPropertyChange<
+ ActionNameToActionInfoMap,
+ ActionNameToActionInfoMap
+ >,
+ revisionActionsChangeRecord?: PolymerDeepPropertyChange<
+ ActionNameToActionInfoMap,
+ ActionNameToActionInfoMap
+ >,
+ additionalActionsChangeRecord?: PolymerDeepPropertyChange<
+ UIActionInfo[],
+ UIActionInfo[]
+ >
+ ) {
+ // Polymer 2: check for undefined
+ if (
+ actionsChangeRecord === undefined ||
+ revisionActionsChangeRecord === undefined ||
+ additionalActionsChangeRecord === undefined
+ ) {
+ return;
+ }
+
+ const additionalActions =
+ (additionalActionsChangeRecord && additionalActionsChangeRecord.base) ||
+ [];
+ this.hidden =
+ this._keyCount(actionsChangeRecord) === 0 &&
+ this._keyCount(revisionActionsChangeRecord) === 0 &&
+ additionalActions.length === 0;
+ this._actionLoadingMessage = '';
+ this._actionLoadingMessage = '';
+ this._disabledMenuActions = [];
+
+ const revisionActions = revisionActionsChangeRecord.base || {};
+ if (Object.keys(revisionActions).length !== 0) {
+ if (!revisionActions.download) {
+ this.set('revisionActions.download', DOWNLOAD_ACTION);
+ }
+ }
+ }
+
+ _deleteAndNotify(actionName: string) {
+ if (this.actions && this.actions[actionName]) {
+ delete this.actions[actionName];
+ // We assign a fake value of 'false' to support Polymer 2
+ // see https://github.com/Polymer/polymer/issues/2631
+ this.notifyPath('actions.' + actionName, false);
+ }
+ }
+
+ @observe(
+ 'editMode',
+ 'editPatchsetLoaded',
+ 'editBasedOnCurrentPatchSet',
+ 'disableEdit',
+ 'actions.*',
+ 'change.*'
+ )
+ _editStatusChanged(
+ editMode: boolean,
+ editPatchsetLoaded: boolean,
+ editBasedOnCurrentPatchSet: boolean,
+ disableEdit: boolean,
+ actionsChangeRecord?: PolymerDeepPropertyChange<
+ ActionNameToActionInfoMap,
+ ActionNameToActionInfoMap
+ >,
+ changeChangeRecord?: PolymerDeepPropertyChange<ChangeInfo, ChangeInfo>
+ ) {
+ if (actionsChangeRecord === undefined || changeChangeRecord === undefined) {
+ return;
+ }
+ if (disableEdit) {
+ this._deleteAndNotify('publishEdit');
+ this._deleteAndNotify('rebaseEdit');
+ this._deleteAndNotify('deleteEdit');
+ this._deleteAndNotify('stopEdit');
+ this._deleteAndNotify('edit');
+ return;
+ }
+ const actions = actionsChangeRecord.base;
+ const change = changeChangeRecord.base;
+ if (actions && editPatchsetLoaded) {
+ // Only show actions that mutate an edit if an actual edit patch set
+ // is loaded.
+ if (changeIsOpen(change)) {
+ if (editBasedOnCurrentPatchSet) {
+ if (!actions.publishEdit) {
+ this.set('actions.publishEdit', PUBLISH_EDIT);
+ }
+ this._deleteAndNotify('rebaseEdit');
+ } else {
+ if (!actions.rebaseEdit) {
+ this.set('actions.rebaseEdit', REBASE_EDIT);
+ }
+ this._deleteAndNotify('publishEdit');
+ }
+ }
+ if (!actions.deleteEdit) {
+ this.set('actions.deleteEdit', DELETE_EDIT);
+ }
+ } else {
+ this._deleteAndNotify('publishEdit');
+ this._deleteAndNotify('rebaseEdit');
+ this._deleteAndNotify('deleteEdit');
+ }
+
+ if (actions && changeIsOpen(change)) {
+ // Only show edit button if there is no edit patchset loaded and the
+ // file list is not in edit mode.
+ if (editPatchsetLoaded || editMode) {
+ this._deleteAndNotify('edit');
+ } else {
+ if (!actions.edit) {
+ this.set('actions.edit', EDIT);
+ }
+ }
+ // Only show STOP_EDIT if edit mode is enabled, but no edit patch set
+ // is loaded.
+ if (editMode && !editPatchsetLoaded) {
+ if (!actions.stopEdit) {
+ this.set('actions.stopEdit', STOP_EDIT);
+ }
+ } else {
+ this._deleteAndNotify('stopEdit');
+ }
+ } else {
+ // Remove edit button.
+ this._deleteAndNotify('edit');
+ }
+ }
+
+ _getValuesFor<T>(obj: {[key: string]: T}): T[] {
+ return Object.keys(obj).map(key => obj[key]);
+ }
+
+ _getLabelStatus(label: LabelInfo): LabelStatus {
+ if (isQuickLabelInfo(label)) {
+ if (label.approved) {
+ return LabelStatus.OK;
+ } else if (label.rejected) {
+ return LabelStatus.REJECT;
+ }
+ }
+ if (label.optional) {
+ return LabelStatus.OPTIONAL;
+ } else {
+ return LabelStatus.NEED;
+ }
+ }
+
+ /**
+ * Get highest score for last missing permitted label for current change.
+ * Returns null if no labels permitted or more than one label missing.
+ */
+ _getTopMissingApproval() {
+ if (!this.change || !this.change.labels || !this.change.permitted_labels) {
+ return null;
+ }
+ let result;
+ for (const label in this.change.labels) {
+ if (!(label in this.change.permitted_labels)) {
+ continue;
+ }
+ if (this.change.permitted_labels[label].length === 0) {
+ continue;
+ }
+ const status = this._getLabelStatus(this.change.labels[label]);
+ if (status === LabelStatus.NEED) {
+ if (result) {
+ // More than one label is missing, so it's unclear which to quick
+ // approve, return null;
+ return null;
+ }
+ result = label;
+ } else if (
+ status === LabelStatus.REJECT ||
+ status === LabelStatus.IMPOSSIBLE
+ ) {
+ return null;
+ }
+ }
+ if (result) {
+ const score = this.change.permitted_labels[result].slice(-1)[0];
+ const labelInfo = this.change.labels[result];
+ if (!isDetailedLabelInfo(labelInfo)) {
+ return null;
+ }
+ const maxScore = Object.keys(labelInfo.values).slice(-1)[0];
+ if (score === maxScore) {
+ // Allow quick approve only for maximal score.
+ return {
+ label: result,
+ score,
+ };
+ }
+ }
+ return null;
+ }
+
+ hideQuickApproveAction() {
+ if (!this._topLevelSecondaryActions) {
+ throw new Error('_topLevelSecondaryActions must be set');
+ }
+ this._topLevelSecondaryActions = this._topLevelSecondaryActions.filter(
+ sa => !isQuckApproveAction(sa)
+ );
+ this._hideQuickApproveAction = true;
+ }
+
+ _getQuickApproveAction(): QuickApproveUIActionInfo | null {
+ if (this._hideQuickApproveAction) {
+ return null;
+ }
+ const approval = this._getTopMissingApproval();
+ if (!approval) {
+ return null;
+ }
+ const action = {...QUICK_APPROVE_ACTION};
+ action.label = approval.label + approval.score;
+
+ const score = Number(approval.score);
+ if (isNaN(score)) {
+ return null;
+ }
+
+ const review: ReviewInput = {
+ drafts: DraftsAction.PUBLISH_ALL_REVISIONS,
+ labels: {
+ [approval.label]: score,
+ },
+ };
+ action.payload = review;
+ return action;
+ }
+
+ _getActionValues(
+ actionsChangeRecord: PolymerDeepPropertyChange<
+ ActionNameToActionInfoMap,
+ ActionNameToActionInfoMap
+ >,
+ primariesChangeRecord: PolymerDeepPropertyChange<
+ PrimaryActionKey[],
+ PrimaryActionKey[]
+ >,
+ additionalActionsChangeRecord: PolymerDeepPropertyChange<
+ UIActionInfo[],
+ UIActionInfo[]
+ >,
+ type: ActionType
+ ): UIActionInfo[] {
+ if (!actionsChangeRecord || !primariesChangeRecord) {
+ return [];
+ }
+
+ const actions = actionsChangeRecord.base || {};
+ const primaryActionKeys = primariesChangeRecord.base || [];
+ const result: UIActionInfo[] = [];
+ const values: Array<ChangeActions | RevisionActions> =
+ type === ActionType.CHANGE
+ ? this._getValuesFor(ChangeActions)
+ : this._getValuesFor(RevisionActions);
+
+ const pluginActions: UIActionInfo[] = [];
+ Object.keys(actions).forEach(a => {
+ const action: UIActionInfo = actions[a] as UIActionInfo;
+ action.__key = a;
+ action.__type = type;
+ action.__primary = primaryActionKeys.includes(a as PrimaryActionKey);
+ // Plugin actions always contain ~ in the key.
+ if (a.indexOf('~') !== -1) {
+ this._populateActionUrl(action);
+ pluginActions.push(action);
+ // Add server-side provided plugin actions to overflow menu.
+ this._overflowActions.push({
+ type,
+ key: a,
+ });
+ return;
+ } else if (!values.includes(a as PrimaryActionKey)) {
+ return;
+ }
+ action.label = this._getActionLabel(action);
+
+ // Triggers a re-render by ensuring object inequality.
+ result.push({...action});
+ });
+
+ let additionalActions =
+ (additionalActionsChangeRecord && additionalActionsChangeRecord.base) ||
+ [];
+ additionalActions = additionalActions
+ .filter(a => a.__type === type)
+ .map(a => {
+ a.__primary = primaryActionKeys.includes(a.__key as PrimaryActionKey);
+ // Triggers a re-render by ensuring object inequality.
+ return {...a};
+ });
+ return result.concat(additionalActions).concat(pluginActions);
+ }
+
+ _populateActionUrl(action: UIActionInfo) {
+ const patchNum =
+ action.__type === ActionType.REVISION ? this.latestPatchNum : undefined;
+ if (!this.changeNum) {
+ return;
+ }
+ this.$.restAPI
+ .getChangeActionURL(this.changeNum, patchNum, '/' + action.__key)
+ .then(url => (action.__url = url));
+ }
+
+ /**
+ * Given a change action, return a display label that uses the appropriate
+ * casing or includes explanatory details.
+ */
+ _getActionLabel(action: UIActionInfo) {
+ if (action.label === 'Delete') {
+ // This label is common within change and revision actions. Make it more
+ // explicit to the user.
+ return 'Delete change';
+ } else if (action.label === 'WIP') {
+ return 'Mark as work in progress';
+ }
+ // Otherwise, just map the name to sentence case.
+ return this._toSentenceCase(action.label);
+ }
+
+ /**
+ * Capitalize the first letter and lowecase all others.
+ */
+ _toSentenceCase(s: string) {
+ if (!s.length) {
+ return '';
+ }
+ return s[0].toUpperCase() + s.slice(1).toLowerCase();
+ }
+
+ _computeLoadingLabel(action: string) {
+ return ActionLoadingLabels[action] || 'Working...';
+ }
+
+ _canSubmitChange() {
+ if (!this.change) {
+ return false;
+ }
+ return this.$.jsAPI.canSubmitChange(
+ this.change,
+ this._getRevision(this.change, this.latestPatchNum)
+ );
+ }
+
+ _getRevision(change: ChangeViewChangeInfo, patchNum?: PatchSetNum) {
+ for (const rev of Object.values(change.revisions)) {
+ if (patchNumEquals(rev._number, patchNum)) {
+ return rev;
+ }
+ }
+ return null;
+ }
+
+ showRevertDialog() {
+ const change = this.change;
+ if (!change) return;
+ // The search is still broken if there is a " in the topic.
+ const query = `submissionid: "${change.submission_id}"`;
+ /* A chromium plugin expects that the modifyRevertMsg hook will only
+ be called after the revert button is pressed, hence we populate the
+ revert dialog after revert button is pressed. */
+ this.$.restAPI.getChanges(0, query).then(changes => {
+ if (!changes) {
+ console.error('changes is undefined');
+ return;
+ }
+ this.$.confirmRevertDialog.populate(change, this.commitMessage, changes);
+ this._showActionDialog(this.$.confirmRevertDialog);
+ });
+ }
+
+ showRevertSubmissionDialog() {
+ const change = this.change;
+ if (!change) return;
+ const query = `submissionid:${change.submission_id}`;
+ this.$.restAPI.getChanges(0, query).then(changes => {
+ if (!changes) {
+ console.error('changes is undefined');
+ return;
+ }
+ this.$.confirmRevertSubmissionDialog._populateRevertSubmissionMessage(
+ change,
+ changes
+ );
+ this._showActionDialog(this.$.confirmRevertSubmissionDialog);
+ });
+ }
+
+ _handleActionTap(e: MouseEvent) {
+ e.preventDefault();
+ let el = (dom(e) as EventApi).localTarget as Element;
+ while (el.tagName.toLowerCase() !== 'gr-button') {
+ if (!el.parentElement) {
+ return;
+ }
+ el = el.parentElement;
+ }
+
+ const key = el.getAttribute('data-action-key');
+ if (!key) {
+ throw new Error("Button doesn't have data-action-key attribute");
+ }
+ if (
+ key.startsWith(ADDITIONAL_ACTION_KEY_PREFIX) ||
+ key.indexOf('~') !== -1
+ ) {
+ this.dispatchEvent(
+ new CustomEvent(`${key}-tap`, {
+ detail: {node: el},
+ composed: true,
+ bubbles: true,
+ })
+ );
+ return;
+ }
+ const type = el.getAttribute('data-action-type') as ActionType;
+ this._handleAction(type, key);
+ }
+
+ _handleOverflowItemTap(e: CustomEvent<MenuAction>) {
+ e.preventDefault();
+ const el = (dom(e) as EventApi).localTarget as Element;
+ const key = e.detail.action.__key;
+ if (
+ key.startsWith(ADDITIONAL_ACTION_KEY_PREFIX) ||
+ key.indexOf('~') !== -1
+ ) {
+ this.dispatchEvent(
+ new CustomEvent(`${key}-tap`, {
+ detail: {node: el},
+ composed: true,
+ bubbles: true,
+ })
+ );
+ return;
+ }
+ this._handleAction(e.detail.action.__type, e.detail.action.__key);
+ }
+
+ _handleAction(type: ActionType, key: string) {
+ this.reporting.reportInteraction(`${type}-${key}`);
+ switch (type) {
+ case ActionType.REVISION:
+ this._handleRevisionAction(key);
+ break;
+ case ActionType.CHANGE:
+ this._handleChangeAction(key);
+ break;
+ default:
+ this._fireAction(
+ this._prependSlash(key),
+ assertUIActionInfo(this.actions[key]),
+ false
+ );
+ }
+ }
+
+ _handleChangeAction(key: string) {
+ switch (key) {
+ case ChangeActions.REVERT:
+ this.showRevertDialog();
+ break;
+ case ChangeActions.REVERT_SUBMISSION:
+ this.showRevertSubmissionDialog();
+ break;
+ case ChangeActions.ABANDON:
+ this._showActionDialog(this.$.confirmAbandonDialog);
+ break;
+ case QUICK_APPROVE_ACTION.key: {
+ const action = this._allActionValues.find(isQuckApproveAction);
+ if (!action) {
+ return;
+ }
+ this._fireAction(this._prependSlash(key), action, true, action.payload);
+ break;
+ }
+ case ChangeActions.EDIT:
+ this._handleEditTap();
+ break;
+ case ChangeActions.STOP_EDIT:
+ this._handleStopEditTap();
+ break;
+ case ChangeActions.DELETE:
+ this._handleDeleteTap();
+ break;
+ case ChangeActions.DELETE_EDIT:
+ this._handleDeleteEditTap();
+ break;
+ case ChangeActions.FOLLOW_UP:
+ this._handleFollowUpTap();
+ break;
+ case ChangeActions.WIP:
+ this._handleWipTap();
+ break;
+ case ChangeActions.MOVE:
+ this._handleMoveTap();
+ break;
+ case ChangeActions.PUBLISH_EDIT:
+ this._handlePublishEditTap();
+ break;
+ case ChangeActions.REBASE_EDIT:
+ this._handleRebaseEditTap();
+ break;
+ default:
+ this._fireAction(
+ this._prependSlash(key),
+ assertUIActionInfo(this.actions[key]),
+ false
+ );
+ }
+ }
+
+ _handleRevisionAction(key: string) {
+ switch (key) {
+ case RevisionActions.REBASE:
+ this._showActionDialog(this.$.confirmRebase);
+ this.$.confirmRebase.fetchRecentChanges();
+ break;
+ case RevisionActions.CHERRYPICK:
+ this._handleCherrypickTap();
+ break;
+ case RevisionActions.DOWNLOAD:
+ this._handleDownloadTap();
+ break;
+ case RevisionActions.SUBMIT:
+ if (!this._canSubmitChange()) {
+ return;
+ }
+ this._showActionDialog(this.$.confirmSubmitDialog);
+ break;
+ default:
+ this._fireAction(
+ this._prependSlash(key),
+ assertUIActionInfo(this.revisionActions[key]),
+ true
+ );
+ }
+ }
+
+ _prependSlash(key: string) {
+ return key === '/' ? key : `/${key}`;
+ }
+
+ /**
+ * _hasKnownChainState set to true true if hasParent is defined (can be
+ * either true or false). set to false otherwise.
+ */
+ _computeChainState() {
+ this._hasKnownChainState = true;
+ }
+
+ _calculateDisabled(action: UIActionInfo, hasKnownChainState: boolean) {
+ if (action.__key === 'rebase') {
+ // Rebase button is only disabled when change has no parent(s).
+ return hasKnownChainState === false;
+ }
+ return !action.enabled;
+ }
+
+ _handleConfirmDialogCancel() {
+ this._hideAllDialogs();
+ }
+
+ _hideAllDialogs() {
+ const dialogEls = this.root!.querySelectorAll('.confirmDialog');
+ for (const dialogEl of dialogEls) {
+ (dialogEl as HTMLElement).hidden = true;
+ }
+ this.$.overlay.close();
+ }
+
+ _handleRebaseConfirm(e: CustomEvent<ConfirmRebaseEventDetail>) {
+ const el = this.$.confirmRebase;
+ const payload = {base: e.detail.base};
+ this.$.overlay.close();
+ el.hidden = true;
+ this._fireAction(
+ '/rebase',
+ assertUIActionInfo(this.revisionActions.rebase),
+ true,
+ payload
+ );
+ }
+
+ _handleCherrypickConfirm() {
+ this._handleCherryPickRestApi(false);
+ }
+
+ _handleCherrypickConflictConfirm() {
+ this._handleCherryPickRestApi(true);
+ }
+
+ _handleCherryPickRestApi(conflicts: boolean) {
+ const el = this.$.confirmCherrypick;
+ if (!el.branch) {
+ this.dispatchEvent(
+ new CustomEvent('show-alert', {
+ detail: {message: ERR_BRANCH_EMPTY},
+ composed: true,
+ bubbles: true,
+ })
+ );
+ return;
+ }
+ if (!el.message) {
+ this.dispatchEvent(
+ new CustomEvent('show-alert', {
+ detail: {message: ERR_COMMIT_EMPTY},
+ composed: true,
+ bubbles: true,
+ })
+ );
+ return;
+ }
+ this.$.overlay.close();
+ el.hidden = true;
+ this._fireAction(
+ '/cherrypick',
+ assertUIActionInfo(this.revisionActions.cherrypick),
+ true,
+ {
+ destination: el.branch,
+ base: el.baseCommit ? el.baseCommit : null,
+ message: el.message,
+ allow_conflicts: conflicts,
+ }
+ );
+ }
+
+ _handleMoveConfirm() {
+ const el = this.$.confirmMove;
+ if (!el.branch) {
+ this.dispatchEvent(
+ new CustomEvent('show-alert', {
+ detail: {message: ERR_BRANCH_EMPTY},
+ composed: true,
+ bubbles: true,
+ })
+ );
+ return;
+ }
+ this.$.overlay.close();
+ el.hidden = true;
+ this._fireAction('/move', assertUIActionInfo(this.actions.move), false, {
+ destination_branch: el.branch,
+ message: el.message,
+ });
+ }
+
+ _handleRevertDialogConfirm(e: CustomEvent<ConfirmRevertEventDetail>) {
+ const revertType = e.detail.revertType;
+ const message = e.detail.message;
+ const el = this.$.confirmRevertDialog;
+ this.$.overlay.close();
+ el.hidden = true;
+ switch (revertType) {
+ case RevertType.REVERT_SINGLE_CHANGE:
+ this._fireAction(
+ '/revert',
+ assertUIActionInfo(this.actions.revert),
+ false,
+ {message}
+ );
+ break;
+ case RevertType.REVERT_SUBMISSION:
+ this._fireAction(
+ '/revert_submission',
+ assertUIActionInfo(this.actions.revert_submission),
+ false,
+ {message}
+ );
+ break;
+ default:
+ console.error('invalid revert type');
+ }
+ }
+
+ _handleRevertSubmissionDialogConfirm() {
+ const el = this.$.confirmRevertSubmissionDialog;
+ this.$.overlay.close();
+ el.hidden = true;
+ this._fireAction(
+ '/revert_submission',
+ assertUIActionInfo(this.actions.revert_submission),
+ false,
+ {message: el.message}
+ );
+ }
+
+ _handleAbandonDialogConfirm() {
+ const el = this.$.confirmAbandonDialog;
+ this.$.overlay.close();
+ el.hidden = true;
+ this._fireAction(
+ '/abandon',
+ assertUIActionInfo(this.actions.abandon),
+ false,
+ {
+ message: el.message,
+ }
+ );
+ }
+
+ _handleCreateFollowUpChange() {
+ this.$.createFollowUpChange.handleCreateChange();
+ this._handleCloseCreateFollowUpChange();
+ }
+
+ _handleCloseCreateFollowUpChange() {
+ this.$.overlay.close();
+ }
+
+ _handleDeleteConfirm() {
+ this._fireAction(
+ '/',
+ assertUIActionInfo(this.actions[ChangeActions.DELETE]),
+ false
+ );
+ }
+
+ _handleDeleteEditConfirm() {
+ this._hideAllDialogs();
+
+ this._fireAction(
+ '/edit',
+ assertUIActionInfo(this.actions.deleteEdit),
+ false
+ );
+ }
+
+ _handleSubmitConfirm() {
+ if (!this._canSubmitChange()) {
+ return;
+ }
+ this._hideAllDialogs();
+ this._fireAction(
+ '/submit',
+ assertUIActionInfo(this.revisionActions.submit),
+ true
+ );
+ }
+
+ _getActionOverflowIndex(type: string, key: string) {
+ return this._overflowActions.findIndex(
+ action => action.type === type && action.key === key
+ );
+ }
+
+ _setLoadingOnButtonWithKey(type: string, key: string) {
+ this._actionLoadingMessage = this._computeLoadingLabel(key);
+ let buttonKey = key;
+ // TODO(dhruvsri): clean this up later
+ // If key is revert-submission, then button key should be 'revert'
+ if (buttonKey === ChangeActions.REVERT_SUBMISSION) {
+ // Revert submission button no longer exists
+ buttonKey = ChangeActions.REVERT;
+ }
+
+ // If the action appears in the overflow menu.
+ if (this._getActionOverflowIndex(type, buttonKey) !== -1) {
+ this.push(
+ '_disabledMenuActions',
+ buttonKey === '/' ? 'delete' : buttonKey
+ );
+ return () => {
+ this._actionLoadingMessage = '';
+ this._disabledMenuActions = [];
+ };
+ }
+
+ // Otherwise it's a top-level action.
+ const buttonEl = this.shadowRoot!.querySelector(
+ `[data-action-key="${buttonKey}"]`
+ ) as GrButton;
+ if (!buttonEl) {
+ throw new Error(`Can't find button by data-action-key '${buttonKey}'`);
+ }
+ buttonEl.setAttribute('loading', 'true');
+ buttonEl.disabled = true;
+ return () => {
+ this._actionLoadingMessage = '';
+ buttonEl.removeAttribute('loading');
+ buttonEl.disabled = false;
+ };
+ }
+
+ _fireAction(
+ endpoint: string,
+ action: UIActionInfo,
+ revAction: boolean,
+ payload?: RequestPayload
+ ) {
+ const cleanupFn = this._setLoadingOnButtonWithKey(
+ action.__type,
+ action.__key
+ );
+
+ this._send(
+ action.method,
+ payload,
+ endpoint,
+ revAction,
+ cleanupFn,
+ action
+ ).then(res => this._handleResponse(action, res));
+ }
+
+ _showActionDialog(dialog: ChangeActionDialog) {
+ this._hideAllDialogs();
+
+ dialog.hidden = false;
+ this.$.overlay.open().then(() => {
+ if (dialog.resetFocus) {
+ dialog.resetFocus();
+ }
+ });
+ }
+
+ // TODO(rmistry): Redo this after
+ // https://bugs.chromium.org/p/gerrit/issues/detail?id=4671 is resolved.
+ _setLabelValuesOnRevert(newChangeId: NumericChangeId) {
+ const labels = this.$.jsAPI.getLabelValuesPostRevert(this.change);
+ if (!labels) {
+ return Promise.resolve(undefined);
+ }
+ return this.$.restAPI.saveChangeReview(newChangeId, 'current', {labels});
+ }
+
+ _handleResponse(action: UIActionInfo, response?: Response) {
+ if (!response) {
+ return;
+ }
+ return this.$.restAPI.getResponseObject(response).then(obj => {
+ switch (action.__key) {
+ case ChangeActions.REVERT: {
+ const revertChangeInfo: ChangeInfo = (obj as unknown) as ChangeInfo;
+ this._waitForChangeReachable(revertChangeInfo._number)
+ .then(() => this._setLabelValuesOnRevert(revertChangeInfo._number))
+ .then(() => {
+ GerritNav.navigateToChange(revertChangeInfo);
+ });
+ break;
+ }
+ case RevisionActions.CHERRYPICK: {
+ const cherrypickChangeInfo: ChangeInfo = (obj as unknown) as ChangeInfo;
+ this._waitForChangeReachable(cherrypickChangeInfo._number).then(
+ () => {
+ GerritNav.navigateToChange(cherrypickChangeInfo);
+ }
+ );
+ break;
+ }
+ case ChangeActions.DELETE:
+ if (action.__type === ActionType.CHANGE) {
+ GerritNav.navigateToRelativeUrl(GerritNav.getUrlForRoot());
+ }
+ break;
+ case ChangeActions.WIP:
+ case ChangeActions.DELETE_EDIT:
+ case ChangeActions.PUBLISH_EDIT:
+ case ChangeActions.REBASE_EDIT:
+ case ChangeActions.REBASE:
+ case ChangeActions.SUBMIT:
+ this.dispatchEvent(
+ new CustomEvent('reload', {
+ detail: {clearPatchset: true},
+ bubbles: false,
+ composed: true,
+ })
+ );
+ break;
+ case ChangeActions.REVERT_SUBMISSION: {
+ const revertSubmistionInfo = (obj as unknown) as RevertSubmissionInfo;
+ if (
+ !revertSubmistionInfo.revert_changes ||
+ !revertSubmistionInfo.revert_changes.length
+ )
+ return;
+ /* If there is only 1 change then gerrit will automatically
+ redirect to that change */
+ GerritNav.navigateToSearchQuery(
+ `topic: ${revertSubmistionInfo.revert_changes[0].topic}`
+ );
+ break;
+ }
+ default:
+ this.dispatchEvent(
+ new CustomEvent('reload', {
+ detail: {action: action.__key, clearPatchset: true},
+ bubbles: false,
+ composed: true,
+ })
+ );
+ break;
+ }
+ });
+ }
+
+ _handleShowRevertSubmissionChangesConfirm() {
+ this._hideAllDialogs();
+ }
+
+ _handleResponseError(
+ action: UIActionInfo,
+ response: Response | undefined | null,
+ body?: RequestPayload
+ ) {
+ if (!response) {
+ return Promise.resolve(() => {
+ this.dispatchEvent(
+ new CustomEvent('show-error', {
+ detail: {message: `Could not perform action '${action.__key}'`},
+ composed: true,
+ bubbles: true,
+ })
+ );
+ });
+ }
+ if (action && action.__key === RevisionActions.CHERRYPICK) {
+ if (
+ response.status === 409 &&
+ body &&
+ !(body as CherryPickInput).allow_conflicts
+ ) {
+ return this._showActionDialog(this.$.confirmCherrypickConflict);
+ }
+ }
+ return response.text().then(errText => {
+ this.dispatchEvent(
+ new CustomEvent('show-error', {
+ detail: {message: `Could not perform action: ${errText}`},
+ composed: true,
+ bubbles: true,
+ })
+ );
+ if (!errText.startsWith('Change is already up to date')) {
+ throw Error(errText);
+ }
+ });
+ }
+
+ _send(
+ method: HttpMethod | undefined,
+ payload: RequestPayload | undefined,
+ actionEndpoint: string,
+ revisionAction: boolean,
+ cleanupFn: () => void,
+ action: UIActionInfo
+ ): Promise<Response | undefined> {
+ const handleError: ErrorCallback = response => {
+ cleanupFn.call(this);
+ this._handleResponseError(action, response, payload);
+ };
+ const change = this.change;
+ const changeNum = this.changeNum;
+ if (!change || !changeNum) {
+ return Promise.reject(
+ new Error('Properties change and changeNum must be set.')
+ );
+ }
+ return fetchChangeUpdates(change, this.$.restAPI).then(result => {
+ if (!result.isLatest) {
+ this.dispatchEvent(
+ new CustomEvent('show-alert', {
+ detail: {
+ message:
+ 'Cannot set label: a newer patch has been ' +
+ 'uploaded to this change.',
+ action: 'Reload',
+ callback: () => {
+ this.dispatchEvent(
+ new CustomEvent('reload', {
+ detail: {clearPatchset: true},
+ bubbles: false,
+ composed: true,
+ })
+ );
+ },
+ },
+ composed: true,
+ bubbles: true,
+ })
+ );
+
+ // Because this is not a network error, call the cleanup function
+ // but not the error handler.
+ cleanupFn();
+
+ return Promise.resolve(undefined);
+ }
+ const patchNum = revisionAction ? this.latestPatchNum : undefined;
+ return this.$.restAPI
+ .executeChangeAction(
+ changeNum,
+ method,
+ actionEndpoint,
+ patchNum,
+ payload,
+ handleError
+ )
+ .then(response => {
+ cleanupFn.call(this);
+ return response;
+ });
+ });
+ }
+
+ _handleAbandonTap() {
+ this._showActionDialog(this.$.confirmAbandonDialog);
+ }
+
+ _handleCherrypickTap() {
+ if (!this.change) {
+ throw new Error('The change property must be set');
+ }
+ this.$.confirmCherrypick.branch = '' as BranchName;
+ const query = `topic: "${this.change.topic}"`;
+ const options = listChangesOptionsToHex(
+ ListChangesOption.MESSAGES,
+ ListChangesOption.ALL_REVISIONS
+ );
+ this.$.restAPI.getChanges(0, query, undefined, options).then(changes => {
+ if (!changes) {
+ console.error('getChanges returns undefined');
+ return;
+ }
+ this.$.confirmCherrypick.updateChanges(changes);
+ this._showActionDialog(this.$.confirmCherrypick);
+ });
+ }
+
+ _handleMoveTap() {
+ this.$.confirmMove.branch = '' as BranchName;
+ this.$.confirmMove.message = '';
+ this._showActionDialog(this.$.confirmMove);
+ }
+
+ _handleDownloadTap() {
+ this.dispatchEvent(
+ new CustomEvent('download-tap', {
+ composed: true,
+ bubbles: false,
+ })
+ );
+ }
+
+ _handleDeleteTap() {
+ this._showActionDialog(this.$.confirmDeleteDialog);
+ }
+
+ _handleDeleteEditTap() {
+ this._showActionDialog(this.$.confirmDeleteEditDialog);
+ }
+
+ _handleFollowUpTap() {
+ this._showActionDialog(this.$.createFollowUpDialog);
+ }
+
+ _handleWipTap() {
+ if (!this.actions.wip) {
+ return;
+ }
+ this._fireAction('/wip', assertUIActionInfo(this.actions.wip), false);
+ }
+
+ _handlePublishEditTap() {
+ // Type of payload is PublishChangeEditInput.
+ const payload = {notify: NotifyType.NONE};
+ if (!this.actions.publishEdit) {
+ return;
+ }
+ this._fireAction(
+ '/edit:publish',
+ assertUIActionInfo(this.actions.publishEdit),
+ false,
+ payload
+ );
+ }
+
+ _handleRebaseEditTap() {
+ if (!this.actions.rebaseEdit) {
+ return;
+ }
+ this._fireAction(
+ '/edit:rebase',
+ assertUIActionInfo(this.actions.rebaseEdit),
+ false
+ );
+ }
+
+ _handleHideBackgroundContent() {
+ this.$.mainContent.classList.add('overlayOpen');
+ }
+
+ _handleShowBackgroundContent() {
+ this.$.mainContent.classList.remove('overlayOpen');
+ }
+
+ /**
+ * Merge sources of change actions into a single ordered array of action
+ * values.
+ */
+ _computeAllActions(
+ changeActionsRecord: PolymerDeepPropertyChange<
+ ActionNameToActionInfoMap,
+ ActionNameToActionInfoMap
+ >,
+ revisionActionsRecord: PolymerDeepPropertyChange<
+ ActionNameToActionInfoMap,
+ ActionNameToActionInfoMap
+ >,
+ primariesRecord: PolymerDeepPropertyChange<
+ PrimaryActionKey[],
+ PrimaryActionKey[]
+ >,
+ additionalActionsRecord: PolymerDeepPropertyChange<
+ UIActionInfo[],
+ UIActionInfo[]
+ >,
+ change?: ChangeInfo,
+ config?: ServerInfo
+ ): UIActionInfo[] {
+ // Polymer 2: check for undefined
+ if (
+ [
+ changeActionsRecord,
+ revisionActionsRecord,
+ primariesRecord,
+ additionalActionsRecord,
+ change,
+ ].includes(undefined)
+ ) {
+ return [];
+ }
+
+ const revisionActionValues = this._getActionValues(
+ revisionActionsRecord,
+ primariesRecord,
+ additionalActionsRecord,
+ ActionType.REVISION
+ );
+ const changeActionValues = this._getActionValues(
+ changeActionsRecord,
+ primariesRecord,
+ additionalActionsRecord,
+ ActionType.CHANGE
+ );
+ const quickApprove = this._getQuickApproveAction();
+ if (quickApprove) {
+ changeActionValues.unshift(quickApprove);
+ }
+
+ return revisionActionValues
+ .concat(changeActionValues)
+ .sort((a, b) => this._actionComparator(a, b))
+ .map(action => {
+ if (ACTIONS_WITH_ICONS.has(action.__key)) {
+ action.icon = action.__key;
+ }
+ // TODO(brohlfs): Temporary hack until change 269573 is live in all
+ // backends.
+ if (action.__key === ChangeActions.READY) {
+ action.label = 'Mark as Active';
+ }
+ // End of hack
+ return action;
+ })
+ .filter(action => !this._shouldSkipAction(action, config));
+ }
+
+ _getActionPriority(action: UIActionInfo) {
+ if (action.__type && action.__key) {
+ const overrideAction = this._actionPriorityOverrides.find(
+ i => i.type === action.__type && i.key === action.__key
+ );
+
+ if (overrideAction !== undefined) {
+ return overrideAction.priority;
+ }
+ }
+ if (action.__key === 'review') {
+ return ActionPriority.REVIEW;
+ } else if (action.__primary) {
+ return ActionPriority.PRIMARY;
+ } else if (action.__type === ActionType.CHANGE) {
+ return ActionPriority.CHANGE;
+ } else if (action.__type === ActionType.REVISION) {
+ return ActionPriority.REVISION;
+ }
+ return ActionPriority.DEFAULT;
+ }
+
+ /**
+ * Sort comparator to define the order of change actions.
+ */
+ _actionComparator(actionA: UIActionInfo, actionB: UIActionInfo) {
+ const priorityDelta =
+ this._getActionPriority(actionA) - this._getActionPriority(actionB);
+ // Sort by the button label if same priority.
+ if (priorityDelta === 0) {
+ return actionA.label > actionB.label ? 1 : -1;
+ } else {
+ return priorityDelta;
+ }
+ }
+
+ _shouldSkipAction(action: UIActionInfo, config?: ServerInfo) {
+ const skipActionKeys: string[] = [...SKIP_ACTION_KEYS];
+ const isAttentionSetEnabled =
+ !!config && !!config.change && config.change.enable_attention_set;
+ if (isAttentionSetEnabled) {
+ skipActionKeys.push(...SKIP_ACTION_KEYS_ATTENTION_SET);
+ }
+ return skipActionKeys.includes(action.__key);
+ }
+
+ _computeTopLevelActions(
+ actionRecord: PolymerDeepPropertyChange<UIActionInfo[], UIActionInfo[]>,
+ hiddenActionsRecord: PolymerDeepPropertyChange<string[], string[]>
+ ): UIActionInfo[] {
+ const hiddenActions = hiddenActionsRecord.base || [];
+ return actionRecord.base.filter(a => {
+ const overflow = this._getActionOverflowIndex(a.__type, a.__key) !== -1;
+ return !(overflow || hiddenActions.includes(a.__key));
+ });
+ }
+
+ _filterPrimaryActions(_topLevelActions: UIActionInfo[]) {
+ this._topLevelPrimaryActions = _topLevelActions.filter(
+ action => action.__primary
+ );
+ this._topLevelSecondaryActions = _topLevelActions.filter(
+ action => !action.__primary
+ );
+ }
+
+ _computeMenuActions(
+ actionRecord: PolymerDeepPropertyChange<UIActionInfo[], UIActionInfo[]>,
+ hiddenActionsRecord: PolymerDeepPropertyChange<string[], string[]>
+ ): MenuAction[] {
+ const hiddenActions = hiddenActionsRecord.base || [];
+ return actionRecord.base
+ .filter(a => {
+ const overflow = this._getActionOverflowIndex(a.__type, a.__key) !== -1;
+ return overflow && !hiddenActions.includes(a.__key);
+ })
+ .map(action => {
+ let key = action.__key;
+ if (key === '/') {
+ key = 'delete';
+ }
+ return {
+ name: action.label,
+ id: `${key}-${action.__type}`,
+ action,
+ tooltip: action.title,
+ };
+ });
+ }
+
+ _computeRebaseOnCurrent(
+ revisionRebaseAction: PropertyType<GrChangeActions, '_revisionRebaseAction'>
+ ) {
+ if (revisionRebaseAction) {
+ return !!revisionRebaseAction.enabled;
+ }
+ return null;
+ }
+
+ /**
+ * Occasionally, a change created by a change action is not yet known to the
+ * API for a brief time. Wait for the given change number to be recognized.
+ *
+ * Returns a promise that resolves with true if a request is recognized, or
+ * false if the change was never recognized after all attempts.
+ *
+ */
+ _waitForChangeReachable(changeNum: NumericChangeId) {
+ let attempsRemaining = AWAIT_CHANGE_ATTEMPTS;
+ return new Promise(resolve => {
+ const check = () => {
+ attempsRemaining--;
+ // Pass a no-op error handler to avoid the "not found" error toast.
+ this.$.restAPI
+ .getChange(changeNum, () => {})
+ .then(response => {
+ // If the response is 404, the response will be undefined.
+ if (response) {
+ resolve(true);
+ return;
+ }
+
+ if (attempsRemaining) {
+ this.async(check, AWAIT_CHANGE_TIMEOUT_MS);
+ } else {
+ resolve(false);
+ }
+ });
+ };
+ check();
+ });
+ }
+
+ _handleEditTap() {
+ this.dispatchEvent(new CustomEvent('edit-tap', {bubbles: false}));
+ }
+
+ _handleStopEditTap() {
+ this.dispatchEvent(new CustomEvent('stop-edit-tap', {bubbles: false}));
+ }
+
+ _computeHasTooltip(title?: string) {
+ return !!title;
+ }
+
+ _computeHasIcon(action: UIActionInfo) {
+ return action.icon ? '' : 'hidden';
+ }
+}
+
+declare global {
+ interface HTMLElementTagNameMap {
+ 'gr-change-actions': GrChangeActions;
+ }
+}
diff --git a/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions_test.js b/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions_test.js
index 091e5a2..301f176 100644
--- a/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions_test.js
+++ b/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions_test.js
@@ -153,19 +153,17 @@
});
});
- test('plugin change actions', done => {
+ test('plugin change actions', async () => {
sinon.stub(element.$.restAPI, 'getChangeActionURL').returns(
Promise.resolve('the-url'));
element.actions = {
'plugin~action': {},
};
assert.isOk(element.actions['plugin~action']);
- flush(() => {
- assert.isTrue(element.$.restAPI.getChangeActionURL.calledWith(
- element.changeNum, null, '/plugin~action'));
- assert.equal(element.actions['plugin~action'].__url, 'the-url');
- done();
- });
+ await flush();
+ assert.isTrue(element.$.restAPI.getChangeActionURL.calledWith(
+ element.changeNum, undefined, '/plugin~action'));
+ assert.equal(element.actions['plugin~action'].__url, 'the-url');
});
test('not supported actions are filtered out', () => {
@@ -813,6 +811,14 @@
setup(() => {
fireActionStub = sinon.stub(element, '_fireAction');
sinon.stub(window, 'alert');
+ element.actions = {
+ move: {
+ method: 'POST',
+ label: 'Move',
+ title: 'Move the change',
+ enabled: true,
+ },
+ };
});
test('works', () => {
@@ -1614,7 +1620,7 @@
assert.isTrue(fireActionStub.called);
assert.isTrue(fireActionStub.calledWith('/review'));
const payload = fireActionStub.lastCall.args[3];
- assert.deepEqual(payload.labels, {foo: '+1'});
+ assert.deepEqual(payload.labels, {foo: 1});
});
test('not added when multiple labels are required', () => {
@@ -1834,16 +1840,12 @@
'navigateToChange').returns(Promise.resolve(true));
});
- test('change action', done => {
- element
- ._send('DELETE', payload, '/endpoint', false, cleanup)
- .then(() => {
- assert.isFalse(onShowError.called);
- assert.isTrue(cleanup.calledOnce);
- assert.isTrue(sendStub.calledWith(42, 'DELETE', '/endpoint',
- null, payload));
- done();
- });
+ test('change action', async () => {
+ await element._send('DELETE', payload, '/endpoint', false, cleanup);
+ assert.isFalse(onShowError.called);
+ assert.isTrue(cleanup.calledOnce);
+ assert.isTrue(sendStub.calledWith(42, 'DELETE', '/endpoint',
+ undefined, payload));
});
suite('show revert submission dialog', () => {
@@ -1990,6 +1992,13 @@
test('_handleAction reports', () => {
sinon.stub(element, '_fireAction');
+ element.actions = {
+ key: {
+ __key: 'key',
+ __type: 'type',
+ },
+ };
+
const reportStub = sinon.stub(element.reporting, 'reportInteraction');
element._handleAction('type', 'key');
assert.isTrue(reportStub.called);
diff --git a/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata.js b/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata.js
deleted file mode 100644
index b038bf6..0000000
--- a/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata.js
+++ /dev/null
@@ -1,541 +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 '../../../styles/shared-styles.js';
-import '../../../styles/gr-change-metadata-shared-styles.js';
-import '../../../styles/gr-change-view-integration-shared-styles.js';
-import '../../../styles/gr-voting-styles.js';
-import '../../plugins/gr-endpoint-decorator/gr-endpoint-decorator.js';
-import '../../plugins/gr-endpoint-param/gr-endpoint-param.js';
-import '../../plugins/gr-external-style/gr-external-style.js';
-import '../../shared/gr-account-chip/gr-account-chip.js';
-import '../../shared/gr-date-formatter/gr-date-formatter.js';
-import '../../shared/gr-editable-label/gr-editable-label.js';
-import '../../shared/gr-icons/gr-icons.js';
-import '../../shared/gr-limited-text/gr-limited-text.js';
-import '../../shared/gr-linked-chip/gr-linked-chip.js';
-import '../../shared/gr-tooltip-content/gr-tooltip-content.js';
-import '../../shared/gr-rest-api-interface/gr-rest-api-interface.js';
-import '../gr-change-requirements/gr-change-requirements.js';
-import '../gr-commit-info/gr-commit-info.js';
-import '../gr-reviewer-list/gr-reviewer-list.js';
-import '../../shared/gr-account-list/gr-account-list.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-change-metadata_html.js';
-import {GrReviewerSuggestionsProvider, SUGGESTIONS_PROVIDERS_USERS_TYPES} from '../../../scripts/gr-reviewer-suggestions-provider/gr-reviewer-suggestions-provider.js';
-import {GerritNav} from '../../core/gr-navigation/gr-navigation.js';
-import {ChangeStatus} from '../../../constants/constants.js';
-import {changeIsOpen} from '../../../utils/change-util.js';
-
-const HASHTAG_ADD_MESSAGE = 'Add Hashtag';
-
-const SubmitTypeLabel = {
- FAST_FORWARD_ONLY: 'Fast Forward Only',
- MERGE_IF_NECESSARY: 'Merge if Necessary',
- REBASE_IF_NECESSARY: 'Rebase if Necessary',
- MERGE_ALWAYS: 'Always Merge',
- REBASE_ALWAYS: 'Rebase Always',
- CHERRY_PICK: 'Cherry Pick',
-};
-
-const NOT_CURRENT_MESSAGE = 'Not current - rebase possible';
-
-/**
- * @enum {string}
- */
-const CertificateStatus = {
- /**
- * This certificate status is bad.
- */
- BAD: 'BAD',
- /**
- * This certificate status is OK.
- */
- OK: 'OK',
- /**
- * This certificate status is TRUSTED.
- */
- TRUSTED: 'TRUSTED',
-};
-
-/**
- * @extends PolymerElement
- */
-class GrChangeMetadata extends GestureEventListeners(
- LegacyElementMixin(PolymerElement)) {
- static get template() { return htmlTemplate; }
-
- static get is() { return 'gr-change-metadata'; }
- /**
- * Fired when the change topic is changed.
- *
- * @event topic-changed
- */
-
- static get properties() {
- return {
- /** @type {?} */
- change: Object,
- labels: {
- type: Object,
- notify: true,
- },
- account: Object,
- /** @type {?} */
- revision: Object,
- commitInfo: Object,
- _mutable: {
- type: Boolean,
- computed: '_computeIsMutable(account)',
- },
- /** @type {?} */
- serverConfig: Object,
- parentIsCurrent: Boolean,
- _notCurrentMessage: {
- type: String,
- value: NOT_CURRENT_MESSAGE,
- readOnly: true,
- },
- _topicReadOnly: {
- type: Boolean,
- computed: '_computeTopicReadOnly(_mutable, change)',
- },
- _hashtagReadOnly: {
- type: Boolean,
- computed: '_computeHashtagReadOnly(_mutable, change)',
- },
- /**
- * @type {Gerrit.PushCertificateValidation}
- */
- _pushCertificateValidation: {
- type: Object,
- computed: '_computePushCertificateValidation(serverConfig, change)',
- },
- _showRequirements: {
- type: Boolean,
- computed: '_computeShowRequirements(change)',
- },
-
- _assignee: Array,
- _isWip: {
- type: Boolean,
- computed: '_computeIsWip(change)',
- },
- _newHashtag: String,
-
- _settingTopic: {
- type: Boolean,
- value: false,
- },
-
- _currentParents: {
- type: Array,
- computed: '_computeParents(change, revision)',
- },
-
- /** @type {?} */
- _CHANGE_ROLE: {
- type: Object,
- readOnly: true,
- value: {
- OWNER: 'owner',
- UPLOADER: 'uploader',
- AUTHOR: 'author',
- COMMITTER: 'committer',
- },
- },
- };
- }
-
- static get observers() {
- return [
- '_changeChanged(change)',
- '_labelsChanged(change.labels)',
- '_assigneeChanged(_assignee.*)',
- ];
- }
-
- _labelsChanged(labels) {
- this.labels = ({...labels}) || null;
- }
-
- _changeChanged(change) {
- this._assignee = change.assignee ? [change.assignee] : [];
- this._settingTopic = false;
- }
-
- _assigneeChanged(assigneeRecord) {
- if (!this.change || !this._isAssigneeEnabled(this.serverConfig)) {
- return;
- }
- const assignee = assigneeRecord.base;
- if (assignee.length) {
- const acct = assignee[0];
- if (this.change.assignee &&
- acct._account_id === this.change.assignee._account_id) { return; }
- this.set(['change', 'assignee'], acct);
- this.$.restAPI.setAssignee(this.change._number, acct._account_id);
- } else {
- if (!this.change.assignee) { return; }
- this.set(['change', 'assignee'], undefined);
- this.$.restAPI.deleteAssignee(this.change._number);
- }
- }
-
- _computeHideStrategy(change) {
- return !changeIsOpen(change);
- }
-
- /**
- * @param {Object} commitInfo
- * @return {?Array} If array is empty, returns null instead so
- * an existential check can be used to hide or show the webLinks
- * section.
- */
- _computeWebLinks(commitInfo, serverConfig) {
- if (!commitInfo) { return null; }
- const weblinks = GerritNav.getChangeWeblinks(
- this.change ? this.change.repo : '',
- commitInfo.commit,
- {
- weblinks: commitInfo.web_links,
- config: serverConfig,
- });
- return weblinks.length ? weblinks : null;
- }
-
- _isAssigneeEnabled(serverConfig) {
- return serverConfig && serverConfig.change
- && !!serverConfig.change.enable_assignee;
- }
-
- _computeStrategy(change) {
- return SubmitTypeLabel[change.submit_type];
- }
-
- _computeLabelNames(labels) {
- return Object.keys(labels).sort();
- }
-
- _handleTopicChanged(e, topic) {
- const lastTopic = this.change.topic;
- if (!topic.length) { topic = null; }
- this._settingTopic = true;
- const topicChangedForChangeNumber = this.change._number;
- this.$.restAPI.setChangeTopic(topicChangedForChangeNumber, topic)
- .then(newTopic => {
- if (this.change._number !== topicChangedForChangeNumber) {
- return;
- }
- this._settingTopic = false;
- this.set(['change', 'topic'], newTopic);
- if (newTopic !== lastTopic) {
- this.dispatchEvent(new CustomEvent(
- 'topic-changed', {bubbles: true, composed: true}));
- }
- });
- }
-
- _showAddTopic(changeRecord, settingTopic) {
- const hasTopic = !!changeRecord &&
- !!changeRecord.base && !!changeRecord.base.topic;
- return !hasTopic && !settingTopic;
- }
-
- _showTopicChip(changeRecord, settingTopic) {
- const hasTopic = !!changeRecord &&
- !!changeRecord.base && !!changeRecord.base.topic;
- return hasTopic && !settingTopic;
- }
-
- _showCherryPickOf(changeRecord) {
- const hasCherryPickOf = !!changeRecord &&
- !!changeRecord.base && !!changeRecord.base.cherry_pick_of_change &&
- !!changeRecord.base.cherry_pick_of_patch_set;
- return hasCherryPickOf;
- }
-
- _handleHashtagChanged(e) {
- const lastHashtag = this.change.hashtag;
- if (!this._newHashtag.length) { return; }
- const newHashtag = this._newHashtag;
- this._newHashtag = '';
- this.$.restAPI.setChangeHashtag(
- this.change._number, {add: [newHashtag]}).then(newHashtag => {
- this.set(['change', 'hashtags'], newHashtag);
- if (newHashtag !== lastHashtag) {
- this.dispatchEvent(
- new CustomEvent('hashtag-changed', {
- bubbles: true, composed: true}));
- }
- });
- }
-
- _computeTopicReadOnly(mutable, change) {
- return !mutable ||
- !change ||
- !change.actions ||
- !change.actions.topic ||
- !change.actions.topic.enabled;
- }
-
- _computeHashtagReadOnly(mutable, change) {
- return !mutable ||
- !change ||
- !change.actions ||
- !change.actions.hashtags ||
- !change.actions.hashtags.enabled;
- }
-
- _computeAssigneeReadOnly(mutable, change) {
- return !mutable ||
- !change ||
- !change.actions ||
- !change.actions.assignee ||
- !change.actions.assignee.enabled;
- }
-
- _computeTopicPlaceholder(_topicReadOnly) {
- // Action items in Material Design are uppercase -- placeholder label text
- // is sentence case.
- return _topicReadOnly ? 'No topic' : 'ADD TOPIC';
- }
-
- _computeHashtagPlaceholder(_hashtagReadOnly) {
- return _hashtagReadOnly ? '' : HASHTAG_ADD_MESSAGE;
- }
-
- _computeShowRequirements(change) {
- if (change.status !== ChangeStatus.NEW) {
- // TODO(maximeg) change this to display the stored
- // requirements, once it is implemented server-side.
- return false;
- }
- const hasRequirements = !!change.requirements &&
- Object.keys(change.requirements).length > 0;
- const hasLabels = !!change.labels &&
- Object.keys(change.labels).length > 0;
- return hasRequirements || hasLabels || !!change.work_in_progress;
- }
-
- /**
- * @return {?Gerrit.PushCertificateValidation} object representing data for
- * the push validation.
- */
- _computePushCertificateValidation(serverConfig, change) {
- if (!change || !serverConfig || !serverConfig.receive ||
- !serverConfig.receive.enable_signed_push) {
- return null;
- }
- const rev = change.revisions[change.current_revision];
- if (!rev.push_certificate || !rev.push_certificate.key) {
- return {
- class: 'help',
- icon: 'gr-icons:help',
- message: 'This patch set was created without a push certificate',
- };
- }
-
- const key = rev.push_certificate.key;
- switch (key.status) {
- case CertificateStatus.BAD:
- return {
- class: 'invalid',
- icon: 'gr-icons:close',
- message: this._problems('Push certificate is invalid', key),
- };
- case CertificateStatus.OK:
- return {
- class: 'notTrusted',
- icon: 'gr-icons:info',
- message: this._problems(
- 'Push certificate is valid, but key is not trusted', key),
- };
- case CertificateStatus.TRUSTED:
- return {
- class: 'trusted',
- icon: 'gr-icons:check',
- message: this._problems(
- 'Push certificate is valid and key is trusted', key),
- };
- default:
- throw new Error(`unknown certificate status: ${key.status}`);
- }
- }
-
- _problems(msg, key) {
- if (!key || !key.problems || key.problems.length === 0) {
- return msg;
- }
-
- return [msg + ':'].concat(key.problems).join('\n');
- }
-
- _computeShowRepoBranchTogether(repo, branch) {
- return !!repo && !!branch && repo.length + branch.length < 40;
- }
-
- _computeProjectUrl(project) {
- return GerritNav.getUrlForProjectChanges(project);
- }
-
- _computeBranchUrl(project, branch) {
- if (!this.change || !this.change.status) return '';
- return GerritNav.getUrlForBranch(branch, project,
- this.change.status == ChangeStatus.NEW ? 'open' :
- this.change.status.toLowerCase());
- }
-
- _computeCherryPickOfUrl(change, patchset, project) {
- return GerritNav.getUrlForChangeById(change, project, patchset);
- }
-
- _computeTopicUrl(topic) {
- return GerritNav.getUrlForTopic(topic);
- }
-
- _computeHashtagUrl(hashtag) {
- return GerritNav.getUrlForHashtag(hashtag);
- }
-
- _handleTopicRemoved(e) {
- const target = dom(e).rootTarget;
- target.disabled = true;
- this.$.restAPI.setChangeTopic(this.change._number, null)
- .then(() => {
- target.disabled = false;
- this.set(['change', 'topic'], '');
- this.dispatchEvent(
- new CustomEvent('topic-changed',
- {bubbles: true, composed: true}));
- })
- .catch(err => {
- target.disabled = false;
- return;
- });
- }
-
- _handleHashtagRemoved(e) {
- e.preventDefault();
- const target = dom(e).rootTarget;
- target.disabled = true;
- this.$.restAPI.setChangeHashtag(this.change._number,
- {remove: [target.text]})
- .then(newHashtag => {
- target.disabled = false;
- this.set(['change', 'hashtags'], newHashtag);
- })
- .catch(err => {
- target.disabled = false;
- return;
- });
- }
-
- _computeIsWip(change) {
- return !!change.work_in_progress;
- }
-
- _computeShowRoleClass(change, role) {
- return this._getNonOwnerRole(change, role) ? '' : 'hideDisplay';
- }
-
- /**
- * Get the user with the specified role on the change. Returns null if the
- * user with that role is the same as the owner.
- *
- * @param {!Object} change
- * @param {string} role One of the values from _CHANGE_ROLE
- * @return {Object|null} either an account or null.
- */
- _getNonOwnerRole(change, role) {
- if (!change || !change.current_revision ||
- !change.revisions[change.current_revision]) {
- return null;
- }
-
- const rev = change.revisions[change.current_revision];
- if (!rev) { return null; }
-
- if (role === this._CHANGE_ROLE.UPLOADER &&
- rev.uploader &&
- change.owner._account_id !== rev.uploader._account_id) {
- return rev.uploader;
- }
-
- if (role === this._CHANGE_ROLE.AUTHOR &&
- rev.commit && rev.commit.author &&
- change.owner.email !== rev.commit.author.email) {
- return rev.commit.author;
- }
-
- if (role === this._CHANGE_ROLE.COMMITTER &&
- rev.commit && rev.commit.committer &&
- change.owner.email !== rev.commit.committer.email) {
- return rev.commit.committer;
- }
-
- return null;
- }
-
- _computeParents(change, revision) {
- if (!revision || !revision.commit) {
- if (!change || !change.current_revision) { return []; }
- revision = change.revisions[change.current_revision];
- if (!revision || !revision.commit) { return []; }
- }
- return revision.commit.parents;
- }
-
- _computeParentsLabel(parents) {
- return parents && parents.length > 1 ? 'Parents' : 'Parent';
- }
-
- _computeParentListClass(parents, parentIsCurrent) {
- // Undefined check for polymer 2
- if (parents === undefined || parentIsCurrent === undefined) {
- return '';
- }
-
- return [
- 'parentList',
- parents && parents.length > 1 ? 'merge' : 'nonMerge',
- parentIsCurrent ? 'current' : 'notCurrent',
- ].join(' ');
- }
-
- _computeIsMutable(account) {
- return !!Object.keys(account).length;
- }
-
- editTopic() {
- if (this._topicReadOnly || this.change.topic) { return; }
- // Cannot use `this.$.ID` syntax because the element exists inside of a
- // dom-if.
- this.shadowRoot.querySelector('.topicEditableLabel').open();
- }
-
- _getReviewerSuggestionsProvider(change) {
- const provider = GrReviewerSuggestionsProvider.create(this.$.restAPI,
- change._number, SUGGESTIONS_PROVIDERS_USERS_TYPES.ANY);
- provider.init();
- return provider;
- }
-}
-
-customElements.define(GrChangeMetadata.is, GrChangeMetadata);
diff --git a/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata.ts b/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata.ts
new file mode 100644
index 0000000..539697e
--- /dev/null
+++ b/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata.ts
@@ -0,0 +1,682 @@
+/**
+ * @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 '../../../styles/gr-change-metadata-shared-styles';
+import '../../../styles/gr-change-view-integration-shared-styles';
+import '../../../styles/gr-voting-styles';
+import '../../plugins/gr-endpoint-decorator/gr-endpoint-decorator';
+import '../../plugins/gr-endpoint-param/gr-endpoint-param';
+import '../../plugins/gr-external-style/gr-external-style';
+import '../../shared/gr-account-chip/gr-account-chip';
+import '../../shared/gr-date-formatter/gr-date-formatter';
+import '../../shared/gr-editable-label/gr-editable-label';
+import '../../shared/gr-icons/gr-icons';
+import '../../shared/gr-limited-text/gr-limited-text';
+import '../../shared/gr-linked-chip/gr-linked-chip';
+import '../../shared/gr-tooltip-content/gr-tooltip-content';
+import '../../shared/gr-rest-api-interface/gr-rest-api-interface';
+import '../gr-change-requirements/gr-change-requirements';
+import '../gr-commit-info/gr-commit-info';
+import '../gr-reviewer-list/gr-reviewer-list';
+import '../../shared/gr-account-list/gr-account-list';
+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-change-metadata_html';
+import {
+ GrReviewerSuggestionsProvider,
+ SUGGESTIONS_PROVIDERS_USERS_TYPES,
+} from '../../../scripts/gr-reviewer-suggestions-provider/gr-reviewer-suggestions-provider';
+import {GerritNav} from '../../core/gr-navigation/gr-navigation';
+import {
+ ChangeStatus,
+ GpgKeyInfoStatus,
+ SubmitType,
+} from '../../../constants/constants';
+import {changeIsOpen} from '../../../utils/change-util';
+import {customElement, property, observe} from '@polymer/decorators';
+import {ParsedChangeInfo} from '../../shared/gr-rest-api-interface/gr-reviewer-updates-parser';
+import {
+ AccountDetailInfo,
+ AccountInfo,
+ BranchName,
+ CommitId,
+ CommitInfo,
+ ElementPropertyDeepChange,
+ GpgKeyInfo,
+ Hashtag,
+ LabelNameToInfoMap,
+ NumericChangeId,
+ ParentCommitInfo,
+ PatchSetNum,
+ RepoName,
+ RevisionInfo,
+ ServerInfo,
+ TopicName,
+} from '../../../types/common';
+import {assertNever} from '../../../utils/common-util';
+import {RestApiService} from '../../../services/services/gr-rest-api/gr-rest-api';
+import {GrEditableLabel} from '../../shared/gr-editable-label/gr-editable-label';
+import {GrLinkedChip} from '../../shared/gr-linked-chip/gr-linked-chip';
+
+const HASHTAG_ADD_MESSAGE = 'Add Hashtag';
+
+enum ChangeRole {
+ OWNER = 'owner',
+ UPLOADER = 'uploader',
+ AUTHOR = 'author',
+ COMMITTER = 'committer',
+}
+
+export interface CommitInfoWithRequiredCommit extends CommitInfo {
+ // gr-change-view always assigns commit to CommitInfo
+ commit: CommitId;
+}
+
+const SubmitTypeLabel = new Map<SubmitType, string>([
+ [SubmitType.FAST_FORWARD_ONLY, 'Fast Forward Only'],
+ [SubmitType.MERGE_IF_NECESSARY, 'Merge if Necessary'],
+ [SubmitType.REBASE_IF_NECESSARY, 'Rebase if Necessary'],
+ [SubmitType.MERGE_ALWAYS, 'Always Merge'],
+ [SubmitType.REBASE_ALWAYS, 'Rebase Always'],
+ [SubmitType.CHERRY_PICK, 'Cherry Pick'],
+]);
+
+const NOT_CURRENT_MESSAGE = 'Not current - rebase possible';
+
+interface PushCertifacteValidationInfo {
+ class: string;
+ icon: string;
+ message: string;
+}
+
+export interface GrChangeMetadata {
+ $: {
+ restAPI: RestApiService & Element;
+ };
+}
+
+@customElement('gr-change-metadata')
+export class GrChangeMetadata extends GestureEventListeners(
+ LegacyElementMixin(PolymerElement)
+) {
+ static get template() {
+ return htmlTemplate;
+ }
+
+ /**
+ * Fired when the change topic is changed.
+ *
+ * @event topic-changed
+ */
+
+ @property({type: Object})
+ change?: ParsedChangeInfo;
+
+ @property({type: Object, notify: true})
+ labels?: LabelNameToInfoMap;
+
+ @property({type: Object})
+ account?: AccountDetailInfo;
+
+ @property({type: Object})
+ revision?: RevisionInfo;
+
+ @property({type: Object})
+ commitInfo?: CommitInfoWithRequiredCommit;
+
+ @property({type: Boolean, computed: '_computeIsMutable(account)'})
+ _mutable = false;
+
+ @property({type: Object})
+ serverConfig?: ServerInfo;
+
+ @property({type: Boolean})
+ parentIsCurrent?: boolean;
+
+ @property({type: String, readOnly: true})
+ _notCurrentMessage = NOT_CURRENT_MESSAGE;
+
+ @property({
+ type: Boolean,
+ computed: '_computeTopicReadOnly(_mutable, change)',
+ })
+ _topicReadOnly = true;
+
+ @property({
+ type: Boolean,
+ computed: '_computeHashtagReadOnly(_mutable, change)',
+ })
+ _hashtagReadOnly = true;
+
+ @property({
+ type: Object,
+ computed: '_computePushCertificateValidation(serverConfig, change)',
+ })
+ _pushCertificateValidation: PushCertifacteValidationInfo | null = null;
+
+ @property({type: Boolean, computed: '_computeShowRequirements(change)'})
+ _showRequirements = false;
+
+ @property({type: Array})
+ _assignee?: AccountInfo[];
+
+ @property({type: Boolean, computed: '_computeIsWip(change)'})
+ _isWip = false;
+
+ @property({type: String})
+ _newHashtag?: Hashtag;
+
+ @property({type: Boolean})
+ _settingTopic = false;
+
+ @property({type: Array, computed: '_computeParents(change, revision)'})
+ _currentParents: ParentCommitInfo[] = [];
+
+ @property({type: Object})
+ _CHANGE_ROLE = ChangeRole;
+
+ @observe('change.labels')
+ _labelsChanged(labels?: LabelNameToInfoMap) {
+ this.labels = {...labels} || null;
+ }
+
+ @observe('change')
+ _changeChanged(change?: ParsedChangeInfo) {
+ this._assignee = change?.assignee ? [change.assignee] : [];
+ this._settingTopic = false;
+ }
+
+ @observe('_assignee.*')
+ _assigneeChanged(
+ assigneeRecord: ElementPropertyDeepChange<GrChangeMetadata, '_assignee'>
+ ) {
+ if (!this.change || !this._isAssigneeEnabled(this.serverConfig)) {
+ return;
+ }
+ const assignee = assigneeRecord.base;
+ if (assignee?.length) {
+ const acct = assignee[0];
+ if (
+ !acct._account_id ||
+ (this.change.assignee &&
+ acct._account_id === this.change.assignee._account_id)
+ ) {
+ return;
+ }
+ this.set(['change', 'assignee'], acct);
+ this.$.restAPI.setAssignee(this.change._number, acct._account_id);
+ } else {
+ if (!this.change.assignee) {
+ return;
+ }
+ this.set(['change', 'assignee'], undefined);
+ this.$.restAPI.deleteAssignee(this.change._number);
+ }
+ }
+
+ _computeHideStrategy(change?: ParsedChangeInfo) {
+ return !changeIsOpen(change);
+ }
+
+ /**
+ * @return If array is empty, returns null instead so
+ * an existential check can be used to hide or show the webLinks
+ * section.
+ */
+ _computeWebLinks(
+ commitInfo?: CommitInfoWithRequiredCommit,
+ serverConfig?: ServerInfo
+ ) {
+ if (!commitInfo) {
+ return null;
+ }
+ const weblinks = GerritNav.getChangeWeblinks(
+ this.change ? this.change.project : ('' as RepoName),
+ commitInfo.commit,
+ {
+ weblinks: commitInfo.web_links,
+ config: serverConfig,
+ }
+ );
+ return weblinks.length ? weblinks : null;
+ }
+
+ _isAssigneeEnabled(serverConfig?: ServerInfo) {
+ return (
+ serverConfig &&
+ serverConfig.change &&
+ !!serverConfig.change.enable_assignee
+ );
+ }
+
+ _computeStrategy(change?: ParsedChangeInfo) {
+ if (!change?.submit_type) {
+ return '';
+ }
+
+ return SubmitTypeLabel.get(change.submit_type);
+ }
+
+ _computeLabelNames(labels?: LabelNameToInfoMap) {
+ return labels ? Object.keys(labels).sort() : [];
+ }
+
+ _handleTopicChanged(e: CustomEvent<string>) {
+ if (!this.change) {
+ throw new Error('change must be set');
+ }
+ const lastTopic = this.change.topic;
+ const topic = e.detail.length ? e.detail : null;
+ this._settingTopic = true;
+ const topicChangedForChangeNumber = this.change._number;
+ this.$.restAPI
+ .setChangeTopic(topicChangedForChangeNumber, topic)
+ .then(newTopic => {
+ if (
+ !this.change ||
+ this.change._number !== topicChangedForChangeNumber
+ ) {
+ return;
+ }
+ this._settingTopic = false;
+ this.set(['change', 'topic'], newTopic);
+ if (newTopic !== lastTopic) {
+ this.dispatchEvent(
+ new CustomEvent('topic-changed', {bubbles: true, composed: true})
+ );
+ }
+ });
+ }
+
+ _showAddTopic(
+ changeRecord: ElementPropertyDeepChange<GrChangeMetadata, 'change'>,
+ settingTopic?: boolean
+ ) {
+ const hasTopic =
+ !!changeRecord && !!changeRecord.base && !!changeRecord.base.topic;
+ return !hasTopic && !settingTopic;
+ }
+
+ _showTopicChip(
+ changeRecord: ElementPropertyDeepChange<GrChangeMetadata, 'change'>,
+ settingTopic?: boolean
+ ) {
+ const hasTopic =
+ !!changeRecord && !!changeRecord.base && !!changeRecord.base.topic;
+ return hasTopic && !settingTopic;
+ }
+
+ _showCherryPickOf(
+ changeRecord: ElementPropertyDeepChange<GrChangeMetadata, 'change'>
+ ) {
+ const hasCherryPickOf =
+ !!changeRecord &&
+ !!changeRecord.base &&
+ !!changeRecord.base.cherry_pick_of_change &&
+ !!changeRecord.base.cherry_pick_of_patch_set;
+ return hasCherryPickOf;
+ }
+
+ _handleHashtagChanged() {
+ if (!this.change) {
+ throw new Error('change must be set');
+ }
+ if (!this._newHashtag?.length) {
+ return;
+ }
+ const newHashtag = this._newHashtag;
+ this._newHashtag = '' as Hashtag;
+ this.$.restAPI
+ .setChangeHashtag(this.change._number, {add: [newHashtag]})
+ .then(newHashtag => {
+ this.set(['change', 'hashtags'], newHashtag);
+ this.dispatchEvent(
+ new CustomEvent('hashtag-changed', {
+ bubbles: true,
+ composed: true,
+ })
+ );
+ });
+ }
+
+ _computeTopicReadOnly(mutable?: boolean, change?: ParsedChangeInfo) {
+ return (
+ !mutable ||
+ !change ||
+ !change.actions ||
+ !change.actions.topic ||
+ !change.actions.topic.enabled
+ );
+ }
+
+ _computeHashtagReadOnly(mutable?: boolean, change?: ParsedChangeInfo) {
+ return (
+ !mutable ||
+ !change ||
+ !change.actions ||
+ !change.actions.hashtags ||
+ !change.actions.hashtags.enabled
+ );
+ }
+
+ _computeAssigneeReadOnly(mutable?: boolean, change?: ParsedChangeInfo) {
+ return (
+ !mutable ||
+ !change ||
+ !change.actions ||
+ !change.actions.assignee ||
+ !change.actions.assignee.enabled
+ );
+ }
+
+ _computeTopicPlaceholder(_topicReadOnly?: boolean) {
+ // Action items in Material Design are uppercase -- placeholder label text
+ // is sentence case.
+ return _topicReadOnly ? 'No topic' : 'ADD TOPIC';
+ }
+
+ _computeHashtagPlaceholder(_hashtagReadOnly?: boolean) {
+ return _hashtagReadOnly ? '' : HASHTAG_ADD_MESSAGE;
+ }
+
+ _computeShowRequirements(change?: ParsedChangeInfo) {
+ if (!change) {
+ return false;
+ }
+ if (change.status !== ChangeStatus.NEW) {
+ // TODO(maximeg) change this to display the stored
+ // requirements, once it is implemented server-side.
+ return false;
+ }
+ const hasRequirements =
+ !!change.requirements && Object.keys(change.requirements).length > 0;
+ const hasLabels = !!change.labels && Object.keys(change.labels).length > 0;
+ return hasRequirements || hasLabels || !!change.work_in_progress;
+ }
+
+ /**
+ * @return object representing data for the push validation.
+ */
+ _computePushCertificateValidation(
+ serverConfig?: ServerInfo,
+ change?: ParsedChangeInfo
+ ): PushCertifacteValidationInfo | null {
+ if (
+ !change ||
+ !serverConfig ||
+ !serverConfig.receive ||
+ !serverConfig.receive.enable_signed_push
+ ) {
+ return null;
+ }
+ const rev = change.revisions[change.current_revision];
+ if (!rev.push_certificate || !rev.push_certificate.key) {
+ return {
+ class: 'help',
+ icon: 'gr-icons:help',
+ message: 'This patch set was created without a push certificate',
+ };
+ }
+
+ const key = rev.push_certificate.key;
+ switch (key.status) {
+ case GpgKeyInfoStatus.BAD:
+ return {
+ class: 'invalid',
+ icon: 'gr-icons:close',
+ message: this._problems('Push certificate is invalid', key),
+ };
+ case GpgKeyInfoStatus.OK:
+ return {
+ class: 'notTrusted',
+ icon: 'gr-icons:info',
+ message: this._problems(
+ 'Push certificate is valid, but key is not trusted',
+ key
+ ),
+ };
+ case GpgKeyInfoStatus.TRUSTED:
+ return {
+ class: 'trusted',
+ icon: 'gr-icons:check',
+ message: this._problems(
+ 'Push certificate is valid and key is trusted',
+ key
+ ),
+ };
+ case undefined:
+ // TODO(TS): Process it correctly
+ throw new Error('deleted certificate');
+ default:
+ assertNever(key.status, `unknown certificate status: ${key.status}`);
+ }
+ }
+
+ _problems(msg: string, key: GpgKeyInfo) {
+ if (!key || !key.problems || key.problems.length === 0) {
+ return msg;
+ }
+
+ return [msg + ':'].concat(key.problems).join('\n');
+ }
+
+ _computeShowRepoBranchTogether(repo?: RepoName, branch?: BranchName) {
+ return !!repo && !!branch && repo.length + branch.length < 40;
+ }
+
+ _computeProjectUrl(project?: RepoName) {
+ if (!project) return '';
+ return GerritNav.getUrlForProjectChanges(project);
+ }
+
+ _computeBranchUrl(project?: RepoName, branch?: BranchName) {
+ if (!project || !branch || !this.change || !this.change.status) return '';
+ return GerritNav.getUrlForBranch(
+ branch,
+ project,
+ this.change.status === ChangeStatus.NEW
+ ? 'open'
+ : this.change.status.toLowerCase()
+ );
+ }
+
+ _computeCherryPickOfUrl(
+ change?: NumericChangeId,
+ patchset?: PatchSetNum,
+ project?: RepoName
+ ) {
+ if (!change || !project) {
+ return '';
+ }
+ return GerritNav.getUrlForChangeById(change, project, patchset);
+ }
+
+ _computeTopicUrl(topic: TopicName) {
+ return GerritNav.getUrlForTopic(topic);
+ }
+
+ _computeHashtagUrl(hashtag: Hashtag) {
+ return GerritNav.getUrlForHashtag(hashtag);
+ }
+
+ _handleTopicRemoved(e: CustomEvent) {
+ if (!this.change) {
+ throw new Error('change must be set');
+ }
+ const target = (dom(e) as EventApi).rootTarget as GrLinkedChip;
+ target.disabled = true;
+ this.$.restAPI
+ .setChangeTopic(this.change._number, null)
+ .then(() => {
+ target.disabled = false;
+ this.set(['change', 'topic'], '');
+ this.dispatchEvent(
+ new CustomEvent('topic-changed', {bubbles: true, composed: true})
+ );
+ })
+ .catch(() => {
+ target.disabled = false;
+ return;
+ });
+ }
+
+ _handleHashtagRemoved(e: CustomEvent) {
+ e.preventDefault();
+ if (!this.change) {
+ throw new Error('change must be set');
+ }
+ const target = (dom(e) as EventApi).rootTarget as GrLinkedChip;
+ target.disabled = true;
+ this.$.restAPI
+ .setChangeHashtag(this.change._number, {remove: [target.text as Hashtag]})
+ .then(newHashtags => {
+ target.disabled = false;
+ this.set(['change', 'hashtags'], newHashtags);
+ })
+ .catch(() => {
+ target.disabled = false;
+ return;
+ });
+ }
+
+ _computeIsWip(change?: ParsedChangeInfo) {
+ return change && !!change.work_in_progress;
+ }
+
+ _computeShowRoleClass(change?: ParsedChangeInfo, role?: ChangeRole) {
+ return this._getNonOwnerRole(change, role) ? '' : 'hideDisplay';
+ }
+
+ /**
+ * Get the user with the specified role on the change. Returns null if the
+ * user with that role is the same as the owner.
+ */
+ _getNonOwnerRole(change?: ParsedChangeInfo, role?: ChangeRole) {
+ if (
+ !change ||
+ !change.current_revision ||
+ !change.revisions[change.current_revision]
+ ) {
+ return null;
+ }
+
+ const rev = change.revisions[change.current_revision];
+ if (!rev) {
+ return null;
+ }
+
+ if (
+ role === ChangeRole.UPLOADER &&
+ rev.uploader &&
+ change.owner._account_id !== rev.uploader._account_id
+ ) {
+ return rev.uploader;
+ }
+
+ if (
+ role === ChangeRole.AUTHOR &&
+ rev.commit &&
+ rev.commit.author &&
+ change.owner.email !== rev.commit.author.email
+ ) {
+ return rev.commit.author;
+ }
+
+ if (
+ role === ChangeRole.COMMITTER &&
+ rev.commit &&
+ rev.commit.committer &&
+ change.owner.email !== rev.commit.committer.email
+ ) {
+ return rev.commit.committer;
+ }
+
+ return null;
+ }
+
+ _computeParents(
+ change?: ParsedChangeInfo,
+ revision?: RevisionInfo
+ ): ParentCommitInfo[] {
+ if (!revision || !revision.commit) {
+ if (!change || !change.current_revision) {
+ return [];
+ }
+ revision = change.revisions[change.current_revision];
+ if (!revision || !revision.commit) {
+ return [];
+ }
+ }
+ return revision.commit.parents;
+ }
+
+ _computeParentsLabel(parents?: ParentCommitInfo[]) {
+ return parents && parents.length > 1 ? 'Parents' : 'Parent';
+ }
+
+ _computeParentListClass(
+ parents?: ParentCommitInfo[],
+ parentIsCurrent?: boolean
+ ) {
+ // Undefined check for polymer 2
+ if (parents === undefined || parentIsCurrent === undefined) {
+ return '';
+ }
+
+ return [
+ 'parentList',
+ parents && parents.length > 1 ? 'merge' : 'nonMerge',
+ parentIsCurrent ? 'current' : 'notCurrent',
+ ].join(' ');
+ }
+
+ _computeIsMutable(account?: AccountDetailInfo) {
+ return account && !!Object.keys(account).length;
+ }
+
+ editTopic() {
+ if (this._topicReadOnly || !this.change || this.change.topic) {
+ return;
+ }
+ // Cannot use `this.$.ID` syntax because the element exists inside of a
+ // dom-if.
+ (this.shadowRoot!.querySelector(
+ '.topicEditableLabel'
+ ) as GrEditableLabel).open();
+ }
+
+ _getReviewerSuggestionsProvider(change?: ParsedChangeInfo) {
+ if (!change) {
+ return undefined;
+ }
+ const provider = GrReviewerSuggestionsProvider.create(
+ this.$.restAPI,
+ change._number,
+ SUGGESTIONS_PROVIDERS_USERS_TYPES.ANY
+ );
+ provider.init();
+ return provider;
+ }
+}
+
+declare global {
+ interface HTMLElementTagNameMap {
+ 'gr-change-metadata': GrChangeMetadata;
+ }
+}
diff --git a/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata_test.js b/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata_test.js
index bdcd480..5f53641 100644
--- a/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata_test.js
+++ b/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata_test.js
@@ -686,7 +686,7 @@
const newTopic = 'the new topic';
sinon.stub(element.$.restAPI, 'setChangeTopic').returns(
Promise.resolve(newTopic));
- element._handleTopicChanged({}, newTopic);
+ element._handleTopicChanged({detail: newTopic});
const topicChangedSpy = sinon.spy();
element.addEventListener('topic-changed', topicChangedSpy);
assert.isTrue(element.$.restAPI.setChangeTopic.calledWith(
diff --git a/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view.js b/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view.js
index e317cf9..20fe1a6 100644
--- a/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view.js
+++ b/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view.js
@@ -37,7 +37,6 @@
import '../gr-commit-info/gr-commit-info.js';
import '../gr-download-dialog/gr-download-dialog.js';
import '../gr-file-list-header/gr-file-list-header.js';
-import '../gr-file-list/gr-file-list.js';
import '../gr-included-in-dialog/gr-included-in-dialog.js';
import '../gr-messages-list/gr-messages-list.js';
import '../gr-related-changes-list/gr-related-changes-list.js';
@@ -73,6 +72,7 @@
} from '../../../utils/patch-set-util.js';
import {changeStatuses, changeStatusString} from '../../../utils/change-util.js';
import {EventType} from '../../plugins/gr-plugin-types.js';
+import {DEFAULT_NUM_FILES_SHOWN} from '../gr-file-list/gr-file-list.js';
const CHANGE_ID_ERROR = {
MISMATCH: 'mismatch',
@@ -82,7 +82,6 @@
/^(Change-Id\:\s|Link:.*\/id\/)(I[0-9a-f]{8,40})/gm;
const MIN_LINES_FOR_COMMIT_COLLAPSE = 30;
-const DEFAULT_NUM_FILES_SHOWN = 200;
const REVIEWERS_REGEX = /^(R|CC)=/gm;
const MIN_CHECK_INTERVAL_SECS = 0;
@@ -1058,6 +1057,13 @@
patchNum: value.patchNum,
basePatchNum: value.basePatchNum || 'PARENT',
};
+ // TODO(TS): remove once proper type for patchRange is defined
+ if (!isNaN(Number(patchRange.patchNum))) {
+ patchRange.patchNum = Number(patchRange.patchNum);
+ }
+ if (!isNaN(Number(patchRange.basePatchNum))) {
+ patchRange.basePatchNum = Number(patchRange.basePatchNum);
+ }
this.$.fileList.collapseAllDiffs();
this._patchRange = patchRange;
diff --git a/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view_html.ts b/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view_html.ts
index f0b8372..6af8960 100644
--- a/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view_html.ts
+++ b/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view_html.ts
@@ -637,6 +637,7 @@
logged-in="[[_loggedIn]]"
only-show-robot-comments-with-human-reply=""
on-thread-list-modified="_handleReloadDiffComments"
+ unresolved-only
></gr-thread-list>
</template>
<template
diff --git a/polygerrit-ui/app/elements/change/gr-confirm-cherrypick-dialog/gr-confirm-cherrypick-dialog.ts b/polygerrit-ui/app/elements/change/gr-confirm-cherrypick-dialog/gr-confirm-cherrypick-dialog.ts
index ae9254a..e05bac0 100644
--- a/polygerrit-ui/app/elements/change/gr-confirm-cherrypick-dialog/gr-confirm-cherrypick-dialog.ts
+++ b/polygerrit-ui/app/elements/change/gr-confirm-cherrypick-dialog/gr-confirm-cherrypick-dialog.ts
@@ -31,6 +31,7 @@
BranchInfo,
RepoName,
BranchName,
+ CommitId,
} from '../../../types/common';
import {ReportingService} from '../../../services/gr-reporting/gr-reporting';
import {customElement, property, observe} from '@polymer/decorators';
@@ -105,7 +106,7 @@
commitMessage?: string;
@property({type: String})
- commitNum?: string;
+ commitNum?: CommitId;
@property({type: String})
message?: string;
diff --git a/polygerrit-ui/app/elements/change/gr-confirm-rebase-dialog/gr-confirm-rebase-dialog.ts b/polygerrit-ui/app/elements/change/gr-confirm-rebase-dialog/gr-confirm-rebase-dialog.ts
index 00b9906..db0e1ff 100644
--- a/polygerrit-ui/app/elements/change/gr-confirm-rebase-dialog/gr-confirm-rebase-dialog.ts
+++ b/polygerrit-ui/app/elements/change/gr-confirm-rebase-dialog/gr-confirm-rebase-dialog.ts
@@ -37,6 +37,10 @@
value: NumericChangeId;
}
+export interface ConfirmRebaseEventDetail {
+ base: string | null;
+}
+
export interface GrConfirmRebaseDialog {
$: {
restAPI: RestApiService & Element;
@@ -187,9 +191,10 @@
_handleConfirmTap(e: Event) {
e.preventDefault();
e.stopPropagation();
- this.dispatchEvent(
- new CustomEvent('confirm', {detail: {base: this._getSelectedBase()}})
- );
+ const detail: ConfirmRebaseEventDetail = {
+ base: this._getSelectedBase(),
+ };
+ this.dispatchEvent(new CustomEvent('confirm', {detail}));
this._text = '';
}
diff --git a/polygerrit-ui/app/elements/change/gr-confirm-revert-dialog/gr-confirm-revert-dialog.ts b/polygerrit-ui/app/elements/change/gr-confirm-revert-dialog/gr-confirm-revert-dialog.ts
index beaf0f8..5c0b19f 100644
--- a/polygerrit-ui/app/elements/change/gr-confirm-revert-dialog/gr-confirm-revert-dialog.ts
+++ b/polygerrit-ui/app/elements/change/gr-confirm-revert-dialog/gr-confirm-revert-dialog.ts
@@ -30,10 +30,15 @@
const CHANGE_SUBJECT_LIMIT = 50;
// TODO(dhruvsri): clean up repeated definitions after moving to js modules
-const REVERT_TYPES = {
- REVERT_SINGLE_CHANGE: 1,
- REVERT_SUBMISSION: 2,
-};
+export enum RevertType {
+ REVERT_SINGLE_CHANGE = 1,
+ REVERT_SUBMISSION = 2,
+}
+
+export interface ConfirmRevertEventDetail {
+ revertType: RevertType;
+ message?: string;
+}
export interface GrConfirmRevertDialog {
$: {
@@ -66,7 +71,7 @@
_message?: string;
@property({type: Number})
- _revertType = REVERT_TYPES.REVERT_SINGLE_CHANGE;
+ _revertType = RevertType.REVERT_SINGLE_CHANGE;
@property({type: Boolean})
_showRevertSubmission = false;
@@ -88,11 +93,11 @@
_revertMessages: string[] = [];
_computeIfSingleRevert(revertType: number) {
- return revertType === REVERT_TYPES.REVERT_SINGLE_CHANGE;
+ return revertType === RevertType.REVERT_SINGLE_CHANGE;
}
_computeIfRevertSubmission(revertType: number) {
- return revertType === REVERT_TYPES.REVERT_SUBMISSION;
+ return revertType === RevertType.REVERT_SUBMISSION;
}
_modifyRevertMsg(change: ChangeInfo, commitMessage: string, message: string) {
@@ -135,7 +140,7 @@
'Reason for revert: <INSERT REASONING HERE>\n';
// This is to give plugins a chance to update message
this._message = this._modifyRevertMsg(change, commitMessage, message);
- this._revertType = REVERT_TYPES.REVERT_SINGLE_CHANGE;
+ this._revertType = RevertType.REVERT_SINGLE_CHANGE;
this._showRevertSubmission = false;
this._revertMessages[this._revertType] = this._message;
this._originalRevertMessages[this._revertType] = this._message;
@@ -190,7 +195,7 @@
message,
commitMessage
);
- this._revertType = REVERT_TYPES.REVERT_SUBMISSION;
+ this._revertType = RevertType.REVERT_SUBMISSION;
this._revertMessages[this._revertType] = this._message;
this._originalRevertMessages[this._revertType] = this._message;
this._showRevertSubmission = true;
@@ -199,17 +204,17 @@
_handleRevertSingleChangeClicked() {
this._showErrorMessage = false;
if (this._message)
- this._revertMessages[REVERT_TYPES.REVERT_SUBMISSION] = this._message;
- this._message = this._revertMessages[REVERT_TYPES.REVERT_SINGLE_CHANGE];
- this._revertType = REVERT_TYPES.REVERT_SINGLE_CHANGE;
+ this._revertMessages[RevertType.REVERT_SUBMISSION] = this._message;
+ this._message = this._revertMessages[RevertType.REVERT_SINGLE_CHANGE];
+ this._revertType = RevertType.REVERT_SINGLE_CHANGE;
}
_handleRevertSubmissionClicked() {
this._showErrorMessage = false;
- this._revertType = REVERT_TYPES.REVERT_SUBMISSION;
+ this._revertType = RevertType.REVERT_SUBMISSION;
if (this._message)
- this._revertMessages[REVERT_TYPES.REVERT_SINGLE_CHANGE] = this._message;
- this._message = this._revertMessages[REVERT_TYPES.REVERT_SUBMISSION];
+ this._revertMessages[RevertType.REVERT_SINGLE_CHANGE] = this._message;
+ this._message = this._revertMessages[RevertType.REVERT_SUBMISSION];
}
_handleConfirmTap(e: MouseEvent) {
@@ -219,9 +224,13 @@
this._showErrorMessage = true;
return;
}
+ const detail: ConfirmRevertEventDetail = {
+ revertType: this._revertType,
+ message: this._message,
+ };
this.dispatchEvent(
new CustomEvent('confirm', {
- detail: {revertType: this._revertType, message: this._message},
+ detail,
composed: true,
bubbles: false,
})
diff --git a/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list.js b/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list.js
deleted file mode 100644
index bf45eb6..0000000
--- a/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list.js
+++ /dev/null
@@ -1,1614 +0,0 @@
-/**
- * @license
- * Copyright (C) 2015 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import '../../../styles/shared-styles.js';
-import '../../diff/gr-diff-cursor/gr-diff-cursor.js';
-import '../../diff/gr-diff-host/gr-diff-host.js';
-import '../../diff/gr-diff-preferences-dialog/gr-diff-preferences-dialog.js';
-import '../../edit/gr-edit-file-controls/gr-edit-file-controls.js';
-import '../../shared/gr-button/gr-button.js';
-import '../../shared/gr-cursor-manager/gr-cursor-manager.js';
-import '../../shared/gr-icons/gr-icons.js';
-import '../../shared/gr-linked-text/gr-linked-text.js';
-import '../../shared/gr-rest-api-interface/gr-rest-api-interface.js';
-import '../../shared/gr-select/gr-select.js';
-import '../../shared/gr-tooltip-content/gr-tooltip-content.js';
-import '../../shared/gr-copy-clipboard/gr-copy-clipboard.js';
-import {flush} 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-file-list_html.js';
-import {asyncForeach} from '../../../utils/async-util.js';
-import {KeyboardShortcutMixin, Shortcut} from '../../../mixins/keyboard-shortcut-mixin/keyboard-shortcut-mixin.js';
-import {FilesExpandedState} from '../gr-file-list-constants.js';
-import {GrCountStringFormatter} from '../../shared/gr-count-string-formatter/gr-count-string-formatter.js';
-import {GerritNav} from '../../core/gr-navigation/gr-navigation.js';
-import {getPluginEndpoints} from '../../shared/gr-js-api-interface/gr-plugin-endpoints.js';
-import {getPluginLoader} from '../../shared/gr-js-api-interface/gr-plugin-loader.js';
-import {appContext} from '../../../services/app-context.js';
-import {SpecialFilePath} from '../../../constants/constants.js';
-import {descendedFromClass} from '../../../utils/dom-util.js';
-import {
- addUnmodifiedFiles,
- computeDisplayPath,
- computeTruncatedPath,
- isMagicPath,
- specialFilePathCompare,
-} from '../../../utils/path-list-util.js';
-
-const WARN_SHOW_ALL_THRESHOLD = 1000;
-const LOADING_DEBOUNCE_INTERVAL = 100;
-
-const SIZE_BAR_MAX_WIDTH = 61;
-const SIZE_BAR_GAP_WIDTH = 1;
-const SIZE_BAR_MIN_WIDTH = 1.5;
-
-const RENDER_TIMING_LABEL = 'FileListRenderTime';
-const RENDER_AVG_TIMING_LABEL = 'FileListRenderTimePerFile';
-const EXPAND_ALL_TIMING_LABEL = 'ExpandAllDiffs';
-const EXPAND_ALL_AVG_TIMING_LABEL = 'ExpandAllPerDiff';
-
-const FileStatus = {
- A: 'Added',
- C: 'Copied',
- D: 'Deleted',
- M: 'Modified',
- R: 'Renamed',
- W: 'Rewritten',
- U: 'Unchanged',
-};
-
-const FILE_ROW_CLASS = 'file-row';
-
-/**
- * Type for FileInfo
- *
- * This should match with the type returned from `files` API plus
- * additional info like `__path`.
- *
- * @typedef {Object} FileInfo
- * @property {string} __path
- * @property {?string} old_path
- * @property {number} size
- * @property {number} size_delta - fallback to 0 if not present in api
- * @property {number} lines_deleted - fallback to 0 if not present in api
- * @property {number} lines_inserted - fallback to 0 if not present in api
- */
-
-/**
- * @extends PolymerElement
- */
-class GrFileList extends KeyboardShortcutMixin(
- GestureEventListeners(
- LegacyElementMixin(PolymerElement))) {
- static get template() { return htmlTemplate; }
-
- static get is() { return 'gr-file-list'; }
- /**
- * Fired when a draft refresh should get triggered
- *
- * @event reload-drafts
- */
-
- static get properties() {
- return {
- /** @type {?} */
- patchRange: Object,
- patchNum: String,
- changeNum: String,
- /** @type {?} */
- changeComments: Object,
- drafts: Object,
- revisions: Array,
- projectConfig: Object,
- selectedIndex: {
- type: Number,
- notify: true,
- },
- keyEventTarget: {
- type: Object,
- value() { return document.body; },
- },
- /** @type {?} */
- change: Object,
- diffViewMode: {
- type: String,
- notify: true,
- observer: '_updateDiffPreferences',
- },
- editMode: {
- type: Boolean,
- observer: '_editModeChanged',
- },
- filesExpanded: {
- type: String,
- value: FilesExpandedState.NONE,
- notify: true,
- },
- _filesByPath: Object,
-
- /** @type {!Array<FileInfo>} */
- _files: {
- type: Array,
- observer: '_filesChanged',
- value() { return []; },
- },
- _loggedIn: {
- type: Boolean,
- value: false,
- },
- _reviewed: {
- type: Array,
- value() { return []; },
- },
- diffPrefs: {
- type: Object,
- notify: true,
- observer: '_updateDiffPreferences',
- },
- /** @type {?} */
- _userPrefs: Object,
- _showInlineDiffs: Boolean,
- numFilesShown: {
- type: Number,
- notify: true,
- },
- /** @type {?} */
- _patchChange: {
- type: Object,
- computed: '_calculatePatchChange(_files)',
- },
- fileListIncrement: Number,
- _hideChangeTotals: {
- type: Boolean,
- computed: '_shouldHideChangeTotals(_patchChange)',
- },
- _hideBinaryChangeTotals: {
- type: Boolean,
- computed: '_shouldHideBinaryChangeTotals(_patchChange)',
- },
-
- _shownFiles: {
- type: Array,
- computed: '_computeFilesShown(numFilesShown, _files)',
- },
-
- /**
- * The amount of files added to the shown files list the last time it was
- * updated. This is used for reporting the average render time.
- */
- _reportinShownFilesIncrement: Number,
-
- /** @type {!Array<Gerrit.FileRange>} */
- _expandedFiles: {
- type: Array,
- value() { return []; },
- },
- _displayLine: Boolean,
- _loading: {
- type: Boolean,
- observer: '_loadingChanged',
- },
- /** @type {Gerrit.LayoutStats|undefined} */
- _sizeBarLayout: {
- type: Object,
- computed: '_computeSizeBarLayout(_shownFiles.*)',
- },
-
- _showSizeBars: {
- type: Boolean,
- value: true,
- computed: '_computeShowSizeBars(_userPrefs)',
- },
-
- /** @type {Function} */
- _cancelForEachDiff: Function,
-
- _showDynamicColumns: {
- type: Boolean,
- computed: '_computeShowDynamicColumns(_dynamicHeaderEndpoints, ' +
- '_dynamicContentEndpoints, _dynamicSummaryEndpoints)',
- },
- _showPrependedDynamicColumns: {
- type: Boolean,
- computed: '_computeShowPrependedDynamicColumns(' +
- '_dynamicPrependedHeaderEndpoints, _dynamicPrependedContentEndpoints)',
- },
- /** @type {Array<string>} */
- _dynamicHeaderEndpoints: {
- type: Array,
- },
- /** @type {Array<string>} */
- _dynamicContentEndpoints: {
- type: Array,
- },
- /** @type {Array<string>} */
- _dynamicSummaryEndpoints: {
- type: Array,
- },
- /** @type {Array<string>} */
- _dynamicPrependedHeaderEndpoints: {
- type: Array,
- },
- /** @type {Array<string>} */
- _dynamicPrependedContentEndpoints: {
- type: Array,
- },
- };
- }
-
- static get observers() {
- return [
- '_expandedFilesChanged(_expandedFiles.splices)',
- '_computeFiles(_filesByPath, changeComments, patchRange, _reviewed, ' +
- '_loading)',
- ];
- }
-
- get keyBindings() {
- return {
- esc: '_handleEscKey',
- };
- }
-
- keyboardShortcuts() {
- return {
- [Shortcut.LEFT_PANE]: '_handleLeftPane',
- [Shortcut.RIGHT_PANE]: '_handleRightPane',
- [Shortcut.TOGGLE_INLINE_DIFF]: '_handleToggleInlineDiff',
- [Shortcut.TOGGLE_ALL_INLINE_DIFFS]: '_handleToggleAllInlineDiffs',
- [Shortcut.TOGGLE_HIDE_ALL_COMMENT_THREADS]:
- '_handleToggleHideAllCommentThreads',
- [Shortcut.CURSOR_NEXT_FILE]: '_handleCursorNext',
- [Shortcut.CURSOR_PREV_FILE]: '_handleCursorPrev',
- [Shortcut.NEXT_LINE]: '_handleCursorNext',
- [Shortcut.PREV_LINE]: '_handleCursorPrev',
- [Shortcut.NEW_COMMENT]: '_handleNewComment',
- [Shortcut.OPEN_LAST_FILE]: '_handleOpenLastFile',
- [Shortcut.OPEN_FIRST_FILE]: '_handleOpenFirstFile',
- [Shortcut.OPEN_FILE]: '_handleOpenFile',
- [Shortcut.NEXT_CHUNK]: '_handleNextChunk',
- [Shortcut.PREV_CHUNK]: '_handlePrevChunk',
- [Shortcut.TOGGLE_FILE_REVIEWED]: '_handleToggleFileReviewed',
- [Shortcut.TOGGLE_LEFT_PANE]: '_handleToggleLeftPane',
-
- // Final two are actually handled by gr-comment-thread.
- [Shortcut.EXPAND_ALL_COMMENT_THREADS]: null,
- [Shortcut.COLLAPSE_ALL_COMMENT_THREADS]: null,
- };
- }
-
- constructor() {
- super();
- this.reporting = appContext.reportingService;
- }
-
- /** @override */
- created() {
- super.created();
- this.addEventListener('keydown',
- e => this._scopedKeydownHandler(e));
- }
-
- /** @override */
- attached() {
- super.attached();
- getPluginLoader().awaitPluginsLoaded()
- .then(() => {
- this._dynamicHeaderEndpoints = getPluginEndpoints()
- .getDynamicEndpoints('change-view-file-list-header');
- this._dynamicContentEndpoints = getPluginEndpoints()
- .getDynamicEndpoints('change-view-file-list-content');
- this._dynamicPrependedHeaderEndpoints = getPluginEndpoints()
- .getDynamicEndpoints('change-view-file-list-header-prepend');
- this._dynamicPrependedContentEndpoints = getPluginEndpoints()
- .getDynamicEndpoints('change-view-file-list-content-prepend');
- this._dynamicSummaryEndpoints = getPluginEndpoints()
- .getDynamicEndpoints('change-view-file-list-summary');
-
- if (this._dynamicHeaderEndpoints.length !==
- this._dynamicContentEndpoints.length) {
- console.warn(
- 'Different number of dynamic file-list header and content.');
- }
- if (this._dynamicPrependedHeaderEndpoints.length !==
- this._dynamicPrependedContentEndpoints.length) {
- console.warn(
- 'Different number of dynamic file-list header and content.');
- }
- if (this._dynamicHeaderEndpoints.length !==
- this._dynamicSummaryEndpoints.length) {
- console.warn(
- 'Different number of dynamic file-list headers and summary.');
- }
- });
- }
-
- /** @override */
- detached() {
- super.detached();
- this._cancelDiffs();
- }
-
- /**
- * Iron-a11y-keys-behavior catches keyboard events globally. Some keyboard
- * events must be scoped to a component level (e.g. `enter`) in order to not
- * override native browser functionality.
- *
- * Context: Issue 7277
- */
- _scopedKeydownHandler(e) {
- if (e.keyCode === 13) {
- // Enter.
- this._handleOpenFile(e);
- }
- }
-
- reload() {
- if (!this.changeNum || !this.patchRange.patchNum) {
- return Promise.resolve();
- }
-
- this._loading = true;
-
- this.collapseAllDiffs();
- const promises = [];
-
- promises.push(this._getFiles().then(filesByPath => {
- this._filesByPath = filesByPath;
- }));
- promises.push(this._getLoggedIn()
- .then(loggedIn => this._loggedIn = loggedIn)
- .then(loggedIn => {
- if (!loggedIn) { return; }
-
- return this._getReviewedFiles().then(reviewed => {
- this._reviewed = reviewed;
- });
- }));
-
- promises.push(this._getDiffPreferences().then(prefs => {
- this.diffPrefs = prefs;
- }));
-
- promises.push(this._getPreferences().then(prefs => {
- this._userPrefs = prefs;
- }));
-
- return Promise.all(promises).then(() => {
- this._loading = false;
- this._detectChromiteButler();
- this.reporting.fileListDisplayed();
- });
- }
-
- _detectChromiteButler() {
- const hasButler = !!document.getElementById('butler-suggested-owners');
- if (hasButler) {
- this.reporting.reportExtension('butler');
- }
- }
-
- get diffs() {
- const diffs = this.root.querySelectorAll('gr-diff-host');
- // It is possible that a bogus diff element is hanging around invisibly
- // from earlier with a different patch set choice and associated with a
- // different entry in the files array. So filter on visible items only.
- return Array.from(diffs).filter(
- el => !!el && !!el.style && el.style.display !== 'none');
- }
-
- openDiffPrefs() {
- this.$.diffPreferencesDialog.open();
- }
-
- _calculatePatchChange(files) {
- const magicFilesExcluded = files.filter(files =>
- !isMagicPath(files.__path)
- );
-
- return magicFilesExcluded.reduce((acc, obj) => {
- const inserted = obj.lines_inserted ? obj.lines_inserted : 0;
- const deleted = obj.lines_deleted ? obj.lines_deleted : 0;
- const total_size = (obj.size && obj.binary) ? obj.size : 0;
- const size_delta_inserted =
- obj.binary && obj.size_delta > 0 ? obj.size_delta : 0;
- const size_delta_deleted =
- obj.binary && obj.size_delta < 0 ? obj.size_delta : 0;
-
- return {
- inserted: acc.inserted + inserted,
- deleted: acc.deleted + deleted,
- size_delta_inserted: acc.size_delta_inserted + size_delta_inserted,
- size_delta_deleted: acc.size_delta_deleted + size_delta_deleted,
- total_size: acc.total_size + total_size,
- };
- }, {inserted: 0, deleted: 0, size_delta_inserted: 0,
- size_delta_deleted: 0, total_size: 0});
- }
-
- _getDiffPreferences() {
- return this.$.restAPI.getDiffPreferences();
- }
-
- _getPreferences() {
- return this.$.restAPI.getPreferences();
- }
-
- _toggleFileExpanded(file) {
- // Is the path in the list of expanded diffs? IF so remove it, otherwise
- // add it to the list.
- const pathIndex = this._expandedFiles.findIndex(f => f.path === file.path);
- if (pathIndex === -1) {
- this.push('_expandedFiles', file);
- } else {
- this.splice('_expandedFiles', pathIndex, 1);
- }
- }
-
- _toggleFileExpandedByIndex(index) {
- this._toggleFileExpanded(this._computeFileRange(this._files[index]));
- }
-
- _updateDiffPreferences() {
- if (!this.diffs.length) { return; }
- // Re-render all expanded diffs sequentially.
- this.reporting.time(EXPAND_ALL_TIMING_LABEL);
- this._renderInOrder(this._expandedFiles, this.diffs,
- this._expandedFiles.length);
- }
-
- _forEachDiff(fn) {
- const diffs = this.diffs;
- for (let i = 0; i < diffs.length; i++) {
- fn(diffs[i]);
- }
- }
-
- expandAllDiffs() {
- this._showInlineDiffs = true;
-
- // Find the list of paths that are in the file list, but not in the
- // expanded list.
- const newFiles = [];
- let path;
- for (let i = 0; i < this._shownFiles.length; i++) {
- path = this._shownFiles[i].__path;
- if (!this._expandedFiles.some(f => f.path === path)) {
- newFiles.push(this._computeFileRange(this._shownFiles[i]));
- }
- }
-
- this.splice(...['_expandedFiles', 0, 0].concat(newFiles));
- }
-
- collapseAllDiffs() {
- this._showInlineDiffs = false;
- this._expandedFiles = [];
- this.filesExpanded = this._computeExpandedFiles(
- this._expandedFiles.length, this._files.length);
- this.$.diffCursor.handleDiffUpdate();
- }
-
- /**
- * Computes a string with the number of comments and unresolved comments.
- *
- * @param {!Object} changeComments
- * @param {!Object} patchRange
- * @param {string} path
- * @return {string}
- */
- _computeCommentsString(changeComments, patchRange, path) {
- if ([changeComments, patchRange, path].includes(undefined)) {
- return '';
- }
- const unresolvedCount =
- changeComments.computeUnresolvedNum({
- patchNum: patchRange.basePatchNum,
- path,
- }) +
- changeComments.computeUnresolvedNum({
- patchNum: patchRange.patchNum,
- path,
- });
- const commentCount =
- changeComments.computeCommentCount({
- patchNum: patchRange.basePatchNum,
- path,
- }) +
- changeComments.computeCommentCount({
- patchNum: patchRange.patchNum,
- path,
- });
- const commentString = GrCountStringFormatter.computePluralString(
- commentCount, 'comment');
- const unresolvedString = GrCountStringFormatter.computeString(
- unresolvedCount, 'unresolved');
-
- return commentString +
- // Add a space if both comments and unresolved
- (commentString && unresolvedString ? ' ' : '') +
- // Add parentheses around unresolved if it exists.
- (unresolvedString ? `(${unresolvedString})` : '');
- }
-
- /**
- * Computes a string with the number of drafts.
- *
- * @param {!Object} changeComments
- * @param {!Object} patchRange
- * @param {string} path
- * @return {string}
- */
- _computeDraftsString(changeComments, patchRange, path) {
- if ([changeComments, patchRange, path].includes(undefined)) {
- return '';
- }
- const draftCount =
- changeComments.computeDraftCount({
- patchNum: patchRange.basePatchNum,
- path,
- }) +
- changeComments.computeDraftCount({
- patchNum: patchRange.patchNum,
- path,
- });
- return GrCountStringFormatter.computePluralString(draftCount, 'draft');
- }
-
- /**
- * Computes a shortened string with the number of drafts.
- *
- * @param {!Object} changeComments
- * @param {!Object} patchRange
- * @param {string} path
- * @return {string}
- */
- _computeDraftsStringMobile(changeComments, patchRange, path) {
- if ([changeComments, patchRange, path].includes(undefined)) {
- return '';
- }
- const draftCount =
- changeComments.computeDraftCount({
- patchNum: patchRange.basePatchNum,
- path,
- }) +
- changeComments.computeDraftCount({
- patchNum: patchRange.patchNum,
- path,
- });
- return GrCountStringFormatter.computeShortString(draftCount, 'd');
- }
-
- /**
- * Computes a shortened string with the number of comments.
- *
- * @param {!Object} changeComments
- * @param {!Object} patchRange
- * @param {string} path
- * @return {string}
- */
- _computeCommentsStringMobile(changeComments, patchRange, path) {
- if ([changeComments, patchRange, path].includes(undefined)) {
- return '';
- }
- const commentCount =
- changeComments.computeCommentCount({
- patchNum: patchRange.basePatchNum,
- path,
- }) +
- changeComments.computeCommentCount({
- patchNum: patchRange.patchNum,
- path,
- });
- return GrCountStringFormatter.computeShortString(commentCount, 'c');
- }
-
- /**
- * @param {string} path
- * @param {boolean=} opt_reviewed
- */
- _reviewFile(path, opt_reviewed) {
- if (this.editMode) { return; }
- const index = this._files.findIndex(file => file.__path === path);
- const reviewed = opt_reviewed || !this._files[index].isReviewed;
-
- this.set(['_files', index, 'isReviewed'], reviewed);
- if (index < this._shownFiles.length) {
- this.notifyPath(`_shownFiles.${index}.isReviewed`);
- }
-
- this._saveReviewedState(path, reviewed);
- }
-
- _saveReviewedState(path, reviewed) {
- return this.$.restAPI.saveFileReviewed(this.changeNum,
- this.patchRange.patchNum, path, reviewed);
- }
-
- _getLoggedIn() {
- return this.$.restAPI.getLoggedIn();
- }
-
- _getReviewedFiles() {
- if (this.editMode) { return Promise.resolve([]); }
- return this.$.restAPI.getReviewedFiles(this.changeNum,
- this.patchRange.patchNum);
- }
-
- _getFiles() {
- return this.$.restAPI.getChangeOrEditFiles(
- this.changeNum, this.patchRange);
- }
-
- /**
- *
- * @returns {!Array<FileInfo>}
- */
- _normalizeChangeFilesResponse(response) {
- if (!response) { return []; }
- const paths = Object.keys(response).sort(specialFilePathCompare);
- const files = [];
- for (let i = 0; i < paths.length; i++) {
- const info = response[paths[i]];
- info.__path = paths[i];
- info.lines_inserted = info.lines_inserted || 0;
- info.lines_deleted = info.lines_deleted || 0;
- info.size_delta = info.size_delta || 0;
- files.push(info);
- }
- return files;
- }
-
- /**
- * Returns true if the event e is a click on an element.
- *
- * The click is: mouse click or pressing Enter or Space key
- * P.S> Screen readers sends click event as well
- */
- _isClickEvent(e) {
- if (e.type === 'click') {
- return true;
- }
- const isSpaceOrEnter = (e.key === 'Enter' || e.key === ' ');
- return e.type === 'keydown' && isSpaceOrEnter;
- }
-
- _fileActionClick(e, fileAction) {
- if (this._isClickEvent(e)) {
- const fileRow = this._getFileRowFromEvent(e);
- if (!fileRow) {
- return;
- }
- // Prevent default actions (e.g. scrolling for space key)
- e.preventDefault();
- // Prevent _handleFileListClick handler call
- e.stopPropagation();
- this.$.fileCursor.setCursor(fileRow.element);
- fileAction(fileRow.file);
- }
- }
-
- _reviewedClick(e) {
- this._fileActionClick(e,
- file => this._reviewFile(file.path));
- }
-
- _expandedClick(e) {
- this._fileActionClick(e,
- file => this._toggleFileExpanded(file));
- }
-
- /**
- * Handle all events from the file list dom-repeat so event handleers don't
- * have to get registered for potentially very long lists.
- */
- _handleFileListClick(e) {
- const fileRow = this._getFileRowFromEvent(e);
- if (!fileRow) {
- return;
- }
- const file = fileRow.file;
- const path = file.path;
- // If a path cannot be interpreted from the click target (meaning it's not
- // somewhere in the row, e.g. diff content) or if the user clicked the
- // link, defer to the native behavior.
- if (!path || descendedFromClass(e.target, 'pathLink')) { return; }
-
- // Disregard the event if the click target is in the edit controls.
- if (descendedFromClass(e.target, 'editFileControls')) { return; }
-
- e.preventDefault();
- this.$.fileCursor.setCursor(fileRow.element);
- this._toggleFileExpanded(file);
- }
-
- _getFileRowFromEvent(e) {
- // Traverse upwards to find the row element if the target is not the row.
- let row = e.target;
- while (!row.classList.contains(FILE_ROW_CLASS) && row.parentElement) {
- row = row.parentElement;
- }
-
- // No action needed for item without a valid file
- if (!row.dataset['file']) {
- return null;
- }
-
- return {
- file: JSON.parse(row.dataset['file']),
- element: row,
- };
- }
-
- /**
- * Generates file range from file info object.
- *
- * @param {FileInfo} file
- * @returns {Gerrit.FileRange}
- */
- _computeFileRange(file) {
- const fileData = {
- path: file.__path,
- };
- if (file.old_path) {
- fileData.basePath = file.old_path;
- }
- return fileData;
- }
-
- _handleLeftPane(e) {
- if (this.shouldSuppressKeyboardShortcut(e) || this._noDiffsExpanded()) {
- return;
- }
-
- e.preventDefault();
- this.$.diffCursor.moveLeft();
- }
-
- _handleRightPane(e) {
- if (this.shouldSuppressKeyboardShortcut(e) || this._noDiffsExpanded()) {
- return;
- }
-
- e.preventDefault();
- this.$.diffCursor.moveRight();
- }
-
- _handleToggleInlineDiff(e) {
- if (this.shouldSuppressKeyboardShortcut(e) ||
- this.modifierPressed(e) ||
- this.$.fileCursor.index === -1) { return; }
-
- e.preventDefault();
- this._toggleFileExpandedByIndex(this.$.fileCursor.index);
- }
-
- _handleToggleAllInlineDiffs(e) {
- if (this.shouldSuppressKeyboardShortcut(e)) { return; }
-
- e.preventDefault();
- this._toggleInlineDiffs();
- }
-
- _handleToggleHideAllCommentThreads(e) {
- if (this.shouldSuppressKeyboardShortcut(e) || this.modifierPressed(e)) {
- return;
- }
-
- e.preventDefault();
- this.toggleClass('hideComments');
- }
-
- _handleCursorNext(e) {
- if (this.shouldSuppressKeyboardShortcut(e) || this.modifierPressed(e)) {
- return;
- }
-
- if (this._showInlineDiffs) {
- e.preventDefault();
- this.$.diffCursor.moveDown();
- this._displayLine = true;
- } else {
- // Down key
- if (this.getKeyboardEvent(e).keyCode === 40) { return; }
- e.preventDefault();
- this.$.fileCursor.next();
- this.selectedIndex = this.$.fileCursor.index;
- }
- }
-
- _handleCursorPrev(e) {
- if (this.shouldSuppressKeyboardShortcut(e) || this.modifierPressed(e)) {
- return;
- }
-
- if (this._showInlineDiffs) {
- e.preventDefault();
- this.$.diffCursor.moveUp();
- this._displayLine = true;
- } else {
- // Up key
- if (this.getKeyboardEvent(e).keyCode === 38) { return; }
- e.preventDefault();
- this.$.fileCursor.previous();
- this.selectedIndex = this.$.fileCursor.index;
- }
- }
-
- _handleNewComment(e) {
- if (this.shouldSuppressKeyboardShortcut(e) ||
- this.modifierPressed(e)) { return; }
- e.preventDefault();
- this.$.diffCursor.createCommentInPlace();
- }
-
- _handleOpenLastFile(e) {
- // Check for meta key to avoid overriding native chrome shortcut.
- if (this.shouldSuppressKeyboardShortcut(e) ||
- this.getKeyboardEvent(e).metaKey) { return; }
-
- e.preventDefault();
- this._openSelectedFile(this._files.length - 1);
- }
-
- _handleOpenFirstFile(e) {
- // Check for meta key to avoid overriding native chrome shortcut.
- if (this.shouldSuppressKeyboardShortcut(e) ||
- this.getKeyboardEvent(e).metaKey) { return; }
-
- e.preventDefault();
- this._openSelectedFile(0);
- }
-
- _handleOpenFile(e) {
- if (this.shouldSuppressKeyboardShortcut(e) ||
- this.modifierPressed(e)) { return; }
- e.preventDefault();
-
- if (this._showInlineDiffs) {
- this._openCursorFile();
- return;
- }
-
- this._openSelectedFile();
- }
-
- _handleNextChunk(e) {
- if (this.shouldSuppressKeyboardShortcut(e) ||
- (this.modifierPressed(e) && !this.isModifierPressed(e, 'shiftKey')) ||
- this._noDiffsExpanded()) {
- return;
- }
-
- e.preventDefault();
- if (this.isModifierPressed(e, 'shiftKey')) {
- this.$.diffCursor.moveToNextCommentThread();
- } else {
- this.$.diffCursor.moveToNextChunk();
- }
- }
-
- _handlePrevChunk(e) {
- if (this.shouldSuppressKeyboardShortcut(e) ||
- (this.modifierPressed(e) && !this.isModifierPressed(e, 'shiftKey')) ||
- this._noDiffsExpanded()) {
- return;
- }
-
- e.preventDefault();
- if (this.isModifierPressed(e, 'shiftKey')) {
- this.$.diffCursor.moveToPreviousCommentThread();
- } else {
- this.$.diffCursor.moveToPreviousChunk();
- }
- }
-
- _handleToggleFileReviewed(e) {
- if (this.shouldSuppressKeyboardShortcut(e) || this.modifierPressed(e)) {
- return;
- }
-
- e.preventDefault();
- if (!this._files[this.$.fileCursor.index]) { return; }
- this._reviewFile(this._files[this.$.fileCursor.index].__path);
- }
-
- _handleToggleLeftPane(e) {
- if (this.shouldSuppressKeyboardShortcut(e)) { return; }
-
- e.preventDefault();
- this._forEachDiff(diff => {
- diff.toggleLeftDiff();
- });
- }
-
- _toggleInlineDiffs() {
- if (this._showInlineDiffs) {
- this.collapseAllDiffs();
- } else {
- this.expandAllDiffs();
- }
- }
-
- _openCursorFile() {
- const diff = this.$.diffCursor.getTargetDiffElement();
- GerritNav.navigateToDiff(this.change, diff.path,
- diff.patchRange.patchNum, this.patchRange.basePatchNum);
- }
-
- /**
- * @param {number=} opt_index
- */
- _openSelectedFile(opt_index) {
- if (opt_index != null) {
- this.$.fileCursor.setCursorAtIndex(opt_index);
- }
- if (!this._files[this.$.fileCursor.index]) { return; }
- GerritNav.navigateToDiff(this.change,
- this._files[this.$.fileCursor.index].__path, this.patchRange.patchNum,
- this.patchRange.basePatchNum);
- }
-
- _addDraftAtTarget() {
- const diff = this.$.diffCursor.getTargetDiffElement();
- const target = this.$.diffCursor.getTargetLineElement();
- if (diff && target) {
- diff.addDraftAtLine(target);
- }
- }
-
- _shouldHideChangeTotals(_patchChange) {
- return _patchChange.inserted === 0 && _patchChange.deleted === 0;
- }
-
- _shouldHideBinaryChangeTotals(_patchChange) {
- return _patchChange.size_delta_inserted === 0 &&
- _patchChange.size_delta_deleted === 0;
- }
-
- _computeFileStatus(status) {
- return status || 'M';
- }
-
- _computeDiffURL(change, patchRange, path, editMode) {
- // Polymer 2: check for undefined
- if ([change, patchRange, path, editMode]
- .some(arg => arg === undefined)) {
- return;
- }
- if (editMode && path !== SpecialFilePath.MERGE_LIST) {
- return GerritNav.getEditUrlForDiff(change, path, patchRange.patchNum,
- patchRange.basePatchNum);
- }
- return GerritNav.getUrlForDiff(change, path, patchRange.patchNum,
- patchRange.basePatchNum);
- }
-
- _formatBytes(bytes) {
- if (bytes == 0) return '+/-0 B';
- const bits = 1024;
- const decimals = 1;
- const sizes =
- ['B', 'KiB', 'MiB', 'GiB', 'TiB', 'PiB', 'EiB', 'ZiB', 'YiB'];
- const exponent = Math.floor(Math.log(Math.abs(bytes)) / Math.log(bits));
- const prepend = bytes > 0 ? '+' : '';
- return prepend + parseFloat((bytes / Math.pow(bits, exponent))
- .toFixed(decimals)) + ' ' + sizes[exponent];
- }
-
- _formatPercentage(size, delta) {
- const oldSize = size - delta;
-
- if (oldSize === 0) { return ''; }
-
- const percentage = Math.round(Math.abs(delta * 100 / oldSize));
- return '(' + (delta > 0 ? '+' : '-') + percentage + '%)';
- }
-
- _computeBinaryClass(delta) {
- if (delta === 0) { return; }
- return delta >= 0 ? 'added' : 'removed';
- }
-
- /**
- * @param {string} baseClass
- * @param {string} path
- */
- _computeClass(baseClass, path) {
- const classes = [];
- if (baseClass) {
- classes.push(baseClass);
- }
- if (path === SpecialFilePath.COMMIT_MESSAGE ||
- path === SpecialFilePath.MERGE_LIST) {
- classes.push('invisible');
- }
- return classes.join(' ');
- }
-
- _computeStatusClass(file) {
- const classStr = this._computeClass('status', file.__path);
- return `${classStr} ${this._computeFileStatus(file.status)}`;
- }
-
- _computePathClass(path, expandedFilesRecord) {
- return this._isFileExpanded(path, expandedFilesRecord) ? 'expanded' : '';
- }
-
- _computeShowHideIcon(path, expandedFilesRecord) {
- return this._isFileExpanded(path, expandedFilesRecord) ?
- 'gr-icons:expand-less' : 'gr-icons:expand-more';
- }
-
- _computeFiles(filesByPath, changeComments, patchRange, reviewed, loading) {
- // Polymer 2: check for undefined
- if ([
- filesByPath,
- changeComments,
- patchRange,
- reviewed,
- loading,
- ].includes(undefined)) {
- return;
- }
-
- // Await all promises resolving from reload. @See Issue 9057
- if (loading || !changeComments) { return; }
-
- const commentedPaths = changeComments.getPaths(patchRange);
- const files = {...filesByPath};
- addUnmodifiedFiles(files, commentedPaths);
- const reviewedSet = new Set(reviewed || []);
- for (const filePath in files) {
- if (!files.hasOwnProperty(filePath)) { continue; }
- files[filePath].isReviewed = reviewedSet.has(filePath);
- }
-
- this._files = this._normalizeChangeFilesResponse(files);
- }
-
- _computeFilesShown(numFilesShown, files) {
- // Polymer 2: check for undefined
- if ([numFilesShown, files].includes(undefined)) {
- return undefined;
- }
-
- const previousNumFilesShown = this._shownFiles ?
- this._shownFiles.length : 0;
-
- const filesShown = files.slice(0, numFilesShown);
- this.dispatchEvent(new CustomEvent('files-shown-changed', {
- detail: {length: filesShown.length},
- composed: true, bubbles: true,
- }));
-
- // Start the timer for the rendering work hwere because this is where the
- // _shownFiles property is being set, and _shownFiles is used in the
- // dom-repeat binding.
- this.reporting.time(RENDER_TIMING_LABEL);
-
- // How many more files are being shown (if it's an increase).
- this._reportinShownFilesIncrement =
- Math.max(0, filesShown.length - previousNumFilesShown);
-
- return filesShown;
- }
-
- _updateDiffCursor() {
- // Overwrite the cursor's list of diffs:
- this.$.diffCursor.splice(
- ...['diffs', 0, this.$.diffCursor.diffs.length].concat(this.diffs));
- }
-
- _filesChanged() {
- if (this._files && this._files.length > 0) {
- flush();
- this.$.fileCursor.stops = Array.from(
- this.root.querySelectorAll(`.${FILE_ROW_CLASS}`));
- this.$.fileCursor.setCursorAtIndex(this.selectedIndex, true);
- }
- }
-
- _incrementNumFilesShown() {
- this.numFilesShown += this.fileListIncrement;
- }
-
- _computeFileListControlClass(numFilesShown, files) {
- return numFilesShown >= files.length ? 'invisible' : '';
- }
-
- _computeIncrementText(numFilesShown, files) {
- if (!files) { return ''; }
- const text =
- Math.min(this.fileListIncrement, files.length - numFilesShown);
- return 'Show ' + text + ' more';
- }
-
- _computeShowAllText(files) {
- if (!files) { return ''; }
- return 'Show all ' + files.length + ' files';
- }
-
- _computeWarnShowAll(files) {
- return files.length > WARN_SHOW_ALL_THRESHOLD;
- }
-
- _computeShowAllWarning(files) {
- if (!this._computeWarnShowAll(files)) { return ''; }
- return 'Warning: showing all ' + files.length +
- ' files may take several seconds.';
- }
-
- _showAllFiles() {
- this.numFilesShown = this._files.length;
- }
-
- /**
- * Get a descriptive label for use in the status indicator's tooltip and
- * ARIA label.
- *
- * @param {string} status
- * @return {string}
- */
- _computeFileStatusLabel(status) {
- const statusCode = this._computeFileStatus(status);
- return FileStatus.hasOwnProperty(statusCode) ?
- FileStatus[statusCode] : 'Status Unknown';
- }
-
- /**
- * Converts any boolean-like variable to the string 'true' or 'false'
- *
- * This method is useful when you bind aria-checked attribute to a boolean
- * value. The aria-checked attribute is string attribute. Binding directly
- * to boolean variable causes problem on gerrit-CI.
- *
- * @param {object} val
- * @return {string} 'true' if val is true-like, otherwise false
- */
- _booleanToString(val) {
- return val ? 'true' : 'false';
- }
-
- _isFileExpanded(path, expandedFilesRecord) {
- return expandedFilesRecord.base.some(f => f.path === path);
- }
-
- _isFileExpandedStr(path, expandedFilesRecord) {
- return this._booleanToString(
- this._isFileExpanded(path, expandedFilesRecord));
- }
-
- _computeExpandedFiles(expandedCount, totalCount) {
- if (expandedCount === 0) {
- return FilesExpandedState.NONE;
- } else if (expandedCount === totalCount) {
- return FilesExpandedState.ALL;
- }
- return FilesExpandedState.SOME;
- }
-
- /**
- * Handle splices to the list of expanded file paths. If there are any new
- * entries in the expanded list, then render each diff corresponding in
- * order by waiting for the previous diff to finish before starting the next
- * one.
- *
- * @param {!Array} record The splice record in the expanded paths list.
- */
- _expandedFilesChanged(record) {
- // Clear content for any diffs that are not open so if they get re-opened
- // the stale content does not flash before it is cleared and reloaded.
- const collapsedDiffs = this.diffs.filter(diff =>
- this._expandedFiles.findIndex(f => f.path === diff.path) === -1);
- this._clearCollapsedDiffs(collapsedDiffs);
-
- if (!record) { return; } // Happens after "Collapse all" clicked.
-
- this.filesExpanded = this._computeExpandedFiles(
- this._expandedFiles.length, this._files.length);
-
- // Find the paths introduced by the new index splices:
- const newFiles = record.indexSplices
- .map(splice => splice.object.slice(
- splice.index, splice.index + splice.addedCount))
- .reduce((acc, paths) => acc.concat(paths), []);
-
- // Required so that the newly created diff view is included in this.diffs.
- flush();
-
- this.reporting.time(EXPAND_ALL_TIMING_LABEL);
-
- if (newFiles.length) {
- this._renderInOrder(newFiles, this.diffs, newFiles.length);
- }
-
- this._updateDiffCursor();
- this.$.diffCursor.reInitAndUpdateStops();
- }
-
- _clearCollapsedDiffs(collapsedDiffs) {
- for (const diff of collapsedDiffs) {
- diff.cancel();
- diff.clearDiffContent();
- }
- }
-
- /**
- * Given an array of paths and a NodeList of diff elements, render the diff
- * for each path in order, awaiting the previous render to complete before
- * continuing.
- *
- * @param {!Array<Gerrit.FileRange>} files
- * @param {!NodeList<!Object>} diffElements (GrDiffHostElement)
- * @param {number} initialCount The total number of paths in the pass. This
- * is used to generate log messages.
- * @return {!Promise}
- */
- _renderInOrder(files, diffElements, initialCount) {
- let iter = 0;
-
- for (const file of files) {
- const path = file.path;
- const diffElem = this._findDiffByPath(path, diffElements);
- if (diffElem) {
- diffElem.prefetchDiff();
- }
- }
-
- return (new Promise(resolve => {
- this.dispatchEvent(new CustomEvent('reload-drafts', {
- detail: {resolve},
- composed: true, bubbles: true,
- }));
- })).then(() => asyncForeach(files, (file, cancel) => {
- const path = file.path;
- this._cancelForEachDiff = cancel;
-
- iter++;
- console.info('Expanding diff', iter, 'of', initialCount, ':',
- path);
- const diffElem = this._findDiffByPath(path, diffElements);
- if (!diffElem) {
- console.warn(`Did not find <gr-diff-host> element for ${path}`);
- return Promise.resolve();
- }
- diffElem.comments = this.changeComments.getCommentsBySideForFile(
- file, this.patchRange, this.projectConfig);
- const promises = [diffElem.reload()];
- if (this._loggedIn && !this.diffPrefs.manual_review) {
- promises.push(this._reviewFile(path, true));
- }
- return Promise.all(promises);
- }).then(() => {
- this._cancelForEachDiff = null;
- this._nextRenderParams = null;
- console.info('Finished expanding', initialCount, 'diff(s)');
- this.reporting.timeEndWithAverage(EXPAND_ALL_TIMING_LABEL,
- EXPAND_ALL_AVG_TIMING_LABEL, initialCount);
- /* Block diff cursor from auto scrolling after files are done rendering.
- * This prevents the bug where the screen jumps to the first diff chunk
- * after files are done being rendered after the user has already begun
- * scrolling.
- * This also however results in the fact that the cursor does not auto
- * focus on the first diff chunk on a small screen. This is however, a use
- * case we are willing to not support for now.
-
- * Using handleDiffUpdate resulted in diffCursor.row being set which
- * prevented the issue of scrolling to top when we expand the second
- * file individually.
- */
- this.$.diffCursor.reInitAndUpdateStops();
- }));
- }
-
- /** Cancel the rendering work of every diff in the list */
- _cancelDiffs() {
- if (this._cancelForEachDiff) { this._cancelForEachDiff(); }
- this._forEachDiff(d => d.cancel());
- }
-
- /**
- * In the given NodeList of diff elements, find the diff for the given path.
- *
- * @param {string} path
- * @param {!NodeList<!Object>} diffElements (GrDiffElement)
- * @return {!Object|undefined} (GrDiffElement)
- */
- _findDiffByPath(path, diffElements) {
- for (let i = 0; i < diffElements.length; i++) {
- if (diffElements[i].path === path) {
- return diffElements[i];
- }
- }
- }
-
- /**
- * Reset the comments of a modified thread
- *
- * @param {string} rootId
- * @param {string} path
- */
- reloadCommentsForThreadWithRootId(rootId, path) {
- // Don't bother continuing if we already know that the path that contains
- // the updated comment thread is not expanded.
- if (!this._expandedFiles.some(f => f.path === path)) { return; }
- const diff = this.diffs.find(d => d.path === path);
-
- const threadEl = diff.getThreadEls().find(t => t.rootId === rootId);
- if (!threadEl) { return; }
-
- const newComments = this.changeComments.getCommentsForThread(rootId);
-
- // If newComments is null, it means that a single draft was
- // removed from a thread in the thread view, and the thread should
- // no longer exist. Remove the existing thread element in the diff
- // view.
- if (!newComments) {
- threadEl.fireRemoveSelf();
- return;
- }
-
- // Comments are not returned with the commentSide attribute from
- // the api, but it's necessary to be stored on the diff's
- // comments due to use in the _handleCommentUpdate function.
- // The comment thread already has a side associated with it, so
- // set the comment's side to match.
- threadEl.comments = newComments.map(c => Object.assign(
- c, {__commentSide: threadEl.commentSide}
- ));
- flush();
- }
-
- _handleEscKey(e) {
- if (this.shouldSuppressKeyboardShortcut(e) ||
- this.modifierPressed(e)) { return; }
- e.preventDefault();
- this._displayLine = false;
- }
-
- /**
- * Update the loading class for the file list rows. The update is inside a
- * debouncer so that the file list doesn't flash gray when the API requests
- * are reasonably fast.
- *
- * @param {boolean} loading
- */
- _loadingChanged(loading) {
- this.debounce('loading-change', () => {
- // Only show set the loading if there have been files loaded to show. In
- // this way, the gray loading style is not shown on initial loads.
- this.classList.toggle('loading', loading && !!this._files.length);
- }, LOADING_DEBOUNCE_INTERVAL);
- }
-
- _editModeChanged(editMode) {
- this.classList.toggle('editMode', editMode);
- }
-
- _computeReviewedClass(isReviewed) {
- return isReviewed ? 'isReviewed' : '';
- }
-
- _computeReviewedText(isReviewed) {
- return isReviewed ? 'MARK UNREVIEWED' : 'MARK REVIEWED';
- }
-
- /**
- * Given a file path, return whether that path should have visible size bars
- * and be included in the size bars calculation.
- *
- * @param {string} path
- * @return {boolean}
- */
- _showBarsForPath(path) {
- return path !== SpecialFilePath.COMMIT_MESSAGE &&
- path !== SpecialFilePath.MERGE_LIST;
- }
-
- /**
- * Compute size bar layout values from the file list.
- *
- * @return {Gerrit.LayoutStats|undefined}
- *
- */
- _computeSizeBarLayout(shownFilesRecord) {
- if (!shownFilesRecord || !shownFilesRecord.base) { return undefined; }
- const stats = {
- maxInserted: 0,
- maxDeleted: 0,
- maxAdditionWidth: 0,
- maxDeletionWidth: 0,
- deletionOffset: 0,
- };
- shownFilesRecord.base
- .filter(f => this._showBarsForPath(f.__path))
- .forEach(f => {
- if (f.lines_inserted) {
- stats.maxInserted = Math.max(stats.maxInserted, f.lines_inserted);
- }
- if (f.lines_deleted) {
- stats.maxDeleted = Math.max(stats.maxDeleted, f.lines_deleted);
- }
- });
- const ratio = stats.maxInserted / (stats.maxInserted + stats.maxDeleted);
- if (!isNaN(ratio)) {
- stats.maxAdditionWidth =
- (SIZE_BAR_MAX_WIDTH - SIZE_BAR_GAP_WIDTH) * ratio;
- stats.maxDeletionWidth =
- SIZE_BAR_MAX_WIDTH - SIZE_BAR_GAP_WIDTH - stats.maxAdditionWidth;
- stats.deletionOffset = stats.maxAdditionWidth + SIZE_BAR_GAP_WIDTH;
- }
- return stats;
- }
-
- /**
- * Get the width of the addition bar for a file.
- *
- * @param {Object} file
- * @param {Gerrit.LayoutStats} stats
- * @return {number}
- */
- _computeBarAdditionWidth(file, stats) {
- if (stats.maxInserted === 0 ||
- !file.lines_inserted ||
- !this._showBarsForPath(file.__path)) {
- return 0;
- }
- const width =
- stats.maxAdditionWidth * file.lines_inserted / stats.maxInserted;
- return width === 0 ? 0 : Math.max(SIZE_BAR_MIN_WIDTH, width);
- }
-
- /**
- * Get the x-offset of the addition bar for a file.
- *
- * @param {Object} file
- * @param {Gerrit.LayoutStats} stats
- * @return {number}
- */
- _computeBarAdditionX(file, stats) {
- return stats.maxAdditionWidth -
- this._computeBarAdditionWidth(file, stats);
- }
-
- /**
- * Get the width of the deletion bar for a file.
- *
- * @param {Object} file
- * @param {Gerrit.LayoutStats} stats
- * @return {number}
- */
- _computeBarDeletionWidth(file, stats) {
- if (stats.maxDeleted === 0 ||
- !file.lines_deleted ||
- !this._showBarsForPath(file.__path)) {
- return 0;
- }
- const width =
- stats.maxDeletionWidth * file.lines_deleted / stats.maxDeleted;
- return width === 0 ? 0 : Math.max(SIZE_BAR_MIN_WIDTH, width);
- }
-
- /**
- * Get the x-offset of the deletion bar for a file.
- *
- * @param {Gerrit.LayoutStats} stats
- *
- * @return {number}
- */
- _computeBarDeletionX(stats) {
- return stats.deletionOffset;
- }
-
- _computeShowSizeBars(userPrefs) {
- return !!userPrefs.size_bar_in_change_table;
- }
-
- _computeSizeBarsClass(showSizeBars, path) {
- let hideClass = '';
- if (!showSizeBars) {
- hideClass = 'hide';
- } else if (!this._showBarsForPath(path)) {
- hideClass = 'invisible';
- }
- return `sizeBars desktop ${hideClass}`;
- }
-
- /**
- * Shows registered dynamic columns iff the 'header', 'content' and
- * 'summary' endpoints are registered the exact same number of times.
- * Ideally, there should be a better way to enforce the expectation of the
- * dependencies between dynamic endpoints.
- */
- _computeShowDynamicColumns(
- headerEndpoints, contentEndpoints, summaryEndpoints) {
- return headerEndpoints && contentEndpoints && summaryEndpoints &&
- headerEndpoints.length &&
- headerEndpoints.length === contentEndpoints.length &&
- headerEndpoints.length === summaryEndpoints.length;
- }
-
- /**
- * Shows registered dynamic prepended columns iff the 'header', 'content'
- * endpoints are registered the exact same number of times.
- */
- _computeShowPrependedDynamicColumns(
- headerEndpoints, contentEndpoints) {
- return headerEndpoints && contentEndpoints &&
- headerEndpoints.length &&
- headerEndpoints.length === contentEndpoints.length;
- }
-
- /**
- * Returns true if none of the inline diffs have been expanded.
- *
- * @return {boolean}
- */
- _noDiffsExpanded() {
- return this.filesExpanded === FilesExpandedState.NONE;
- }
-
- /**
- * Method to call via binding when each file list row is rendered. This
- * allows approximate detection of when the dom-repeat has completed
- * rendering.
- *
- * @param {number} index The index of the row being rendered.
- * @return {string} an empty string.
- */
- _reportRenderedRow(index) {
- if (index === this._shownFiles.length - 1) {
- this.async(() => {
- this.reporting.timeEndWithAverage(RENDER_TIMING_LABEL,
- RENDER_AVG_TIMING_LABEL, this._reportinShownFilesIncrement);
- }, 1);
- }
- return '';
- }
-
- _reviewedTitle(reviewed) {
- if (reviewed) {
- return 'Mark as not reviewed (shortcut: r)';
- }
-
- return 'Mark as reviewed (shortcut: r)';
- }
-
- _handleReloadingDiffPreference() {
- this._getDiffPreferences().then(prefs => {
- this.diffPrefs = prefs;
- });
- }
-
- /**
- * Wrapper for using in the element template and computed properties
- */
- _computeDisplayPath(path) {
- return computeDisplayPath(path);
- }
-
- /**
- * Wrapper for using in the element template and computed properties
- */
- _computeTruncatedPath(path) {
- return computeTruncatedPath(path);
- }
-}
-
-customElements.define(GrFileList.is, GrFileList);
diff --git a/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list.ts b/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list.ts
new file mode 100644
index 0000000..3a2c858
--- /dev/null
+++ b/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list.ts
@@ -0,0 +1,1901 @@
+/**
+ * @license
+ * Copyright (C) 2015 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import '../../../styles/shared-styles';
+import '../../diff/gr-diff-cursor/gr-diff-cursor';
+import '../../diff/gr-diff-host/gr-diff-host';
+import '../../diff/gr-diff-preferences-dialog/gr-diff-preferences-dialog';
+import '../../edit/gr-edit-file-controls/gr-edit-file-controls';
+import '../../shared/gr-button/gr-button';
+import '../../shared/gr-cursor-manager/gr-cursor-manager';
+import '../../shared/gr-icons/gr-icons';
+import '../../shared/gr-linked-text/gr-linked-text';
+import '../../shared/gr-rest-api-interface/gr-rest-api-interface';
+import '../../shared/gr-select/gr-select';
+import '../../shared/gr-tooltip-content/gr-tooltip-content';
+import '../../shared/gr-copy-clipboard/gr-copy-clipboard';
+import {flush} 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-file-list_html';
+import {asyncForeach} from '../../../utils/async-util';
+import {
+ CustomKeyboardEvent,
+ KeyboardShortcutMixin,
+ Modifier,
+ Shortcut,
+} from '../../../mixins/keyboard-shortcut-mixin/keyboard-shortcut-mixin';
+import {FilesExpandedState} from '../gr-file-list-constants';
+import {GrCountStringFormatter} from '../../shared/gr-count-string-formatter/gr-count-string-formatter';
+import {GerritNav} from '../../core/gr-navigation/gr-navigation';
+import {getPluginEndpoints} from '../../shared/gr-js-api-interface/gr-plugin-endpoints';
+import {getPluginLoader} from '../../shared/gr-js-api-interface/gr-plugin-loader';
+import {appContext} from '../../../services/app-context';
+import {DiffViewMode, SpecialFilePath} from '../../../constants/constants';
+import {descendedFromClass} from '../../../utils/dom-util';
+import {
+ addUnmodifiedFiles,
+ computeDisplayPath,
+ computeTruncatedPath,
+ isMagicPath,
+ specialFilePathCompare,
+} from '../../../utils/path-list-util';
+import {customElement, observe, property} from '@polymer/decorators';
+import {RestApiService} from '../../../services/services/gr-rest-api/gr-rest-api';
+import {
+ ConfigInfo,
+ DiffPreferencesInfo,
+ ElementPropertyDeepChange,
+ FileInfo,
+ FileNameToFileInfoMap,
+ NumericChangeId,
+ PatchRange,
+ PreferencesInfo,
+ RevisionInfo,
+ UrlEncodedCommentId,
+} from '../../../types/common';
+import {GrDiffHost} from '../../diff/gr-diff-host/gr-diff-host';
+import {GrDiffPreferencesDialog} from '../../diff/gr-diff-preferences-dialog/gr-diff-preferences-dialog';
+import {hasOwnProperty} from '../../../utils/common-util';
+import {GrDiffCursor} from '../../diff/gr-diff-cursor/gr-diff-cursor';
+import {GrCursorManager} from '../../shared/gr-cursor-manager/gr-cursor-manager';
+import {PolymerSpliceChange} from '@polymer/polymer/interfaces';
+import {ChangeComments} from '../../diff/gr-comment-api/gr-comment-api';
+import {PatchSetFile, UIDraft} from '../../../utils/comment-util';
+import {ParsedChangeInfo} from '../../shared/gr-rest-api-interface/gr-reviewer-updates-parser';
+
+export const DEFAULT_NUM_FILES_SHOWN = 200;
+
+const WARN_SHOW_ALL_THRESHOLD = 1000;
+const LOADING_DEBOUNCE_INTERVAL = 100;
+
+const SIZE_BAR_MAX_WIDTH = 61;
+const SIZE_BAR_GAP_WIDTH = 1;
+const SIZE_BAR_MIN_WIDTH = 1.5;
+
+const RENDER_TIMING_LABEL = 'FileListRenderTime';
+const RENDER_AVG_TIMING_LABEL = 'FileListRenderTimePerFile';
+const EXPAND_ALL_TIMING_LABEL = 'ExpandAllDiffs';
+const EXPAND_ALL_AVG_TIMING_LABEL = 'ExpandAllPerDiff';
+
+const FileStatus = {
+ A: 'Added',
+ C: 'Copied',
+ D: 'Deleted',
+ M: 'Modified',
+ R: 'Renamed',
+ W: 'Rewritten',
+ U: 'Unchanged',
+};
+
+const FILE_ROW_CLASS = 'file-row';
+
+export interface GrFileList {
+ $: {
+ restAPI: RestApiService & Element;
+ diffPreferencesDialog: GrDiffPreferencesDialog;
+ diffCursor: GrDiffCursor;
+ fileCursor: GrCursorManager;
+ };
+}
+
+interface ReviewedFileInfo extends FileInfo {
+ isReviewed?: boolean;
+}
+interface NormalizedFileInfo extends ReviewedFileInfo {
+ __path: string;
+}
+
+interface PatchChange {
+ inserted: number;
+ deleted: number;
+ size_delta_inserted: number;
+ size_delta_deleted: number;
+ total_size: number;
+}
+
+function createDefaultPatchChange(): PatchChange {
+ // Use function instead of const to prevent unexpected changes in the default
+ // values.
+ return {
+ inserted: 0,
+ deleted: 0,
+ size_delta_inserted: 0,
+ size_delta_deleted: 0,
+ total_size: 0,
+ };
+}
+
+interface SizeBarLayout {
+ maxInserted: number;
+ maxDeleted: number;
+ maxAdditionWidth: number;
+ maxDeletionWidth: number;
+ deletionOffset: number;
+}
+
+function createDefaultSizeBarLayout(): SizeBarLayout {
+ // Use function instead of const to prevent unexpected changes in the default
+ // values.
+ return {
+ maxInserted: 0,
+ maxDeleted: 0,
+ maxAdditionWidth: 0,
+ maxDeletionWidth: 0,
+ deletionOffset: 0,
+ };
+}
+
+interface FileRow {
+ file: PatchSetFile;
+ element: HTMLElement;
+}
+
+export type FileNameToReviewedFileInfoMap = {[name: string]: ReviewedFileInfo};
+
+/**
+ * Type for FileInfo
+ *
+ * This should match with the type returned from `files` API plus
+ * additional info like `__path`.
+ *
+ * @typedef {Object} FileInfo
+ * @property {string} __path
+ * @property {?string} old_path
+ * @property {number} size
+ * @property {number} size_delta - fallback to 0 if not present in api
+ * @property {number} lines_deleted - fallback to 0 if not present in api
+ * @property {number} lines_inserted - fallback to 0 if not present in api
+ */
+
+@customElement('gr-file-list')
+export class GrFileList extends KeyboardShortcutMixin(
+ GestureEventListeners(LegacyElementMixin(PolymerElement))
+) {
+ static get template() {
+ return htmlTemplate;
+ }
+
+ /**
+ * Fired when a draft refresh should get triggered
+ *
+ * @event reload-drafts
+ */
+
+ @property({type: Object})
+ patchRange?: PatchRange;
+
+ @property({type: String})
+ patchNum?: string;
+
+ @property({type: Number})
+ changeNum?: NumericChangeId;
+
+ @property({type: Object})
+ changeComments?: ChangeComments;
+
+ @property({type: Object})
+ drafts?: {[path: string]: UIDraft[]};
+
+ @property({type: Array})
+ revisions?: {[revisionId: string]: RevisionInfo};
+
+ @property({type: Object})
+ projectConfig?: ConfigInfo;
+
+ @property({type: Number, notify: true})
+ selectedIndex = -1;
+
+ @property({type: Object})
+ keyEventTarget = document.body;
+
+ @property({type: Object})
+ change?: ParsedChangeInfo;
+
+ @property({type: String, notify: true, observer: '_updateDiffPreferences'})
+ diffViewMode?: DiffViewMode;
+
+ @property({type: Boolean, observer: '_editModeChanged'})
+ editMode?: boolean;
+
+ @property({type: String, notify: true})
+ filesExpanded = FilesExpandedState.NONE;
+
+ @property({type: Object})
+ _filesByPath?: FileNameToFileInfoMap;
+
+ @property({type: Array, observer: '_filesChanged'})
+ _files: NormalizedFileInfo[] = [];
+
+ @property({type: Boolean})
+ _loggedIn = false;
+
+ @property({type: Array})
+ _reviewed?: string[] = [];
+
+ @property({type: Object, notify: true, observer: '_updateDiffPreferences'})
+ diffPrefs?: DiffPreferencesInfo;
+
+ @property({type: Object})
+ _userPrefs?: PreferencesInfo;
+
+ @property({type: Boolean})
+ _showInlineDiffs?: boolean;
+
+ @property({type: Number, notify: true})
+ numFilesShown: number = DEFAULT_NUM_FILES_SHOWN;
+
+ @property({type: Object, computed: '_calculatePatchChange(_files)'})
+ _patchChange: PatchChange = createDefaultPatchChange();
+
+ @property({type: Number})
+ fileListIncrement: number = DEFAULT_NUM_FILES_SHOWN;
+
+ @property({type: Boolean, computed: '_shouldHideChangeTotals(_patchChange)'})
+ _hideChangeTotals = true;
+
+ @property({
+ type: Boolean,
+ computed: '_shouldHideBinaryChangeTotals(_patchChange)',
+ })
+ _hideBinaryChangeTotals = true;
+
+ @property({
+ type: Array,
+ computed: '_computeFilesShown(numFilesShown, _files)',
+ })
+ _shownFiles: NormalizedFileInfo[] = [];
+
+ @property({type: Number})
+ _reportinShownFilesIncrement = 0;
+
+ @property({type: Array})
+ _expandedFiles: PatchSetFile[] = [];
+
+ @property({type: Boolean})
+ _displayLine?: boolean;
+
+ @property({type: Boolean, observer: '_loadingChanged'})
+ _loading?: boolean;
+
+ @property({type: Object, computed: '_computeSizeBarLayout(_shownFiles.*)'})
+ _sizeBarLayout: SizeBarLayout = createDefaultSizeBarLayout();
+
+ @property({type: Boolean, computed: '_computeShowSizeBars(_userPrefs)'})
+ _showSizeBars = true;
+
+ private _cancelForEachDiff?: () => void;
+
+ @property({
+ type: Boolean,
+ computed:
+ '_computeShowDynamicColumns(_dynamicHeaderEndpoints, ' +
+ '_dynamicContentEndpoints, _dynamicSummaryEndpoints)',
+ })
+ _showDynamicColumns = false;
+
+ @property({
+ type: Boolean,
+ computed:
+ '_computeShowPrependedDynamicColumns(' +
+ '_dynamicPrependedHeaderEndpoints, _dynamicPrependedContentEndpoints)',
+ })
+ _showPrependedDynamicColumns = false;
+
+ @property({type: Array})
+ _dynamicHeaderEndpoints?: string[];
+
+ @property({type: Array})
+ _dynamicContentEndpoints?: string[];
+
+ @property({type: Array})
+ _dynamicSummaryEndpoints?: string[];
+
+ @property({type: Array})
+ _dynamicPrependedHeaderEndpoints?: string[];
+
+ @property({type: Array})
+ _dynamicPrependedContentEndpoints?: string[];
+
+ private readonly reporting = appContext.reportingService;
+
+ get keyBindings() {
+ return {
+ esc: '_handleEscKey',
+ };
+ }
+
+ keyboardShortcuts() {
+ return {
+ [Shortcut.LEFT_PANE]: '_handleLeftPane',
+ [Shortcut.RIGHT_PANE]: '_handleRightPane',
+ [Shortcut.TOGGLE_INLINE_DIFF]: '_handleToggleInlineDiff',
+ [Shortcut.TOGGLE_ALL_INLINE_DIFFS]: '_handleToggleAllInlineDiffs',
+ [Shortcut.TOGGLE_HIDE_ALL_COMMENT_THREADS]:
+ '_handleToggleHideAllCommentThreads',
+ [Shortcut.CURSOR_NEXT_FILE]: '_handleCursorNext',
+ [Shortcut.CURSOR_PREV_FILE]: '_handleCursorPrev',
+ [Shortcut.NEXT_LINE]: '_handleCursorNext',
+ [Shortcut.PREV_LINE]: '_handleCursorPrev',
+ [Shortcut.NEW_COMMENT]: '_handleNewComment',
+ [Shortcut.OPEN_LAST_FILE]: '_handleOpenLastFile',
+ [Shortcut.OPEN_FIRST_FILE]: '_handleOpenFirstFile',
+ [Shortcut.OPEN_FILE]: '_handleOpenFile',
+ [Shortcut.NEXT_CHUNK]: '_handleNextChunk',
+ [Shortcut.PREV_CHUNK]: '_handlePrevChunk',
+ [Shortcut.TOGGLE_FILE_REVIEWED]: '_handleToggleFileReviewed',
+ [Shortcut.TOGGLE_LEFT_PANE]: '_handleToggleLeftPane',
+
+ // Final two are actually handled by gr-comment-thread.
+ [Shortcut.EXPAND_ALL_COMMENT_THREADS]: null,
+ [Shortcut.COLLAPSE_ALL_COMMENT_THREADS]: null,
+ };
+ }
+
+ /** @override */
+ created() {
+ super.created();
+ this.addEventListener('keydown', e => this._scopedKeydownHandler(e));
+ }
+
+ /** @override */
+ attached() {
+ super.attached();
+ getPluginLoader()
+ .awaitPluginsLoaded()
+ .then(() => {
+ this._dynamicHeaderEndpoints = getPluginEndpoints().getDynamicEndpoints(
+ 'change-view-file-list-header'
+ );
+ this._dynamicContentEndpoints = getPluginEndpoints().getDynamicEndpoints(
+ 'change-view-file-list-content'
+ );
+ this._dynamicPrependedHeaderEndpoints = getPluginEndpoints().getDynamicEndpoints(
+ 'change-view-file-list-header-prepend'
+ );
+ this._dynamicPrependedContentEndpoints = getPluginEndpoints().getDynamicEndpoints(
+ 'change-view-file-list-content-prepend'
+ );
+ this._dynamicSummaryEndpoints = getPluginEndpoints().getDynamicEndpoints(
+ 'change-view-file-list-summary'
+ );
+
+ if (
+ this._dynamicHeaderEndpoints.length !==
+ this._dynamicContentEndpoints.length
+ ) {
+ console.warn(
+ 'Different number of dynamic file-list header and content.'
+ );
+ }
+ if (
+ this._dynamicPrependedHeaderEndpoints.length !==
+ this._dynamicPrependedContentEndpoints.length
+ ) {
+ console.warn(
+ 'Different number of dynamic file-list header and content.'
+ );
+ }
+ if (
+ this._dynamicHeaderEndpoints.length !==
+ this._dynamicSummaryEndpoints.length
+ ) {
+ console.warn(
+ 'Different number of dynamic file-list headers and summary.'
+ );
+ }
+ });
+ }
+
+ /** @override */
+ detached() {
+ super.detached();
+ this._cancelDiffs();
+ }
+
+ /**
+ * Iron-a11y-keys-behavior catches keyboard events globally. Some keyboard
+ * events must be scoped to a component level (e.g. `enter`) in order to not
+ * override native browser functionality.
+ *
+ * Context: Issue 7277
+ */
+ _scopedKeydownHandler(e: KeyboardEvent) {
+ if (e.keyCode === 13) {
+ // TODO(TS): e is not an instance of CustomKeyboardEvent.
+ // However, to fix it we should fix keyboard-shortcut-mixin first
+ // The keyboard-shortcut-mixin will be updated in a separate change
+ this._handleOpenFile((e as unknown) as CustomKeyboardEvent);
+ }
+ }
+
+ reload() {
+ if (!this.changeNum || !this.patchRange?.patchNum) {
+ return Promise.resolve();
+ }
+ const changeNum = this.changeNum;
+ const patchRange = this.patchRange;
+
+ this._loading = true;
+
+ this.collapseAllDiffs();
+ const promises = [];
+
+ promises.push(
+ this.$.restAPI
+ .getChangeOrEditFiles(changeNum, patchRange)
+ .then(filesByPath => {
+ this._filesByPath = filesByPath;
+ })
+ );
+ promises.push(
+ this._getLoggedIn()
+ .then(loggedIn => (this._loggedIn = loggedIn))
+ .then(loggedIn => {
+ if (!loggedIn) {
+ return;
+ }
+
+ return this._getReviewedFiles(changeNum, patchRange).then(
+ reviewed => {
+ this._reviewed = reviewed;
+ }
+ );
+ })
+ );
+
+ promises.push(
+ this._getDiffPreferences().then(prefs => {
+ this.diffPrefs = prefs;
+ })
+ );
+
+ promises.push(
+ this._getPreferences().then(prefs => {
+ this._userPrefs = prefs;
+ })
+ );
+
+ return Promise.all(promises).then(() => {
+ this._loading = false;
+ this._detectChromiteButler();
+ this.reporting.fileListDisplayed();
+ });
+ }
+
+ _detectChromiteButler() {
+ const hasButler = !!document.getElementById('butler-suggested-owners');
+ if (hasButler) {
+ this.reporting.reportExtension('butler');
+ }
+ }
+
+ get diffs(): GrDiffHost[] {
+ const diffs = this.root!.querySelectorAll('gr-diff-host');
+ // It is possible that a bogus diff element is hanging around invisibly
+ // from earlier with a different patch set choice and associated with a
+ // different entry in the files array. So filter on visible items only.
+ return Array.from(diffs).filter(
+ el => !!el && !!el.style && el.style.display !== 'none'
+ );
+ }
+
+ openDiffPrefs() {
+ this.$.diffPreferencesDialog.open();
+ }
+
+ _calculatePatchChange(files: NormalizedFileInfo[]): PatchChange {
+ const magicFilesExcluded = files.filter(
+ files => !isMagicPath(files.__path)
+ );
+
+ return magicFilesExcluded.reduce((acc, obj) => {
+ const inserted = obj.lines_inserted ? obj.lines_inserted : 0;
+ const deleted = obj.lines_deleted ? obj.lines_deleted : 0;
+ const total_size = obj.size && obj.binary ? obj.size : 0;
+ const size_delta_inserted =
+ obj.binary && obj.size_delta > 0 ? obj.size_delta : 0;
+ const size_delta_deleted =
+ obj.binary && obj.size_delta < 0 ? obj.size_delta : 0;
+
+ return {
+ inserted: acc.inserted + inserted,
+ deleted: acc.deleted + deleted,
+ size_delta_inserted: acc.size_delta_inserted + size_delta_inserted,
+ size_delta_deleted: acc.size_delta_deleted + size_delta_deleted,
+ total_size: acc.total_size + total_size,
+ };
+ }, createDefaultPatchChange());
+ }
+
+ _getDiffPreferences() {
+ return this.$.restAPI.getDiffPreferences();
+ }
+
+ _getPreferences() {
+ return this.$.restAPI.getPreferences();
+ }
+
+ private _toggleFileExpanded(file: PatchSetFile) {
+ // Is the path in the list of expanded diffs? IF so remove it, otherwise
+ // add it to the list.
+ const pathIndex = this._expandedFiles.findIndex(f => f.path === file.path);
+ if (pathIndex === -1) {
+ this.push('_expandedFiles', file);
+ } else {
+ this.splice('_expandedFiles', pathIndex, 1);
+ }
+ }
+
+ _toggleFileExpandedByIndex(index: number) {
+ this._toggleFileExpanded(this._computePatchSetFile(this._files[index]));
+ }
+
+ _updateDiffPreferences() {
+ if (!this.diffs.length) {
+ return;
+ }
+ // Re-render all expanded diffs sequentially.
+ this.reporting.time(EXPAND_ALL_TIMING_LABEL);
+ this._renderInOrder(
+ this._expandedFiles,
+ this.diffs,
+ this._expandedFiles.length
+ );
+ }
+
+ _forEachDiff(fn: (host: GrDiffHost) => void) {
+ const diffs = this.diffs;
+ for (let i = 0; i < diffs.length; i++) {
+ fn(diffs[i]);
+ }
+ }
+
+ expandAllDiffs() {
+ this._showInlineDiffs = true;
+
+ // Find the list of paths that are in the file list, but not in the
+ // expanded list.
+ const newFiles: PatchSetFile[] = [];
+ let path: string;
+ for (let i = 0; i < this._shownFiles.length; i++) {
+ path = this._shownFiles[i].__path;
+ if (!this._expandedFiles.some(f => f.path === path)) {
+ newFiles.push(this._computePatchSetFile(this._shownFiles[i]));
+ }
+ }
+
+ this.splice('_expandedFiles', 0, 0, ...newFiles);
+ }
+
+ collapseAllDiffs() {
+ this._showInlineDiffs = false;
+ this._expandedFiles = [];
+ this.filesExpanded = this._computeExpandedFiles(
+ this._expandedFiles.length,
+ this._files.length
+ );
+ this.$.diffCursor.handleDiffUpdate();
+ }
+
+ /**
+ * Computes a string with the number of comments and unresolved comments.
+ */
+ _computeCommentsString(
+ changeComments?: ChangeComments,
+ patchRange?: PatchRange,
+ path?: string
+ ) {
+ if (
+ changeComments === undefined ||
+ patchRange === undefined ||
+ path === undefined
+ ) {
+ return '';
+ }
+ const unresolvedCount =
+ changeComments.computeUnresolvedNum({
+ patchNum: patchRange.basePatchNum,
+ path,
+ }) +
+ changeComments.computeUnresolvedNum({
+ patchNum: patchRange.patchNum,
+ path,
+ });
+ const commentCount =
+ changeComments.computeCommentCount({
+ patchNum: patchRange.basePatchNum,
+ path,
+ }) +
+ changeComments.computeCommentCount({
+ patchNum: patchRange.patchNum,
+ path,
+ });
+ const commentString = GrCountStringFormatter.computePluralString(
+ commentCount,
+ 'comment'
+ );
+ const unresolvedString = GrCountStringFormatter.computeString(
+ unresolvedCount,
+ 'unresolved'
+ );
+
+ return (
+ commentString +
+ // Add a space if both comments and unresolved
+ (commentString && unresolvedString ? ' ' : '') +
+ // Add parentheses around unresolved if it exists.
+ (unresolvedString ? `(${unresolvedString})` : '')
+ );
+ }
+
+ /**
+ * Computes a string with the number of drafts.
+ */
+ _computeDraftsString(
+ changeComments?: ChangeComments,
+ patchRange?: PatchRange,
+ path?: string
+ ) {
+ if (
+ changeComments === undefined ||
+ patchRange === undefined ||
+ path === undefined
+ ) {
+ return '';
+ }
+ const draftCount =
+ changeComments.computeDraftCount({
+ patchNum: patchRange.basePatchNum,
+ path,
+ }) +
+ changeComments.computeDraftCount({
+ patchNum: patchRange.patchNum,
+ path,
+ });
+ return GrCountStringFormatter.computePluralString(draftCount, 'draft');
+ }
+
+ /**
+ * Computes a shortened string with the number of drafts.
+ */
+ _computeDraftsStringMobile(
+ changeComments?: ChangeComments,
+ patchRange?: PatchRange,
+ path?: string
+ ) {
+ if (
+ changeComments === undefined ||
+ patchRange === undefined ||
+ path === undefined
+ ) {
+ return '';
+ }
+ const draftCount =
+ changeComments.computeDraftCount({
+ patchNum: patchRange.basePatchNum,
+ path,
+ }) +
+ changeComments.computeDraftCount({
+ patchNum: patchRange.patchNum,
+ path,
+ });
+ return GrCountStringFormatter.computeShortString(draftCount, 'd');
+ }
+
+ /**
+ * Computes a shortened string with the number of comments.
+ */
+ _computeCommentsStringMobile(
+ changeComments?: ChangeComments,
+ patchRange?: PatchRange,
+ path?: string
+ ) {
+ if (
+ changeComments === undefined ||
+ patchRange === undefined ||
+ path === undefined
+ ) {
+ return '';
+ }
+ const commentCount =
+ changeComments.computeCommentCount({
+ patchNum: patchRange.basePatchNum,
+ path,
+ }) +
+ changeComments.computeCommentCount({
+ patchNum: patchRange.patchNum,
+ path,
+ });
+ return GrCountStringFormatter.computeShortString(commentCount, 'c');
+ }
+
+ private _reviewFile(path: string, reviewed?: boolean) {
+ if (this.editMode) {
+ return Promise.resolve();
+ }
+ const index = this._files.findIndex(file => file.__path === path);
+ reviewed = reviewed || !this._files[index].isReviewed;
+
+ this.set(['_files', index, 'isReviewed'], reviewed);
+ if (index < this._shownFiles.length) {
+ this.notifyPath(`_shownFiles.${index}.isReviewed`);
+ }
+
+ return this._saveReviewedState(path, reviewed);
+ }
+
+ _saveReviewedState(path: string, reviewed: boolean) {
+ if (!this.changeNum || !this.patchRange) {
+ throw new Error('changeNum and patchRange must be set');
+ }
+
+ return this.$.restAPI.saveFileReviewed(
+ this.changeNum,
+ this.patchRange.patchNum,
+ path,
+ reviewed
+ );
+ }
+
+ _getLoggedIn() {
+ return this.$.restAPI.getLoggedIn();
+ }
+
+ _getReviewedFiles(changeNum: NumericChangeId, patchRange: PatchRange) {
+ if (this.editMode) {
+ return Promise.resolve([]);
+ }
+ return this.$.restAPI.getReviewedFiles(changeNum, patchRange.patchNum);
+ }
+
+ _normalizeChangeFilesResponse(
+ response: FileNameToReviewedFileInfoMap
+ ): NormalizedFileInfo[] {
+ const paths = Object.keys(response).sort(specialFilePathCompare);
+ const files: NormalizedFileInfo[] = [];
+ for (let i = 0; i < paths.length; i++) {
+ // TODO(TS): make copy instead of as NormalizedFileInfo
+ const info = response[paths[i]] as NormalizedFileInfo;
+ info.__path = paths[i];
+ info.lines_inserted = info.lines_inserted || 0;
+ info.lines_deleted = info.lines_deleted || 0;
+ info.size_delta = info.size_delta || 0;
+ files.push(info);
+ }
+ return files;
+ }
+
+ /**
+ * Returns true if the event e is a click on an element.
+ *
+ * The click is: mouse click or pressing Enter or Space key
+ * P.S> Screen readers sends click event as well
+ */
+ _isClickEvent(e: MouseEvent | KeyboardEvent) {
+ if (e.type === 'click') {
+ return true;
+ }
+ const ke = e as KeyboardEvent;
+ const isSpaceOrEnter = ke.key === 'Enter' || ke.key === ' ';
+ return ke.type === 'keydown' && isSpaceOrEnter;
+ }
+
+ _fileActionClick(
+ e: MouseEvent | KeyboardEvent,
+ fileAction: (file: PatchSetFile) => void
+ ) {
+ if (this._isClickEvent(e)) {
+ const fileRow = this._getFileRowFromEvent(e);
+ if (!fileRow) {
+ return;
+ }
+ // Prevent default actions (e.g. scrolling for space key)
+ e.preventDefault();
+ // Prevent _handleFileListClick handler call
+ e.stopPropagation();
+ this.$.fileCursor.setCursor(fileRow.element);
+ fileAction(fileRow.file);
+ }
+ }
+
+ _reviewedClick(e: MouseEvent | KeyboardEvent) {
+ this._fileActionClick(e, file => this._reviewFile(file.path));
+ }
+
+ _expandedClick(e: MouseEvent | KeyboardEvent) {
+ this._fileActionClick(e, file => this._toggleFileExpanded(file));
+ }
+
+ /**
+ * Handle all events from the file list dom-repeat so event handleers don't
+ * have to get registered for potentially very long lists.
+ */
+ _handleFileListClick(e: MouseEvent) {
+ if (!e.target) {
+ return;
+ }
+ const fileRow = this._getFileRowFromEvent(e);
+ if (!fileRow) {
+ return;
+ }
+ const file = fileRow.file;
+ const path = file.path;
+ // If a path cannot be interpreted from the click target (meaning it's not
+ // somewhere in the row, e.g. diff content) or if the user clicked the
+ // link, defer to the native behavior.
+ if (!path || descendedFromClass(e.target as Element, 'pathLink')) {
+ return;
+ }
+
+ // Disregard the event if the click target is in the edit controls.
+ if (descendedFromClass(e.target as Element, 'editFileControls')) {
+ return;
+ }
+
+ e.preventDefault();
+ this.$.fileCursor.setCursor(fileRow.element);
+ this._toggleFileExpanded(file);
+ }
+
+ _getFileRowFromEvent(e: Event): FileRow | null {
+ // Traverse upwards to find the row element if the target is not the row.
+ let row = e.target as HTMLElement;
+ while (!row.classList.contains(FILE_ROW_CLASS) && row.parentElement) {
+ row = row.parentElement;
+ }
+
+ // No action needed for item without a valid file
+ if (!row.dataset['file']) {
+ return null;
+ }
+
+ return {
+ file: JSON.parse(row.dataset['file']) as PatchSetFile,
+ element: row,
+ };
+ }
+
+ /**
+ * Generates file range from file info object.
+ */
+ _computePatchSetFile(file: NormalizedFileInfo): PatchSetFile {
+ const fileData: PatchSetFile = {
+ path: file.__path,
+ };
+ if (file.old_path) {
+ fileData.basePath = file.old_path;
+ }
+ return fileData;
+ }
+
+ _handleLeftPane(e: CustomKeyboardEvent) {
+ if (this.shouldSuppressKeyboardShortcut(e) || this._noDiffsExpanded()) {
+ return;
+ }
+
+ e.preventDefault();
+ this.$.diffCursor.moveLeft();
+ }
+
+ _handleRightPane(e: CustomKeyboardEvent) {
+ if (this.shouldSuppressKeyboardShortcut(e) || this._noDiffsExpanded()) {
+ return;
+ }
+
+ e.preventDefault();
+ this.$.diffCursor.moveRight();
+ }
+
+ _handleToggleInlineDiff(e: CustomKeyboardEvent) {
+ if (
+ this.shouldSuppressKeyboardShortcut(e) ||
+ this.modifierPressed(e) ||
+ this.$.fileCursor.index === -1
+ ) {
+ return;
+ }
+
+ e.preventDefault();
+ this._toggleFileExpandedByIndex(this.$.fileCursor.index);
+ }
+
+ _handleToggleAllInlineDiffs(e: CustomKeyboardEvent) {
+ if (this.shouldSuppressKeyboardShortcut(e)) {
+ return;
+ }
+
+ e.preventDefault();
+ this._toggleInlineDiffs();
+ }
+
+ _handleToggleHideAllCommentThreads(e: CustomKeyboardEvent) {
+ if (this.shouldSuppressKeyboardShortcut(e) || this.modifierPressed(e)) {
+ return;
+ }
+
+ e.preventDefault();
+ this.toggleClass('hideComments');
+ }
+
+ _handleCursorNext(e: CustomKeyboardEvent) {
+ if (this.shouldSuppressKeyboardShortcut(e) || this.modifierPressed(e)) {
+ return;
+ }
+
+ if (this._showInlineDiffs) {
+ e.preventDefault();
+ this.$.diffCursor.moveDown();
+ this._displayLine = true;
+ } else {
+ // Down key
+ if (this.getKeyboardEvent(e).keyCode === 40) {
+ return;
+ }
+ e.preventDefault();
+ this.$.fileCursor.next();
+ this.selectedIndex = this.$.fileCursor.index;
+ }
+ }
+
+ _handleCursorPrev(e: CustomKeyboardEvent) {
+ if (this.shouldSuppressKeyboardShortcut(e) || this.modifierPressed(e)) {
+ return;
+ }
+
+ if (this._showInlineDiffs) {
+ e.preventDefault();
+ this.$.diffCursor.moveUp();
+ this._displayLine = true;
+ } else {
+ // Up key
+ if (this.getKeyboardEvent(e).keyCode === 38) {
+ return;
+ }
+ e.preventDefault();
+ this.$.fileCursor.previous();
+ this.selectedIndex = this.$.fileCursor.index;
+ }
+ }
+
+ _handleNewComment(e: CustomKeyboardEvent) {
+ if (this.shouldSuppressKeyboardShortcut(e) || this.modifierPressed(e)) {
+ return;
+ }
+ e.preventDefault();
+ this.$.diffCursor.createCommentInPlace();
+ }
+
+ _handleOpenLastFile(e: CustomKeyboardEvent) {
+ // Check for meta key to avoid overriding native chrome shortcut.
+ if (
+ this.shouldSuppressKeyboardShortcut(e) ||
+ this.getKeyboardEvent(e).metaKey
+ ) {
+ return;
+ }
+
+ e.preventDefault();
+ this._openSelectedFile(this._files.length - 1);
+ }
+
+ _handleOpenFirstFile(e: CustomKeyboardEvent) {
+ // Check for meta key to avoid overriding native chrome shortcut.
+ if (
+ this.shouldSuppressKeyboardShortcut(e) ||
+ this.getKeyboardEvent(e).metaKey
+ ) {
+ return;
+ }
+
+ e.preventDefault();
+ this._openSelectedFile(0);
+ }
+
+ _handleOpenFile(e: CustomKeyboardEvent) {
+ if (this.shouldSuppressKeyboardShortcut(e) || this.modifierPressed(e)) {
+ return;
+ }
+ e.preventDefault();
+
+ if (this._showInlineDiffs) {
+ this._openCursorFile();
+ return;
+ }
+
+ this._openSelectedFile();
+ }
+
+ _handleNextChunk(e: CustomKeyboardEvent) {
+ if (
+ this.shouldSuppressKeyboardShortcut(e) ||
+ (this.modifierPressed(e) &&
+ !this.isModifierPressed(e, Modifier.SHIFT_KEY)) ||
+ this._noDiffsExpanded()
+ ) {
+ return;
+ }
+
+ e.preventDefault();
+ if (this.isModifierPressed(e, Modifier.SHIFT_KEY)) {
+ this.$.diffCursor.moveToNextCommentThread();
+ } else {
+ this.$.diffCursor.moveToNextChunk();
+ }
+ }
+
+ _handlePrevChunk(e: CustomKeyboardEvent) {
+ if (
+ this.shouldSuppressKeyboardShortcut(e) ||
+ (this.modifierPressed(e) &&
+ !this.isModifierPressed(e, Modifier.SHIFT_KEY)) ||
+ this._noDiffsExpanded()
+ ) {
+ return;
+ }
+
+ e.preventDefault();
+ if (this.isModifierPressed(e, Modifier.SHIFT_KEY)) {
+ this.$.diffCursor.moveToPreviousCommentThread();
+ } else {
+ this.$.diffCursor.moveToPreviousChunk();
+ }
+ }
+
+ _handleToggleFileReviewed(e: CustomKeyboardEvent) {
+ if (this.shouldSuppressKeyboardShortcut(e) || this.modifierPressed(e)) {
+ return;
+ }
+
+ e.preventDefault();
+ if (!this._files[this.$.fileCursor.index]) {
+ return;
+ }
+ this._reviewFile(this._files[this.$.fileCursor.index].__path);
+ }
+
+ _handleToggleLeftPane(e: CustomKeyboardEvent) {
+ if (this.shouldSuppressKeyboardShortcut(e)) {
+ return;
+ }
+
+ e.preventDefault();
+ this._forEachDiff(diff => {
+ diff.toggleLeftDiff();
+ });
+ }
+
+ _toggleInlineDiffs() {
+ if (this._showInlineDiffs) {
+ this.collapseAllDiffs();
+ } else {
+ this.expandAllDiffs();
+ }
+ }
+
+ _openCursorFile() {
+ const diff = this.$.diffCursor.getTargetDiffElement();
+ if (
+ !this.change ||
+ !diff ||
+ !this.patchRange ||
+ !diff.path ||
+ !diff.patchRange
+ ) {
+ throw new Error('change, diff and patchRange must be all set and valid');
+ }
+ GerritNav.navigateToDiff(
+ this.change,
+ diff.path,
+ diff.patchRange.patchNum,
+ this.patchRange.basePatchNum
+ );
+ }
+
+ _openSelectedFile(index?: number) {
+ if (index !== undefined) {
+ this.$.fileCursor.setCursorAtIndex(index);
+ }
+ if (!this._files[this.$.fileCursor.index]) {
+ return;
+ }
+ if (!this.change || !this.patchRange) {
+ throw new Error('change and patchRange must be set');
+ }
+ GerritNav.navigateToDiff(
+ this.change,
+ this._files[this.$.fileCursor.index].__path,
+ this.patchRange.patchNum,
+ this.patchRange.basePatchNum
+ );
+ }
+
+ _addDraftAtTarget() {
+ const diff = this.$.diffCursor.getTargetDiffElement();
+ const target = this.$.diffCursor.getTargetLineElement();
+ if (diff && target) {
+ diff.addDraftAtLine(target);
+ }
+ }
+
+ _shouldHideChangeTotals(_patchChange: PatchChange): boolean {
+ return _patchChange.inserted === 0 && _patchChange.deleted === 0;
+ }
+
+ _shouldHideBinaryChangeTotals(_patchChange: PatchChange) {
+ return (
+ _patchChange.size_delta_inserted === 0 &&
+ _patchChange.size_delta_deleted === 0
+ );
+ }
+
+ _computeFileStatus(
+ status?: keyof typeof FileStatus
+ ): keyof typeof FileStatus {
+ return status || 'M';
+ }
+
+ _computeDiffURL(
+ change?: ParsedChangeInfo,
+ patchRange?: PatchRange,
+ path?: string,
+ editMode?: boolean
+ ) {
+ // Polymer 2: check for undefined
+ if (
+ change === undefined ||
+ patchRange === undefined ||
+ path === undefined ||
+ editMode === undefined
+ ) {
+ return;
+ }
+ if (editMode && path !== SpecialFilePath.MERGE_LIST) {
+ return GerritNav.getEditUrlForDiff(change, path, patchRange.patchNum);
+ }
+ return GerritNav.getUrlForDiff(
+ change,
+ path,
+ patchRange.patchNum,
+ patchRange.basePatchNum
+ );
+ }
+
+ _formatBytes(bytes?: number) {
+ if (!bytes) return '+/-0 B';
+ const bits = 1024;
+ const decimals = 1;
+ const sizes = ['B', 'KiB', 'MiB', 'GiB', 'TiB', 'PiB', 'EiB', 'ZiB', 'YiB'];
+ const exponent = Math.floor(Math.log(Math.abs(bytes)) / Math.log(bits));
+ const prepend = bytes > 0 ? '+' : '';
+ const value = parseFloat(
+ (bytes / Math.pow(bits, exponent)).toFixed(decimals)
+ );
+ return `${prepend}${value} ${sizes[exponent]}`;
+ }
+
+ _formatPercentage(size?: number, delta?: number) {
+ if (size === undefined || delta === undefined) {
+ return '';
+ }
+ const oldSize = size - delta;
+
+ if (oldSize === 0) {
+ return '';
+ }
+
+ const percentage = Math.round(Math.abs((delta * 100) / oldSize));
+ return `(${delta > 0 ? '+' : '-'}${percentage}%)`;
+ }
+
+ _computeBinaryClass(delta?: number) {
+ if (!delta) {
+ return;
+ }
+ return delta > 0 ? 'added' : 'removed';
+ }
+
+ _computeClass(baseClass?: string, path?: string) {
+ const classes = [];
+ if (baseClass) {
+ classes.push(baseClass);
+ }
+ if (
+ path === SpecialFilePath.COMMIT_MESSAGE ||
+ path === SpecialFilePath.MERGE_LIST
+ ) {
+ classes.push('invisible');
+ }
+ return classes.join(' ');
+ }
+
+ _computeStatusClass(file?: NormalizedFileInfo) {
+ if (!file) return '';
+ const classStr = this._computeClass('status', file.__path);
+ return `${classStr} ${this._computeFileStatus(file.status)}`;
+ }
+
+ _computePathClass(
+ path: string | undefined,
+ expandedFilesRecord: ElementPropertyDeepChange<GrFileList, '_expandedFiles'>
+ ) {
+ return this._isFileExpanded(path, expandedFilesRecord) ? 'expanded' : '';
+ }
+
+ _computeShowHideIcon(
+ path: string | undefined,
+ expandedFilesRecord: ElementPropertyDeepChange<GrFileList, '_expandedFiles'>
+ ) {
+ return this._isFileExpanded(path, expandedFilesRecord)
+ ? 'gr-icons:expand-less'
+ : 'gr-icons:expand-more';
+ }
+
+ @observe(
+ '_filesByPath',
+ 'changeComments',
+ 'patchRange',
+ '_reviewed',
+ '_loading'
+ )
+ _computeFiles(
+ filesByPath?: FileNameToFileInfoMap,
+ changeComments?: ChangeComments,
+ patchRange?: PatchRange,
+ reviewed?: string[],
+ loading?: boolean
+ ) {
+ // Polymer 2: check for undefined
+ if (
+ filesByPath === undefined ||
+ changeComments === undefined ||
+ patchRange === undefined ||
+ reviewed === undefined ||
+ loading === undefined
+ ) {
+ return;
+ }
+
+ // Await all promises resolving from reload. @See Issue 9057
+ if (loading || !changeComments) {
+ return;
+ }
+
+ const commentedPaths = changeComments.getPaths(patchRange);
+ const files: FileNameToReviewedFileInfoMap = {...filesByPath};
+ addUnmodifiedFiles(files, commentedPaths);
+ const reviewedSet = new Set(reviewed || []);
+ for (const filePath in files) {
+ if (!hasOwnProperty(files, filePath)) {
+ continue;
+ }
+ files[filePath].isReviewed = reviewedSet.has(filePath);
+ }
+
+ this._files = this._normalizeChangeFilesResponse(files);
+ }
+
+ _computeFilesShown(
+ numFilesShown: number,
+ files: NormalizedFileInfo[]
+ ): NormalizedFileInfo[] | undefined {
+ // Polymer 2: check for undefined
+ if (numFilesShown === undefined || files === undefined) return undefined;
+
+ const previousNumFilesShown = this._shownFiles
+ ? this._shownFiles.length
+ : 0;
+
+ const filesShown = files.slice(0, numFilesShown);
+ this.dispatchEvent(
+ new CustomEvent('files-shown-changed', {
+ detail: {length: filesShown.length},
+ composed: true,
+ bubbles: true,
+ })
+ );
+
+ // Start the timer for the rendering work hwere because this is where the
+ // _shownFiles property is being set, and _shownFiles is used in the
+ // dom-repeat binding.
+ this.reporting.time(RENDER_TIMING_LABEL);
+
+ // How many more files are being shown (if it's an increase).
+ this._reportinShownFilesIncrement = Math.max(
+ 0,
+ filesShown.length - previousNumFilesShown
+ );
+
+ return filesShown;
+ }
+
+ _updateDiffCursor() {
+ // Overwrite the cursor's list of diffs:
+ this.$.diffCursor.splice(
+ 'diffs',
+ 0,
+ this.$.diffCursor.diffs.length,
+ ...this.diffs
+ );
+ }
+
+ _filesChanged() {
+ if (this._files && this._files.length > 0) {
+ flush();
+ this.$.fileCursor.stops = Array.from(
+ this.root!.querySelectorAll(`.${FILE_ROW_CLASS}`)
+ );
+ this.$.fileCursor.setCursorAtIndex(this.selectedIndex, true);
+ }
+ }
+
+ _incrementNumFilesShown() {
+ this.numFilesShown += this.fileListIncrement;
+ }
+
+ _computeFileListControlClass(
+ numFilesShown?: number,
+ files?: NormalizedFileInfo[]
+ ) {
+ if (numFilesShown === undefined || files === undefined) return 'invisible';
+ return numFilesShown >= files.length ? 'invisible' : '';
+ }
+
+ _computeIncrementText(numFilesShown?: number, files?: NormalizedFileInfo[]) {
+ if (numFilesShown === undefined || files === undefined) return '';
+ const text = Math.min(this.fileListIncrement, files.length - numFilesShown);
+ return `Show ${text} more`;
+ }
+
+ _computeShowAllText(files: NormalizedFileInfo[]) {
+ if (!files) {
+ return '';
+ }
+ return `Show all ${files.length} files`;
+ }
+
+ _computeWarnShowAll(files: NormalizedFileInfo[]) {
+ return files.length > WARN_SHOW_ALL_THRESHOLD;
+ }
+
+ _computeShowAllWarning(files: NormalizedFileInfo[]) {
+ if (!this._computeWarnShowAll(files)) {
+ return '';
+ }
+ return `Warning: showing all ${files.length} files may take several seconds.`;
+ }
+
+ _showAllFiles() {
+ this.numFilesShown = this._files.length;
+ }
+
+ /**
+ * Get a descriptive label for use in the status indicator's tooltip and
+ * ARIA label.
+ */
+ _computeFileStatusLabel(status?: keyof typeof FileStatus) {
+ const statusCode = this._computeFileStatus(status);
+ return hasOwnProperty(FileStatus, statusCode)
+ ? FileStatus[statusCode]
+ : 'Status Unknown';
+ }
+
+ /**
+ * Converts any boolean-like variable to the string 'true' or 'false'
+ *
+ * This method is useful when you bind aria-checked attribute to a boolean
+ * value. The aria-checked attribute is string attribute. Binding directly
+ * to boolean variable causes problem on gerrit-CI.
+ *
+ * @return 'true' if val is true-like, otherwise false
+ */
+ _booleanToString(val?: unknown) {
+ return val ? 'true' : 'false';
+ }
+
+ _isFileExpanded(
+ path: string | undefined,
+ expandedFilesRecord: ElementPropertyDeepChange<GrFileList, '_expandedFiles'>
+ ) {
+ return expandedFilesRecord.base.some(f => f.path === path);
+ }
+
+ _isFileExpandedStr(
+ path: string | undefined,
+ expandedFilesRecord: ElementPropertyDeepChange<GrFileList, '_expandedFiles'>
+ ) {
+ return this._booleanToString(
+ this._isFileExpanded(path, expandedFilesRecord)
+ );
+ }
+
+ private _computeExpandedFiles(
+ expandedCount: number,
+ totalCount: number
+ ): FilesExpandedState {
+ if (expandedCount === 0) {
+ return FilesExpandedState.NONE;
+ } else if (expandedCount === totalCount) {
+ return FilesExpandedState.ALL;
+ }
+ return FilesExpandedState.SOME;
+ }
+
+ /**
+ * Handle splices to the list of expanded file paths. If there are any new
+ * entries in the expanded list, then render each diff corresponding in
+ * order by waiting for the previous diff to finish before starting the next
+ * one.
+ *
+ * @param record The splice record in the expanded paths list.
+ */
+ @observe('_expandedFiles.splices')
+ _expandedFilesChanged(record?: PolymerSpliceChange<PatchSetFile[]>) {
+ // Clear content for any diffs that are not open so if they get re-opened
+ // the stale content does not flash before it is cleared and reloaded.
+ const collapsedDiffs = this.diffs.filter(
+ diff => this._expandedFiles.findIndex(f => f.path === diff.path) === -1
+ );
+ this._clearCollapsedDiffs(collapsedDiffs);
+
+ if (!record) {
+ return;
+ } // Happens after "Collapse all" clicked.
+
+ this.filesExpanded = this._computeExpandedFiles(
+ this._expandedFiles.length,
+ this._files.length
+ );
+
+ // Find the paths introduced by the new index splices:
+ const newFiles = record.indexSplices
+ .map(splice =>
+ splice.object.slice(splice.index, splice.index + splice.addedCount)
+ )
+ .reduce((acc, paths) => acc.concat(paths), []);
+
+ // Required so that the newly created diff view is included in this.diffs.
+ flush();
+
+ this.reporting.time(EXPAND_ALL_TIMING_LABEL);
+
+ if (newFiles.length) {
+ this._renderInOrder(newFiles, this.diffs, newFiles.length);
+ }
+
+ this._updateDiffCursor();
+ this.$.diffCursor.reInitAndUpdateStops();
+ }
+
+ private _clearCollapsedDiffs(collapsedDiffs: GrDiffHost[]) {
+ for (const diff of collapsedDiffs) {
+ diff.cancel();
+ diff.clearDiffContent();
+ }
+ }
+
+ /**
+ * Given an array of paths and a NodeList of diff elements, render the diff
+ * for each path in order, awaiting the previous render to complete before
+ * continuing.
+ *
+ * @param initialCount The total number of paths in the pass. This
+ * is used to generate log messages.
+ */
+ private _renderInOrder(
+ files: PatchSetFile[],
+ diffElements: GrDiffHost[],
+ initialCount: number
+ ) {
+ let iter = 0;
+
+ for (const file of files) {
+ const path = file.path;
+ const diffElem = this._findDiffByPath(path, diffElements);
+ if (diffElem) {
+ diffElem.prefetchDiff();
+ }
+ }
+
+ return new Promise(resolve => {
+ this.dispatchEvent(
+ new CustomEvent('reload-drafts', {
+ detail: {resolve},
+ composed: true,
+ bubbles: true,
+ })
+ );
+ }).then(() =>
+ asyncForeach(files, (file, cancel) => {
+ const path = file.path;
+ this._cancelForEachDiff = cancel;
+
+ iter++;
+ console.info('Expanding diff', iter, 'of', initialCount, ':', path);
+ const diffElem = this._findDiffByPath(path, diffElements);
+ if (!diffElem) {
+ console.warn(`Did not find <gr-diff-host> element for ${path}`);
+ return Promise.resolve();
+ }
+ if (!this.changeComments || !this.patchRange || !this.diffPrefs) {
+ throw new Error(
+ 'changeComments, patchRange and diffPrefs must be set'
+ );
+ }
+ diffElem.comments = this.changeComments.getCommentsBySideForFile(
+ file,
+ this.patchRange,
+ this.projectConfig
+ );
+ const promises: Array<Promise<unknown>> = [diffElem.reload()];
+ if (this._loggedIn && !this.diffPrefs.manual_review) {
+ promises.push(this._reviewFile(path, true));
+ }
+ return Promise.all(promises);
+ }).then(() => {
+ this._cancelForEachDiff = undefined;
+ console.info('Finished expanding', initialCount, 'diff(s)');
+ this.reporting.timeEndWithAverage(
+ EXPAND_ALL_TIMING_LABEL,
+ EXPAND_ALL_AVG_TIMING_LABEL,
+ initialCount
+ );
+ /* Block diff cursor from auto scrolling after files are done rendering.
+ * This prevents the bug where the screen jumps to the first diff chunk
+ * after files are done being rendered after the user has already begun
+ * scrolling.
+ * This also however results in the fact that the cursor does not auto
+ * focus on the first diff chunk on a small screen. This is however, a use
+ * case we are willing to not support for now.
+
+ * Using handleDiffUpdate resulted in diffCursor.row being set which
+ * prevented the issue of scrolling to top when we expand the second
+ * file individually.
+ */
+ this.$.diffCursor.reInitAndUpdateStops();
+ })
+ );
+ }
+
+ /** Cancel the rendering work of every diff in the list */
+ _cancelDiffs() {
+ if (this._cancelForEachDiff) {
+ this._cancelForEachDiff();
+ }
+ this._forEachDiff(d => d.cancel());
+ }
+
+ /**
+ * In the given NodeList of diff elements, find the diff for the given path.
+ */
+ private _findDiffByPath(path: string, diffElements: GrDiffHost[]) {
+ for (let i = 0; i < diffElements.length; i++) {
+ if (diffElements[i].path === path) {
+ return diffElements[i];
+ }
+ }
+ return undefined;
+ }
+
+ /**
+ * Reset the comments of a modified thread
+ */
+ reloadCommentsForThreadWithRootId(rootId: UrlEncodedCommentId, path: string) {
+ // Don't bother continuing if we already know that the path that contains
+ // the updated comment thread is not expanded.
+ if (!this._expandedFiles.some(f => f.path === path)) {
+ return;
+ }
+ const diff = this.diffs.find(d => d.path === path);
+
+ if (!diff) {
+ throw new Error("Can't find diff by path");
+ }
+
+ const threadEl = diff.getThreadEls().find(t => t.rootId === rootId);
+ if (!threadEl) {
+ return;
+ }
+
+ if (!this.changeComments) {
+ throw new Error('changeComments must be set');
+ }
+
+ const newComments = this.changeComments.getCommentsForThread(rootId);
+
+ // If newComments is null, it means that a single draft was
+ // removed from a thread in the thread view, and the thread should
+ // no longer exist. Remove the existing thread element in the diff
+ // view.
+ if (!newComments) {
+ threadEl.fireRemoveSelf();
+ return;
+ }
+
+ // Comments are not returned with the commentSide attribute from
+ // the api, but it's necessary to be stored on the diff's
+ // comments due to use in the _handleCommentUpdate function.
+ // The comment thread already has a side associated with it, so
+ // set the comment's side to match.
+ threadEl.comments = newComments.map(c =>
+ Object.assign(c, {__commentSide: threadEl.commentSide})
+ );
+ flush();
+ }
+
+ _handleEscKey(e: CustomKeyboardEvent) {
+ if (this.shouldSuppressKeyboardShortcut(e) || this.modifierPressed(e)) {
+ return;
+ }
+ e.preventDefault();
+ this._displayLine = false;
+ }
+
+ /**
+ * Update the loading class for the file list rows. The update is inside a
+ * debouncer so that the file list doesn't flash gray when the API requests
+ * are reasonably fast.
+ */
+ _loadingChanged(loading?: boolean) {
+ this.debounce(
+ 'loading-change',
+ () => {
+ // Only show set the loading if there have been files loaded to show. In
+ // this way, the gray loading style is not shown on initial loads.
+ this.classList.toggle('loading', loading && !!this._files.length);
+ },
+ LOADING_DEBOUNCE_INTERVAL
+ );
+ }
+
+ _editModeChanged(editMode?: boolean) {
+ this.classList.toggle('editMode', editMode);
+ }
+
+ _computeReviewedClass(isReviewed?: boolean) {
+ return isReviewed ? 'isReviewed' : '';
+ }
+
+ _computeReviewedText(isReviewed?: boolean) {
+ return isReviewed ? 'MARK UNREVIEWED' : 'MARK REVIEWED';
+ }
+
+ /**
+ * Given a file path, return whether that path should have visible size bars
+ * and be included in the size bars calculation.
+ */
+ _showBarsForPath(path?: string) {
+ return (
+ path !== SpecialFilePath.COMMIT_MESSAGE &&
+ path !== SpecialFilePath.MERGE_LIST
+ );
+ }
+
+ /**
+ * Compute size bar layout values from the file list.
+ */
+ _computeSizeBarLayout(
+ shownFilesRecord?: ElementPropertyDeepChange<GrFileList, '_shownFiles'>
+ ) {
+ const stats: SizeBarLayout = createDefaultSizeBarLayout();
+ if (!shownFilesRecord || !shownFilesRecord.base) {
+ return stats;
+ }
+ shownFilesRecord.base
+ .filter(f => this._showBarsForPath(f.__path))
+ .forEach(f => {
+ if (f.lines_inserted) {
+ stats.maxInserted = Math.max(stats.maxInserted, f.lines_inserted);
+ }
+ if (f.lines_deleted) {
+ stats.maxDeleted = Math.max(stats.maxDeleted, f.lines_deleted);
+ }
+ });
+ const ratio = stats.maxInserted / (stats.maxInserted + stats.maxDeleted);
+ if (!isNaN(ratio)) {
+ stats.maxAdditionWidth =
+ (SIZE_BAR_MAX_WIDTH - SIZE_BAR_GAP_WIDTH) * ratio;
+ stats.maxDeletionWidth =
+ SIZE_BAR_MAX_WIDTH - SIZE_BAR_GAP_WIDTH - stats.maxAdditionWidth;
+ stats.deletionOffset = stats.maxAdditionWidth + SIZE_BAR_GAP_WIDTH;
+ }
+ return stats;
+ }
+
+ /**
+ * Get the width of the addition bar for a file.
+ */
+ _computeBarAdditionWidth(file?: NormalizedFileInfo, stats?: SizeBarLayout) {
+ if (
+ !file ||
+ !stats ||
+ stats.maxInserted === 0 ||
+ !file.lines_inserted ||
+ !this._showBarsForPath(file.__path)
+ ) {
+ return 0;
+ }
+ const width =
+ (stats.maxAdditionWidth * file.lines_inserted) / stats.maxInserted;
+ return width === 0 ? 0 : Math.max(SIZE_BAR_MIN_WIDTH, width);
+ }
+
+ /**
+ * Get the x-offset of the addition bar for a file.
+ */
+ _computeBarAdditionX(file?: NormalizedFileInfo, stats?: SizeBarLayout) {
+ if (!file || !stats) return;
+ return stats.maxAdditionWidth - this._computeBarAdditionWidth(file, stats);
+ }
+
+ /**
+ * Get the width of the deletion bar for a file.
+ */
+ _computeBarDeletionWidth(file?: NormalizedFileInfo, stats?: SizeBarLayout) {
+ if (
+ !file ||
+ !stats ||
+ stats.maxDeleted === 0 ||
+ !file.lines_deleted ||
+ !this._showBarsForPath(file.__path)
+ ) {
+ return 0;
+ }
+ const width =
+ (stats.maxDeletionWidth * file.lines_deleted) / stats.maxDeleted;
+ return width === 0 ? 0 : Math.max(SIZE_BAR_MIN_WIDTH, width);
+ }
+
+ /**
+ * Get the x-offset of the deletion bar for a file.
+ */
+ _computeBarDeletionX(stats: SizeBarLayout) {
+ return stats.deletionOffset;
+ }
+
+ _computeShowSizeBars(userPrefs?: PreferencesInfo) {
+ return !!userPrefs?.size_bar_in_change_table;
+ }
+
+ _computeSizeBarsClass(showSizeBars?: boolean, path?: string) {
+ let hideClass = '';
+ if (!showSizeBars) {
+ hideClass = 'hide';
+ } else if (!this._showBarsForPath(path)) {
+ hideClass = 'invisible';
+ }
+ return `sizeBars desktop ${hideClass}`;
+ }
+
+ /**
+ * Shows registered dynamic columns iff the 'header', 'content' and
+ * 'summary' endpoints are registered the exact same number of times.
+ * Ideally, there should be a better way to enforce the expectation of the
+ * dependencies between dynamic endpoints.
+ */
+ _computeShowDynamicColumns(
+ headerEndpoints?: string,
+ contentEndpoints?: string,
+ summaryEndpoints?: string
+ ) {
+ return (
+ headerEndpoints &&
+ contentEndpoints &&
+ summaryEndpoints &&
+ headerEndpoints.length &&
+ headerEndpoints.length === contentEndpoints.length &&
+ headerEndpoints.length === summaryEndpoints.length
+ );
+ }
+
+ /**
+ * Shows registered dynamic prepended columns iff the 'header', 'content'
+ * endpoints are registered the exact same number of times.
+ */
+ _computeShowPrependedDynamicColumns(
+ headerEndpoints?: string,
+ contentEndpoints?: string
+ ) {
+ return (
+ headerEndpoints &&
+ contentEndpoints &&
+ headerEndpoints.length &&
+ headerEndpoints.length === contentEndpoints.length
+ );
+ }
+
+ /**
+ * Returns true if none of the inline diffs have been expanded.
+ */
+ _noDiffsExpanded() {
+ return this.filesExpanded === FilesExpandedState.NONE;
+ }
+
+ /**
+ * Method to call via binding when each file list row is rendered. This
+ * allows approximate detection of when the dom-repeat has completed
+ * rendering.
+ *
+ * @param index The index of the row being rendered.
+ */
+ _reportRenderedRow(index: number) {
+ if (index === this._shownFiles.length - 1) {
+ this.async(() => {
+ this.reporting.timeEndWithAverage(
+ RENDER_TIMING_LABEL,
+ RENDER_AVG_TIMING_LABEL,
+ this._reportinShownFilesIncrement
+ );
+ }, 1);
+ }
+ return '';
+ }
+
+ _reviewedTitle(reviewed?: boolean) {
+ if (reviewed) {
+ return 'Mark as not reviewed (shortcut: r)';
+ }
+
+ return 'Mark as reviewed (shortcut: r)';
+ }
+
+ _handleReloadingDiffPreference() {
+ this._getDiffPreferences().then(prefs => {
+ this.diffPrefs = prefs;
+ });
+ }
+
+ /**
+ * Wrapper for using in the element template and computed properties
+ */
+ _computeDisplayPath(path: string) {
+ return computeDisplayPath(path);
+ }
+
+ /**
+ * Wrapper for using in the element template and computed properties
+ */
+ _computeTruncatedPath(path: string) {
+ return computeTruncatedPath(path);
+ }
+}
+
+declare global {
+ interface HTMLElementTagNameMap {
+ 'gr-file-list': GrFileList;
+ }
+}
diff --git a/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list_html.ts b/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list_html.ts
index a577eb6..1c30a65 100644
--- a/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list_html.ts
+++ b/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list_html.ts
@@ -375,7 +375,7 @@
<div class="stickyArea">
<div
class$="file-row row [[_computePathClass(file.__path, _expandedFiles.*)]]"
- data-file$="[[_computeFileRange(file)]]"
+ data-file$="[[_computePatchSetFile(file)]]"
tabindex="-1"
role="row"
>
@@ -657,7 +657,7 @@
hidden="[[!_isFileExpanded(file.__path, _expandedFiles.*)]]"
change-num="[[changeNum]]"
patch-range="[[patchRange]]"
- file="[[_computeFileRange(file)]]"
+ file="[[_computePatchSetFile(file)]]"
path="[[file.__path]]"
prefs="[[diffPrefs]]"
project-name="[[change.project]]"
diff --git a/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list_test.js b/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list_test.js
index 8aebf9b..129b65e 100644
--- a/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list_test.js
+++ b/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list_test.js
@@ -312,7 +312,7 @@
for (const bytes in table) {
if (table.hasOwnProperty(bytes)) {
- assert.equal(element._formatBytes(bytes), table[bytes]);
+ assert.equal(element._formatBytes(Number(bytes)), table[bytes]);
}
}
});
@@ -1329,15 +1329,23 @@
suite('size bars', () => {
test('_computeSizeBarLayout', () => {
- assert.isUndefined(element._computeSizeBarLayout(null));
- assert.isUndefined(element._computeSizeBarLayout({}));
- assert.deepEqual(element._computeSizeBarLayout({base: []}), {
+ const defaultSizeBarLayout = {
maxInserted: 0,
maxDeleted: 0,
maxAdditionWidth: 0,
maxDeletionWidth: 0,
deletionOffset: 0,
- });
+ };
+
+ assert.deepEqual(
+ element._computeSizeBarLayout(null),
+ defaultSizeBarLayout);
+ assert.deepEqual(
+ element._computeSizeBarLayout({}),
+ defaultSizeBarLayout);
+ assert.deepEqual(
+ element._computeSizeBarLayout({base: []}),
+ defaultSizeBarLayout);
const files = [
{__path: '/COMMIT_MSG', lines_inserted: 10000},
diff --git a/polygerrit-ui/app/elements/change/gr-label-scores/gr-label-scores.ts b/polygerrit-ui/app/elements/change/gr-label-scores/gr-label-scores.ts
index 245c65f..2e9189e 100644
--- a/polygerrit-ui/app/elements/change/gr-label-scores/gr-label-scores.ts
+++ b/polygerrit-ui/app/elements/change/gr-label-scores/gr-label-scores.ts
@@ -61,7 +61,7 @@
@property({type: Object})
_labelValues?: LabelValuesMap;
- getLabelValues(): LabelNameToValuesMap {
+ getLabelValues(includeDefaults = true): LabelNameToValuesMap {
const labels: LabelNameToValuesMap = {};
if (this.shadowRoot === null || !this.change) {
return labels;
@@ -106,8 +106,12 @@
prevValNum = prevVal;
}
+ const defValNum = this._getDefaultValue(this.change.labels, label);
+
if (selectedVal !== prevValNum) {
- labels[label] = selectedVal;
+ if (includeDefaults || !!prevValNum || selectedVal !== defValNum) {
+ labels[label] = selectedVal;
+ }
}
}
return labels;
@@ -126,6 +130,12 @@
return numberValue;
}
+ _getDefaultValue(labels?: LabelNameToInfoMap, labelName?: string) {
+ if (!labelName || !labels?.[labelName]) return undefined;
+ const labelInfo = labels[labelName] as DetailedLabelInfo;
+ return labelInfo.default_value;
+ }
+
_getVoteForAccount(
labels: LabelNameToInfoMap | undefined,
labelName: string,
diff --git a/polygerrit-ui/app/elements/change/gr-label-scores/gr-label-scores_test.js b/polygerrit-ui/app/elements/change/gr-label-scores/gr-label-scores_test.js
index ffc17cd..ae639e1 100644
--- a/polygerrit-ui/app/elements/change/gr-label-scores/gr-label-scores_test.js
+++ b/polygerrit-ui/app/elements/change/gr-label-scores/gr-label-scores_test.js
@@ -99,6 +99,22 @@
});
});
+ test('getLabelValues includeDefaults', async () => {
+ element.change = {
+ _number: '123',
+ labels: {
+ 'Code-Review': {
+ values: {'0': 'meh', '+1': 'good', '-1': 'bad'},
+ default_value: 0,
+ },
+ },
+ };
+ await flush();
+
+ assert.deepEqual(element.getLabelValues(true), {'Code-Review': 0});
+ assert.deepEqual(element.getLabelValues(false), {});
+ });
+
test('_getVoteForAccount', () => {
const labelName = 'Code-Review';
assert.strictEqual(element._getVoteForAccount(
diff --git a/polygerrit-ui/app/elements/change/gr-message/gr-message.ts b/polygerrit-ui/app/elements/change/gr-message/gr-message.ts
index 87d443b..8b0cf4b 100644
--- a/polygerrit-ui/app/elements/change/gr-message/gr-message.ts
+++ b/polygerrit-ui/app/elements/change/gr-message/gr-message.ts
@@ -39,9 +39,10 @@
ReviewInputTag,
VotingRangeInfo,
NumericChangeId,
+ ChangeMessageId,
} from '../../../types/common';
import {RestApiService} from '../../../services/services/gr-rest-api/gr-rest-api';
-import {CommentThread} from '../../diff/gr-comment-api/gr-comment-api';
+import {CommentThread} from '../../../utils/comment-util';
import {hasOwnProperty} from '../../../utils/common-util';
const PATCH_SET_PREFIX_PATTERN = /^(?:Uploaded\s*)?(?:P|p)atch (?:S|s)et \d+:\s*(.*)/;
@@ -53,6 +54,10 @@
}
}
+export interface MessageAnchorTapDetail {
+ id: ChangeMessageId;
+}
+
export interface GrMessage {
$: {
restAPI: RestApiService & Element;
@@ -438,11 +443,16 @@
_handleAnchorClick(e: Event) {
e.preventDefault();
+ // The element which triggers _handleAnchorClick is rendered only if
+ // message.id defined: the elemenet is wrapped in dom-if if="[[message.id]]"
+ const detail: MessageAnchorTapDetail = {
+ id: this.message!.id,
+ };
this.dispatchEvent(
new CustomEvent('message-anchor-tap', {
bubbles: true,
composed: true,
- detail: {id: this.message?.id},
+ detail,
})
);
}
diff --git a/polygerrit-ui/app/elements/change/gr-messages-list/gr-messages-list.js b/polygerrit-ui/app/elements/change/gr-messages-list/gr-messages-list.js
deleted file mode 100644
index bed78df..0000000
--- a/polygerrit-ui/app/elements/change/gr-messages-list/gr-messages-list.js
+++ /dev/null
@@ -1,433 +0,0 @@
-/**
- * @license
- * 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.
- */
-import '@polymer/paper-toggle-button/paper-toggle-button.js';
-import '../../shared/gr-button/gr-button.js';
-import '../../shared/gr-icons/gr-icons.js';
-import '../gr-message/gr-message.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-messages-list_html.js';
-import {
- KeyboardShortcutMixin,
- Shortcut, ShortcutSection,
-} from '../../../mixins/keyboard-shortcut-mixin/keyboard-shortcut-mixin.js';
-import {parseDate} from '../../../utils/date-util.js';
-import {MessageTag} from '../../../constants/constants.js';
-import {appContext} from '../../../services/app-context.js';
-
-/**
- * The content of the enum is also used in the UI for the button text.
- *
- * @enum {string}
- */
-const ExpandAllState = {
- EXPAND_ALL: 'Expand All',
- COLLAPSE_ALL: 'Collapse All',
-};
-
-/**
- * Computes message author's comments for this change message. The backend
- * sets comment.change_message_id for matching, so this computation is fairly
- * straightforward.
- */
-function computeThreads(message, allMessages, changeComments) {
- if ([message, allMessages, changeComments].includes(undefined)) {
- return [];
- }
- if (message._index === undefined) {
- return [];
- }
-
- return changeComments.getAllThreadsForChange().filter(
- thread => thread.comments.map(comment => {
- // collapse all by default
- comment.collapsed = true;
- return comment;
- }).some(comment => {
- const condition = comment.change_message_id === message.id;
- // Since getAllThreadsForChange() always returns a new copy of
- // all comments we can modify them here without worrying about
- // polluting other threads.
- comment.collapsed = !condition;
- return condition;
- })
- );
-}
-
-/**
- * If messages have the same tag, then that influences grouping and whether
- * a message is initally hidden or not, see isImportant(). So we are applying
- * some "magic" rules here in order to hide exactly the right messages.
- *
- * 1. If a message does not have a tag, but is associated with robot comments,
- * then it gets a tag.
- *
- * 2. Use the same tag for some of Gerrit's standard events, if they should be
- * considered one group, e.g. normal and wip patchset uploads.
- *
- * 3. Everything beyond the ~ character is cut off from the tag. That gives
- * tools control over which messages will be hidden.
- */
-function computeTag(message) {
- if (!message.tag) {
- const threads = message.commentThreads || [];
- const comments = threads.map(
- t => t.comments.find(c => c.change_message_id === message.id));
- const isRobot = comments.some(c => c && !!c.robot_id);
- return isRobot ? 'autogenerated:has-robot-comments' : undefined;
- }
-
- if (message.tag === MessageTag.TAG_NEW_WIP_PATCHSET) {
- return MessageTag.TAG_NEW_PATCHSET;
- }
- if (message.tag === MessageTag.TAG_UNSET_ASSIGNEE) {
- return MessageTag.TAG_SET_ASSIGNEE;
- }
- if (message.tag === MessageTag.TAG_UNSET_PRIVATE) {
- return MessageTag.TAG_SET_PRIVATE;
- }
- if (message.tag === MessageTag.TAG_SET_WIP) {
- return MessageTag.TAG_SET_READY;
- }
-
- return message.tag.replace(/~.*/, '');
-}
-
-/**
- * Try to set a revision number that makes sense, if none is set. Just copy
- * over the revision number of the next older message. This is mostly relevant
- * for reviewer updates. Other messages should typically have the revision
- * number already set.
- */
-function computeRevision(message, allMessages) {
- if (message._revision_number > 0) return message._revision_number;
- let revision = 0;
- for (const m of allMessages) {
- if (m.date > message.date) break;
- if (m._revision_number > revision) revision = m._revision_number;
- }
- return revision > 0 ? revision : undefined;
-}
-
-/**
- * Unimportant messages are initially hidden.
- *
- * Human messages are always important. They have an undefined tag.
- *
- * Autogenerated messages are unimportant, if there is a message with the same
- * tag and a higher revision number.
- */
-function computeIsImportant(message, allMessages) {
- if (!message.tag) return true;
-
- const hasSameTag = m => m.tag === message.tag;
- const revNumber = message._revision_number || 0;
- const hasHigherRevisionNumber = m => m._revision_number > revNumber;
- return !allMessages.filter(hasSameTag).some(hasHigherRevisionNumber);
-}
-
-export const TEST_ONLY = {
- computeThreads,
- computeTag,
- computeRevision,
- computeIsImportant,
-};
-
-/**
- * @extends PolymerElement
- */
-class GrMessagesList extends KeyboardShortcutMixin(
- GestureEventListeners(
- LegacyElementMixin(
- PolymerElement))) {
- static get template() { return htmlTemplate; }
-
- static get is() { return 'gr-messages-list'; }
-
- static get properties() {
- return {
- /** @type {?} */
- change: Object,
- changeNum: Number,
- /**
- * These are just the change messages. They are combined with reviewer
- * updates below. So _combinedMessages is the more important property.
- */
- messages: {
- type: Array,
- value() { return []; },
- },
- /**
- * These are just the reviewer updates. They are combined with change
- * messages above. So _combinedMessages is the more important property.
- */
- reviewerUpdates: {
- type: Array,
- value() { return []; },
- },
- changeComments: Object,
- projectName: String,
- showReplyButtons: {
- type: Boolean,
- value: false,
- },
- labels: Object,
-
- /**
- * Keeps track of the state of the "Expand All" toggle button. Note that
- * you can individually expand/collapse some messages without affecting
- * the toggle button's state.
- *
- * @type {ExpandAllState}
- */
- _expandAllState: {
- type: String,
- value: ExpandAllState.EXPAND_ALL,
- },
- _expandAllTitle: {
- type: String,
- computed: '_computeExpandAllTitle(_expandAllState)',
- },
-
- _showAllActivity: {
- type: Boolean,
- value: false,
- observer: '_observeShowAllActivity',
- },
- /**
- * The merged array of change messages and reviewer updates.
- */
- _combinedMessages: {
- type: Array,
- computed: '_computeCombinedMessages(messages, reviewerUpdates, '
- + 'changeComments)',
- observer: '_combinedMessagesChanged',
- },
-
- _labelExtremes: {
- type: Object,
- computed: '_computeLabelExtremes(labels.*)',
- },
- };
- }
-
- constructor() {
- super();
- this.reporting = appContext.reportingService;
- }
-
- scrollToMessage(messageID) {
- const selector = `[data-message-id="${messageID}"]`;
- const el = this.shadowRoot.querySelector(selector);
-
- if (!el && this._showAllActivity) {
- console.warn(`Failed to scroll to message: ${messageID}`);
- return;
- }
- if (!el) {
- this._showAllActivity = true;
- setTimeout(() => this.scrollToMessage(messageID));
- return;
- }
-
- el.set('message.expanded', true);
- let top = el.offsetTop;
- for (let offsetParent = el.offsetParent;
- offsetParent;
- offsetParent = offsetParent.offsetParent) {
- top += offsetParent.offsetTop;
- }
- window.scrollTo(0, top);
- this._highlightEl(el);
- }
-
- _observeShowAllActivity(showAllActivity) {
- // We have to call render() such that the dom-repeat filter picks up the
- // change.
- this.$.messageRepeat.render();
- }
-
- /**
- * Filter for the dom-repeat of combinedMessages.
- */
- _isMessageVisible(message) {
- return this._showAllActivity || message.isImportant;
- }
-
- /**
- * Merges change messages and reviewer updates into one array. Also processes
- * all messages and updates, aligns or massages some of the properties.
- */
- _computeCombinedMessages(messages, reviewerUpdates, changeComments) {
- const params = [messages, reviewerUpdates, changeComments];
- if (params.some(o => o === undefined)) return [];
-
- let mi = 0;
- let ri = 0;
- let combinedMessages = [];
- let mDate;
- let rDate;
- for (let i = 0; i < messages.length; i++) {
- messages[i]._index = i;
- }
-
- while (mi < messages.length || ri < reviewerUpdates.length) {
- if (mi >= messages.length) {
- combinedMessages = combinedMessages.concat(reviewerUpdates.slice(ri));
- break;
- }
- if (ri >= reviewerUpdates.length) {
- combinedMessages = combinedMessages.concat(messages.slice(mi));
- break;
- }
- mDate = mDate || parseDate(messages[mi].date);
- rDate = rDate || parseDate(reviewerUpdates[ri].date);
- if (rDate < mDate) {
- combinedMessages.push(reviewerUpdates[ri++]);
- rDate = null;
- } else {
- combinedMessages.push(messages[mi++]);
- mDate = null;
- }
- }
- combinedMessages.forEach(m => {
- if (m.expanded === undefined) {
- m.expanded = false;
- }
- m.commentThreads = computeThreads(m, combinedMessages, changeComments);
- m._revision_number = computeRevision(m, combinedMessages);
- m.tag = computeTag(m);
- });
- // computeIsImportant() depends on tags and revision numbers already being
- // updated for all messages, so we have to compute this in its own forEach
- // loop.
- combinedMessages.forEach(m => {
- m.isImportant = computeIsImportant(m, combinedMessages);
- });
- return combinedMessages;
- }
-
- _updateExpandedStateOfAllMessages(exp) {
- if (this._combinedMessages) {
- for (let i = 0; i < this._combinedMessages.length; i++) {
- this._combinedMessages[i].expanded = exp;
- this.notifyPath(`_combinedMessages.${i}.expanded`);
- }
- }
- }
-
- _computeExpandAllTitle(_expandAllState) {
- if (_expandAllState === ExpandAllState.COLLAPSE_ALL) {
- return this.createTitle(
- Shortcut.COLLAPSE_ALL_MESSAGES, ShortcutSection.ACTIONS);
- }
- if (_expandAllState === ExpandAllState.EXPAND_ALL) {
- return this.createTitle(
- Shortcut.EXPAND_ALL_MESSAGES, ShortcutSection.ACTIONS);
- }
- return '';
- }
-
- _highlightEl(el) {
- const highlightedEls =
- this.root.querySelectorAll('.highlighted');
- for (const highlightedEl of highlightedEls) {
- highlightedEl.classList.remove('highlighted');
- }
- function handleAnimationEnd() {
- el.removeEventListener('animationend', handleAnimationEnd);
- el.classList.remove('highlighted');
- }
- el.addEventListener('animationend', handleAnimationEnd);
- el.classList.add('highlighted');
- }
-
- /**
- * @param {boolean} expand
- */
- handleExpandCollapse(expand) {
- this._expandAllState = expand ? ExpandAllState.COLLAPSE_ALL
- : ExpandAllState.EXPAND_ALL;
- this._updateExpandedStateOfAllMessages(expand);
- }
-
- _handleExpandCollapseTap(e) {
- e.preventDefault();
- this.handleExpandCollapse(
- this._expandAllState === ExpandAllState.EXPAND_ALL);
- }
-
- _handleAnchorClick(e) {
- this.scrollToMessage(e.detail.id);
- }
-
- _isVisibleShowAllActivityToggle(messages = []) {
- return messages.some(m => !m.isImportant);
- }
-
- _computeHiddenEntriesCount(messages = []) {
- return messages.filter(m => !m.isImportant).length;
- }
-
- /**
- * This method is for reporting stats only.
- */
- _combinedMessagesChanged(combinedMessages) {
- if (combinedMessages) {
- if (combinedMessages.length === 0) return;
- const tags = combinedMessages.map(
- message => message.tag || message.type ||
- (message.comments ? 'comments' : 'none'));
- const tagsCounted = tags.reduce((acc, val) => {
- acc[val] = (acc[val] || 0) + 1;
- return acc;
- }, {all: combinedMessages.length});
- this.reporting.reportInteraction('messages-count', tagsCounted);
- }
- }
-
- /**
- * Compute a mapping from label name to objects representing the minimum and
- * maximum possible values for that label.
- */
- _computeLabelExtremes(labelRecord) {
- const extremes = {};
- const labels = labelRecord.base;
- if (!labels) { return extremes; }
- for (const key of Object.keys(labels)) {
- if (!labels[key] || !labels[key].values) { continue; }
- const values = Object.keys(labels[key].values)
- .map(v => parseInt(v, 10));
- values.sort((a, b) => a - b);
- if (!values.length) { continue; }
- extremes[key] = {min: values[0], max: values[values.length - 1]};
- }
- return extremes;
- }
-
- /**
- * Work around a issue on iOS when clicking turns into double tap
- */
- _onTapShowAllActivityToggle(e) {
- e.preventDefault();
- }
-}
-
-customElements.define(GrMessagesList.is,
- GrMessagesList);
diff --git a/polygerrit-ui/app/elements/change/gr-messages-list/gr-messages-list.ts b/polygerrit-ui/app/elements/change/gr-messages-list/gr-messages-list.ts
new file mode 100644
index 0000000..8557c10
--- /dev/null
+++ b/polygerrit-ui/app/elements/change/gr-messages-list/gr-messages-list.ts
@@ -0,0 +1,495 @@
+/**
+ * @license
+ * 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.
+ */
+import '@polymer/paper-toggle-button/paper-toggle-button';
+import '../../shared/gr-button/gr-button';
+import '../../shared/gr-icons/gr-icons';
+import '../gr-message/gr-message';
+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-messages-list_html';
+import {
+ KeyboardShortcutMixin,
+ Shortcut,
+ ShortcutSection,
+} from '../../../mixins/keyboard-shortcut-mixin/keyboard-shortcut-mixin';
+import {parseDate} from '../../../utils/date-util';
+import {MessageTag} from '../../../constants/constants';
+import {appContext} from '../../../services/app-context';
+import {customElement, property} from '@polymer/decorators';
+import {
+ ChangeId,
+ ChangeMessageId,
+ ChangeMessageInfo,
+ ChangeViewChangeInfo,
+ LabelNameToInfoMap,
+ NumericChangeId,
+ PatchSetNum,
+ RepoName,
+ ReviewerUpdateInfo,
+ VotingRangeInfo,
+} from '../../../types/common';
+import {ChangeComments} from '../../diff/gr-comment-api/gr-comment-api';
+import {CommentThread, isRobot} from '../../../utils/comment-util';
+import {GrMessage, MessageAnchorTapDetail} from '../gr-message/gr-message';
+import {PolymerDeepPropertyChange} from '@polymer/polymer/interfaces';
+import {FormattedReviewerUpdateInfo} from '../../shared/gr-rest-api-interface/gr-reviewer-updates-parser';
+import {DomRepeat} from '@polymer/polymer/lib/elements/dom-repeat';
+import {getVotingRange} from '../../../utils/label-util';
+
+/**
+ * The content of the enum is also used in the UI for the button text.
+ */
+enum ExpandAllState {
+ EXPAND_ALL = 'Expand All',
+ COLLAPSE_ALL = 'Collapse All',
+}
+
+interface TagsCountReportInfo {
+ [tag: string]: number;
+ all: number;
+}
+
+type CombinedMessage = Omit<
+ FormattedReviewerUpdateInfo | ChangeMessageInfo,
+ 'tag'
+> & {
+ _revision_number?: PatchSetNum;
+ _index?: number;
+ expanded?: boolean;
+ isImportant?: boolean;
+ commentThreads?: CommentThread[];
+ tag?: string;
+};
+
+function isChangeMessageInfo(x: CombinedMessage): x is ChangeMessageInfo {
+ return (x as ChangeMessageInfo).id !== undefined;
+}
+
+function getMessageId(x: CombinedMessage): ChangeMessageId | undefined {
+ return isChangeMessageInfo(x) ? x.id : undefined;
+}
+
+/**
+ * Computes message author's comments for this change message. The backend
+ * sets comment.change_message_id for matching, so this computation is fairly
+ * straightforward.
+ */
+function computeThreads(
+ message: CombinedMessage,
+ changeComments: ChangeComments
+): CommentThread[] {
+ if (message._index === undefined) {
+ return [];
+ }
+ const messageId = getMessageId(message);
+ return changeComments.getAllThreadsForChange().filter(thread =>
+ thread.comments
+ .map(comment => {
+ // collapse all by default
+ comment.collapsed = true;
+ return comment;
+ })
+ .some(comment => {
+ const condition = comment.change_message_id === messageId;
+ // Since getAllThreadsForChange() always returns a new copy of
+ // all comments we can modify them here without worrying about
+ // polluting other threads.
+ comment.collapsed = !condition;
+ return condition;
+ })
+ );
+}
+
+/**
+ * If messages have the same tag, then that influences grouping and whether
+ * a message is initally hidden or not, see isImportant(). So we are applying
+ * some "magic" rules here in order to hide exactly the right messages.
+ *
+ * 1. If a message does not have a tag, but is associated with robot comments,
+ * then it gets a tag.
+ *
+ * 2. Use the same tag for some of Gerrit's standard events, if they should be
+ * considered one group, e.g. normal and wip patchset uploads.
+ *
+ * 3. Everything beyond the ~ character is cut off from the tag. That gives
+ * tools control over which messages will be hidden.
+ */
+function computeTag(message: CombinedMessage) {
+ if (!message.tag) {
+ const threads = message.commentThreads || [];
+ const messageId = getMessageId(message);
+ const comments = threads.map(t =>
+ t.comments.find(c => c.change_message_id === messageId)
+ );
+ const hasRobotComments = comments.some(isRobot);
+ return hasRobotComments ? 'autogenerated:has-robot-comments' : undefined;
+ }
+
+ if (message.tag === MessageTag.TAG_NEW_WIP_PATCHSET) {
+ return MessageTag.TAG_NEW_PATCHSET;
+ }
+ if (message.tag === MessageTag.TAG_UNSET_ASSIGNEE) {
+ return MessageTag.TAG_SET_ASSIGNEE;
+ }
+ if (message.tag === MessageTag.TAG_UNSET_PRIVATE) {
+ return MessageTag.TAG_SET_PRIVATE;
+ }
+ if (message.tag === MessageTag.TAG_SET_WIP) {
+ return MessageTag.TAG_SET_READY;
+ }
+
+ return message.tag.replace(/~.*/, '');
+}
+
+/**
+ * Try to set a revision number that makes sense, if none is set. Just copy
+ * over the revision number of the next older message. This is mostly relevant
+ * for reviewer updates. Other messages should typically have the revision
+ * number already set.
+ */
+function computeRevision(
+ message: CombinedMessage,
+ allMessages: CombinedMessage[]
+): PatchSetNum | undefined {
+ if (message._revision_number && message._revision_number > 0)
+ return message._revision_number;
+ let revision: PatchSetNum = 0 as PatchSetNum;
+ for (const m of allMessages) {
+ if (m.date > message.date) break;
+ if (m._revision_number && m._revision_number > revision)
+ revision = m._revision_number;
+ }
+ return revision > 0 ? revision : undefined;
+}
+
+/**
+ * Unimportant messages are initially hidden.
+ *
+ * Human messages are always important. They have an undefined tag.
+ *
+ * Autogenerated messages are unimportant, if there is a message with the same
+ * tag and a higher revision number.
+ */
+function computeIsImportant(
+ message: CombinedMessage,
+ allMessages: CombinedMessage[]
+) {
+ if (!message.tag) return true;
+
+ const hasSameTag = (m: CombinedMessage) => m.tag === message.tag;
+ const revNumber = message._revision_number || 0;
+ const hasHigherRevisionNumber = (m: CombinedMessage) =>
+ (m._revision_number || 0) > revNumber;
+ return !allMessages.filter(hasSameTag).some(hasHigherRevisionNumber);
+}
+
+export const TEST_ONLY = {
+ computeThreads,
+ computeTag,
+ computeRevision,
+ computeIsImportant,
+};
+
+export interface GrMessagesList {
+ $: {
+ messageRepeat: DomRepeat;
+ };
+}
+
+@customElement('gr-messages-list')
+export class GrMessagesList extends KeyboardShortcutMixin(
+ GestureEventListeners(LegacyElementMixin(PolymerElement))
+) {
+ static get template() {
+ return htmlTemplate;
+ }
+
+ @property({type: Object})
+ change?: ChangeViewChangeInfo;
+
+ @property({type: String})
+ changeNum?: ChangeId | NumericChangeId;
+
+ @property({type: Array})
+ messages: ChangeMessageInfo[] = [];
+
+ @property({type: Array})
+ reviewerUpdates: ReviewerUpdateInfo[] = [];
+
+ @property({type: Object})
+ changeComments?: ChangeComments;
+
+ @property({type: String})
+ projectName?: RepoName;
+
+ @property({type: Boolean})
+ showReplyButtons = false;
+
+ @property({type: Object})
+ labels?: LabelNameToInfoMap;
+
+ @property({type: String})
+ _expandAllState = ExpandAllState.EXPAND_ALL;
+
+ @property({type: String, computed: '_computeExpandAllTitle(_expandAllState)'})
+ _expandAllTitle = '';
+
+ @property({type: Boolean, observer: '_observeShowAllActivity'})
+ _showAllActivity = false;
+
+ @property({
+ type: Array,
+ computed:
+ '_computeCombinedMessages(messages, reviewerUpdates, ' +
+ 'changeComments)',
+ observer: '_combinedMessagesChanged',
+ })
+ _combinedMessages: CombinedMessage[] = [];
+
+ @property({type: Object, computed: '_computeLabelExtremes(labels.*)'})
+ _labelExtremes: {[lableName: string]: VotingRangeInfo} = {};
+
+ private readonly reporting = appContext.reportingService;
+
+ scrollToMessage(messageID: string) {
+ const selector = `[data-message-id="${messageID}"]`;
+ const el = this.shadowRoot!.querySelector(selector) as
+ | GrMessage
+ | undefined;
+
+ if (!el && this._showAllActivity) {
+ console.warn(`Failed to scroll to message: ${messageID}`);
+ return;
+ }
+ if (!el) {
+ this._showAllActivity = true;
+ setTimeout(() => this.scrollToMessage(messageID));
+ return;
+ }
+
+ el.set('message.expanded', true);
+ let top = el.offsetTop;
+ for (
+ let offsetParent = el.offsetParent as HTMLElement | null;
+ offsetParent;
+ offsetParent = offsetParent.offsetParent as HTMLElement | null
+ ) {
+ top += offsetParent.offsetTop;
+ }
+ window.scrollTo(0, top);
+ this._highlightEl(el);
+ }
+
+ _observeShowAllActivity() {
+ // We have to call render() such that the dom-repeat filter picks up the
+ // change.
+ this.$.messageRepeat.render();
+ }
+
+ /**
+ * Filter for the dom-repeat of combinedMessages.
+ */
+ _isMessageVisible(message: CombinedMessage) {
+ return this._showAllActivity || message.isImportant;
+ }
+
+ /**
+ * Merges change messages and reviewer updates into one array. Also processes
+ * all messages and updates, aligns or massages some of the properties.
+ */
+ _computeCombinedMessages(
+ messages?: ChangeMessageInfo[],
+ reviewerUpdates?: FormattedReviewerUpdateInfo[],
+ changeComments?: ChangeComments
+ ) {
+ if (
+ messages === undefined ||
+ reviewerUpdates === undefined ||
+ changeComments === undefined
+ )
+ return [];
+
+ let mi = 0;
+ let ri = 0;
+ let combinedMessages: CombinedMessage[] = [];
+ let mDate;
+ let rDate;
+ for (let i = 0; i < messages.length; i++) {
+ // TODO(TS): clone message instead and avoid API object mutation
+ (messages[i] as CombinedMessage)._index = i;
+ }
+
+ while (mi < messages.length || ri < reviewerUpdates.length) {
+ if (mi >= messages.length) {
+ combinedMessages = combinedMessages.concat(reviewerUpdates.slice(ri));
+ break;
+ }
+ if (ri >= reviewerUpdates.length) {
+ combinedMessages = combinedMessages.concat(messages.slice(mi));
+ break;
+ }
+ mDate = mDate || parseDate(messages[mi].date);
+ rDate = rDate || parseDate(reviewerUpdates[ri].date);
+ if (rDate < mDate) {
+ combinedMessages.push(reviewerUpdates[ri++]);
+ rDate = null;
+ } else {
+ combinedMessages.push(messages[mi++]);
+ mDate = null;
+ }
+ }
+ combinedMessages.forEach(m => {
+ if (m.expanded === undefined) {
+ m.expanded = false;
+ }
+ m.commentThreads = computeThreads(m, changeComments);
+ m._revision_number = computeRevision(m, combinedMessages);
+ m.tag = computeTag(m);
+ });
+ // computeIsImportant() depends on tags and revision numbers already being
+ // updated for all messages, so we have to compute this in its own forEach
+ // loop.
+ combinedMessages.forEach(m => {
+ m.isImportant = computeIsImportant(m, combinedMessages);
+ });
+ return combinedMessages;
+ }
+
+ _updateExpandedStateOfAllMessages(exp: boolean) {
+ if (this._combinedMessages) {
+ for (let i = 0; i < this._combinedMessages.length; i++) {
+ this._combinedMessages[i].expanded = exp;
+ this.notifyPath(`_combinedMessages.${i}.expanded`);
+ }
+ }
+ }
+
+ _computeExpandAllTitle(_expandAllState?: string) {
+ if (_expandAllState === ExpandAllState.COLLAPSE_ALL) {
+ return this.createTitle(
+ Shortcut.COLLAPSE_ALL_MESSAGES,
+ ShortcutSection.ACTIONS
+ );
+ }
+ if (_expandAllState === ExpandAllState.EXPAND_ALL) {
+ return this.createTitle(
+ Shortcut.EXPAND_ALL_MESSAGES,
+ ShortcutSection.ACTIONS
+ );
+ }
+ return '';
+ }
+
+ _highlightEl(el: HTMLElement) {
+ const highlightedEls = this.root!.querySelectorAll('.highlighted');
+ for (const highlightedEl of highlightedEls) {
+ highlightedEl.classList.remove('highlighted');
+ }
+ function handleAnimationEnd() {
+ el.removeEventListener('animationend', handleAnimationEnd);
+ el.classList.remove('highlighted');
+ }
+ el.addEventListener('animationend', handleAnimationEnd);
+ el.classList.add('highlighted');
+ }
+
+ handleExpandCollapse(expand: boolean) {
+ this._expandAllState = expand
+ ? ExpandAllState.COLLAPSE_ALL
+ : ExpandAllState.EXPAND_ALL;
+ this._updateExpandedStateOfAllMessages(expand);
+ }
+
+ _handleExpandCollapseTap(e: Event) {
+ e.preventDefault();
+ this.handleExpandCollapse(
+ this._expandAllState === ExpandAllState.EXPAND_ALL
+ );
+ }
+
+ _handleAnchorClick(e: CustomEvent<MessageAnchorTapDetail>) {
+ this.scrollToMessage(e.detail.id);
+ }
+
+ _isVisibleShowAllActivityToggle(messages: CombinedMessage[] = []) {
+ return messages.some(m => !m.isImportant);
+ }
+
+ _computeHiddenEntriesCount(messages: CombinedMessage[] = []) {
+ return messages.filter(m => !m.isImportant).length;
+ }
+
+ /**
+ * This method is for reporting stats only.
+ */
+ _combinedMessagesChanged(combinedMessages?: CombinedMessage[]) {
+ if (combinedMessages) {
+ if (combinedMessages.length === 0) return;
+ const tags = combinedMessages.map(
+ message =>
+ message.tag || (message as FormattedReviewerUpdateInfo).type || 'none'
+ );
+ const tagsCounted = tags.reduce(
+ (acc, val) => {
+ acc[val] = (acc[val] || 0) + 1;
+ return acc;
+ },
+ {all: combinedMessages.length} as TagsCountReportInfo
+ );
+ this.reporting.reportInteraction('messages-count', tagsCounted);
+ }
+ }
+
+ /**
+ * Compute a mapping from label name to objects representing the minimum and
+ * maximum possible values for that label.
+ */
+ _computeLabelExtremes(
+ labelRecord: PolymerDeepPropertyChange<
+ LabelNameToInfoMap,
+ LabelNameToInfoMap
+ >
+ ) {
+ const extremes: {[lableName: string]: VotingRangeInfo} = {};
+ const labels = labelRecord.base;
+ if (!labels) {
+ return extremes;
+ }
+ for (const key of Object.keys(labels)) {
+ const range = getVotingRange(labels[key]);
+ if (range) {
+ extremes[key] = range;
+ }
+ }
+ return extremes;
+ }
+
+ /**
+ * Work around a issue on iOS when clicking turns into double tap
+ */
+ _onTapShowAllActivityToggle(e: Event) {
+ e.preventDefault();
+ }
+}
+
+declare global {
+ interface HTMLElementTagNameMap {
+ 'gr-messages-list': GrMessagesList;
+ }
+}
diff --git a/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog.ts b/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog.ts
index de56e9d..b3478b1 100644
--- a/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog.ts
+++ b/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog.ts
@@ -97,12 +97,19 @@
PolymerSplice,
PolymerSpliceChange,
} from '@polymer/polymer/interfaces';
-import {assertNever} from '../../../utils/common-util';
-import {CommentThread, isDraft} from '../../diff/gr-comment-api/gr-comment-api';
+import {
+ areSetsEqual,
+ assertNever,
+ containsAll,
+} from '../../../utils/common-util';
+import {CommentThread} from '../../../utils/comment-util';
import {GrTextarea} from '../../shared/gr-textarea/gr-textarea';
import {GrAccountChip} from '../../shared/gr-account-chip/gr-account-chip';
import {GrOverlay} from '../../shared/gr-overlay/gr-overlay';
import {GrStorage, StorageLocation} from '../../shared/gr-storage/gr-storage';
+import {isAttentionSetEnabled} from '../../../utils/attention-set-util';
+import {CODE_REVIEW, getMaxAccounts} from '../../../utils/label-util';
+import {isUnresolved} from '../../../utils/comment-util';
const STORAGE_DEBOUNCE_INTERVAL_MS = 400;
@@ -240,10 +247,10 @@
quote = '';
@property({type: Object})
- filterReviewerSuggestion: () => (input: Suggestion) => boolean;
+ filterReviewerSuggestion: (input: Suggestion) => boolean;
@property({type: Object})
- filterCCSuggestion: () => (input: Suggestion) => boolean;
+ filterCCSuggestion: (input: Suggestion) => boolean;
@property({type: Object})
permittedLabels?: LabelNameToValueMap;
@@ -266,6 +273,9 @@
@property({type: Array})
_ccs: (AccountInfo | GroupInfo)[] = [];
+ @property({type: Number})
+ _attentionCcsCount = 0;
+
@property({type: Object, observer: '_reviewerPendingConfirmationUpdated'})
_ccPendingConfirmation: GroupObjectInput | null = null;
@@ -311,6 +321,11 @@
@property({type: Boolean})
_reviewersMutated = false;
+ /**
+ * Signifies that the user has changed their vote on a label or (if they have
+ * not yet voted on a label) if a selected vote is different from the default
+ * vote.
+ */
@property({type: Boolean})
_labelsChanged = false;
@@ -324,7 +339,7 @@
_commentEditing = false;
@property({type: Boolean})
- _attentionModified = false;
+ _attentionExpanded = false;
@property({type: Object})
_currentAttentionSet: Set<AccountId> = new Set();
@@ -337,7 +352,8 @@
computed:
'_computeSendButtonDisabled(canBeStarted, ' +
'draftCommentThreads, draft, _reviewersMutated, _labelsChanged, ' +
- '_includeComments, disabled, _commentEditing, _attentionModified)',
+ '_includeComments, disabled, _commentEditing, _attentionExpanded, ' +
+ '_currentAttentionSet, _newAttentionSet)',
observer: '_sendDisabledChanged',
})
_sendDisabled?: boolean;
@@ -362,10 +378,10 @@
constructor() {
super();
- this.filterReviewerSuggestion = () =>
- this._filterReviewerSuggestionGenerator(false);
- this.filterCCSuggestion = () =>
- this._filterReviewerSuggestionGenerator(true);
+ this.filterReviewerSuggestion = this._filterReviewerSuggestionGenerator(
+ false
+ );
+ this.filterCCSuggestion = this._filterReviewerSuggestionGenerator(true);
}
/** @override */
@@ -628,7 +644,7 @@
reviewInput.ready = true;
}
- if (this._isAttentionSetEnabled(this.serverConfig)) {
+ if (isAttentionSetEnabled(this.serverConfig)) {
const selfName = getDisplayName(this.serverConfig, this._account);
const reason = `${selfName} replied on the change`;
@@ -646,7 +662,7 @@
}
}
this.reportAttentionSetChanges(
- this._attentionModified,
+ this._attentionExpanded,
reviewInput.add_to_attention_set,
reviewInput.remove_from_attention_set
);
@@ -872,7 +888,7 @@
}
_handleAttentionModify() {
- this._attentionModified = true;
+ this._attentionExpanded = true;
// If the attention-detail section is expanded without dispatching this
// event, then the dialog may expand beyond the screen's bottom border.
this.dispatchEvent(
@@ -880,16 +896,19 @@
);
}
- _showAttentionSummary(config?: ServerInfo, attentionModified?: boolean) {
- return this._isAttentionSetEnabled(config) && !attentionModified;
+ _showAttentionSummary(config?: ServerInfo, attentionExpanded?: boolean) {
+ return isAttentionSetEnabled(config) && !attentionExpanded;
}
- _showAttentionDetails(config?: ServerInfo, attentionModified?: boolean) {
- return this._isAttentionSetEnabled(config) && attentionModified;
+ _showAttentionDetails(config?: ServerInfo, attentionExpanded?: boolean) {
+ return isAttentionSetEnabled(config) && attentionExpanded;
}
- _isAttentionSetEnabled(config?: ServerInfo) {
- return !!config && !!config.change && config.change.enable_attention_set;
+ _computeAttentionButtonTitle(sendDisabled?: boolean) {
+ return sendDisabled
+ ? 'Modify the attention set by adding a comment or use the account ' +
+ 'hovercard in the change page.'
+ : 'Edit attention set changes';
}
_handleAttentionClick(e: Event) {
@@ -935,7 +954,10 @@
'_reviewers.*',
'_ccs.*',
'change',
- 'draftCommentThreads'
+ 'draftCommentThreads',
+ '_includeComments',
+ '_labelsChanged',
+ 'draft'
)
_computeNewAttention(
currentUser?: AccountInfo,
@@ -943,20 +965,32 @@
AccountInfoInput[],
AccountInfoInput[]
>,
- _?: PolymerDeepPropertyChange<AccountInfoInput[], AccountInfoInput[]>,
+ ccs?: PolymerDeepPropertyChange<AccountInfoInput[], AccountInfoInput[]>,
change?: ChangeInfo,
- draftCommentThreads?: CommentThread[]
+ draftCommentThreads?: CommentThread[],
+ includeComments?: boolean,
+ _labelsChanged?: boolean,
+ draft?: boolean
) {
if (
currentUser === undefined ||
currentUser._account_id === undefined ||
reviewers === undefined ||
+ ccs === undefined ||
change === undefined ||
- draftCommentThreads === undefined
+ draftCommentThreads === undefined ||
+ includeComments === undefined
) {
return;
}
- this._attentionModified = false;
+ // The draft comments are only relevant for the attention set as long as the
+ // user actually plans to publish their drafts.
+ draftCommentThreads = includeComments ? draftCommentThreads : [];
+ const hasDraft = draftCommentThreads.length > 0 || !!draft;
+ const hasVote = !!_labelsChanged;
+ const isOwner = this._isOwner(currentUser, change);
+ const isUploader = this._uploader?._account_id === currentUser._account_id;
+ this._attentionCcsCount = removeServiceUsers(ccs.base).length;
this._currentAttentionSet = new Set(
Object.keys(change.attention_set || {}).map(
id => parseInt(id) as AccountId
@@ -970,30 +1004,28 @@
);
// Remove the current user.
newAttention.delete(currentUser._account_id);
- // Add all new reviewers.
+ // Add all new reviewers, but not the current reviewer, if they are also
+ // sending a draft or a label vote.
+ const notIsReviewerAndHasDraftOrLabel = (r: AccountInfo) =>
+ !(r._account_id === currentUser._account_id && (hasDraft || hasVote));
reviewers.base
.filter(r => r._pendingAdd && r._account_id)
+ .filter(notIsReviewerAndHasDraftOrLabel)
.forEach(r => newAttention.add(r._account_id!));
- // Add the uploader, if someone else replies.
- if (
- this._uploader &&
- this._uploader._account_id !== currentUser._account_id
- ) {
- // An uploader must have an _account_id.
- newAttention.add(this._uploader._account_id!);
- }
- // Add the owner, if someone else replies. Also add the owner, if the
- // attention set would otherwise be empty.
- if (change.owner) {
- if (!this._isOwner(currentUser, change) || newAttention.size === 0) {
- // A change owner must have an _account_id.
- newAttention.add(change.owner._account_id!);
+ // Add owner and uploader, if someone else replies.
+ if (hasDraft || hasVote) {
+ if (this._uploader?._account_id && !isUploader) {
+ newAttention.add(this._uploader._account_id);
+ }
+ if (change.owner?._account_id && !isOwner) {
+ newAttention.add(change.owner._account_id);
}
}
} else {
// The only reason for adding someone to the attention set for merged or
- // abandoned changes is that someone adds a new comment thread.
- if (change.owner && this._containsNewCommentThread(draftCommentThreads)) {
+ // abandoned changes is that someone makes a comment thread unresolved.
+ const hasUnresolvedDraft = draftCommentThreads.some(isUnresolved);
+ if (change.owner && hasUnresolvedDraft) {
// A change owner must have an _account_id.
newAttention.add(change.owner._account_id!);
}
@@ -1008,34 +1040,55 @@
this._newAttentionSet = new Set(
[...newAttention].filter(id => allAccountIds.includes(id))
);
+ this._attentionExpanded = this._computeShowAttentionTip(
+ currentUser,
+ change.owner,
+ this._currentAttentionSet,
+ this._newAttentionSet
+ );
+ }
+
+ _computeShowAttentionTip(
+ currentUser?: AccountInfo,
+ owner?: AccountInfo,
+ currentAttentionSet?: Set<AccountId>,
+ newAttentionSet?: Set<AccountId>
+ ) {
+ if (!currentUser || !owner || !currentAttentionSet || !newAttentionSet)
+ return false;
+ const isOwner = currentUser._account_id === owner._account_id;
+ const addedIds = [...newAttentionSet].filter(
+ id => !currentAttentionSet.has(id)
+ );
+ return isOwner && addedIds.length > 2;
}
_computeCommentAccounts(threads: CommentThread[]) {
+ const crLabel = this.change?.labels?.[CODE_REVIEW];
+ const maxCrVoteAccountIds = getMaxAccounts(crLabel).map(a => a._account_id);
const accountIds = new Set<AccountId>();
threads.forEach(thread => {
+ const unresolved = isUnresolved(thread);
thread.comments.forEach(comment => {
if (comment.author) {
// A comment author must have an _account_id.
- accountIds.add(comment.author._account_id!);
+ const authorId = comment.author._account_id!;
+ const hasGivenMaxReviewVote = maxCrVoteAccountIds.includes(authorId);
+ if (unresolved || !hasGivenMaxReviewVote) accountIds.add(authorId);
}
});
});
return accountIds;
}
- _containsNewCommentThread(threads: CommentThread[]) {
- return threads.some(
- thread =>
- !!thread.comments && !!thread.comments[0] && isDraft(thread.comments[0])
- );
- }
-
- _isNewAttentionEmpty(
+ _computeShowNoAttentionUpdate(
config?: ServerInfo,
currentAttentionSet?: Set<AccountId>,
- newAttentionSet?: Set<AccountId>
+ newAttentionSet?: Set<AccountId>,
+ sendDisabled?: boolean
) {
return (
+ sendDisabled ||
this._computeNewAttentionAccounts(
config,
currentAttentionSet,
@@ -1044,6 +1097,24 @@
);
}
+ _computeDoNotUpdateMessage(
+ currentAttentionSet?: Set<AccountId>,
+ newAttentionSet?: Set<AccountId>,
+ sendDisabled?: boolean
+ ) {
+ if (!currentAttentionSet || !newAttentionSet) return '';
+ if (sendDisabled || areSetsEqual(currentAttentionSet, newAttentionSet)) {
+ return 'No changes to the attention set.';
+ }
+ if (containsAll(currentAttentionSet, newAttentionSet)) {
+ return 'No additions to the attention set.';
+ }
+ console.error(
+ '_computeDoNotUpdateMessage() should not be called when users were added to the attention set.'
+ );
+ return '';
+ }
+
_computeNewAttentionAccounts(
_?: ServerInfo,
currentAttentionSet?: Set<AccountId>,
@@ -1078,10 +1149,6 @@
return removeServiceUsers(accounts);
}
- _computeShowAttentionCcs(ccs: AccountInfo[]) {
- return removeServiceUsers(ccs).length > 0;
- }
-
_computeUploader(change: ChangeInfo) {
if (
!change ||
@@ -1311,7 +1378,7 @@
_handleLabelsChanged() {
this._labelsChanged =
- Object.keys(this.$.labelScores.getLabelValues()).length !== 0;
+ Object.keys(this.$.labelScores.getLabelValues(false)).length !== 0;
}
_isState(knownLatestState?: LatestPatchState, value?: LatestPatchState) {
@@ -1351,8 +1418,7 @@
labelsChanged?: boolean,
includeComments?: boolean,
disabled?: boolean,
- commentEditing?: boolean,
- attentionModified?: boolean
+ commentEditing?: boolean
) {
if (
canBeStarted === undefined ||
@@ -1362,8 +1428,7 @@
labelsChanged === undefined ||
includeComments === undefined ||
disabled === undefined ||
- commentEditing === undefined ||
- attentionModified === undefined
+ commentEditing === undefined
) {
return undefined;
}
@@ -1374,13 +1439,7 @@
return false;
}
const hasDrafts = includeComments && draftCommentThreads.length;
- return (
- !hasDrafts &&
- !text.length &&
- !reviewersMutated &&
- !labelsChanged &&
- !attentionModified
- );
+ return !hasDrafts && !text.length && !reviewersMutated && !labelsChanged;
}
_computePatchSetWarning(patchNum?: PatchSetNum, labelsChanged?: boolean) {
diff --git a/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog_html.ts b/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog_html.ts
index 1be2f75..c56a5c9 100644
--- a/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog_html.ts
+++ b/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog_html.ts
@@ -47,6 +47,7 @@
.stickyBottom {
background-color: var(--dialog-background-color);
box-shadow: 0px 0px 8px 0px rgba(60, 64, 67, 0.15);
+ margin-top: var(--spacing-s);
bottom: 0;
position: sticky;
/* @see Issue 8602 */
@@ -167,33 +168,39 @@
display: flex;
justify-content: space-between;
}
- .attentionSummary gr-account-chip {
- display: inline-block;
- vertical-align: top;
- /* The account chip is misbehaving currently: It consumes 22px height, so
- it does not fit nicely into the 20px line-height of the standard
- inline layout flow. :-( */
- position: relative;
- top: -1px;
- }
- .attentionSummary,
- .attention-detail {
- --account-max-length: 150px;
+ .attentionSummary {
+ /* The account label for selection is misbehaving currently: It consumes
+ 26px height instead of 20px, which is the default line-height and thus
+ the max that can be nicely fit into an inline layout flow. We
+ acknowledge that using a fixed 26px value here is a hack and not a
+ great solution. */
+ line-height: 26px;
}
.attention-detail .peopleList .accountList {
display: flex;
flex-wrap: wrap;
}
+ .attentionSummary gr-account-label,
.attention-detail gr-account-label {
+ --account-max-length: 150px;
display: inline-block;
padding: var(--spacing-xs) var(--spacing-m);
- vertical-align: baseline;
user-select: none;
--label-border-radius: 8px;
}
+ .attentionSummary gr-account-label {
+ margin: 0 var(--spacing-xs);
+ line-height: var(--line-height-normal);
+ vertical-align: top;
+ }
+ .attention-detail gr-account-label {
+ vertical-align: baseline;
+ }
+ .attentionSummary gr-account-label:focus,
.attention-detail gr-account-label:focus {
outline: none;
}
+ .attentionSummary gr-account-label:hover,
.attention-detail gr-account-label:hover {
box-shadow: var(--elevation-level-1);
cursor: pointer;
@@ -206,6 +213,16 @@
color: var(--deemphasized-text-color);
margin-bottom: var(--spacing-m);
}
+ .attentionTip {
+ padding: var(--spacing-m);
+ border: 1px solid var(--border-color);
+ border-radius: var(--border-radius);
+ margin-top: var(--spacing-m);
+ background-color: var(--assignee-highlight-color);
+ }
+ .attentionTip div iron-icon {
+ margin-right: var(--spacing-s);
+ }
</style>
<div class="container" tabindex="-1">
<section class="peopleContainer">
@@ -352,20 +369,23 @@
</section>
<div class="stickyBottom">
<section
- hidden$="[[!_showAttentionSummary(serverConfig, _attentionModified)]]"
+ hidden$="[[!_showAttentionSummary(serverConfig, _attentionExpanded)]]"
class="attention"
>
<div class="attentionSummary">
<div>
<template
is="dom-if"
- if="[[_isNewAttentionEmpty(serverConfig, _currentAttentionSet, _newAttentionSet)]]"
+ if="[[_computeShowNoAttentionUpdate(serverConfig, _currentAttentionSet, _newAttentionSet, _sendDisabled)]]"
>
- <span>Do not update the attention set.</span>
+ <span
+ >[[_computeDoNotUpdateMessage(_currentAttentionSet,
+ _newAttentionSet, _sendDisabled)]]</span
+ >
</template>
<template
is="dom-if"
- if="[[!_isNewAttentionEmpty(serverConfig, _currentAttentionSet, _newAttentionSet)]]"
+ if="[[!_computeShowNoAttentionUpdate(serverConfig, _currentAttentionSet, _newAttentionSet, _sendDisabled)]]"
>
<span>Bring to attention of</span>
<template
@@ -373,19 +393,27 @@
items="[[_computeNewAttentionAccounts(serverConfig, _currentAttentionSet, _newAttentionSet)]]"
as="account"
>
- <gr-account-chip account="[[account]]" force-attention="">
- </gr-account-chip>
+ <gr-account-label
+ account="[[account]]"
+ force-attention="[[_computeHasNewAttention(account, _newAttentionSet)]]"
+ selected$="[[_computeHasNewAttention(account, _newAttentionSet)]]"
+ deselected$="[[!_computeHasNewAttention(account, _newAttentionSet)]]"
+ hide-hovercard=""
+ on-click="_handleAttentionClick"
+ ></gr-account-label>
</template>
</template>
<gr-button
class="edit-attention-button"
on-click="_handleAttentionModify"
+ disabled="[[_sendDisabled]]"
link=""
position-below=""
data-label="Edit"
data-action-type="change"
data-action-key="edit"
- title="Edit attention set changes"
+ has-tooltip=""
+ title="[[_computeAttentionButtonTitle(_sendDisabled)]]"
role="button"
tabindex="0"
>
@@ -416,7 +444,7 @@
</div>
</section>
<section
- hidden$="[[!_showAttentionDetails(serverConfig, _attentionModified)]]"
+ hidden$="[[!_showAttentionDetails(serverConfig, _attentionExpanded)]]"
class="attention-detail"
>
<div class="attentionDetailsTitle">
@@ -501,7 +529,7 @@
</template>
</div>
</div>
- <template is="dom-if" if="[[_computeShowAttentionCcs(_ccs)]]">
+ <template is="dom-if" if="[[_attentionCcsCount]]">
<div class="peopleList">
<div class="peopleListLabel">CC</div>
<div>
@@ -523,6 +551,18 @@
</div>
</div>
</template>
+ <template
+ is="dom-if"
+ if="[[_computeShowAttentionTip(_account, _owner, _currentAttentionSet, _newAttentionSet)]]"
+ >
+ <div class="attentionTip">
+ <iron-icon
+ class="pointer"
+ icon="gr-icons:lightbulb-outline"
+ ></iron-icon>
+ Be mindful of requiring attention from too many users.
+ </div>
+ </template>
</section>
<section class="actions">
<div class="left">
diff --git a/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog_test.js b/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog_test.js
index abbb1d4..7a89f3d 100644
--- a/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog_test.js
+++ b/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog_test.js
@@ -203,7 +203,8 @@
});
function checkComputeAttention(status, userId, reviewerIds, ownerId,
- attSetIds, replyToIds, expectedIds, uploaderId, hasDraft) {
+ attSetIds, replyToIds, expectedIds, uploaderId, hasDraft,
+ includeComments = true) {
const user = {_account_id: userId};
const reviewers = {base: reviewerIds.map(id => {
return {_account_id: id};
@@ -212,7 +213,7 @@
{comments: []},
];
if (hasDraft) {
- draftThreads[0].comments.push({__draft: true});
+ draftThreads[0].comments.push({__draft: true, unresolved: true});
}
replyToIds.forEach(id => draftThreads[0].comments.push({
author: {_account_id: id},
@@ -230,7 +231,8 @@
element.change = change;
element._reviewers = reviewers.base;
flush();
- element._computeNewAttention(user, reviewers, [], change, draftThreads);
+ element._computeNewAttention(
+ user, reviewers, [], change, draftThreads, includeComments);
assert.sameMembers([...element._newAttentionSet], expectedIds);
}
@@ -242,9 +244,11 @@
checkComputeAttention('NEW', 1, [22], 999, [22], [], [22, 999]);
checkComputeAttention('NEW', 1, [22], 999, [], [22], [22, 999]);
checkComputeAttention('NEW', 1, [22, 33], 999, [33], [22], [22, 33, 999]);
- checkComputeAttention('NEW', 1, [], 1, [], [], [1]);
- checkComputeAttention('NEW', 1, [], 1, [1], [], [1]);
- checkComputeAttention('NEW', 1, [22], 1, [], [], [1]);
+ // If the owner replies, then do not add them.
+ checkComputeAttention('NEW', 1, [], 1, [], [], []);
+ checkComputeAttention('NEW', 1, [], 1, [1], [], []);
+ checkComputeAttention('NEW', 1, [22], 1, [], [], []);
+
checkComputeAttention('NEW', 1, [22], 1, [], [22], [22]);
checkComputeAttention('NEW', 1, [22, 33], 1, [33], [22], [22, 33]);
checkComputeAttention('NEW', 1, [22, 33], 1, [], [22], [22]);
@@ -260,6 +264,8 @@
checkComputeAttention('MERGED', null, [], 999, [], [], []);
checkComputeAttention('MERGED', 1, [], 999, [], [], []);
checkComputeAttention('MERGED', 1, [], 999, [], [], [999], undefined, true);
+ checkComputeAttention(
+ 'MERGED', 1, [], 999, [], [], [], undefined, true, false);
checkComputeAttention('MERGED', 1, [], 999, [1], [], []);
checkComputeAttention('MERGED', 1, [22], 999, [], [], []);
checkComputeAttention('MERGED', 1, [22], 999, [22], [], [22]);
@@ -277,6 +283,33 @@
checkComputeAttention('MERGED', 1, [22, 33], 1, [22, 33], [], [22, 33]);
});
+ test('computeNewAttention when adding reviewers', () => {
+ const user = {_account_id: 1};
+ const reviewers = {base: [
+ {_account_id: 1, _pendingAdd: true},
+ {_account_id: 2, _pendingAdd: true},
+ ]};
+ const change = {
+ owner: {_account_id: 5},
+ status: 'NEW',
+ attention_set: {},
+ };
+ element.change = change;
+ element._reviewers = reviewers.base;
+ flush();
+
+ element._computeNewAttention(user, reviewers, [], change, [], true);
+ assert.sameMembers([...element._newAttentionSet], [1, 2]);
+
+ // If the user votes on the change, then they should not be added to the
+ // attention set, even if they have just added themselves as reviewer.
+ // But voting should also add the owner (5).
+ const labelsChanged = true;
+ element._computeNewAttention(
+ user, reviewers, [], change, [], true, labelsChanged);
+ assert.sameMembers([...element._newAttentionSet], [2, 5]);
+ });
+
test('computeNewAttentionAccounts', () => {
element._reviewers = [
{_account_id: 123, display_name: 'Ernie'},
@@ -298,6 +331,45 @@
assert.sameMembers(compute([999], [7, 123, 999]), [7, 123]);
});
+ test('_computeCommentAccounts', () => {
+ element.change = {
+ labels: {
+ 'Code-Review': {
+ all: [
+ {_account_id: 1, value: 0},
+ {_account_id: 2, value: 1},
+ {_account_id: 3, value: 2},
+ ],
+ values: {
+ '-2': 'Do not submit',
+ '-1': 'I would prefer that you didnt submit this',
+ ' 0': 'No score',
+ '+1': 'Looks good to me, but someone else must approve',
+ '+2': 'Looks good to me, approved',
+ },
+ },
+ },
+ };
+ const threads = [
+ {
+ comments: [
+ {author: {_account_id: 1}, unresolved: false},
+ {author: {_account_id: 2}, unresolved: true},
+ ],
+ },
+ {
+ comments: [
+ {author: {_account_id: 3}, unresolved: false},
+ {author: {_account_id: 4}, unresolved: false},
+ ],
+ },
+ ];
+ const actualAccounts = [...element._computeCommentAccounts(threads)];
+ // Account 3 is not included, because the comment is resolved *and* they
+ // have given the highest possible vote on the Code-Review label.
+ assert.sameMembers(actualAccounts, [1, 2, 4]);
+ });
+
test('toggle resolved checkbox', done => {
// Async tick is needed because iron-selector content is distributed and
// distributed content requires an observer to be set up.
@@ -954,11 +1026,18 @@
element._reviewers = [makeAccount(), makeAccount()];
element._ccs = [makeAccount(), makeAccount()];
element.draftCommentThreads = [];
- MockInteractions.tap(
- element.shadowRoot.querySelector('.edit-attention-button'));
+ const modifyButton =
+ element.shadowRoot.querySelector('.edit-attention-button');
+ MockInteractions.tap(modifyButton);
flush();
- assert.isTrue(element._attentionModified);
+ // "Modify" button disabled, because "Send" button is disabled.
+ assert.isFalse(element._attentionExpanded);
+ element.draft = 'a test comment';
+ MockInteractions.tap(modifyButton);
+ flush();
+ assert.isTrue(element._attentionExpanded);
+
let accountLabels = Array.from(element.shadowRoot.querySelectorAll(
'.attention-detail gr-account-label'));
assert.equal(accountLabels.length, 5);
@@ -969,13 +1048,13 @@
// The 'attention modified' section collapses and resets when reviewers or
// ccs change.
- assert.isFalse(element._attentionModified);
+ assert.isFalse(element._attentionExpanded);
MockInteractions.tap(
element.shadowRoot.querySelector('.edit-attention-button'));
flush();
- assert.isTrue(element._attentionModified);
+ assert.isTrue(element._attentionExpanded);
accountLabels = Array.from(element.shadowRoot.querySelectorAll(
'.attention-detail gr-account-label'));
assert.equal(accountLabels.length, 7);
@@ -1323,8 +1402,7 @@
/* labelsChanged= */ false,
/* includeComments= */ false,
/* disabled= */ false,
- /* commentEditing= */ false,
- /* attentionModified= */ false
+ /* commentEditing= */ false
));
});
@@ -1339,24 +1417,7 @@
/* labelsChanged= */ false,
/* includeComments= */ false,
/* disabled= */ false,
- /* commentEditing= */ false,
- /* attentionModified= */ false
- ));
- });
-
- test('_computeSendButtonDisabled_attentionModified true', () => {
- const fn = element._computeSendButtonDisabled.bind(element);
- // Mock everything false
- assert.isFalse(fn(
- /* canBeStarted= */ false,
- /* draftCommentThreads= */ [],
- /* text= */ '',
- /* reviewersMutated= */ false,
- /* labelsChanged= */ false,
- /* includeComments= */ false,
- /* disabled= */ false,
- /* commentEditing= */ false,
- /* attentionModified= */ true
+ /* commentEditing= */ false
));
});
@@ -1371,8 +1432,7 @@
/* labelsChanged= */ false,
/* includeComments= */ true,
/* disabled= */ false,
- /* commentEditing= */ false,
- /* attentionModified= */ false
+ /* commentEditing= */ false
));
});
@@ -1387,8 +1447,7 @@
/* labelsChanged= */ false,
/* includeComments= */ false,
/* disabled= */ false,
- /* commentEditing= */ false,
- /* attentionModified= */ false
+ /* commentEditing= */ false
));
});
@@ -1403,8 +1462,7 @@
/* labelsChanged= */ false,
/* includeComments= */ false,
/* disabled= */ false,
- /* commentEditing= */ false,
- /* attentionModified= */ false
+ /* commentEditing= */ false
));
});
@@ -1419,8 +1477,7 @@
/* labelsChanged= */ false,
/* includeComments= */ false,
/* disabled= */ false,
- /* commentEditing= */ false,
- /* attentionModified= */ false
+ /* commentEditing= */ false
));
});
@@ -1435,8 +1492,7 @@
/* labelsChanged= */ true,
/* includeComments= */ false,
/* disabled= */ false,
- /* commentEditing= */ false,
- /* attentionModified= */ false
+ /* commentEditing= */ false
));
});
@@ -1451,8 +1507,7 @@
/* labelsChanged= */ true,
/* includeComments= */ false,
/* disabled= */ true,
- /* commentEditing= */ false,
- /* attentionModified= */ false
+ /* commentEditing= */ false
));
assert.isTrue(fn(
/* buttonLabel= */ 'Send',
@@ -1462,8 +1517,7 @@
/* labelsChanged= */ true,
/* includeComments= */ false,
/* disabled= */ false,
- /* commentEditing= */ true,
- /* attentionModified= */ false
+ /* commentEditing= */ true
));
});
diff --git a/polygerrit-ui/app/elements/change/gr-reviewer-list/gr-reviewer-list.ts b/polygerrit-ui/app/elements/change/gr-reviewer-list/gr-reviewer-list.ts
index 70e7ba7..d7a1f17 100644
--- a/polygerrit-ui/app/elements/change/gr-reviewer-list/gr-reviewer-list.ts
+++ b/polygerrit-ui/app/elements/change/gr-reviewer-list/gr-reviewer-list.ts
@@ -23,7 +23,8 @@
import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
import {PolymerElement} from '@polymer/polymer/polymer-element';
import {htmlTemplate} from './gr-reviewer-list_html';
-import {hasAttention, isServiceUser} from '../../../utils/account-util';
+import {isServiceUser} from '../../../utils/account-util';
+import {hasAttention} from '../../../utils/attention-set-util';
import {customElement, property, computed, observe} from '@polymer/decorators';
import {
ChangeInfo,
@@ -39,6 +40,7 @@
import {GrAccountChip} from '../../shared/gr-account-chip/gr-account-chip';
import {RestApiService} from '../../../services/services/gr-rest-api/gr-rest-api';
import {hasOwnProperty} from '../../../utils/common-util';
+import {isRemovableReviewer} from '../../../utils/change-util';
export interface GrReviewerList {
$: {
@@ -251,25 +253,7 @@
}
_computeCanRemoveReviewer(reviewer: AccountInfo, mutable: boolean) {
- if (
- !mutable ||
- this.change === undefined ||
- this.change.removable_reviewers === undefined
- ) {
- return false;
- }
-
- let current;
- for (let i = 0; i < this.change.removable_reviewers.length; i++) {
- current = this.change.removable_reviewers[i];
- if (
- current._account_id === reviewer._account_id ||
- (!reviewer._account_id && current.email === reviewer.email)
- ) {
- return true;
- }
- }
- return false;
+ return mutable && isRemovableReviewer(this.change, reviewer);
}
_handleRemove(e: Event) {
diff --git a/polygerrit-ui/app/elements/change/gr-thread-list/gr-thread-list.ts b/polygerrit-ui/app/elements/change/gr-thread-list/gr-thread-list.ts
index 4b02075..6a32834 100644
--- a/polygerrit-ui/app/elements/change/gr-thread-list/gr-thread-list.ts
+++ b/polygerrit-ui/app/elements/change/gr-thread-list/gr-thread-list.ts
@@ -24,16 +24,14 @@
import {htmlTemplate} from './gr-thread-list_html';
import {parseDate} from '../../../utils/date-util';
-import {NO_THREADS_MSG} from '../../../constants/messages';
import {CommentSide, SpecialFilePath} from '../../../constants/constants';
import {customElement, observe, property} from '@polymer/decorators';
import {
- CommentThread,
- isDraft,
- UIRobot,
-} from '../../diff/gr-comment-api/gr-comment-api';
-import {PolymerSpliceChange} from '@polymer/polymer/interfaces';
+ PolymerSpliceChange,
+ PolymerDeepPropertyChange,
+} from '@polymer/polymer/interfaces';
import {ChangeInfo} from '../../../types/common';
+import {CommentThread, isDraft, UIRobot} from '../../../utils/comment-util';
interface CommentThreadWithInfo {
thread: CommentThread;
@@ -68,8 +66,19 @@
@property({type: Array})
_sortedThreads: CommentThread[] = [];
+ @property({
+ computed:
+ '_computeDisplayedThreads(_sortedThreads.*, unresolvedOnly, ' +
+ '_draftsOnly, onlyShowRobotCommentsWithHumanReply)',
+ type: Array,
+ })
+ _displayedThreads: CommentThread[] = [];
+
+ // thread-list is used in multiple places like the change log, hence
+ // keeping the default to be false. When used in comments tab, it's
+ // set as true.
@property({type: Boolean})
- _unresolvedOnly = false;
+ unresolvedOnly = false;
@property({type: Boolean})
_draftsOnly = false;
@@ -80,13 +89,53 @@
@property({type: Boolean})
hideToggleButtons = false;
- @property({type: String})
- emptyThreadMsg = NO_THREADS_MSG;
-
_computeShowDraftToggle(loggedIn?: boolean) {
return loggedIn ? 'show' : '';
}
+ _showEmptyThreadsMessage(
+ threads: CommentThread[],
+ displayedThreads: CommentThread[],
+ unresolvedOnly: boolean
+ ) {
+ if (!threads || !displayedThreads) return false;
+ return !threads.length || (unresolvedOnly && !displayedThreads.length);
+ }
+
+ _computeEmptyThreadsMessage(threads: CommentThread[]) {
+ return !threads.length ? 'No comments.' : 'No unresolved comments';
+ }
+
+ _showPartyPopper(threads: CommentThread[]) {
+ return !!threads.length;
+ }
+
+ _computeResolvedCommentsMessage(
+ threads: CommentThread[],
+ displayedThreads: CommentThread[],
+ unresolvedOnly: boolean
+ ) {
+ if (unresolvedOnly && threads.length && !displayedThreads.length) {
+ return (
+ `Show ${threads.length} resolved comment` +
+ (threads.length > 1 ? 's' : '')
+ );
+ }
+ return '';
+ }
+
+ _showResolvedCommentsButton(
+ threads: CommentThread[],
+ displayedThreads: CommentThread[],
+ unresolvedOnly: boolean
+ ) {
+ return unresolvedOnly && threads.length && !displayedThreads.length;
+ }
+
+ _handleResolvedCommentsMessageClick() {
+ this.unresolvedOnly = !this.unresolvedOnly;
+ }
+
_compareThreads(c1: CommentThreadWithInfo, c2: CommentThreadWithInfo) {
if (c1.thread.path !== c2.thread.path) {
// '/PATCHSET' will not come before '/COMMIT' when sorting
@@ -170,6 +219,7 @@
) {
if (!threads || threads.length === 0) {
this._sortedThreads = [];
+ this._displayedThreads = [];
return;
}
// We only want to sort on thread additions / removals to avoid
@@ -200,14 +250,34 @@
.map(threadInfo => threadInfo.thread);
}
+ _computeDisplayedThreads(
+ sortedThreadsRecord?: PolymerDeepPropertyChange<
+ CommentThread[],
+ CommentThread[]
+ >,
+ unresolvedOnly?: boolean,
+ draftsOnly?: boolean,
+ onlyShowRobotCommentsWithHumanReply?: boolean
+ ) {
+ if (!sortedThreadsRecord || !sortedThreadsRecord.base) return [];
+ return sortedThreadsRecord.base.filter(t =>
+ this._shouldShowThread(
+ t,
+ unresolvedOnly,
+ draftsOnly,
+ onlyShowRobotCommentsWithHumanReply
+ )
+ );
+ }
+
_isFirstThreadWithFileName(
- sortedThreads: CommentThread[],
+ displayedThreads: CommentThread[],
thread: CommentThread,
unresolvedOnly?: boolean,
draftsOnly?: boolean,
onlyShowRobotCommentsWithHumanReply?: boolean
) {
- const threads = sortedThreads.filter(t =>
+ const threads = displayedThreads.filter(t =>
this._shouldShowThread(
t,
unresolvedOnly,
@@ -223,13 +293,13 @@
}
_shouldRenderSeparator(
- sortedThreads: CommentThread[],
+ displayedThreads: CommentThread[],
thread: CommentThread,
unresolvedOnly?: boolean,
draftsOnly?: boolean,
onlyShowRobotCommentsWithHumanReply?: boolean
) {
- const threads = sortedThreads.filter(t =>
+ const threads = displayedThreads.filter(t =>
this._shouldShowThread(
t,
unresolvedOnly,
@@ -244,7 +314,7 @@
return (
index > 0 &&
this._isFirstThreadWithFileName(
- sortedThreads,
+ displayedThreads,
thread,
unresolvedOnly,
draftsOnly,
diff --git a/polygerrit-ui/app/elements/change/gr-thread-list/gr-thread-list_html.ts b/polygerrit-ui/app/elements/change/gr-thread-list/gr-thread-list_html.ts
index d74c985..e55f98a 100644
--- a/polygerrit-ui/app/elements/change/gr-thread-list/gr-thread-list_html.ts
+++ b/polygerrit-ui/app/elements/change/gr-thread-list/gr-thread-list_html.ts
@@ -57,15 +57,23 @@
border-top: 1px solid var(--border-color);
margin-top: var(--spacing-xl);
}
+ .resolved-comments-message {
+ color: var(--link-color);
+ cursor: pointer;
+ }
+ .show-resolved-comments {
+ box-shadow: none;
+ padding-left: var(--spacing-m);
+ }
</style>
<template is="dom-if" if="[[!hideToggleButtons]]">
<div class="header">
<div class="toggleItem">
<paper-toggle-button
id="unresolvedToggle"
- checked="{{_unresolvedOnly}}"
+ checked="{{!unresolvedOnly}}"
on-tap="_onTapUnresolvedToggle"
- >Only unresolved threads</paper-toggle-button
+ >All comments</paper-toggle-button
>
</div>
<div
@@ -75,48 +83,63 @@
id="draftToggle"
checked="{{_draftsOnly}}"
on-tap="_onTapUnresolvedToggle"
- >Only threads with drafts</paper-toggle-button
+ >Comments with drafts</paper-toggle-button
>
</div>
</div>
</template>
<div id="threads">
- <template is="dom-if" if="[[!threads.length]]">
- [[emptyThreadMsg]]
+ <template
+ is="dom-if"
+ if="[[_showEmptyThreadsMessage(threads, _displayedThreads, unresolvedOnly)]]"
+ >
+ <div>
+ <span>
+ <template is="dom-if" if="[[_showPartyPopper(threads)]]">
+ <span> \🎉 </span>
+ </template>
+ [[_computeEmptyThreadsMessage(threads, _displayedThreads,
+ unresolvedOnly)]]
+ <template is="dom-if" if="[[_showResolvedCommentsButton(threads, _displayedThreads, unresolvedOnly)]]">
+ <gr-button
+ class="show-resolved-comments"
+ link
+ on-click="_handleResolvedCommentsMessageClick">
+ [[_computeResolvedCommentsMessage(threads, _displayedThreads,
+ unresolvedOnly)]]
+ </gr-button>
+ </template>
+ </span>
+ </div>
</template>
<template
is="dom-repeat"
- items="[[_sortedThreads]]"
+ items="[[_displayedThreads]]"
as="thread"
initial-count="10"
target-framerate="60"
>
<template
is="dom-if"
- if="[[_shouldShowThread(thread, _unresolvedOnly, _draftsOnly, onlyShowRobotCommentsWithHumanReply)]]"
+ if="[[_shouldRenderSeparator(_displayedThreads, thread, unresolvedOnly, _draftsOnly, onlyShowRobotCommentsWithHumanReply)]]"
>
- <template
- is="dom-if"
- if="[[_shouldRenderSeparator(_sortedThreads, thread, _unresolvedOnly, _draftsOnly, onlyShowRobotCommentsWithHumanReply)]]"
- >
- <div class="thread-separator"></div>
- </template>
- <gr-comment-thread
- show-file-path=""
- change-num="[[changeNum]]"
- comments="[[thread.comments]]"
- comment-side="[[thread.commentSide]]"
- show-file-name="[[_isFirstThreadWithFileName(_sortedThreads, thread, _unresolvedOnly, _draftsOnly, onlyShowRobotCommentsWithHumanReply)]]"
- project-name="[[change.project]]"
- is-on-parent="[[_isOnParent(thread.commentSide)]]"
- line-num="[[thread.line]]"
- patch-num="[[thread.patchNum]]"
- path="[[thread.path]]"
- root-id="{{thread.rootId}}"
- on-thread-changed="_handleCommentsChanged"
- on-thread-discard="_handleThreadDiscard"
- ></gr-comment-thread>
+ <div class="thread-separator"></div>
</template>
+ <gr-comment-thread
+ show-file-path=""
+ change-num="[[changeNum]]"
+ comments="[[thread.comments]]"
+ comment-side="[[thread.commentSide]]"
+ show-file-name="[[_isFirstThreadWithFileName(_displayedThreads, thread, unresolvedOnly, _draftsOnly, onlyShowRobotCommentsWithHumanReply)]]"
+ project-name="[[change.project]]"
+ is-on-parent="[[_isOnParent(thread.commentSide)]]"
+ line-num="[[thread.line]]"
+ patch-num="[[thread.patchNum]]"
+ path="[[thread.path]]"
+ root-id="{{thread.rootId}}"
+ on-thread-changed="_handleCommentsChanged"
+ on-thread-discard="_handleThreadDiscard"
+ ></gr-comment-thread>
</template>
</div>
`;
diff --git a/polygerrit-ui/app/elements/change/gr-thread-list/gr-thread-list_test.js b/polygerrit-ui/app/elements/change/gr-thread-list/gr-thread-list_test.js
index bad3a99..efc072f 100644
--- a/polygerrit-ui/app/elements/change/gr-thread-list/gr-thread-list_test.js
+++ b/polygerrit-ui/app/elements/change/gr-thread-list/gr-thread-list_test.js
@@ -18,7 +18,6 @@
import '../../../test/common-test-setup-karma.js';
import './gr-thread-list.js';
import {dom} from '@polymer/polymer/lib/legacy/polymer.dom.js';
-import {NO_THREADS_MSG} from '../../../constants/messages.js';
import {SpecialFilePath} from '../../../constants/constants.js';
const basicFixture = fixtureFromElement('gr-thread-list');
@@ -287,13 +286,23 @@
assert.equal(getVisibleThreads().length, element.threads.length);
});
+ test('show unresolved threads if unresolvedOnly is set', done => {
+ element.unresolvedOnly = true;
+ flush();
+ const unresolvedThreads = element.threads.filter(t => t.comments.some(
+ c => c.unresolved
+ ));
+ assert.equal(getVisibleThreads().length, unresolvedThreads.length);
+ done();
+ });
+
test('showing file name takes visible threads into account', () => {
assert.equal(element._isFirstThreadWithFileName(element._sortedThreads,
- element._sortedThreads[2], element._unresolvedOnly, element._draftsOnly,
+ element._sortedThreads[2], element.unresolvedOnly, element._draftsOnly,
element.onlyShowRobotCommentsWithHumanReply), true);
- element._unresolvedOnly = true;
+ element.unresolvedOnly = true;
assert.equal(element._isFirstThreadWithFileName(element._sortedThreads,
- element._sortedThreads[2], element._unresolvedOnly, element._draftsOnly,
+ element._sortedThreads[2], element.unresolvedOnly, element._draftsOnly,
element.onlyShowRobotCommentsWithHumanReply), false);
});
@@ -539,7 +548,7 @@
});
});
- test('toggle unresolved only shows unresolved comments', () => {
+ test('toggle unresolved shows all comments', () => {
MockInteractions.tap(element.shadowRoot.querySelector(
'#unresolvedToggle'));
flush();
@@ -617,18 +626,9 @@
});
test('default empty message should show', () => {
- assert.equal(
- element.shadowRoot.querySelector('#threads').textContent.trim(),
- NO_THREADS_MSG
- );
- });
-
- test('can override empty message', () => {
- element.emptyThreadMsg = 'test';
- assert.equal(
- element.shadowRoot.querySelector('#threads').textContent.trim(),
- 'test'
- );
+ assert.isTrue(
+ element.shadowRoot.querySelector('#threads').textContent.trim()
+ .includes('No comments.'));
});
});
});
diff --git a/polygerrit-ui/app/elements/core/gr-main-header/gr-main-header.js b/polygerrit-ui/app/elements/core/gr-main-header/gr-main-header.js
deleted file mode 100644
index 88c4cef..0000000
--- a/polygerrit-ui/app/elements/core/gr-main-header/gr-main-header.js
+++ /dev/null
@@ -1,363 +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 '../../plugins/gr-endpoint-decorator/gr-endpoint-decorator.js';
-import '../../shared/gr-dropdown/gr-dropdown.js';
-import '../../shared/gr-icons/gr-icons.js';
-import '../../shared/gr-js-api-interface/gr-js-api-interface.js';
-import '../../shared/gr-rest-api-interface/gr-rest-api-interface.js';
-import '../gr-account-dropdown/gr-account-dropdown.js';
-import '../gr-smart-search/gr-smart-search.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-main-header_html.js';
-import {getBaseUrl, getDocsBaseUrl} from '../../../utils/url-util.js';
-import {getPluginLoader} from '../../shared/gr-js-api-interface/gr-plugin-loader.js';
-import {getAdminLinks} from '../../../utils/admin-nav-util.js';
-
-const DEFAULT_LINKS = [{
- title: 'Changes',
- links: [
- {
- url: '/q/status:open+-is:wip',
- name: 'Open',
- },
- {
- url: '/q/status:merged',
- name: 'Merged',
- },
- {
- url: '/q/status:abandoned',
- name: 'Abandoned',
- },
- ],
-}];
-
-const DOCUMENTATION_LINKS = [
- {
- url: '/index.html',
- name: 'Table of Contents',
- },
- {
- url: '/user-search.html',
- name: 'Searching',
- },
- {
- url: '/user-upload.html',
- name: 'Uploading',
- },
- {
- url: '/access-control.html',
- name: 'Access Control',
- },
- {
- url: '/rest-api.html',
- name: 'REST API',
- },
- {
- url: '/intro-project-owner.html',
- name: 'Project Owner Guide',
- },
-];
-
-// Set of authentication methods that can provide custom registration page.
-const AUTH_TYPES_WITH_REGISTER_URL = new Set([
- 'LDAP',
- 'LDAP_BIND',
- 'CUSTOM_EXTENSION',
-]);
-
-/**
- * @extends PolymerElement
- */
-class GrMainHeader extends GestureEventListeners(
- LegacyElementMixin(
- PolymerElement)) {
- static get template() { return htmlTemplate; }
-
- static get is() { return 'gr-main-header'; }
-
- static get properties() {
- return {
- searchQuery: {
- type: String,
- notify: true,
- },
- loggedIn: {
- type: Boolean,
- reflectToAttribute: true,
- },
- loading: {
- type: Boolean,
- reflectToAttribute: true,
- },
-
- /** @type {?Object} */
- _account: Object,
- _adminLinks: {
- type: Array,
- value() { return []; },
- },
- _defaultLinks: {
- type: Array,
- value() {
- return DEFAULT_LINKS;
- },
- },
- _docBaseUrl: {
- type: String,
- value: null,
- },
- _links: {
- type: Array,
- computed: '_computeLinks(_defaultLinks, _userLinks, _adminLinks, ' +
- '_topMenus, _docBaseUrl)',
- },
- loginUrl: {
- type: String,
- value: '/login',
- },
- _userLinks: {
- type: Array,
- value() { return []; },
- },
- _topMenus: {
- type: Array,
- value() { return []; },
- },
- _registerText: {
- type: String,
- value: 'Sign up',
- },
- _registerURL: {
- type: String,
- value: null,
- },
- mobileSearchHidden: {
- type: Boolean,
- value: false,
- },
- };
- }
-
- static get observers() {
- return [
- '_accountLoaded(_account)',
- ];
- }
-
- /** @override */
- ready() {
- super.ready();
- this._ensureAttribute('role', 'banner');
- }
-
- /** @override */
- attached() {
- super.attached();
- this._loadAccount();
- this._loadConfig();
- }
-
- /** @override */
- detached() {
- super.detached();
- }
-
- reload() {
- this._loadAccount();
- }
-
- _computeRelativeURL(path) {
- return '//' + window.location.host + getBaseUrl() + path;
- }
-
- _computeLinks(defaultLinks, userLinks, adminLinks, topMenus, docBaseUrl) {
- // Polymer 2: check for undefined
- if ([
- defaultLinks,
- userLinks,
- adminLinks,
- topMenus,
- docBaseUrl,
- ].includes(undefined)) {
- return undefined;
- }
-
- const links = defaultLinks.map(menu => {
- return {
- title: menu.title,
- links: menu.links.slice(),
- };
- });
- if (userLinks && userLinks.length > 0) {
- links.push({
- title: 'Your',
- links: userLinks.slice(),
- });
- }
- const docLinks = this._getDocLinks(docBaseUrl, DOCUMENTATION_LINKS);
- if (docLinks.length) {
- links.push({
- title: 'Documentation',
- links: docLinks,
- class: 'hideOnMobile',
- });
- }
- links.push({
- title: 'Browse',
- links: adminLinks.slice(),
- });
- const topMenuLinks = [];
- links.forEach(link => { topMenuLinks[link.title] = link.links; });
- for (const m of topMenus) {
- const items = m.items.map(this._fixCustomMenuItem).filter(link =>
- // Ignore GWT project links
- !link.url.includes('${projectName}')
- );
- if (m.name in topMenuLinks) {
- items.forEach(link => { topMenuLinks[m.name].push(link); });
- } else {
- links.push({
- title: m.name,
- links: topMenuLinks[m.name] = items,
- });
- }
- }
- return links;
- }
-
- _getDocLinks(docBaseUrl, docLinks) {
- if (!docBaseUrl || !docLinks) {
- return [];
- }
- return docLinks.map(link => {
- let url = docBaseUrl;
- if (url && url[url.length - 1] === '/') {
- url = url.substring(0, url.length - 1);
- }
- return {
- url: url + link.url,
- name: link.name,
- target: '_blank',
- };
- });
- }
-
- _loadAccount() {
- this.loading = true;
- const promises = [
- this.$.restAPI.getAccount(),
- this.$.restAPI.getTopMenus(),
- getPluginLoader().awaitPluginsLoaded(),
- ];
-
- return Promise.all(promises).then(result => {
- const account = result[0];
- this._account = account;
- this.loggedIn = !!account;
- this.loading = false;
- this._topMenus = result[1];
-
- return getAdminLinks(account,
- params => this.$.restAPI.getAccountCapabilities(params),
- () => this.$.jsAPI.getAdminMenuLinks())
- .then(res => {
- this._adminLinks = res.links;
- });
- });
- }
-
- _loadConfig() {
- this.$.restAPI.getConfig()
- .then(config => {
- this._retrieveRegisterURL(config);
- return getDocsBaseUrl(config, this.$.restAPI);
- })
- .then(docBaseUrl => { this._docBaseUrl = docBaseUrl; });
- }
-
- _accountLoaded(account) {
- if (!account) { return; }
-
- this.$.restAPI.getPreferences().then(prefs => {
- this._userLinks = prefs && prefs.my ?
- prefs.my.map(this._fixCustomMenuItem) : [];
- });
- }
-
- _retrieveRegisterURL(config) {
- if (AUTH_TYPES_WITH_REGISTER_URL.has(config.auth.auth_type)) {
- this._registerURL = config.auth.register_url;
- if (config.auth.register_text) {
- this._registerText = config.auth.register_text;
- }
- }
- }
-
- _computeIsInvisible(registerURL) {
- return registerURL ? '' : 'invisible';
- }
-
- _fixCustomMenuItem(linkObj) {
- // Normalize all urls to PolyGerrit style.
- if (linkObj.url.startsWith('#')) {
- linkObj.url = linkObj.url.slice(1);
- }
-
- // Delete target property due to complications of
- // https://bugs.chromium.org/p/gerrit/issues/detail?id=5888
- //
- // The server tries to guess whether URL is a view within the UI.
- // If not, it sets target='_blank' on the menu item. The server
- // makes assumptions that work for the GWT UI, but not PolyGerrit,
- // so we'll just disable it altogether for now.
- delete linkObj.target;
-
- return linkObj;
- }
-
- _generateSettingsLink() {
- return getBaseUrl() + '/settings/';
- }
-
- _onMobileSearchTap(e) {
- e.preventDefault();
- e.stopPropagation();
- this.dispatchEvent(new CustomEvent('mobile-search', {
- composed: true, bubbles: false,
- }));
- }
-
- _computeLinkGroupClass(linkGroup) {
- if (linkGroup && linkGroup.class) {
- return linkGroup.class;
- }
-
- return '';
- }
-
- _computeShowHideAriaLabel(mobileSearchHidden) {
- if (mobileSearchHidden) {
- return 'Show Searchbar';
- } else {
- return 'Hide Searchbar';
- }
- }
-}
-
-customElements.define(GrMainHeader.is, GrMainHeader);
diff --git a/polygerrit-ui/app/elements/core/gr-main-header/gr-main-header.ts b/polygerrit-ui/app/elements/core/gr-main-header/gr-main-header.ts
new file mode 100644
index 0000000..5ad0d92
--- /dev/null
+++ b/polygerrit-ui/app/elements/core/gr-main-header/gr-main-header.ts
@@ -0,0 +1,396 @@
+/**
+ * @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 '../../plugins/gr-endpoint-decorator/gr-endpoint-decorator';
+import '../../shared/gr-dropdown/gr-dropdown';
+import '../../shared/gr-icons/gr-icons';
+import '../../shared/gr-js-api-interface/gr-js-api-interface';
+import '../../shared/gr-rest-api-interface/gr-rest-api-interface';
+import '../gr-account-dropdown/gr-account-dropdown';
+import '../gr-smart-search/gr-smart-search';
+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-main-header_html';
+import {getBaseUrl, getDocsBaseUrl} from '../../../utils/url-util';
+import {getPluginLoader} from '../../shared/gr-js-api-interface/gr-plugin-loader';
+import {getAdminLinks, NavLink} from '../../../utils/admin-nav-util';
+import {customElement, property, observe} from '@polymer/decorators';
+import {RestApiService} from '../../../services/services/gr-rest-api/gr-rest-api';
+import {
+ AccountDetailInfo,
+ ServerInfo,
+ TopMenuEntryInfo,
+ TopMenuItemInfo,
+} from '../../../types/common';
+import {JsApiService} from '../../shared/gr-js-api-interface/gr-js-api-types';
+import {AuthType} from '../../../constants/constants';
+
+interface FixedTopMenuItemInfo extends Omit<TopMenuItemInfo, 'target'> {
+ target?: never;
+}
+interface MainHeaderLink {
+ url: string;
+ name: string;
+}
+interface MainHeaderLinkGroup {
+ title: string;
+ links: MainHeaderLink[];
+ class?: string;
+}
+
+const DEFAULT_LINKS: MainHeaderLinkGroup[] = [
+ {
+ title: 'Changes',
+ links: [
+ {
+ url: '/q/status:open+-is:wip',
+ name: 'Open',
+ },
+ {
+ url: '/q/status:merged',
+ name: 'Merged',
+ },
+ {
+ url: '/q/status:abandoned',
+ name: 'Abandoned',
+ },
+ ],
+ },
+];
+
+const DOCUMENTATION_LINKS: MainHeaderLink[] = [
+ {
+ url: '/index.html',
+ name: 'Table of Contents',
+ },
+ {
+ url: '/user-search.html',
+ name: 'Searching',
+ },
+ {
+ url: '/user-upload.html',
+ name: 'Uploading',
+ },
+ {
+ url: '/access-control.html',
+ name: 'Access Control',
+ },
+ {
+ url: '/rest-api.html',
+ name: 'REST API',
+ },
+ {
+ url: '/intro-project-owner.html',
+ name: 'Project Owner Guide',
+ },
+];
+
+// Set of authentication methods that can provide custom registration page.
+const AUTH_TYPES_WITH_REGISTER_URL: Set<AuthType> = new Set([
+ AuthType.LDAP,
+ AuthType.LDAP_BIND,
+ AuthType.CUSTOM_EXTENSION,
+]);
+
+export interface GrMainHeader {
+ $: {
+ restAPI: RestApiService & Element;
+ jsAPI: JsApiService & Element;
+ };
+}
+
+@customElement('gr-main-header')
+export class GrMainHeader extends GestureEventListeners(
+ LegacyElementMixin(PolymerElement)
+) {
+ static get template() {
+ return htmlTemplate;
+ }
+
+ @property({type: String, notify: true})
+ searchQuery?: string;
+
+ @property({type: Boolean, reflectToAttribute: true})
+ loggedIn?: boolean;
+
+ @property({type: Boolean, reflectToAttribute: true})
+ loading?: boolean;
+
+ @property({type: Object})
+ _account?: AccountDetailInfo;
+
+ @property({type: Array})
+ _adminLinks: NavLink[] = [];
+
+ @property({type: String})
+ _docBaseUrl: string | null = null;
+
+ @property({
+ type: Array,
+ computed: '_computeLinks(_userLinks, _adminLinks, _topMenus, _docBaseUrl)',
+ })
+ _links?: MainHeaderLinkGroup[];
+
+ @property({type: String})
+ loginUrl = '/login';
+
+ @property({type: Array})
+ _userLinks: FixedTopMenuItemInfo[] = [];
+
+ @property({type: Array})
+ _topMenus?: TopMenuEntryInfo[] = [];
+
+ @property({type: String})
+ _registerText = 'Sign up';
+
+ @property({type: String})
+ _registerURL?: string;
+
+ @property({type: Boolean})
+ mobileSearchHidden = false;
+
+ /** @override */
+ ready() {
+ super.ready();
+ this._ensureAttribute('role', 'banner');
+ }
+
+ /** @override */
+ attached() {
+ super.attached();
+ this._loadAccount();
+ this._loadConfig();
+ }
+
+ /** @override */
+ detached() {
+ super.detached();
+ }
+
+ reload() {
+ this._loadAccount();
+ }
+
+ _computeRelativeURL(path: string) {
+ return '//' + window.location.host + getBaseUrl() + path;
+ }
+
+ _computeLinks(
+ userLinks?: FixedTopMenuItemInfo[],
+ adminLinks?: NavLink[],
+ topMenus?: TopMenuEntryInfo[],
+ docBaseUrl?: string | null,
+ // defaultLinks parameter is used in tests only
+ defaultLinks = DEFAULT_LINKS
+ ) {
+ // Polymer 2: check for undefined
+ if (
+ userLinks === undefined ||
+ adminLinks === undefined ||
+ topMenus === undefined ||
+ docBaseUrl === undefined
+ ) {
+ return undefined;
+ }
+
+ const links: MainHeaderLinkGroup[] = defaultLinks.map(menu => {
+ return {
+ title: menu.title,
+ links: menu.links.slice(),
+ };
+ });
+ if (userLinks && userLinks.length > 0) {
+ links.push({
+ title: 'Your',
+ links: userLinks.slice(),
+ });
+ }
+ const docLinks = this._getDocLinks(docBaseUrl, DOCUMENTATION_LINKS);
+ if (docLinks.length) {
+ links.push({
+ title: 'Documentation',
+ links: docLinks,
+ class: 'hideOnMobile',
+ });
+ }
+ links.push({
+ title: 'Browse',
+ links: adminLinks.slice(),
+ });
+ const topMenuLinks: {[name: string]: MainHeaderLink[]} = {};
+ links.forEach(link => {
+ topMenuLinks[link.title] = link.links;
+ });
+ for (const m of topMenus) {
+ const items = m.items.map(this._fixCustomMenuItem).filter(
+ link =>
+ // Ignore GWT project links
+ !link.url.includes('${projectName}')
+ );
+ if (m.name in topMenuLinks) {
+ items.forEach(link => {
+ topMenuLinks[m.name].push(link);
+ });
+ } else {
+ links.push({
+ title: m.name,
+ links: topMenuLinks[m.name] = items,
+ });
+ }
+ }
+ return links;
+ }
+
+ _getDocLinks(docBaseUrl: string | null, docLinks: MainHeaderLink[]) {
+ if (!docBaseUrl) {
+ return [];
+ }
+ return docLinks.map(link => {
+ let url = docBaseUrl;
+ if (url && url[url.length - 1] === '/') {
+ url = url.substring(0, url.length - 1);
+ }
+ return {
+ url: url + link.url,
+ name: link.name,
+ target: '_blank',
+ };
+ });
+ }
+
+ _loadAccount() {
+ this.loading = true;
+
+ return Promise.all([
+ this.$.restAPI.getAccount(),
+ this.$.restAPI.getTopMenus(),
+ getPluginLoader().awaitPluginsLoaded(),
+ ]).then(result => {
+ const account = result[0];
+ this._account = account;
+ this.loggedIn = !!account;
+ this.loading = false;
+ this._topMenus = result[1];
+
+ return getAdminLinks(
+ account,
+ () =>
+ this.$.restAPI.getAccountCapabilities().then(capabilities => {
+ if (!capabilities) {
+ throw new Error('getAccountCapabilities returns undefined');
+ }
+ return capabilities;
+ }),
+ () => this.$.jsAPI.getAdminMenuLinks()
+ ).then(res => {
+ this._adminLinks = res.links;
+ });
+ });
+ }
+
+ _loadConfig() {
+ this.$.restAPI
+ .getConfig()
+ .then(config => {
+ if (!config) {
+ throw new Error('getConfig returned undefined');
+ }
+ this._retrieveRegisterURL(config);
+ return getDocsBaseUrl(config, this.$.restAPI);
+ })
+ .then(docBaseUrl => {
+ this._docBaseUrl = docBaseUrl;
+ });
+ }
+
+ @observe('_account')
+ _accountLoaded(account?: AccountDetailInfo) {
+ if (!account) {
+ return;
+ }
+
+ this.$.restAPI.getPreferences().then(prefs => {
+ this._userLinks =
+ prefs && prefs.my ? prefs.my.map(this._fixCustomMenuItem) : [];
+ });
+ }
+
+ _retrieveRegisterURL(config: ServerInfo) {
+ if (AUTH_TYPES_WITH_REGISTER_URL.has(config.auth.auth_type)) {
+ this._registerURL = config.auth.register_url;
+ if (config.auth.register_text) {
+ this._registerText = config.auth.register_text;
+ }
+ }
+ }
+
+ _computeIsInvisible(registerURL?: string) {
+ return registerURL ? '' : 'invisible';
+ }
+
+ _fixCustomMenuItem(linkObj: TopMenuItemInfo): FixedTopMenuItemInfo {
+ // TODO(TS): make a copy of linkObj instead of modifying the existing one
+ // Normalize all urls to PolyGerrit style.
+ if (linkObj.url.startsWith('#')) {
+ linkObj.url = linkObj.url.slice(1);
+ }
+
+ // Delete target property due to complications of
+ // https://bugs.chromium.org/p/gerrit/issues/detail?id=5888
+ //
+ // The server tries to guess whether URL is a view within the UI.
+ // If not, it sets target='_blank' on the menu item. The server
+ // makes assumptions that work for the GWT UI, but not PolyGerrit,
+ // so we'll just disable it altogether for now.
+ delete linkObj.target;
+
+ return (linkObj as unknown) as FixedTopMenuItemInfo;
+ }
+
+ _generateSettingsLink() {
+ return getBaseUrl() + '/settings/';
+ }
+
+ _onMobileSearchTap(e: Event) {
+ e.preventDefault();
+ e.stopPropagation();
+ this.dispatchEvent(
+ new CustomEvent('mobile-search', {
+ composed: true,
+ bubbles: false,
+ })
+ );
+ }
+
+ _computeLinkGroupClass(linkGroup: MainHeaderLinkGroup) {
+ return linkGroup.class ?? '';
+ }
+
+ _computeShowHideAriaLabel(mobileSearchHidden: boolean) {
+ if (mobileSearchHidden) {
+ return 'Show Searchbar';
+ } else {
+ return 'Hide Searchbar';
+ }
+ }
+}
+
+declare global {
+ interface HTMLElementTagNameMap {
+ 'gr-main-header': GrMainHeader;
+ }
+}
diff --git a/polygerrit-ui/app/elements/core/gr-main-header/gr-main-header_test.js b/polygerrit-ui/app/elements/core/gr-main-header/gr-main-header_test.js
index 48194a6..e4db65f 100644
--- a/polygerrit-ui/app/elements/core/gr-main-header/gr-main-header_test.js
+++ b/polygerrit-ui/app/elements/core/gr-main-header/gr-main-header_test.js
@@ -103,22 +103,22 @@
// When no admin links are passed, it should use the default.
assert.deepEqual(element._computeLinks(
- defaultLinks,
/* userLinks= */[],
adminLinks,
/* topMenus= */[],
- /* docBaseUrl= */ ''
+ /* docBaseUrl= */ '',
+ defaultLinks
),
defaultLinks.concat({
title: 'Browse',
links: adminLinks,
}));
assert.deepEqual(element._computeLinks(
- defaultLinks,
userLinks,
adminLinks,
/* topMenus= */[],
- /* docBaseUrl= */ ''
+ /* docBaseUrl= */ '',
+ defaultLinks
),
defaultLinks.concat([
{
@@ -142,7 +142,6 @@
assert.deepEqual(element._getDocLinks(null, docLinks), []);
assert.deepEqual(element._getDocLinks('', docLinks), []);
- assert.deepEqual(element._getDocLinks('base', null), []);
assert.deepEqual(element._getDocLinks('base', []), []);
assert.deepEqual(element._getDocLinks('base', docLinks), [{
@@ -172,11 +171,11 @@
}],
}];
assert.deepEqual(element._computeLinks(
- /* defaultLinks= */ [],
/* userLinks= */ [],
adminLinks,
topMenus,
- /* baseDocUrl= */ ''
+ /* baseDocUrl= */ '',
+ /* defaultLinks= */ []
), [{
title: 'Browse',
links: adminLinks,
@@ -208,11 +207,11 @@
}],
}];
assert.deepEqual(element._computeLinks(
- /* defaultLinks= */ [],
/* userLinks= */ [],
adminLinks,
topMenus,
- /* baseDocUrl= */ ''
+ /* baseDocUrl= */ '',
+ /* defaultLinks= */ []
), [{
title: 'Browse',
links: adminLinks,
@@ -247,11 +246,11 @@
}],
}];
assert.deepEqual(element._computeLinks(
- /* defaultLinks= */ [],
/* userLinks= */ [],
adminLinks,
topMenus,
- /* baseDocUrl= */ ''
+ /* baseDocUrl= */ '',
+ /* defaultLinks= */ []
), [{
title: 'Browse',
links: adminLinks,
@@ -284,11 +283,11 @@
}],
}];
assert.deepEqual(element._computeLinks(
- defaultLinks,
/* userLinks= */ [],
/* adminLinks= */ [],
topMenus,
- /* baseDocUrl= */ ''
+ /* baseDocUrl= */ '',
+ defaultLinks
), [{
title: 'Faves',
links: defaultLinks[0].links.concat([{
@@ -315,11 +314,11 @@
}],
}];
assert.deepEqual(element._computeLinks(
- /* defaultLinks= */ [],
userLinks,
/* adminLinks= */ [],
topMenus,
- /* baseDocUrl= */ ''
+ /* baseDocUrl= */ '',
+ /* defaultLinks= */ []
), [{
title: 'Your',
links: userLinks.concat([{
@@ -346,11 +345,11 @@
}],
}];
assert.deepEqual(element._computeLinks(
- /* defaultLinks= */ [],
/* userLinks= */ [],
adminLinks,
topMenus,
- /* baseDocUrl= */ ''
+ /* baseDocUrl= */ '',
+ /* defaultLinks= */ []
), [{
title: 'Browse',
links: adminLinks.concat([{
diff --git a/polygerrit-ui/app/elements/core/gr-navigation/gr-navigation.ts b/polygerrit-ui/app/elements/core/gr-navigation/gr-navigation.ts
index db0bd64..8470611 100644
--- a/polygerrit-ui/app/elements/core/gr-navigation/gr-navigation.ts
+++ b/polygerrit-ui/app/elements/core/gr-navigation/gr-navigation.ts
@@ -32,6 +32,7 @@
ParentPatchSetNum,
ServerInfo,
} from '../../../types/common';
+import {ParsedChangeInfo} from '../../shared/gr-rest-api-interface/gr-reviewer-updates-parser';
// Navigation parameters object format:
//
@@ -126,7 +127,7 @@
export interface DashboardSection {
name: string;
query: string;
- suffixForDashboard: string;
+ suffixForDashboard?: string;
attentionSetOnly?: boolean;
selfOnly?: boolean;
hideIfEmpty?: boolean;
@@ -140,87 +141,95 @@
}
export interface UserDashboard {
- title: string;
+ title?: string;
sections: DashboardSection[];
}
// NOTE: These queries are tested in Java. Any changes made to definitions
// here require corresponding changes to:
// java/com/google/gerrit/httpd/raw/IndexPreloadingUtil.java
+const HAS_DRAFTS: 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',
+};
+export const YOUR_TURN: DashboardSection = {
+ // Changes where the user is in the attention set.
+ name: 'Your Turn',
+ query: 'attention:${user}',
+ hideIfEmpty: false,
+ suffixForDashboard: 'limit:25',
+ attentionSetOnly: true,
+};
+const ASSIGNED: DashboardSection = {
+ // 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,
+};
+const WIP: DashboardSection = {
+ // 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',
+};
+const OUTGOING: DashboardSection = {
+ // 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',
+};
+const INCOMING: DashboardSection = {
+ // 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',
+};
+const CCED: DashboardSection = {
+ // 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',
+};
+export const CLOSED: DashboardSection = {
+ 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',
+};
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',
- },
+ HAS_DRAFTS,
+ YOUR_TURN,
+ ASSIGNED,
+ WIP,
+ OUTGOING,
+ INCOMING,
+ CCED,
+ CLOSED,
];
export interface GenerateUrlSearchViewParameters {
@@ -656,7 +665,7 @@
* @param basePatchNum The string 'PARENT' can be used for none.
*/
getUrlForDiff(
- change: ChangeInfo,
+ change: ChangeInfo | ParsedChangeInfo,
filePath: string,
patchNum?: PatchSetNum,
basePatchNum?: PatchSetNum,
@@ -715,7 +724,7 @@
},
getEditUrlForDiff(
- change: ChangeInfo,
+ change: ChangeInfo | ParsedChangeInfo,
filePath: string,
patchNum?: PatchSetNum,
lineNum?: number
@@ -755,7 +764,7 @@
* @param basePatchNum The string 'PARENT' can be used for none.
*/
navigateToDiff(
- change: ChangeInfo,
+ change: ChangeInfo | ParsedChangeInfo,
filePath: string,
patchNum?: PatchSetNum,
basePatchNum?: PatchSetNum,
diff --git a/polygerrit-ui/app/elements/core/gr-router/gr-router.ts b/polygerrit-ui/app/elements/core/gr-router/gr-router.ts
index aa2e4ce..94f0302 100644
--- a/polygerrit-ui/app/elements/core/gr-router/gr-router.ts
+++ b/polygerrit-ui/app/elements/core/gr-router/gr-router.ts
@@ -52,6 +52,7 @@
import {customElement, property} from '@polymer/decorators';
import {assertNever} from '../../../utils/common-util';
import {
+ DashboardId,
GroupId,
NumericChangeId,
PatchSetNum,
@@ -1256,7 +1257,7 @@
this._setParams({
view: GerritView.DASHBOARD,
project,
- dashboard: decodeURIComponent(data.params[1]),
+ dashboard: decodeURIComponent(data.params[1]) as DashboardId,
});
this.reporting.setRepoName(project);
}
diff --git a/polygerrit-ui/app/elements/core/gr-search-bar/gr-search-bar.ts b/polygerrit-ui/app/elements/core/gr-search-bar/gr-search-bar.ts
index acc2cbd..180e4a7 100644
--- a/polygerrit-ui/app/elements/core/gr-search-bar/gr-search-bar.ts
+++ b/polygerrit-ui/app/elements/core/gr-search-bar/gr-search-bar.ts
@@ -124,11 +124,15 @@
const TOKENIZE_REGEX = /(?:[^\s"]+|"[^"]*")+\s*/g;
-type SuggestionProvider = (
+export type SuggestionProvider = (
predicate: string,
expression: string
) => Promise<AutocompleteSuggestion[]>;
+export interface SearchBarHandleSearchDetail {
+ inputVal: string;
+}
+
export interface GrSearchBar {
$: {
restAPI: RestApiService & Element;
@@ -254,7 +258,8 @@
} else {
target.blur();
}
- const trimmedInput = this._inputVal && this._inputVal.trim();
+ if (!this._inputVal) return;
+ const trimmedInput = this._inputVal.trim();
if (trimmedInput) {
const predefinedOpOnlyQuery = [
...SEARCH_OPERATORS_WITH_NEGATIONS_SET,
@@ -262,9 +267,12 @@
if (predefinedOpOnlyQuery) {
return;
}
+ const detail: SearchBarHandleSearchDetail = {
+ inputVal: this._inputVal,
+ };
this.dispatchEvent(
new CustomEvent('handle-search', {
- detail: {inputVal: this._inputVal},
+ detail,
})
);
}
diff --git a/polygerrit-ui/app/elements/core/gr-smart-search/gr-smart-search.js b/polygerrit-ui/app/elements/core/gr-smart-search/gr-smart-search.js
deleted file mode 100644
index 813298c..0000000
--- a/polygerrit-ui/app/elements/core/gr-smart-search/gr-smart-search.js
+++ /dev/null
@@ -1,180 +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 '../../shared/gr-rest-api-interface/gr-rest-api-interface.js';
-import '../gr-search-bar/gr-search-bar.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-smart-search_html.js';
-import {GerritNav} from '../gr-navigation/gr-navigation.js';
-import {getUserName} from '../../../utils/display-name-util.js';
-
-const MAX_AUTOCOMPLETE_RESULTS = 10;
-const SELF_EXPRESSION = 'self';
-const ME_EXPRESSION = 'me';
-
-/**
- * @extends PolymerElement
- */
-class GrSmartSearch extends GestureEventListeners(
- LegacyElementMixin(
- PolymerElement)) {
- static get template() { return htmlTemplate; }
-
- static get is() { return 'gr-smart-search'; }
-
- static get properties() {
- return {
- searchQuery: String,
- _config: Object,
- _projectSuggestions: {
- type: Function,
- value() {
- return (predicate, expression) =>
- this._fetchProjects(predicate, expression);
- },
- },
- _groupSuggestions: {
- type: Function,
- value() {
- return (predicate, expression) =>
- this._fetchGroups(predicate, expression);
- },
- },
- _accountSuggestions: {
- type: Function,
- value() {
- return (predicate, expression) =>
- this._fetchAccounts(predicate, expression);
- },
- },
- /**
- * Invisible label for input element. This label is exposed to
- * screen readers by nested element
- */
- label: {
- type: String,
- value: '',
- },
- };
- }
-
- /** @override */
- attached() {
- super.attached();
- this.$.restAPI.getConfig().then(cfg => {
- this._config = cfg;
- });
- }
-
- _handleSearch(e) {
- const input = e.detail.inputVal;
- if (input) {
- GerritNav.navigateToSearchQuery(input);
- }
- }
-
- /**
- * Fetch from the API the predicted projects.
- *
- * @param {string} predicate - The first part of the search term, e.g.
- * 'project'
- * @param {string} expression - The second part of the search term, e.g.
- * 'gerr'
- * @return {!Promise} This returns a promise that resolves to an array of
- * strings.
- */
- _fetchProjects(predicate, expression) {
- return this.$.restAPI.getSuggestedProjects(
- expression,
- MAX_AUTOCOMPLETE_RESULTS)
- .then(projects => {
- if (!projects) { return []; }
- const keys = Object.keys(projects);
- return keys.map(key => { return {text: predicate + ':' + key}; });
- });
- }
-
- /**
- * Fetch from the API the predicted groups.
- *
- * @param {string} predicate - The first part of the search term, e.g.
- * 'ownerin'
- * @param {string} expression - The second part of the search term, e.g.
- * 'polyger'
- * @return {!Promise} This returns a promise that resolves to an array of
- * strings.
- */
- _fetchGroups(predicate, expression) {
- if (expression.length === 0) { return Promise.resolve([]); }
- return this.$.restAPI.getSuggestedGroups(
- expression,
- MAX_AUTOCOMPLETE_RESULTS)
- .then(groups => {
- if (!groups) { return []; }
- const keys = Object.keys(groups);
- return keys.map(key => { return {text: predicate + ':' + key}; });
- });
- }
-
- /**
- * Fetch from the API the predicted accounts.
- *
- * @param {string} predicate - The first part of the search term, e.g.
- * 'owner'
- * @param {string} expression - The second part of the search term, e.g.
- * 'kasp'
- * @return {!Promise} This returns a promise that resolves to an array of
- * strings.
- */
- _fetchAccounts(predicate, expression) {
- if (expression.length === 0) { return Promise.resolve([]); }
- return this.$.restAPI.getSuggestedAccounts(
- expression,
- MAX_AUTOCOMPLETE_RESULTS)
- .then(accounts => {
- if (!accounts) { return []; }
- return this._mapAccountsHelper(accounts, predicate);
- })
- .then(accounts => {
- // When the expression supplied is a beginning substring of 'self',
- // add it as an autocomplete option.
- if (SELF_EXPRESSION.startsWith(expression)) {
- return accounts.concat(
- [{text: predicate + ':' + SELF_EXPRESSION}]);
- } else if (ME_EXPRESSION.startsWith(expression)) {
- return accounts.concat([{text: predicate + ':' + ME_EXPRESSION}]);
- } else {
- return accounts;
- }
- });
- }
-
- _mapAccountsHelper(accounts, predicate) {
- return accounts.map(account => {
- const userName = getUserName(this._serverConfig, account);
- return {
- label: account.name || '',
- text: account.email ?
- `${predicate}:${account.email}` :
- `${predicate}:"${userName}"`,
- };
- });
- }
-}
-
-customElements.define(GrSmartSearch.is, GrSmartSearch);
diff --git a/polygerrit-ui/app/elements/core/gr-smart-search/gr-smart-search.ts b/polygerrit-ui/app/elements/core/gr-smart-search/gr-smart-search.ts
new file mode 100644
index 0000000..a818c59
--- /dev/null
+++ b/polygerrit-ui/app/elements/core/gr-smart-search/gr-smart-search.ts
@@ -0,0 +1,197 @@
+/**
+ * @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 '../../shared/gr-rest-api-interface/gr-rest-api-interface';
+import '../gr-search-bar/gr-search-bar';
+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-smart-search_html';
+import {GerritNav} from '../gr-navigation/gr-navigation';
+import {getUserName} from '../../../utils/display-name-util';
+import {customElement, property} from '@polymer/decorators';
+import {RestApiService} from '../../../services/services/gr-rest-api/gr-rest-api';
+import {AccountInfo, ServerInfo} from '../../../types/common';
+import {
+ SearchBarHandleSearchDetail,
+ SuggestionProvider,
+} from '../gr-search-bar/gr-search-bar';
+import {AutocompleteSuggestion} from '../../shared/gr-autocomplete/gr-autocomplete';
+
+const MAX_AUTOCOMPLETE_RESULTS = 10;
+const SELF_EXPRESSION = 'self';
+const ME_EXPRESSION = 'me';
+
+export interface GrSmartSearch {
+ $: {
+ restAPI: RestApiService & Element;
+ };
+}
+
+@customElement('gr-smart-search')
+export class GrSmartSearch extends GestureEventListeners(
+ LegacyElementMixin(PolymerElement)
+) {
+ static get template() {
+ return htmlTemplate;
+ }
+
+ @property({type: String})
+ searchQuery?: string;
+
+ @property({type: Object})
+ _config?: ServerInfo;
+
+ @property({type: Object})
+ _projectSuggestions: SuggestionProvider = (predicate, expression) =>
+ this._fetchProjects(predicate, expression);
+
+ @property({type: Object})
+ _groupSuggestions: SuggestionProvider = (predicate, expression) =>
+ this._fetchGroups(predicate, expression);
+
+ @property({type: Object})
+ _accountSuggestions: SuggestionProvider = (predicate, expression) =>
+ this._fetchAccounts(predicate, expression);
+
+ @property({type: String})
+ label = '';
+
+ /** @override */
+ attached() {
+ super.attached();
+ this.$.restAPI.getConfig().then(cfg => {
+ this._config = cfg;
+ });
+ }
+
+ _handleSearch(e: CustomEvent<SearchBarHandleSearchDetail>) {
+ const input = e.detail.inputVal;
+ if (input) {
+ GerritNav.navigateToSearchQuery(input);
+ }
+ }
+
+ /**
+ * Fetch from the API the predicted projects.
+ *
+ * @param predicate - The first part of the search term, e.g.
+ * 'project'
+ * @param expression - The second part of the search term, e.g.
+ * 'gerr'
+ */
+ _fetchProjects(
+ predicate: string,
+ expression: string
+ ): Promise<AutocompleteSuggestion[]> {
+ return this.$.restAPI
+ .getSuggestedProjects(expression, MAX_AUTOCOMPLETE_RESULTS)
+ .then(projects => {
+ if (!projects) {
+ return [];
+ }
+ const keys = Object.keys(projects);
+ return keys.map(key => {
+ return {text: predicate + ':' + key};
+ });
+ });
+ }
+
+ /**
+ * Fetch from the API the predicted groups.
+ *
+ * @param predicate - The first part of the search term, e.g.
+ * 'ownerin'
+ * @param expression - The second part of the search term, e.g.
+ * 'polyger'
+ */
+ _fetchGroups(
+ predicate: string,
+ expression: string
+ ): Promise<AutocompleteSuggestion[]> {
+ if (expression.length === 0) {
+ return Promise.resolve([]);
+ }
+ return this.$.restAPI
+ .getSuggestedGroups(expression, MAX_AUTOCOMPLETE_RESULTS)
+ .then(groups => {
+ if (!groups) {
+ return [];
+ }
+ const keys = Object.keys(groups);
+ return keys.map(key => {
+ return {text: predicate + ':' + key};
+ });
+ });
+ }
+
+ /**
+ * Fetch from the API the predicted accounts.
+ *
+ * @param predicate - The first part of the search term, e.g.
+ * 'owner'
+ * @param expression - The second part of the search term, e.g.
+ * 'kasp'
+ */
+ _fetchAccounts(
+ predicate: string,
+ expression: string
+ ): Promise<AutocompleteSuggestion[]> {
+ if (expression.length === 0) {
+ return Promise.resolve([]);
+ }
+ return this.$.restAPI
+ .getSuggestedAccounts(expression, MAX_AUTOCOMPLETE_RESULTS)
+ .then(accounts => {
+ if (!accounts) {
+ return [];
+ }
+ return this._mapAccountsHelper(accounts, predicate);
+ })
+ .then(accounts => {
+ // When the expression supplied is a beginning substring of 'self',
+ // add it as an autocomplete option.
+ if (SELF_EXPRESSION.startsWith(expression)) {
+ return accounts.concat([{text: predicate + ':' + SELF_EXPRESSION}]);
+ } else if (ME_EXPRESSION.startsWith(expression)) {
+ return accounts.concat([{text: predicate + ':' + ME_EXPRESSION}]);
+ } else {
+ return accounts;
+ }
+ });
+ }
+
+ _mapAccountsHelper(
+ accounts: AccountInfo[],
+ predicate: string
+ ): AutocompleteSuggestion[] {
+ return accounts.map(account => {
+ const userName = getUserName(this._config, account);
+ return {
+ label: account.name || '',
+ text: account.email
+ ? `${predicate}:${account.email}`
+ : `${predicate}:"${userName}"`,
+ };
+ });
+ }
+}
+
+declare global {
+ interface HTMLElementTagNameMap {
+ 'gr-smart-search': GrSmartSearch;
+ }
+}
diff --git a/polygerrit-ui/app/elements/diff/gr-apply-fix-dialog/gr-apply-fix-dialog.ts b/polygerrit-ui/app/elements/diff/gr-apply-fix-dialog/gr-apply-fix-dialog.ts
index 675a048..2236db2 100644
--- a/polygerrit-ui/app/elements/diff/gr-apply-fix-dialog/gr-apply-fix-dialog.ts
+++ b/polygerrit-ui/app/elements/diff/gr-apply-fix-dialog/gr-apply-fix-dialog.ts
@@ -40,7 +40,7 @@
import {GrOverlay} from '../../shared/gr-overlay/gr-overlay';
import {CommentEventDetail} from '../../shared/gr-comment/gr-comment';
import {RestApiService} from '../../../services/services/gr-rest-api/gr-rest-api';
-import {isRobot} from '../gr-comment-api/gr-comment-api';
+import {isRobot} from '../../../utils/comment-util';
export interface GrApplyFixDialog {
$: {
diff --git a/polygerrit-ui/app/elements/diff/gr-comment-api/gr-comment-api.ts b/polygerrit-ui/app/elements/diff/gr-comment-api/gr-comment-api.ts
index deecdbf..33c1346 100644
--- a/polygerrit-ui/app/elements/diff/gr-comment-api/gr-comment-api.ts
+++ b/polygerrit-ui/app/elements/diff/gr-comment-api/gr-comment-api.ts
@@ -19,7 +19,6 @@
import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
import {PolymerElement} from '@polymer/polymer/polymer-element';
import {htmlTemplate} from './gr-comment-api_html';
-import {parseDate} from '../../../utils/date-util';
import {
getParentIndex,
isMergeParent,
@@ -28,121 +27,33 @@
import {customElement, property} from '@polymer/decorators';
import {
CommentBasics,
- CommentInfo,
ConfigInfo,
ParentPatchSetNum,
PatchRange,
PatchSetNum,
PathToRobotCommentsInfoMap,
RobotCommentInfo,
- Timestamp,
UrlEncodedCommentId,
NumericChangeId,
} from '../../../types/common';
import {hasOwnProperty} from '../../../utils/common-util';
-import {CommentSide, Side} from '../../../constants/constants';
+import {CommentSide} from '../../../constants/constants';
import {RestApiService} from '../../../services/services/gr-rest-api/gr-rest-api';
-
-export interface DraftCommentProps {
- __draft?: boolean;
- __draftID?: string;
- __date?: Date;
-}
-
-export type DraftInfo = CommentBasics & DraftCommentProps;
-
-/**
- * Each of the type implements or extends CommentBasics.
- */
-export type Comment = DraftInfo | CommentInfo | RobotCommentInfo;
-
-export interface UIStateCommentProps {
- // The `side` of the comment is PARENT or REVISION, but this is LEFT or RIGHT.
- // TODO(TS): Remove the naming confusion of commentSide being of type of Side,
- // but side being of type CommentSide. :-)
- __commentSide?: Side;
- // TODO(TS): Remove this. Seems to be exactly the same as `path`??
- __path?: string;
- collapsed?: boolean;
- // TODO(TS): Consider allowing this only for drafts.
- __editing?: boolean;
- __otherEditing?: boolean;
-}
-
-export type UIDraft = DraftInfo & UIStateCommentProps;
-
-export type UIHuman = CommentInfo & UIStateCommentProps;
-
-export type UIRobot = RobotCommentInfo & UIStateCommentProps;
-
-export type UIComment = UIHuman | UIRobot | UIDraft;
-
-export type CommentMap = {[path: string]: boolean};
-
-export function isRobot<T extends CommentInfo>(
- x: T | DraftInfo | RobotCommentInfo | undefined
-): x is RobotCommentInfo {
- return !!x && !!(x as RobotCommentInfo).robot_id;
-}
-
-export function isDraft<T extends CommentInfo>(
- x: T | UIDraft | undefined
-): x is UIDraft {
- return !!x && !!(x as UIDraft).__draft;
-}
-
-export interface PatchSetFile {
- path: string;
- basePath?: string;
- patchNum?: PatchSetNum;
-}
-
-export interface PatchNumOnly {
- patchNum: PatchSetNum;
-}
-
-export function isPatchSetFile(
- x: PatchSetFile | PatchNumOnly
-): x is PatchSetFile {
- return !!(x as PatchSetFile).path;
-}
-
-interface SortableComment {
- __draft?: boolean;
- __date?: Date;
- updated?: Timestamp;
- id?: UrlEncodedCommentId;
-}
-
-export function sortComments<T extends SortableComment>(comments: T[]): T[] {
- return comments.slice(0).sort((c1, c2) => {
- const d1 = !!c1.__draft;
- const d2 = !!c2.__draft;
- if (d1 !== d2) return d1 ? 1 : -1;
-
- const date1 = (c1.updated && parseDate(c1.updated)) || c1.__date;
- const date2 = (c2.updated && parseDate(c2.updated)) || c2.__date;
- const dateDiff = date1!.valueOf() - date2!.valueOf();
- if (dateDiff !== 0) return dateDiff;
-
- const id1 = c1.id ?? '';
- const id2 = c2.id ?? '';
- return id1.localeCompare(id2);
- });
-}
-
-export interface CommentThread {
- comments: UIComment[];
- patchNum?: PatchSetNum;
- path: string;
- // TODO(TS): It would be nice to use LineNumber here, but the comment thread
- // element actually relies on line to be undefined for file comments. Be
- // aware of element attribute getters and setters, if you try to refactor
- // this. :-) Still worthwhile to do ...
- line?: number;
- rootId: UrlEncodedCommentId;
- commentSide?: CommentSide;
-}
+import {
+ Comment,
+ CommentMap,
+ CommentThread,
+ DraftInfo,
+ isPatchSetFile,
+ isUnresolved,
+ PatchNumOnly,
+ PatchSetFile,
+ sortComments,
+ UIComment,
+ UIDraft,
+ UIHuman,
+ UIRobot,
+} from '../../../utils/comment-util';
export type CommentIdToCommentThreadMap = {
[urlEncodedCommentId: string]: CommentThread;
@@ -560,15 +471,8 @@
}
comments = comments.concat(drafts);
-
const threads = this.getCommentThreads(sortComments(comments));
-
- const unresolvedThreads = threads.filter(
- thread =>
- thread.comments.length &&
- thread.comments[thread.comments.length - 1].unresolved
- );
-
+ const unresolvedThreads = threads.filter(isUnresolved);
return unresolvedThreads.length;
}
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder-element.ts b/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder-element.ts
index 5256564..bb7b518 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder-element.ts
+++ b/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder-element.ts
@@ -120,9 +120,6 @@
@property({type: String})
path?: string;
- @property({type: String})
- projectName?: string;
-
@property({type: Object})
_builder?: GrDiffBuilder;
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-cursor/gr-diff-cursor.ts b/polygerrit-ui/app/elements/diff/gr-diff-cursor/gr-diff-cursor.ts
index 59a0370..2486c03 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-cursor/gr-diff-cursor.ts
+++ b/polygerrit-ui/app/elements/diff/gr-diff-cursor/gr-diff-cursor.ts
@@ -32,6 +32,7 @@
import {PolymerSpliceChange} from '@polymer/polymer/interfaces';
import {PolymerDomWrapper} from '../../../types/types';
import {GrDiffGroupType} from '../gr-diff/gr-diff-group';
+import {GrDiff} from '../gr-diff/gr-diff';
const DiffViewMode = {
SIDE_BY_SIDE: 'SIDE_BY_SIDE',
@@ -46,15 +47,6 @@
// Time in which pressing n key again after the toast navigates to next file
const NAVIGATE_TO_NEXT_FILE_TIMEOUT_MS = 5000;
-// TODO(TS): Use proper GrDiff type once that file is converted to TS.
-interface GrDiff extends HTMLElement {
- path: string;
- addDraftAtLine: (element: HTMLElement) => void;
- createRangeComment: () => void;
- getCursorStops: () => HTMLElement[];
- isRangeSelected: () => boolean;
-}
-
export interface GrDiffCursor {
$: {
cursorManager: GrCursorManager;
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-host/gr-diff-host.ts b/polygerrit-ui/app/elements/diff/gr-diff-host/gr-diff-host.ts
index 49826bb..e7c9a4a 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-host/gr-diff-host.ts
+++ b/polygerrit-ui/app/elements/diff/gr-diff-host/gr-diff-host.ts
@@ -36,9 +36,9 @@
isDraft,
PatchSetFile,
sortComments,
- TwoSidesComments,
UIComment,
-} from '../gr-comment-api/gr-comment-api';
+} from '../../../utils/comment-util';
+import {TwoSidesComments} from '../gr-comment-api/gr-comment-api';
import {customElement, observe, property} from '@polymer/decorators';
import {
CommitRange,
@@ -231,9 +231,6 @@
@property({type: Boolean})
_loggedIn = false;
- @property({type: Boolean})
- _loading = false;
-
@property({type: String})
_errorMessage: string | null = null;
@@ -327,23 +324,15 @@
* signal to report metrics event that started on location change.
* @return
*/
- reload(shouldReportMetric?: boolean) {
+ async reload(shouldReportMetric?: boolean) {
this.clear();
if (!this.path) throw new Error('Missing required "path" property.');
if (!this.changeNum) throw new Error('Missing required "changeNum" prop.');
- this._loading = true;
+ this.diff = undefined;
this._errorMessage = null;
const whitespaceLevel = this._getIgnoreWhitespace();
- const layers: DiffLayer[] = [this.$.syntaxLayer];
- // Get layers from plugins (if any).
- for (const pluginLayer of this.$.jsAPI.getDiffLayers(
- this.path,
- this.changeNum
- )) {
- layers.push(pluginLayer);
- }
- this._layers = layers;
+ this._layers = this._getLayers(this.path, this.changeNum);
if (shouldReportMetric) {
// We listen on render viewport only on DiffPage (on paramsChanged)
@@ -352,67 +341,58 @@
this._coverageRanges = [];
this._getCoverageData();
- const diffRequest = this._getDiff()
- .then(diff => {
- this._loadedWhitespaceLevel = whitespaceLevel;
- this._reportDiff(diff);
- return diff;
- })
- .catch(e => {
- this._handleGetDiffError(e);
- return null;
- });
- const assetRequest = diffRequest.then(diff => {
- // If the diff is null, then it's failed to load.
- if (!diff) {
- return null;
+ try {
+ const diff = await this._getDiff();
+ this._loadedWhitespaceLevel = whitespaceLevel;
+ this._reportDiff(diff);
+
+ await this._loadDiffAssets(diff);
+
+ // Not waiting for coverage ranges intentionally as
+ // plugin loading should not block the content rendering
+
+ this.filesWeblinks = this._getFilesWeblinks(diff);
+ this.diff = diff;
+ const event = await this._onRenderOnce();
+ if (shouldReportMetric) {
+ // We report diffViewContentDisplayed only on reload caused
+ // by params changed - expected only on Diff Page.
+ this.reporting.diffViewContentDisplayed();
}
-
- return this._loadDiffAssets(diff);
- });
-
- // Not waiting for coverage ranges intentionally as
- // plugin loading should not block the content rendering
- return Promise.all([diffRequest, assetRequest])
- .then(results => {
- const diff = results[0];
- if (!diff) {
- return Promise.resolve();
+ const needsSyntaxHighlighting = !!event.detail?.contentRendered;
+ if (needsSyntaxHighlighting) {
+ this.reporting.time(TimingLabel.SYNTAX);
+ try {
+ await this.$.syntaxLayer.process();
+ } finally {
+ this.reporting.timeEnd(TimingLabel.SYNTAX);
}
- this.filesWeblinks = this._getFilesWeblinks(diff);
- return new Promise(resolve => {
- const callback = (event: CustomEvent) => {
- const needsSyntaxHighlighting =
- event.detail && event.detail.contentRendered;
- if (needsSyntaxHighlighting) {
- this.reporting.time(TimingLabel.SYNTAX);
- this.$.syntaxLayer.process().finally(() => {
- this.reporting.timeEnd(TimingLabel.SYNTAX);
- this.reporting.timeEnd(TimingLabel.TOTAL);
- resolve();
- });
- } else {
- this.reporting.timeEnd(TimingLabel.TOTAL);
- resolve();
- }
- this.removeEventListener('render', callback);
- if (shouldReportMetric) {
- // We report diffViewContentDisplayed only on reload caused
- // by params changed - expected only on Diff Page.
- this.reporting.diffViewContentDisplayed();
- }
- };
- this.addEventListener('render', callback);
- this.diff = diff;
- });
- })
- .catch(err => {
- console.warn('Error encountered loading diff:', err);
- })
- .then(() => {
- this._loading = false;
- });
+ }
+ } catch (e) {
+ if (e instanceof Response) {
+ this._handleGetDiffError(e);
+ } else {
+ console.warn('Error encountered loading diff:', e);
+ }
+ } finally {
+ this.reporting.timeEnd(TimingLabel.TOTAL);
+ }
+ }
+
+ private _getLayers(path: string, changeNum: NumericChangeId): DiffLayer[] {
+ // Get layers from plugins (if any).
+ return [this.$.syntaxLayer, ...this.$.jsAPI.getDiffLayers(path, changeNum)];
+ }
+
+ private _onRenderOnce(): Promise<CustomEvent> {
+ return new Promise<CustomEvent>(resolve => {
+ const callback = (event: CustomEvent) => {
+ this.removeEventListener('render', callback);
+ resolve(event);
+ };
+ this.addEventListener('render', callback);
+ });
}
clear() {
@@ -427,12 +407,12 @@
const changeNum = this.changeNum;
const path = this.path;
// Coverage providers do not provide data for EDIT and PARENT patch sets.
- const basePatchNum = isNumber(this.patchRange.basePatchNum)
- ? this.patchRange.basePatchNum
- : undefined;
- const patchNum = isNumber(this.patchRange.patchNum)
- ? this.patchRange.patchNum
- : undefined;
+
+ const toNumberOnly = (patchNum: PatchSetNum) =>
+ isNumber(patchNum) ? patchNum : undefined;
+
+ const basePatchNum = toNumberOnly(this.patchRange.basePatchNum);
+ const patchNum = toNumberOnly(this.patchRange.patchNum);
this.$.jsAPI
.getCoverageAnnotationApi()
.then(coverageAnnotationApi => {
@@ -446,8 +426,8 @@
!coverageRanges ||
changeNum !== this.changeNum ||
path !== this.path ||
- basePatchNum !== this.patchRange.basePatchNum ||
- patchNum !== this.patchRange.patchNum
+ basePatchNum !== toNumberOnly(this.patchRange.basePatchNum) ||
+ patchNum !== toNumberOnly(this.patchRange.patchNum)
) {
return;
}
@@ -887,7 +867,9 @@
}
function matchesRange(threadEl: GrCommentThread) {
const rangeAtt = threadEl.getAttribute('range');
- const threadRange = rangeAtt ? JSON.parse(rangeAtt) : undefined;
+ const threadRange = rangeAtt
+ ? (JSON.parse(rangeAtt) as CommentRange)
+ : undefined;
return rangesEqual(threadRange, range);
}
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-host/gr-diff-host_html.ts b/polygerrit-ui/app/elements/diff/gr-diff-host/gr-diff-host_html.ts
index aae7808..d1564b0 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-host/gr-diff-host_html.ts
+++ b/polygerrit-ui/app/elements/diff/gr-diff-host/gr-diff-host_html.ts
@@ -24,7 +24,6 @@
patch-range="[[patchRange]]"
path="[[path]]"
prefs="[[prefs]]"
- project-name="[[projectName]]"
display-line="[[displayLine]]"
is-image-diff="[[isImageDiff]]"
hidden$="[[hidden]]"
@@ -33,7 +32,6 @@
view-mode="[[viewMode]]"
line-of-interest="[[lineOfInterest]]"
logged-in="[[_loggedIn]]"
- loading="[[_loading]]"
error-message="[[_errorMessage]]"
base-image="[[_baseImage]]"
revision-image="[[_revisionImage]]"
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-host/gr-diff-host_test.js b/polygerrit-ui/app/elements/diff/gr-diff-host/gr-diff-host_test.js
index ac96ba2..cd2c860 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-host/gr-diff-host_test.js
+++ b/polygerrit-ui/app/elements/diff/gr-diff-host/gr-diff-host_test.js
@@ -20,7 +20,7 @@
import {GrDiffBuilderImage} from '../gr-diff-builder/gr-diff-builder-image.js';
import {GerritNav} from '../../core/gr-navigation/gr-navigation.js';
import {dom} from '@polymer/polymer/lib/legacy/polymer.dom.js';
-import {sortComments} from '../gr-comment-api/gr-comment-api.js';
+import {sortComments} from '../../../utils/comment-util.js';
import {Side} from '../../../constants/constants.js';
const basicFixture = fixtureFromElement('gr-diff-host');
@@ -311,7 +311,7 @@
'Diff Content Render'));
});
- test('ends total and syntax timer after syntax layer processing', done => {
+ test('ends total and syntax timer after syntax layer', async () => {
sinon.stub(element.reporting, 'diffViewContentDisplayed');
let notifySyntaxProcessed;
sinon.stub(element.$.syntaxLayer, 'process').returns(new Promise(
@@ -326,39 +326,34 @@
return element.reload(true);
});
// Multiple cascading microtasks are scheduled.
- setTimeout(() => {
- notifySyntaxProcessed();
- // Assert after the notification task is processed.
- Promise.resolve().then(() => {
- assert.isTrue(element.reporting.timeEnd.calledWithExactly(
- 'Diff Total Render'));
- assert.isTrue(element.reporting.timeEnd.calledWithExactly(
- 'Diff Syntax Render'));
- assert.isTrue(element.reporting.diffViewContentDisplayed.called);
- done();
- });
- });
+ await flush();
+ notifySyntaxProcessed();
+ // Multiple cascading microtasks are scheduled.
+ await flush();
+ assert.isTrue(element.reporting.timeEnd.calledWithExactly(
+ 'Diff Total Render'));
+ assert.isTrue(element.reporting.timeEnd.calledWithExactly(
+ 'Diff Syntax Render'));
+ assert.isTrue(element.reporting.diffViewContentDisplayed.called);
});
- test('ends total timer w/ no syntax layer processing', done => {
+ test('ends total timer w/ no syntax layer processing', async () => {
sinon.stub(element.$.restAPI, 'getDiff').returns(
Promise.resolve({content: []}));
element.patchRange = {};
element.reload();
// Multiple cascading microtasks are scheduled.
- setTimeout(() => {
- // Reporting can be called with other parameters (ex. PluginsLoaded),
- // but only 'Diff Total Render' is important in this test.
- assert.equal(
- element.reporting.timeEnd.getCalls()
- .filter(call => call.calledWithExactly('Diff Total Render'))
- .length,
- 1);
- done();
- });
+ await flush();
+ // Reporting can be called with other parameters (ex. PluginsLoaded),
+ // but only 'Diff Total Render' is important in this test.
+ assert.equal(
+ element.reporting.timeEnd.getCalls()
+ .filter(call => call.calledWithExactly('Diff Total Render'))
+ .length,
+ 1);
});
- test('completes reload promise after syntax layer processing', done => {
+ test('completes reload promise after syntax layer processing', async () => {
let notifySyntaxProcessed;
sinon.stub(element.$.syntaxLayer, 'process').returns(new Promise(
resolve => {
@@ -377,15 +372,12 @@
reloadComplete = true;
});
// Multiple cascading microtasks are scheduled.
- setTimeout(() => {
- assert.isFalse(reloadComplete);
- notifySyntaxProcessed();
- // Assert after the notification task is processed.
- setTimeout(() => {
- assert.isTrue(reloadComplete);
- done();
- });
- });
+ await flush();
+ assert.isFalse(reloadComplete);
+ notifySyntaxProcessed();
+ // Assert after the notification task is processed.
+ await flush();
+ assert.isTrue(reloadComplete);
});
});
@@ -396,6 +388,13 @@
sinon.stub(element, '_getDiff').callsFake(() => new Promise(() => {}));
element.patchRange = {};
+ // Needs to be set to something first for it to cancel.
+ element.diff = {
+ content: [{
+ a: ['foo'],
+ }],
+ };
+
element.reload();
assert.isTrue(cancelStub.called);
});
@@ -468,9 +467,9 @@
test('reload resolves on error', () => {
const onErrStub = sinon.stub(element, '_handleGetDiffError');
- const error = {ok: false, status: 500};
+ const error = new Response(null, {ok: false, status: 500});
sinon.stub(element.$.restAPI, 'getDiff').callsFake(
- (changeNum, basePatchNum, patchNum, path, onErr) => {
+ (changeNum, basePatchNum, patchNum, path, whitespace, onErr) => {
onErr(error);
});
element.patchRange = {};
@@ -832,7 +831,7 @@
test('delegates cancel()', () => {
const stub = sinon.stub(element.$.diff, 'cancel');
element.patchRange = {};
- element.reload();
+ element.cancel();
assert.isTrue(stub.calledOnce);
assert.equal(stub.lastCall.args.length, 0);
});
@@ -981,12 +980,6 @@
assert.equal(element.$.diff.changeNum, 12345);
});
- test('passes in projectName', () => {
- const value = 'Gerrit';
- element.projectName = value;
- assert.equal(element.$.diff.projectName, value);
- });
-
test('passes in displayLine', () => {
const value = true;
element.displayLine = value;
@@ -1473,18 +1466,16 @@
assert.isFalse(element.$.syntaxLayer.enabled);
});
- test('starts syntax layer processing on render event', done => {
+ test('starts syntax layer processing on render event', async () => {
sinon.stub(element.$.syntaxLayer, 'process')
.returns(Promise.resolve());
sinon.stub(element.$.restAPI, 'getDiff').returns(
Promise.resolve({content: []}));
element.reload();
- setTimeout(() => {
- element.dispatchEvent(
- new CustomEvent('render', {bubbles: true, composed: true}));
- assert.isTrue(element.$.syntaxLayer.process.called);
- done();
- });
+ await flush();
+ element.dispatchEvent(
+ new CustomEvent('render', {bubbles: true, composed: true}));
+ assert.isTrue(element.$.syntaxLayer.process.called);
});
});
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view.js b/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view.js
index 57a9e97..4bfa6a9 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view.js
+++ b/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view.js
@@ -52,6 +52,7 @@
isMagicPath, specialFilePathCompare,
} from '../../../utils/path-list-util.js';
import {changeBaseURL, changeIsOpen} from '../../../utils/change-util.js';
+import {Side} from '../../../constants/constants.js';
const ERR_REVIEW_STATUS = 'Couldn’t change file review status.';
const MSG_LOADING_BLAME = 'Loading blame...';
@@ -1218,8 +1219,8 @@
if (!this._change) { return; }
const number = detail.number;
// for on-comment-anchor-tap side can be PARENT/REVISIONS
- // for on-line-selected side can be LEFT/RIGHT
- const leftSide = detail.side === 'LEFT' || detail.side === 'PARENT';
+ // for on-line-selected side can be left/right
+ const leftSide = detail.side === Side.LEFT || detail.side === 'PARENT';
const url = GerritNav.getUrlForDiffById(this._changeNum,
this._change.project, this._path, this._patchRange.patchNum,
this._patchRange.basePatchNum, number, leftSide);
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view_test.js b/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view_test.js
index 5429ea2..b63af54 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view_test.js
+++ b/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view_test.js
@@ -1282,6 +1282,29 @@
assert.isTrue(replaceStateStub.called);
assert.isTrue(getUrlStub.called);
+ assert.isFalse(getUrlStub.lastCall.args[6]);
+ });
+
+ test('line selected on left side', () => {
+ const getUrlStub = sinon.stub(GerritNav, 'getUrlForDiffById');
+ const replaceStateStub = sinon.stub(history, 'replaceState');
+ sinon.stub(element.$.cursor, 'getAddress')
+ .returns({number: 123, isLeftSide: true});
+
+ element._changeNum = 321;
+ element._change = {_number: 321, project: 'foo/bar'};
+ element._patchRange = {
+ basePatchNum: '3',
+ patchNum: '5',
+ };
+ const e = {};
+ const detail = {number: 123, side: 'left'};
+
+ element._onLineSelected(e, detail);
+
+ assert.isTrue(replaceStateStub.called);
+ assert.isTrue(getUrlStub.called);
+ assert.isTrue(getUrlStub.lastCall.args[6]);
});
test('_getDiffViewMode', () => {
diff --git a/polygerrit-ui/app/elements/diff/gr-diff/gr-diff.ts b/polygerrit-ui/app/elements/diff/gr-diff/gr-diff.ts
index 159c056..7c317bb 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff/gr-diff.ts
+++ b/polygerrit-ui/app/elements/diff/gr-diff/gr-diff.ts
@@ -164,9 +164,6 @@
@property({type: Object, observer: '_prefsObserver'})
prefs?: DiffPreferencesInfo;
- @property({type: String})
- projectName?: string;
-
@property({type: Boolean})
displayLine = false;
@@ -194,8 +191,9 @@
@property({type: Object})
lineOfInterest?: LineOfInterest;
- @property({type: Boolean, observer: '_loadingChanged'})
- loading = false;
+ /** True when diff is changed, until the content is done rendering. */
+ @property({type: Boolean})
+ _loading = false;
@property({type: Boolean})
loggedIn = false;
@@ -458,12 +456,14 @@
this.cancelDebouncer(RENDER_DIFF_TABLE_DEBOUNCE_NAME);
}
- getCursorStops() {
+ getCursorStops(): HTMLElement[] {
if (this.hidden && this.noAutoRender) return [];
if (!this.root) return [];
return Array.from(
- this.root.querySelectorAll(':not(.contextControl) > .diff-row')
+ this.root.querySelectorAll<HTMLElement>(
+ ':not(.contextControl) > .diff-row'
+ )
).filter(tr => tr.querySelector('button'));
}
@@ -788,12 +788,6 @@
this.clearDiffContent();
}
- _loadingChanged(newValue?: boolean) {
- if (newValue) {
- this._cleanup();
- }
- }
-
_lineWrappingObserver() {
this._prefsChanged(this.prefs);
}
@@ -832,8 +826,9 @@
}
_diffChanged(newValue?: DiffInfo) {
+ this._loading = true;
+ this._cleanup();
if (newValue) {
- this._cleanup();
this._diffLength = this.getDiffLength(newValue);
this._debounceRenderDiffTable();
}
@@ -891,6 +886,7 @@
}
_handleRenderContent() {
+ this._loading = false;
this._unobserveIncrementalNodes();
this._incrementalNodeObserver = (dom(
this
diff --git a/polygerrit-ui/app/elements/diff/gr-diff/gr-diff_html.ts b/polygerrit-ui/app/elements/diff/gr-diff/gr-diff_html.ts
index 08f5cd3..e9de9e7 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff/gr-diff_html.ts
+++ b/polygerrit-ui/app/elements/diff/gr-diff/gr-diff_html.ts
@@ -428,7 +428,6 @@
id="diffBuilder"
comment-ranges="[[_commentRanges]]"
coverage-ranges="[[coverageRanges]]"
- project-name="[[projectName]]"
diff="[[diff]]"
path="[[path]]"
change-num="[[changeNum]]"
@@ -447,7 +446,7 @@
<template
is="dom-if"
- if="[[showNoChangeMessage(loading, prefs, _diffLength, diff)]]"
+ if="[[showNoChangeMessage(_loading, prefs, _diffLength, diff)]]"
>
<div class="whitespace-change-only-message">
This file only contains whitespace changes. Modify the whitespace
@@ -458,7 +457,7 @@
</gr-diff-highlight>
</gr-diff-selection>
</div>
- <div class$="[[_computeNewlineWarningClass(_newlineWarning, loading)]]">
+ <div class$="[[_computeNewlineWarningClass(_newlineWarning, _loading)]]">
[[_newlineWarning]]
</div>
<div id="loadingError" class$="[[_computeErrorClass(errorMessage)]]">
diff --git a/polygerrit-ui/app/elements/gr-app-element.js b/polygerrit-ui/app/elements/gr-app-element.js
index e5d424c..b67bea0 100644
--- a/polygerrit-ui/app/elements/gr-app-element.js
+++ b/polygerrit-ui/app/elements/gr-app-element.js
@@ -419,6 +419,7 @@
this.set('_showChangeView', view === GerritNav.View.CHANGE);
this.set('_showDiffView', view === GerritNav.View.DIFF);
this.set('_showSettingsView', view === GerritNav.View.SETTINGS);
+ // _showAdminView must be in sync with the gr-admin-view AdminViewParams type
this.set('_showAdminView', view === GerritNav.View.ADMIN ||
view === GerritNav.View.GROUP || view === GerritNav.View.REPO);
this.set('_showCLAView', view === GerritNav.View.AGREEMENTS);
diff --git a/polygerrit-ui/app/elements/gr-app-types.ts b/polygerrit-ui/app/elements/gr-app-types.ts
index ef382ad..aee5bcb 100644
--- a/polygerrit-ui/app/elements/gr-app-types.ts
+++ b/polygerrit-ui/app/elements/gr-app-types.ts
@@ -20,7 +20,7 @@
GroupDetailView,
RepoDetailView,
} from './core/gr-navigation/gr-navigation';
-import {GroupId, RepoName} from '../types/common';
+import {DashboardId, GroupId, RepoName} from '../types/common';
export interface AppElement extends HTMLElement {
params: AppElementParams | GenerateUrlParameters;
@@ -32,9 +32,9 @@
export interface AppElementDashboardParams {
view: GerritView.DASHBOARD;
project?: RepoName;
- dashboard?: string;
+ dashboard: DashboardId;
user?: string;
- sections?: Array<{name: string; query: string}>;
+ sections: Array<{name: string; query: string}>;
title?: string;
}
@@ -87,6 +87,12 @@
}
export interface AppElementJustRegisteredParams {
+ // We use params.view === ... as a type guard.
+ // The view?: never tells to the compiler that
+ // AppElementJustRegisteredParams can't have view property.
+ // Otherwise, the compiler reports an error when the code tries to use
+ // the property 'view' of AppElementParams.
+ view?: never;
justRegistered: true;
}
diff --git a/polygerrit-ui/app/elements/plugins/gr-change-metadata-api/gr-change-metadata-api.ts b/polygerrit-ui/app/elements/plugins/gr-change-metadata-api/gr-change-metadata-api.ts
index d3452dc..322d32e 100644
--- a/polygerrit-ui/app/elements/plugins/gr-change-metadata-api/gr-change-metadata-api.ts
+++ b/polygerrit-ui/app/elements/plugins/gr-change-metadata-api/gr-change-metadata-api.ts
@@ -30,8 +30,7 @@
this._hook = this.plugin.hook('change-metadata-item');
}
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
- onLabelsChanged(callback: (value: any) => void) {
+ onLabelsChanged(callback: (value: unknown) => void) {
if (!this._hook) {
this._createHook();
}
diff --git a/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-view.js b/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-view.js
deleted file mode 100644
index 06d9183..0000000
--- a/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-view.js
+++ /dev/null
@@ -1,506 +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 '@polymer/iron-input/iron-input.js';
-import '@polymer/paper-toggle-button/paper-toggle-button.js';
-import '../../../styles/gr-form-styles.js';
-import '../../../styles/gr-menu-page-styles.js';
-import '../../../styles/gr-page-nav-styles.js';
-import '../../../styles/shared-styles.js';
-import {applyTheme as applyDarkTheme, removeTheme as removeDarkTheme} from '../../../styles/themes/dark-theme.js';
-import '../../plugins/gr-endpoint-decorator/gr-endpoint-decorator.js';
-import '../gr-change-table-editor/gr-change-table-editor.js';
-import '../../shared/gr-button/gr-button.js';
-import '../../shared/gr-date-formatter/gr-date-formatter.js';
-import '../../shared/gr-diff-preferences/gr-diff-preferences.js';
-import '../../shared/gr-page-nav/gr-page-nav.js';
-import '../../shared/gr-rest-api-interface/gr-rest-api-interface.js';
-import '../../shared/gr-select/gr-select.js';
-import '../gr-account-info/gr-account-info.js';
-import '../gr-agreements-list/gr-agreements-list.js';
-import '../gr-edit-preferences/gr-edit-preferences.js';
-import '../gr-email-editor/gr-email-editor.js';
-import '../gr-gpg-editor/gr-gpg-editor.js';
-import '../gr-group-list/gr-group-list.js';
-import '../gr-http-password/gr-http-password.js';
-import '../gr-identities/gr-identities.js';
-import '../gr-menu-editor/gr-menu-editor.js';
-import '../gr-ssh-editor/gr-ssh-editor.js';
-import '../gr-watched-projects-editor/gr-watched-projects-editor.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-settings-view_html.js';
-import {getDocsBaseUrl} from '../../../utils/url-util.js';
-import {ChangeTableMixin} from '../../../mixins/gr-change-table-mixin/gr-change-table-mixin.js';
-
-const PREFS_SECTION_FIELDS = [
- 'changes_per_page',
- 'date_format',
- 'time_format',
- 'email_strategy',
- 'diff_view',
- 'publish_comments_on_push',
- 'work_in_progress_by_default',
- 'default_base_for_merges',
- 'signed_off_by',
- 'email_format',
- 'size_bar_in_change_table',
- 'relative_date_in_change_table',
-];
-
-const GERRIT_DOCS_BASE_URL = 'https://gerrit-review.googlesource.com/' +
- 'Documentation';
-const GERRIT_DOCS_FILTER_PATH = '/user-notify.html';
-const ABSOLUTE_URL_PATTERN = /^https?:/;
-const TRAILING_SLASH_PATTERN = /\/$/;
-
-const HTTP_AUTH = [
- 'HTTP',
- 'HTTP_LDAP',
-];
-
-/**
- * @extends PolymerElement
- */
-class GrSettingsView extends ChangeTableMixin(GestureEventListeners(
- LegacyElementMixin(
- PolymerElement))) {
- static get template() { return htmlTemplate; }
-
- static get is() { return 'gr-settings-view'; }
- /**
- * Fired when the title of the page should change.
- *
- * @event title-change
- */
-
- /**
- * Fired with email confirmation text, or when the page reloads.
- *
- * @event show-alert
- */
-
- static get properties() {
- return {
- prefs: {
- type: Object,
- value() { return {}; },
- },
- params: {
- type: Object,
- value() { return {}; },
- },
- _accountInfoChanged: Boolean,
- _changeTableColumnsNotDisplayed: Array,
- /** @type {?} */
- _localPrefs: {
- type: Object,
- value() { return {}; },
- },
- _localChangeTableColumns: {
- type: Array,
- value() { return []; },
- },
- _localMenu: {
- type: Array,
- value() { return []; },
- },
- _loading: {
- type: Boolean,
- value: true,
- },
- _changeTableChanged: {
- type: Boolean,
- value: false,
- },
- _prefsChanged: {
- type: Boolean,
- value: false,
- },
- /** @type {?} */
- _diffPrefsChanged: Boolean,
- /** @type {?} */
- _editPrefsChanged: Boolean,
- _menuChanged: {
- type: Boolean,
- value: false,
- },
- _watchedProjectsChanged: {
- type: Boolean,
- value: false,
- },
- _keysChanged: {
- type: Boolean,
- value: false,
- },
- _gpgKeysChanged: {
- type: Boolean,
- value: false,
- },
- _newEmail: String,
- _addingEmail: {
- type: Boolean,
- value: false,
- },
- _lastSentVerificationEmail: {
- type: String,
- value: null,
- },
- /** @type {?} */
- _serverConfig: Object,
- /** @type {?string} */
- _docsBaseUrl: String,
- _emailsChanged: Boolean,
-
- /**
- * For testing purposes.
- */
- _loadingPromise: Object,
-
- _showNumber: Boolean,
-
- _isDark: {
- type: Boolean,
- value: false,
- },
- };
- }
-
- static get observers() {
- return [
- '_handlePrefsChanged(_localPrefs.*)',
- '_handleMenuChanged(_localMenu.splices)',
- '_handleChangeTableChanged(_localChangeTableColumns, _showNumber)',
- ];
- }
-
- /** @override */
- attached() {
- super.attached();
- // Polymer 2: anchor tag won't work on shadow DOM
- // we need to manually calling scrollIntoView when hash changed
- this.listen(window, 'location-change', '_handleLocationChange');
- this.dispatchEvent(new CustomEvent('title-change', {
- detail: {title: 'Settings'},
- composed: true, bubbles: true,
- }));
-
- this._isDark = !!window.localStorage.getItem('dark-theme');
-
- const promises = [
- this.$.accountInfo.loadData(),
- this.$.watchedProjectsEditor.loadData(),
- this.$.groupList.loadData(),
- this.$.identities.loadData(),
- this.$.editPrefs.loadData(),
- this.$.diffPrefs.loadData(),
- ];
-
- promises.push(this.$.restAPI.getPreferences().then(prefs => {
- this.prefs = prefs;
- this._showNumber = !!prefs.legacycid_in_change_table;
- this._copyPrefs('_localPrefs', 'prefs');
- this._cloneMenu(prefs.my);
- this._cloneChangeTableColumns();
- }));
-
- promises.push(this.$.restAPI.getConfig().then(config => {
- this._serverConfig = config;
- const configPromises = [];
-
- if (this._serverConfig && this._serverConfig.sshd) {
- configPromises.push(this.$.sshEditor.loadData());
- }
-
- if (this._serverConfig &&
- this._serverConfig.receive &&
- this._serverConfig.receive.enable_signed_push) {
- configPromises.push(this.$.gpgEditor.loadData());
- }
-
- configPromises.push(
- getDocsBaseUrl(config, this.$.restAPI)
- .then(baseUrl => { this._docsBaseUrl = baseUrl; }));
-
- return Promise.all(configPromises);
- }));
-
- if (this.params.emailToken) {
- promises.push(this.$.restAPI.confirmEmail(this.params.emailToken).then(
- message => {
- if (message) {
- this.dispatchEvent(new CustomEvent('show-alert', {
- detail: {message},
- composed: true, bubbles: true,
- }));
- }
- this.$.emailEditor.loadData();
- }));
- } else {
- promises.push(this.$.emailEditor.loadData());
- }
-
- this._loadingPromise = Promise.all(promises).then(() => {
- this._loading = false;
-
- // Handle anchor tag for initial load
- this._handleLocationChange();
- });
- }
-
- /** @override */
- detached() {
- super.detached();
- this.unlisten(window, 'location-change', '_handleLocationChange');
- }
-
- _handleLocationChange() {
- // Handle anchor tag after dom attached
- const urlHash = window.location.hash;
- if (urlHash) {
- // Use shadowRoot for Polymer 2
- const elem = (this.shadowRoot || document).querySelector(urlHash);
- if (elem) {
- elem.scrollIntoView();
- }
- }
- }
-
- reloadAccountDetail() {
- Promise.all([
- this.$.accountInfo.loadData(),
- this.$.emailEditor.loadData(),
- ]);
- }
-
- _isLoading() {
- return this._loading || this._loading === undefined;
- }
-
- _copyPrefs(to, from) {
- for (let i = 0; i < PREFS_SECTION_FIELDS.length; i++) {
- this.set([to, PREFS_SECTION_FIELDS[i]],
- this[from][PREFS_SECTION_FIELDS[i]]);
- }
- }
-
- _cloneMenu(prefs) {
- const menu = [];
- for (const item of prefs) {
- menu.push({
- name: item.name,
- url: item.url,
- target: item.target,
- });
- }
- this._localMenu = menu;
- }
-
- _cloneChangeTableColumns() {
- let columns = this.getVisibleColumns(this.prefs.change_table);
-
- if (columns.length === 0) {
- columns = this.columnNames;
- this._changeTableColumnsNotDisplayed = [];
- } else {
- this._changeTableColumnsNotDisplayed = this.getComplementColumns(
- this.prefs.change_table);
- }
- this._localChangeTableColumns = columns;
- }
-
- _formatChangeTableColumns(changeTableArray) {
- return changeTableArray.map(item => {
- return {column: item};
- });
- }
-
- _handleChangeTableChanged() {
- if (this._isLoading()) { return; }
- this._changeTableChanged = true;
- }
-
- _handlePrefsChanged(prefs) {
- if (this._isLoading()) { return; }
- this._prefsChanged = true;
- }
-
- _handleRelativeDateInChangeTable() {
- this.set('_localPrefs.relative_date_in_change_table',
- this.$.relativeDateInChangeTable.checked);
- }
-
- _handleShowSizeBarsInFileListChanged() {
- this.set('_localPrefs.size_bar_in_change_table',
- this.$.showSizeBarsInFileList.checked);
- }
-
- _handlePublishCommentsOnPushChanged() {
- this.set('_localPrefs.publish_comments_on_push',
- this.$.publishCommentsOnPush.checked);
- }
-
- _handleWorkInProgressByDefault() {
- this.set('_localPrefs.work_in_progress_by_default',
- this.$.workInProgressByDefault.checked);
- }
-
- _handleInsertSignedOff() {
- this.set('_localPrefs.signed_off_by', this.$.insertSignedOff.checked);
- }
-
- _handleMenuChanged() {
- if (this._isLoading()) { return; }
- this._menuChanged = true;
- }
-
- _handleSaveAccountInfo() {
- this.$.accountInfo.save();
- }
-
- _handleSavePreferences() {
- this._copyPrefs('prefs', '_localPrefs');
-
- return this.$.restAPI.savePreferences(this.prefs).then(() => {
- this._prefsChanged = false;
- });
- }
-
- _handleSaveChangeTable() {
- this.set('prefs.change_table', this._localChangeTableColumns);
- this.set('prefs.legacycid_in_change_table', this._showNumber);
- this._cloneChangeTableColumns();
- return this.$.restAPI.savePreferences(this.prefs).then(() => {
- this._changeTableChanged = false;
- });
- }
-
- _handleSaveDiffPreferences() {
- this.$.diffPrefs.save();
- }
-
- _handleSaveEditPreferences() {
- this.$.editPrefs.save();
- }
-
- _handleSaveMenu() {
- this.set('prefs.my', this._localMenu);
- this._cloneMenu(this.prefs.my);
- return this.$.restAPI.savePreferences(this.prefs).then(() => {
- this._menuChanged = false;
- });
- }
-
- _handleResetMenuButton() {
- return this.$.restAPI.getDefaultPreferences().then(data => {
- if (data && data.my) {
- this._cloneMenu(data.my);
- }
- });
- }
-
- _handleSaveWatchedProjects() {
- this.$.watchedProjectsEditor.save();
- }
-
- _computeHeaderClass(changed) {
- return changed ? 'edited' : '';
- }
-
- _handleSaveEmails() {
- this.$.emailEditor.save();
- }
-
- _handleNewEmailKeydown(e) {
- if (e.keyCode === 13) { // Enter
- e.stopPropagation();
- this._handleAddEmailButton();
- }
- }
-
- _isNewEmailValid(newEmail) {
- return newEmail && newEmail.includes('@');
- }
-
- _computeAddEmailButtonEnabled(newEmail, addingEmail) {
- return this._isNewEmailValid(newEmail) && !addingEmail;
- }
-
- _handleAddEmailButton() {
- if (!this._isNewEmailValid(this._newEmail)) { return; }
-
- this._addingEmail = true;
- this.$.restAPI.addAccountEmail(this._newEmail).then(response => {
- this._addingEmail = false;
-
- // If it was unsuccessful.
- if (response.status < 200 || response.status >= 300) { return; }
-
- this._lastSentVerificationEmail = this._newEmail;
- this._newEmail = '';
- });
- }
-
- _getFilterDocsLink(docsBaseUrl) {
- let base = docsBaseUrl;
- if (!base || !ABSOLUTE_URL_PATTERN.test(base)) {
- base = GERRIT_DOCS_BASE_URL;
- }
-
- // Remove any trailing slash, since it is in the GERRIT_DOCS_FILTER_PATH.
- base = base.replace(TRAILING_SLASH_PATTERN, '');
-
- return base + GERRIT_DOCS_FILTER_PATH;
- }
-
- _handleToggleDark() {
- if (this._isDark) {
- window.localStorage.removeItem('dark-theme');
- removeDarkTheme();
- } else {
- window.localStorage.setItem('dark-theme', 'true');
- applyDarkTheme();
- }
- this._isDark = !!window.localStorage.getItem('dark-theme');
- this.dispatchEvent(new CustomEvent('show-alert', {
- detail: {
- message: `Theme changed to ${this._isDark ? 'dark' : 'light'}.`,
- },
- bubbles: true,
- composed: true,
- }));
- }
-
- _showHttpAuth(config) {
- if (config && config.auth &&
- config.auth.git_basic_auth_policy) {
- return HTTP_AUTH.includes(
- config.auth.git_basic_auth_policy.toUpperCase());
- }
-
- return false;
- }
-
- /**
- * Work around a issue on iOS when clicking turns into double tap
- */
- _onTapDarkToggle(e) {
- e.preventDefault();
- }
-}
-
-customElements.define(GrSettingsView.is, GrSettingsView);
diff --git a/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-view.ts b/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-view.ts
new file mode 100644
index 0000000..cd0d7b8
--- /dev/null
+++ b/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-view.ts
@@ -0,0 +1,578 @@
+/**
+ * @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 '@polymer/iron-input/iron-input';
+import '@polymer/paper-toggle-button/paper-toggle-button';
+import '../../../styles/gr-form-styles';
+import '../../../styles/gr-menu-page-styles';
+import '../../../styles/gr-page-nav-styles';
+import '../../../styles/shared-styles';
+import {
+ applyTheme as applyDarkTheme,
+ removeTheme as removeDarkTheme,
+} from '../../../styles/themes/dark-theme';
+import '../../plugins/gr-endpoint-decorator/gr-endpoint-decorator';
+import '../gr-change-table-editor/gr-change-table-editor';
+import '../../shared/gr-button/gr-button';
+import '../../shared/gr-date-formatter/gr-date-formatter';
+import '../../shared/gr-diff-preferences/gr-diff-preferences';
+import '../../shared/gr-page-nav/gr-page-nav';
+import '../../shared/gr-rest-api-interface/gr-rest-api-interface';
+import '../../shared/gr-select/gr-select';
+import '../gr-account-info/gr-account-info';
+import '../gr-agreements-list/gr-agreements-list';
+import '../gr-edit-preferences/gr-edit-preferences';
+import '../gr-email-editor/gr-email-editor';
+import '../gr-gpg-editor/gr-gpg-editor';
+import '../gr-group-list/gr-group-list';
+import '../gr-http-password/gr-http-password';
+import '../gr-identities/gr-identities';
+import '../gr-menu-editor/gr-menu-editor';
+import '../gr-ssh-editor/gr-ssh-editor';
+import '../gr-watched-projects-editor/gr-watched-projects-editor';
+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-settings-view_html';
+import {getDocsBaseUrl} from '../../../utils/url-util';
+import {ChangeTableMixin} from '../../../mixins/gr-change-table-mixin/gr-change-table-mixin';
+import {customElement, property, observe} from '@polymer/decorators';
+import {AppElementParams} from '../../gr-app-types';
+import {GrAccountInfo} from '../gr-account-info/gr-account-info';
+import {GrWatchedProjectsEditor} from '../gr-watched-projects-editor/gr-watched-projects-editor';
+import {GrGroupList} from '../gr-group-list/gr-group-list';
+import {GrIdentities} from '../gr-identities/gr-identities';
+import {GrEditPreferences} from '../gr-edit-preferences/gr-edit-preferences';
+import {GrDiffPreferences} from '../../shared/gr-diff-preferences/gr-diff-preferences';
+import {RestApiService} from '../../../services/services/gr-rest-api/gr-rest-api';
+import {
+ PreferencesInput,
+ ServerInfo,
+ TopMenuItemInfo,
+} from '../../../types/common';
+import {GrSshEditor} from '../gr-ssh-editor/gr-ssh-editor';
+import {GrGpgEditor} from '../gr-gpg-editor/gr-gpg-editor';
+import {GerritView} from '../../core/gr-navigation/gr-navigation';
+import {GrEmailEditor} from '../gr-email-editor/gr-email-editor';
+import {CustomKeyboardEvent} from '../../../mixins/keyboard-shortcut-mixin/keyboard-shortcut-mixin';
+
+const PREFS_SECTION_FIELDS: Array<keyof PreferencesInput> = [
+ 'changes_per_page',
+ 'date_format',
+ 'time_format',
+ 'email_strategy',
+ 'diff_view',
+ 'publish_comments_on_push',
+ 'work_in_progress_by_default',
+ 'default_base_for_merges',
+ 'signed_off_by',
+ 'email_format',
+ 'size_bar_in_change_table',
+ 'relative_date_in_change_table',
+];
+
+const GERRIT_DOCS_BASE_URL =
+ 'https://gerrit-review.googlesource.com/' + 'Documentation';
+const GERRIT_DOCS_FILTER_PATH = '/user-notify.html';
+const ABSOLUTE_URL_PATTERN = /^https?:/;
+const TRAILING_SLASH_PATTERN = /\/$/;
+
+const HTTP_AUTH = ['HTTP', 'HTTP_LDAP'];
+
+enum CopyPrefsDirection {
+ PrefsToLocalPrefs,
+ LocalPrefsToPrefs,
+}
+
+type LocalMenuItemInfo = Omit<TopMenuItemInfo, 'id'>;
+
+export interface GrSettingsView {
+ $: {
+ restAPI: RestApiService & Element;
+ accountInfo: GrAccountInfo;
+ watchedProjectsEditor: GrWatchedProjectsEditor;
+ groupList: GrGroupList;
+ identities: GrIdentities;
+ editPrefs: GrEditPreferences;
+ diffPrefs: GrDiffPreferences;
+ sshEditor: GrSshEditor;
+ gpgEditor: GrGpgEditor;
+ emailEditor: GrEmailEditor;
+ insertSignedOff: HTMLInputElement;
+ workInProgressByDefault: HTMLInputElement;
+ showSizeBarsInFileList: HTMLInputElement;
+ publishCommentsOnPush: HTMLInputElement;
+ relativeDateInChangeTable: HTMLInputElement;
+ };
+}
+
+@customElement('gr-settings-view')
+export class GrSettingsView extends ChangeTableMixin(
+ GestureEventListeners(LegacyElementMixin(PolymerElement))
+) {
+ static get template() {
+ return htmlTemplate;
+ }
+
+ /**
+ * Fired when the title of the page should change.
+ *
+ * @event title-change
+ */
+
+ /**
+ * Fired with email confirmation text, or when the page reloads.
+ *
+ * @event show-alert
+ */
+
+ @property({type: Object})
+ prefs: PreferencesInput = {};
+
+ @property({type: Object})
+ params?: AppElementParams;
+
+ @property({type: Boolean})
+ _accountInfoChanged?: boolean;
+
+ @property({type: Array})
+ _changeTableColumnsNotDisplayed?: string[];
+
+ @property({type: Object})
+ _localPrefs: PreferencesInput = {};
+
+ @property({type: Array})
+ _localChangeTableColumns: string[] = [];
+
+ @property({type: Array})
+ _localMenu: LocalMenuItemInfo[] = [];
+
+ @property({type: Boolean})
+ _loading = true;
+
+ @property({type: Boolean})
+ _changeTableChanged = false;
+
+ @property({type: Boolean})
+ _prefsChanged = false;
+
+ @property({type: Boolean})
+ _diffPrefsChanged?: boolean;
+
+ @property({type: Boolean})
+ _editPrefsChanged?: boolean;
+
+ @property({type: Boolean})
+ _menuChanged = false;
+
+ @property({type: Boolean})
+ _watchedProjectsChanged = false;
+
+ @property({type: Boolean})
+ _keysChanged = false;
+
+ @property({type: Boolean})
+ _gpgKeysChanged = false;
+
+ @property({type: String})
+ _newEmail?: string;
+
+ @property({type: Boolean})
+ _addingEmail = false;
+
+ @property({type: String})
+ _lastSentVerificationEmail?: string | null = null;
+
+ @property({type: Object})
+ _serverConfig?: ServerInfo;
+
+ @property({type: String})
+ _docsBaseUrl?: string | null;
+
+ @property({type: Boolean})
+ _emailsChanged?: boolean;
+
+ @property({type: Boolean})
+ _showNumber?: boolean;
+
+ @property({type: Boolean})
+ _isDark = false;
+
+ public _testOnly_loadingPromise?: Promise<void>;
+
+ /** @override */
+ attached() {
+ super.attached();
+ // Polymer 2: anchor tag won't work on shadow DOM
+ // we need to manually calling scrollIntoView when hash changed
+ this.listen(window, 'location-change', '_handleLocationChange');
+ this.dispatchEvent(
+ new CustomEvent('title-change', {
+ detail: {title: 'Settings'},
+ composed: true,
+ bubbles: true,
+ })
+ );
+
+ this._isDark = !!window.localStorage.getItem('dark-theme');
+
+ const promises: Array<Promise<unknown>> = [
+ this.$.accountInfo.loadData(),
+ this.$.watchedProjectsEditor.loadData(),
+ this.$.groupList.loadData(),
+ this.$.identities.loadData(),
+ this.$.editPrefs.loadData(),
+ this.$.diffPrefs.loadData(),
+ ];
+
+ promises.push(
+ this.$.restAPI.getPreferences().then(prefs => {
+ if (!prefs) {
+ throw new Error('getPreferences returned undefined');
+ }
+ this.prefs = prefs;
+ this._showNumber = !!prefs.legacycid_in_change_table;
+ this._copyPrefs(CopyPrefsDirection.PrefsToLocalPrefs);
+ this._cloneMenu(prefs.my);
+ this._cloneChangeTableColumns(prefs.change_table);
+ })
+ );
+
+ promises.push(
+ this.$.restAPI.getConfig().then(config => {
+ this._serverConfig = config;
+ const configPromises: Array<Promise<void>> = [];
+
+ if (this._serverConfig && this._serverConfig.sshd) {
+ configPromises.push(this.$.sshEditor.loadData());
+ }
+
+ if (
+ this._serverConfig &&
+ this._serverConfig.receive &&
+ this._serverConfig.receive.enable_signed_push
+ ) {
+ configPromises.push(this.$.gpgEditor.loadData());
+ }
+
+ configPromises.push(
+ getDocsBaseUrl(config, this.$.restAPI).then(baseUrl => {
+ this._docsBaseUrl = baseUrl;
+ })
+ );
+
+ return Promise.all(configPromises);
+ })
+ );
+
+ if (
+ this.params &&
+ this.params.view === GerritView.SETTINGS &&
+ this.params.emailToken
+ ) {
+ promises.push(
+ this.$.restAPI.confirmEmail(this.params.emailToken).then(message => {
+ if (message) {
+ this.dispatchEvent(
+ new CustomEvent('show-alert', {
+ detail: {message},
+ composed: true,
+ bubbles: true,
+ })
+ );
+ }
+ this.$.emailEditor.loadData();
+ })
+ );
+ } else {
+ promises.push(this.$.emailEditor.loadData());
+ }
+
+ this._testOnly_loadingPromise = Promise.all(promises).then(() => {
+ this._loading = false;
+
+ // Handle anchor tag for initial load
+ this._handleLocationChange();
+ });
+ }
+
+ detached() {
+ super.detached();
+ this.unlisten(window, 'location-change', '_handleLocationChange');
+ }
+
+ _handleLocationChange() {
+ // Handle anchor tag after dom attached
+ const urlHash = window.location.hash;
+ if (urlHash) {
+ // Use shadowRoot for Polymer 2
+ const elem = (this.shadowRoot || document).querySelector(urlHash);
+ if (elem) {
+ elem.scrollIntoView();
+ }
+ }
+ }
+
+ reloadAccountDetail() {
+ Promise.all([this.$.accountInfo.loadData(), this.$.emailEditor.loadData()]);
+ }
+
+ _isLoading() {
+ return this._loading || this._loading === undefined;
+ }
+
+ _copyPrefs(direction: CopyPrefsDirection) {
+ let to;
+ let from;
+ if (direction === CopyPrefsDirection.LocalPrefsToPrefs) {
+ from = this._localPrefs;
+ to = 'prefs';
+ } else {
+ from = this.prefs;
+ to = '_localPrefs';
+ }
+ for (let i = 0; i < PREFS_SECTION_FIELDS.length; i++) {
+ this.set([to, PREFS_SECTION_FIELDS[i]], from[PREFS_SECTION_FIELDS[i]]);
+ }
+ }
+
+ _cloneMenu(prefs: TopMenuItemInfo[]) {
+ const menu = [];
+ for (const item of prefs) {
+ menu.push({
+ name: item.name,
+ url: item.url,
+ target: item.target,
+ });
+ }
+ this._localMenu = menu;
+ }
+
+ _cloneChangeTableColumns(changeTable: string[]) {
+ let columns = this.getVisibleColumns(changeTable);
+
+ if (columns.length === 0) {
+ columns = this.columnNames;
+ this._changeTableColumnsNotDisplayed = [];
+ } else {
+ this._changeTableColumnsNotDisplayed = this.getComplementColumns(
+ changeTable
+ );
+ }
+ this._localChangeTableColumns = columns;
+ }
+
+ @observe('_localChangeTableColumns', '_showNumber')
+ _handleChangeTableChanged() {
+ if (this._isLoading()) {
+ return;
+ }
+ this._changeTableChanged = true;
+ }
+
+ @observe('_localPrefs.*')
+ _handlePrefsChanged() {
+ if (this._isLoading()) {
+ return;
+ }
+ this._prefsChanged = true;
+ }
+
+ _handleRelativeDateInChangeTable() {
+ this.set(
+ '_localPrefs.relative_date_in_change_table',
+ this.$.relativeDateInChangeTable.checked
+ );
+ }
+
+ _handleShowSizeBarsInFileListChanged() {
+ this.set(
+ '_localPrefs.size_bar_in_change_table',
+ this.$.showSizeBarsInFileList.checked
+ );
+ }
+
+ _handlePublishCommentsOnPushChanged() {
+ this.set(
+ '_localPrefs.publish_comments_on_push',
+ this.$.publishCommentsOnPush.checked
+ );
+ }
+
+ _handleWorkInProgressByDefault() {
+ this.set(
+ '_localPrefs.work_in_progress_by_default',
+ this.$.workInProgressByDefault.checked
+ );
+ }
+
+ _handleInsertSignedOff() {
+ this.set('_localPrefs.signed_off_by', this.$.insertSignedOff.checked);
+ }
+
+ @observe('_localMenu.splices')
+ _handleMenuChanged() {
+ if (this._isLoading()) {
+ return;
+ }
+ this._menuChanged = true;
+ }
+
+ _handleSaveAccountInfo() {
+ this.$.accountInfo.save();
+ }
+
+ _handleSavePreferences() {
+ this._copyPrefs(CopyPrefsDirection.LocalPrefsToPrefs);
+
+ return this.$.restAPI.savePreferences(this.prefs).then(() => {
+ this._prefsChanged = false;
+ });
+ }
+
+ _handleSaveChangeTable() {
+ this.set('prefs.change_table', this._localChangeTableColumns);
+ this.set('prefs.legacycid_in_change_table', this._showNumber);
+ this._cloneChangeTableColumns(this._localChangeTableColumns);
+ return this.$.restAPI.savePreferences(this.prefs).then(() => {
+ this._changeTableChanged = false;
+ });
+ }
+
+ _handleSaveDiffPreferences() {
+ this.$.diffPrefs.save();
+ }
+
+ _handleSaveEditPreferences() {
+ this.$.editPrefs.save();
+ }
+
+ _handleSaveMenu() {
+ this.set('prefs.my', this._localMenu);
+ this._cloneMenu(this._localMenu);
+ return this.$.restAPI.savePreferences(this.prefs).then(() => {
+ this._menuChanged = false;
+ });
+ }
+
+ _handleResetMenuButton() {
+ return this.$.restAPI.getDefaultPreferences().then(data => {
+ if (data?.my) {
+ this._cloneMenu(data.my);
+ }
+ });
+ }
+
+ _handleSaveWatchedProjects() {
+ this.$.watchedProjectsEditor.save();
+ }
+
+ _computeHeaderClass(changed?: boolean) {
+ return changed ? 'edited' : '';
+ }
+
+ _handleSaveEmails() {
+ this.$.emailEditor.save();
+ }
+
+ _handleNewEmailKeydown(e: CustomKeyboardEvent) {
+ if (e.keyCode === 13) {
+ // Enter
+ e.stopPropagation();
+ this._handleAddEmailButton();
+ }
+ }
+
+ _isNewEmailValid(newEmail?: string): newEmail is string {
+ return !!newEmail && newEmail.includes('@');
+ }
+
+ _computeAddEmailButtonEnabled(newEmail?: string, addingEmail?: boolean) {
+ return this._isNewEmailValid(newEmail) && !addingEmail;
+ }
+
+ _handleAddEmailButton() {
+ if (!this._isNewEmailValid(this._newEmail)) return;
+
+ this._addingEmail = true;
+ this.$.restAPI.addAccountEmail(this._newEmail).then(response => {
+ this._addingEmail = false;
+
+ // If it was unsuccessful.
+ if (response.status < 200 || response.status >= 300) {
+ return;
+ }
+
+ this._lastSentVerificationEmail = this._newEmail;
+ this._newEmail = '';
+ });
+ }
+
+ _getFilterDocsLink(docsBaseUrl?: string) {
+ let base = docsBaseUrl;
+ if (!base || !ABSOLUTE_URL_PATTERN.test(base)) {
+ base = GERRIT_DOCS_BASE_URL;
+ }
+
+ // Remove any trailing slash, since it is in the GERRIT_DOCS_FILTER_PATH.
+ base = base.replace(TRAILING_SLASH_PATTERN, '');
+
+ return base + GERRIT_DOCS_FILTER_PATH;
+ }
+
+ _handleToggleDark() {
+ if (this._isDark) {
+ window.localStorage.removeItem('dark-theme');
+ removeDarkTheme();
+ } else {
+ window.localStorage.setItem('dark-theme', 'true');
+ applyDarkTheme();
+ }
+ this._isDark = !!window.localStorage.getItem('dark-theme');
+ this.dispatchEvent(
+ new CustomEvent('show-alert', {
+ detail: {
+ message: `Theme changed to ${this._isDark ? 'dark' : 'light'}.`,
+ },
+ bubbles: true,
+ composed: true,
+ })
+ );
+ }
+
+ _showHttpAuth(config?: ServerInfo) {
+ if (config && config.auth && config.auth.git_basic_auth_policy) {
+ return HTTP_AUTH.includes(
+ config.auth.git_basic_auth_policy.toUpperCase()
+ );
+ }
+
+ return false;
+ }
+
+ /**
+ * Work around a issue on iOS when clicking turns into double tap
+ */
+ _onTapDarkToggle(e: Event) {
+ e.preventDefault();
+ }
+}
+
+declare global {
+ interface HTMLElementTagNameMap {
+ 'gr-settings-view': GrSettingsView;
+ }
+}
diff --git a/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-view_html.ts b/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-view_html.ts
index ea26a51..78f84b1 100644
--- a/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-view_html.ts
+++ b/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-view_html.ts
@@ -175,6 +175,9 @@
<select>
<option value="CC_ON_OWN_COMMENTS">Every comment</option>
<option value="ENABLED">Only comments left by others</option>
+ <option value="ATTENTION_SET_ONLY"
+ >Only when I am in the attention set</option
+ >
<option value="DISABLED">None</option>
</select>
</gr-select>
diff --git a/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-view_test.js b/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-view_test.js
index 1929f4e..0535e15 100644
--- a/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-view_test.js
+++ b/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-view_test.js
@@ -19,6 +19,7 @@
import {getComputedStyleValue} from '../../../utils/dom-util.js';
import './gr-settings-view.js';
import {flush} from '@polymer/polymer/lib/legacy/polymer.dom.js';
+import {GerritView} from '../../core/gr-navigation/gr-navigation.js';
const basicFixture = fixtureFromElement('gr-settings-view');
const blankFixture = fixtureFromElement('div');
@@ -95,7 +96,7 @@
element = basicFixture.instantiate();
// Allow the element to render.
- element._loadingPromise.then(done);
+ element._testOnly_loadingPromise.then(done);
});
test('theme changing', () => {
@@ -485,7 +486,7 @@
.callsFake(
() => new Promise(
resolve => { resolveConfirm = resolve; }));
- element.params = {emailToken: 'foo'};
+ element.params = {view: GerritView.SETTINGS, emailToken: 'foo'};
element.attached();
});
@@ -499,7 +500,7 @@
});
test('user emails are loaded after email confirmed', done => {
- element._loadingPromise.then(() => {
+ element._testOnly_loadingPromise.then(() => {
assert.isTrue(element.$.emailEditor.loadData.calledOnce);
done();
});
@@ -508,7 +509,7 @@
test('show-alert is fired when email is confirmed', done => {
sinon.spy(element, 'dispatchEvent');
- element._loadingPromise.then(() => {
+ element._testOnly_loadingPromise.then(() => {
assert.equal(
element.dispatchEvent.lastCall.args[0].type, 'show-alert');
assert.deepEqual(
diff --git a/polygerrit-ui/app/elements/shared/gr-account-label/gr-account-label.ts b/polygerrit-ui/app/elements/shared/gr-account-label/gr-account-label.ts
index c1fee2dc..be23cb3 100644
--- a/polygerrit-ui/app/elements/shared/gr-account-label/gr-account-label.ts
+++ b/polygerrit-ui/app/elements/shared/gr-account-label/gr-account-label.ts
@@ -183,6 +183,18 @@
);
}
+ _computeHasAttentionClass(
+ config: ServerInfo | undefined,
+ highlight: boolean,
+ account: AccountInfo,
+ change: ChangeInfo,
+ force: boolean
+ ) {
+ return this._hasAttention(config, highlight, account, change, force)
+ ? 'hasAttention'
+ : '';
+ }
+
_computeName(
account?: AccountInfo,
config?: ServerInfo,
diff --git a/polygerrit-ui/app/elements/shared/gr-account-label/gr-account-label_html.ts b/polygerrit-ui/app/elements/shared/gr-account-label/gr-account-label_html.ts
index d2b58a6..1d8b13e 100644
--- a/polygerrit-ui/app/elements/shared/gr-account-label/gr-account-label_html.ts
+++ b/polygerrit-ui/app/elements/shared/gr-account-label/gr-account-label_html.ts
@@ -85,6 +85,9 @@
position: relative;
top: 2px;
}
+ .hasAttention .name {
+ font-weight: var(--font-weight-bold);
+ }
</style>
<span>
<template is="dom-if" if="[[!hideHovercard]]">
@@ -113,7 +116,10 @@
</gr-button>
</template>
</span>
- <span id="hovercardTarget">
+ <span
+ id="hovercardTarget"
+ class$="[[_computeHasAttentionClass(_config, highlightAttention, account, change, forceAttention)]]"
+ >
<template is="dom-if" if="[[!hideAvatar]]">
<gr-avatar account="[[account]]" image-size="32"></gr-avatar>
</template>
diff --git a/polygerrit-ui/app/elements/shared/gr-change-star/gr-change-star.ts b/polygerrit-ui/app/elements/shared/gr-change-star/gr-change-star.ts
index 0aa077e..1ecaf7f 100644
--- a/polygerrit-ui/app/elements/shared/gr-change-star/gr-change-star.ts
+++ b/polygerrit-ui/app/elements/shared/gr-change-star/gr-change-star.ts
@@ -30,6 +30,11 @@
}
}
+export interface ChangeStarToggleStarDetail {
+ change: ChangeInfo;
+ starred: boolean;
+}
+
@customElement('gr-change-star')
export class GrChangeStar extends KeyboardShortcutMixin(
GestureEventListeners(LegacyElementMixin(PolymerElement))
@@ -69,11 +74,15 @@
}
const newVal = !this.change.starred;
this.set('change.starred', newVal);
+ const detail: ChangeStarToggleStarDetail = {
+ change: this.change,
+ starred: newVal,
+ };
this.dispatchEvent(
new CustomEvent('toggle-star', {
bubbles: true,
composed: true,
- detail: {change: this.change, starred: newVal},
+ detail,
})
);
}
diff --git a/polygerrit-ui/app/elements/shared/gr-comment-thread/gr-comment-thread.ts b/polygerrit-ui/app/elements/shared/gr-comment-thread/gr-comment-thread.ts
index fe6a0fd..a9df3f9 100644
--- a/polygerrit-ui/app/elements/shared/gr-comment-thread/gr-comment-thread.ts
+++ b/polygerrit-ui/app/elements/shared/gr-comment-thread/gr-comment-thread.ts
@@ -34,7 +34,7 @@
UIComment,
UIDraft,
UIRobot,
-} from '../../diff/gr-comment-api/gr-comment-api';
+} from '../../../utils/comment-util';
import {GerritNav} from '../../core/gr-navigation/gr-navigation';
import {appContext} from '../../../services/app-context';
import {CommentSide, Side, SpecialFilePath} from '../../../constants/constants';
@@ -143,7 +143,7 @@
notify: true,
computed: '_computeRootId(comments.*)',
})
- rootId?: string;
+ rootId?: UrlEncodedCommentId;
@property({type: Boolean})
showFilePath = false;
diff --git a/polygerrit-ui/app/elements/shared/gr-comment-thread/gr-comment-thread_test.js b/polygerrit-ui/app/elements/shared/gr-comment-thread/gr-comment-thread_test.js
index 52264ec..1833b73 100644
--- a/polygerrit-ui/app/elements/shared/gr-comment-thread/gr-comment-thread_test.js
+++ b/polygerrit-ui/app/elements/shared/gr-comment-thread/gr-comment-thread_test.js
@@ -19,7 +19,7 @@
import './gr-comment-thread.js';
import {GerritNav} from '../../core/gr-navigation/gr-navigation.js';
import {SpecialFilePath} from '../../../constants/constants.js';
-import {sortComments} from '../../diff/gr-comment-api/gr-comment-api.js';
+import {sortComments} from '../../../utils/comment-util.js';
const basicFixture = fixtureFromElement('gr-comment-thread');
diff --git a/polygerrit-ui/app/elements/shared/gr-comment/gr-comment.ts b/polygerrit-ui/app/elements/shared/gr-comment/gr-comment.ts
index d68da94..9a1d789 100644
--- a/polygerrit-ui/app/elements/shared/gr-comment/gr-comment.ts
+++ b/polygerrit-ui/app/elements/shared/gr-comment/gr-comment.ts
@@ -58,7 +58,7 @@
UIComment,
UIDraft,
UIRobot,
-} from '../../diff/gr-comment-api/gr-comment-api';
+} from '../../../utils/comment-util';
const STORAGE_DEBOUNCE_INTERVAL = 400;
const TOAST_DEBOUNCE_INTERVAL = 200;
diff --git a/polygerrit-ui/app/elements/shared/gr-date-formatter/gr-date-formatter.ts b/polygerrit-ui/app/elements/shared/gr-date-formatter/gr-date-formatter.ts
index 798f58b..c64dc2a 100644
--- a/polygerrit-ui/app/elements/shared/gr-date-formatter/gr-date-formatter.ts
+++ b/polygerrit-ui/app/elements/shared/gr-date-formatter/gr-date-formatter.ts
@@ -122,7 +122,13 @@
_timeFormat?: string;
@property({type: Boolean})
- _relative?: boolean;
+ _relative = false;
+
+ @property({type: Boolean})
+ forceRelative = false;
+
+ @property({type: Boolean})
+ relativeOptionNoAgo = false;
constructor() {
super();
@@ -143,7 +149,7 @@
if (!loggedIn) {
this._timeFormat = TimeFormats.TIME_24;
this._dateFormat = DateFormats.STD;
- this._relative = false;
+ this._relative = this.forceRelative;
return;
}
return Promise.all([this._loadTimeFormat(), this._loadRelative()]);
@@ -198,7 +204,8 @@
_loadRelative() {
return this._getPreferences().then(prefs => {
// prefs.relative_date_in_change_table is not set when false.
- this._relative = !!(prefs && prefs.relative_date_in_change_table);
+ this._relative =
+ this.forceRelative || !!(prefs && prefs.relative_date_in_change_table);
});
}
@@ -225,7 +232,7 @@
return '';
}
if (relative) {
- return fromNow(date);
+ return fromNow(date, this.relativeOptionNoAgo);
}
const now = new Date();
let format = dateFormat.full;
diff --git a/polygerrit-ui/app/elements/shared/gr-dropdown/gr-dropdown.ts b/polygerrit-ui/app/elements/shared/gr-dropdown/gr-dropdown.ts
index 1fadce5..47bad2c 100644
--- a/polygerrit-ui/app/elements/shared/gr-dropdown/gr-dropdown.ts
+++ b/polygerrit-ui/app/elements/shared/gr-dropdown/gr-dropdown.ts
@@ -47,7 +47,7 @@
};
}
-interface DropdownLink {
+export interface DropdownLink {
url?: string;
name?: string;
external?: boolean;
diff --git a/polygerrit-ui/app/elements/shared/gr-hovercard-account/gr-hovercard-account.ts b/polygerrit-ui/app/elements/shared/gr-hovercard-account/gr-hovercard-account.ts
index b3a53c5..da2881e 100644
--- a/polygerrit-ui/app/elements/shared/gr-hovercard-account/gr-hovercard-account.ts
+++ b/polygerrit-ui/app/elements/shared/gr-hovercard-account/gr-hovercard-account.ts
@@ -26,13 +26,26 @@
import {PolymerElement} from '@polymer/polymer/polymer-element';
import {htmlTemplate} from './gr-hovercard-account_html';
import {appContext} from '../../../services/app-context';
-import {isServiceUser} from '../../../utils/account-util';
+import {accountKey} from '../../../utils/account-util';
import {getDisplayName} from '../../../utils/display-name-util';
import {customElement, property} from '@polymer/decorators';
import {RestApiService} from '../../../services/services/gr-rest-api/gr-rest-api';
-import {AccountInfo, ChangeInfo, ServerInfo} from '../../../types/common';
+import {
+ AccountInfo,
+ ChangeInfo,
+ ServerInfo,
+ ReviewInput,
+} from '../../../types/common';
import {ReportingService} from '../../../services/gr-reporting/gr-reporting';
-import {hasOwnProperty} from '../../../utils/common-util';
+import {
+ canHaveAttention,
+ getLastUpdate,
+ getReason,
+ hasAttention,
+ isAttentionSetEnabled,
+} from '../../../utils/attention-set-util';
+import {ReviewerState} from '../../../constants/constants';
+import {isRemovableReviewer} from '../../../utils/change-util';
export interface GrHovercardAccount {
$: {
@@ -101,70 +114,132 @@
return account._account_id === selfAccount._account_id ? 'Your' : 'Their';
}
- get isAttentionSetEnabled() {
+ get isAttentionEnabled() {
return (
- !!this._config &&
- !!this._config.change &&
- !!this._config.change.enable_attention_set &&
+ isAttentionSetEnabled(this._config) &&
!!this.highlightAttention &&
!!this.change &&
- !!this.account &&
- !isServiceUser(this.account)
+ canHaveAttention(this.account)
);
}
- get hasAttention() {
- if (
- !this.isAttentionSetEnabled ||
- !this.change?.attention_set ||
- !this.account._account_id
- )
- return false;
- return hasOwnProperty(this.change.attention_set, this.account._account_id);
+ get hasUserAttention() {
+ return hasAttention(this._config, this.account, this.change);
}
_computeReason(change?: ChangeInfo) {
- if (!change || !change.attention_set || !this.account._account_id) {
- return '';
- }
- const entry = change.attention_set[this.account._account_id];
- if (!entry || !entry.reason) return '';
- return entry.reason;
+ return getReason(this.account, change);
}
_computeLastUpdate(change?: ChangeInfo) {
- if (!change || !change.attention_set || !this.account._account_id) {
- return '';
+ return getLastUpdate(this.account, change);
+ }
+
+ _showReviewerOrCCActions(account?: AccountInfo, change?: ChangeInfo) {
+ return !!this._selfAccount && isRemovableReviewer(change, account);
+ }
+
+ _getReviewerState(account: AccountInfo, change: ChangeInfo) {
+ if (
+ change.reviewers[ReviewerState.REVIEWER]?.some(
+ (reviewer: AccountInfo) => {
+ return reviewer._account_id === account._account_id;
+ }
+ )
+ ) {
+ return ReviewerState.REVIEWER;
}
- const entry = change.attention_set[this.account._account_id];
- if (!entry || !entry.last_update) return '';
- return entry.last_update;
+ return ReviewerState.CC;
+ }
+
+ _computeReviewerOrCCText(account?: AccountInfo, change?: ChangeInfo) {
+ if (!change || !account) return '';
+ return this._getReviewerState(account, change) === ReviewerState.REVIEWER
+ ? 'Reviewer'
+ : 'CC';
+ }
+
+ _computeChangeReviewerOrCCText(account?: AccountInfo, change?: ChangeInfo) {
+ if (!change || !account) return '';
+ return this._getReviewerState(account, change) === ReviewerState.REVIEWER
+ ? 'Move Reviewer to CC'
+ : 'Move CC to Reviewer';
+ }
+
+ _handleChangeReviewerOrCCStatus() {
+ if (!this.change) throw new Error('expected change object to be present');
+ // accountKey() throws an error if _account_id & email is not found, which
+ // we want to check before showing reloading toast
+ const _accountKey = accountKey(this.account);
+ this.dispatchEventThroughTarget('show-alert', {
+ message: 'Reloading page...',
+ });
+ const reviewInput: Partial<ReviewInput> = {};
+ reviewInput.reviewers = [
+ {
+ reviewer: _accountKey,
+ state:
+ this._getReviewerState(this.account, this.change) === ReviewerState.CC
+ ? ReviewerState.REVIEWER
+ : ReviewerState.CC,
+ },
+ ];
+
+ this.$.restAPI
+ .saveChangeReview(this.change._number, 'current', reviewInput)
+ .then(response => {
+ if (!response || !response.ok) {
+ throw new Error(
+ 'something went wrong when toggling' +
+ this._getReviewerState(this.account, this.change!)
+ );
+ }
+ this.dispatchEventThroughTarget('reload', {clearPatchset: true});
+ });
+ }
+
+ _handleRemoveReviewerOrCC() {
+ if (!this.change || !(this.account?._account_id || this.account?.email))
+ throw new Error('Missing change or account.');
+ this.dispatchEventThroughTarget('show-alert', {
+ message: 'Reloading page...',
+ });
+ this.$.restAPI
+ .removeChangeReviewer(
+ this.change._number,
+ (this.account?._account_id || this.account?.email)!
+ )
+ .then((response: Response | undefined) => {
+ if (!response || !response.ok) {
+ throw new Error('something went wrong when removing user');
+ }
+ this.dispatchEventThroughTarget('reload', {clearPatchset: true});
+ return response;
+ });
}
_computeShowLabelNeedsAttention() {
- return this.isAttentionSetEnabled && this.hasAttention;
+ return this.isAttentionEnabled && this.hasUserAttention;
}
_computeShowActionAddToAttentionSet() {
- return this.isAttentionSetEnabled && !this.hasAttention;
+ return (
+ this._selfAccount && this.isAttentionEnabled && !this.hasUserAttention
+ );
}
_computeShowActionRemoveFromAttentionSet() {
- return this.isAttentionSetEnabled && this.hasAttention;
+ return (
+ this._selfAccount && this.isAttentionEnabled && this.hasUserAttention
+ );
}
_handleClickAddToAttentionSet() {
if (!this.change || !this.account._account_id) return;
- this.dispatchEvent(
- new CustomEvent('show-alert', {
- detail: {
- message: 'Saving attention set update ...',
- dismissOnNavigation: true,
- },
- composed: true,
- bubbles: true,
- })
- );
+ this.dispatchEventThroughTarget('show-alert', {
+ message: 'Saving attention set update ...',
+ dismissOnNavigation: true,
+ });
// We are deliberately updating the UI before making the API call. It is a
// risk that we are taking to achieve a better UX for 99.9% of the cases.
@@ -191,16 +266,10 @@
_handleClickRemoveFromAttentionSet() {
if (!this.change || !this.account._account_id) return;
- this.dispatchEvent(
- new CustomEvent('show-alert', {
- detail: {
- message: 'Saving attention set update ...',
- dismissOnNavigation: true,
- },
- composed: true,
- bubbles: true,
- })
- );
+ this.dispatchEventThroughTarget('show-alert', {
+ message: 'Saving attention set update ...',
+ dismissOnNavigation: true,
+ });
// We are deliberately updating the UI before making the API call. It is a
// risk that we are taking to achieve a better UX for 99.9% of the cases.
diff --git a/polygerrit-ui/app/elements/shared/gr-hovercard-account/gr-hovercard-account_html.ts b/polygerrit-ui/app/elements/shared/gr-hovercard-account/gr-hovercard-account_html.ts
index 6556a94..1d437fb 100644
--- a/polygerrit-ui/app/elements/shared/gr-hovercard-account/gr-hovercard-account_html.ts
+++ b/polygerrit-ui/app/elements/shared/gr-hovercard-account/gr-hovercard-account_html.ts
@@ -172,6 +172,28 @@
</gr-button>
</div>
</template>
+ <template is="dom-if" if="[[_showReviewerOrCCActions(account, change)]]">
+ <div class="action">
+ <gr-button
+ class="removeReviewerOrCC"
+ link=""
+ no-uppercase=""
+ on-click="_handleRemoveReviewerOrCC"
+ >
+ Remove [[_computeReviewerOrCCText(account, change)]]
+ </gr-button>
+ </div>
+ <div class="action">
+ <gr-button
+ class="changeReviewerOrCC"
+ link=""
+ no-uppercase=""
+ on-click="_handleChangeReviewerOrCCStatus"
+ >
+ [[_computeChangeReviewerOrCCText(account, change)]]
+ </gr-button>
+ </div>
+ </template>
</template>
</div>
<gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
diff --git a/polygerrit-ui/app/elements/shared/gr-hovercard-account/gr-hovercard-account_test.js b/polygerrit-ui/app/elements/shared/gr-hovercard-account/gr-hovercard-account_test.js
index f272507..b09f0ce 100644
--- a/polygerrit-ui/app/elements/shared/gr-hovercard-account/gr-hovercard-account_test.js
+++ b/polygerrit-ui/app/elements/shared/gr-hovercard-account/gr-hovercard-account_test.js
@@ -18,6 +18,7 @@
import '../../../test/common-test-setup-karma.js';
import './gr-hovercard-account.js';
import {html} from '@polymer/polymer/lib/utils/html-tag.js';
+import {ReviewerState} from '../../../constants/constants.js';
const basicFixture = fixtureFromTemplate(html`
<gr-hovercard-account class="hovered"></gr-hovercard-account>
@@ -39,6 +40,7 @@
new Promise(resolve => { '2'; })
);
+ element._selfAccount = {...ACCOUNT};
element.account = {...ACCOUNT};
element._config = {
change: {enable_attention_set: true},
@@ -59,17 +61,6 @@
'Kermit The Frog');
});
- test('_computeReason', () => {
- const change = {
- attention_set: {
- 31415926535: {
- reason: 'a good reason',
- },
- },
- };
- assert.equal(element._computeReason(change), 'a good reason');
- });
-
test('_computeLastUpdate', () => {
const last_update = '2019-07-17 19:39:02.000000000';
const change = {
@@ -112,6 +103,104 @@
element.voteableText);
});
+ test('remove reviewer', async () => {
+ element.change = {
+ removable_reviewers: [ACCOUNT],
+ reviewers: {
+ [ReviewerState.REVIEWER]: [ACCOUNT],
+ },
+ };
+ sinon.stub(element.$.restAPI, 'removeChangeReviewer').returns(
+ Promise.resolve({ok: true}));
+ const reloadListener = sinon.spy();
+ element._target.addEventListener('reload', reloadListener);
+ flush();
+ const button = element.shadowRoot.querySelector('.removeReviewerOrCC');
+ assert.isOk(button);
+ assert.equal(button.innerText, 'Remove Reviewer');
+ MockInteractions.tap(button);
+ await flush();
+ assert.isTrue(reloadListener.called);
+ });
+
+ test('move reviewer to cc', async () => {
+ element.change = {
+ removable_reviewers: [ACCOUNT],
+ reviewers: {
+ [ReviewerState.REVIEWER]: [ACCOUNT],
+ },
+ };
+ const saveReviewStub = sinon.stub(element.$.restAPI,
+ 'saveChangeReview').returns(
+ Promise.resolve({ok: true}));
+ sinon.stub(element.$.restAPI, 'removeChangeReviewer').returns(
+ Promise.resolve({ok: true}));
+ const reloadListener = sinon.spy();
+ element._target.addEventListener('reload', reloadListener);
+
+ flush();
+ const button = element.shadowRoot.querySelector('.changeReviewerOrCC');
+
+ assert.isOk(button);
+ assert.equal(button.innerText, 'Move Reviewer to CC');
+ MockInteractions.tap(button);
+ await flush();
+
+ assert.isTrue(saveReviewStub.called);
+ assert.isTrue(reloadListener.called);
+ });
+
+ test('move reviewer to cc', async () => {
+ element.change = {
+ removable_reviewers: [ACCOUNT],
+ reviewers: {
+ [ReviewerState.REVIEWER]: [],
+ },
+ };
+ const saveReviewStub = sinon.stub(element.$.restAPI,
+ 'saveChangeReview').returns(
+ Promise.resolve({ok: true}));
+ sinon.stub(element.$.restAPI, 'removeChangeReviewer').returns(
+ Promise.resolve({ok: true}));
+ const reloadListener = sinon.spy();
+ element._target.addEventListener('reload', reloadListener);
+ flush();
+
+ const button = element.shadowRoot.querySelector('.changeReviewerOrCC');
+ assert.isOk(button);
+ assert.equal(button.innerText, 'Move CC to Reviewer');
+
+ MockInteractions.tap(button);
+ await flush();
+
+ assert.isTrue(saveReviewStub.called);
+ assert.isTrue(reloadListener.called);
+ });
+
+ test('remove cc', async () => {
+ element.change = {
+ removable_reviewers: [ACCOUNT],
+ reviewers: {
+ [ReviewerState.REVIEWER]: [],
+ },
+ };
+ sinon.stub(element.$.restAPI, 'removeChangeReviewer').returns(
+ Promise.resolve({ok: true}));
+ const reloadListener = sinon.spy();
+ element._target.addEventListener('reload', reloadListener);
+
+ flush();
+ const button = element.shadowRoot.querySelector('.removeReviewerOrCC');
+
+ assert.equal(button.innerText, 'Remove CC');
+ assert.isOk(button);
+ MockInteractions.tap(button);
+
+ await flush();
+
+ assert.isTrue(reloadListener.called);
+ });
+
test('add to attention set', async () => {
let apiResolve;
const apiPromise = new Promise(r => {
@@ -125,7 +214,7 @@
const showAlertListener = sinon.spy();
const hideAlertListener = sinon.spy();
const updatedListener = sinon.spy();
- element.addEventListener('show-alert', showAlertListener);
+ element._target.addEventListener('show-alert', showAlertListener);
element._target.addEventListener('hide-alert', hideAlertListener);
element._target.addEventListener('attention-set-updated', updatedListener);
@@ -159,7 +248,7 @@
const showAlertListener = sinon.spy();
const hideAlertListener = sinon.spy();
const updatedListener = sinon.spy();
- element.addEventListener('show-alert', showAlertListener);
+ element._target.addEventListener('show-alert', showAlertListener);
element._target.addEventListener('hide-alert', hideAlertListener);
element._target.addEventListener('attention-set-updated', updatedListener);
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-change-actions-js-api.ts b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-change-actions-js-api.ts
index 3a86700..a493a2e 100644
--- a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-change-actions-js-api.ts
+++ b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-change-actions-js-api.ts
@@ -15,50 +15,113 @@
* limitations under the License.
*/
import {
- GrChangeActions,
ActionType,
ActionPriority,
} from '../../../services/services/gr-rest-api/gr-rest-api';
import {JsApiService} from './gr-js-api-types';
import {TargetElement} from '../../plugins/gr-plugin-types';
+import {ActionInfo, RequireProperties} from '../../../types/common';
interface Plugin {
getPluginName(): string;
}
-export class GrChangeActionsInterface {
- private _el?: GrChangeActions;
- // TODO(TS): define correct types when gr-change-actions is converted to ts
+export enum ChangeActions {
+ ABANDON = 'abandon',
+ DELETE = '/',
+ DELETE_EDIT = 'deleteEdit',
+ EDIT = 'edit',
+ FOLLOW_UP = 'followup',
+ IGNORE = 'ignore',
+ MOVE = 'move',
+ PRIVATE = 'private',
+ PRIVATE_DELETE = 'private.delete',
+ PUBLISH_EDIT = 'publishEdit',
+ REBASE = 'rebase',
+ REBASE_EDIT = 'rebaseEdit',
+ READY = 'ready',
+ RESTORE = 'restore',
+ REVERT = 'revert',
+ REVERT_SUBMISSION = 'revert_submission',
+ REVIEWED = 'reviewed',
+ STOP_EDIT = 'stopEdit',
+ SUBMIT = 'submit',
+ UNIGNORE = 'unignore',
+ UNREVIEWED = 'unreviewed',
+ WIP = 'wip',
+}
+export enum RevisionActions {
+ CHERRYPICK = 'cherrypick',
+ REBASE = 'rebase',
+ SUBMIT = 'submit',
+ DOWNLOAD = 'download',
+}
+
+export type PrimaryActionKey = ChangeActions | RevisionActions;
+
+export interface UIActionInfo extends RequireProperties<ActionInfo, 'label'> {
+ __key: string;
+ __url?: string;
+ __primary?: boolean;
+ __type: ActionType;
+ icon?: string;
+}
+
+// This interface is required to avoid circular dependencies between files;
+export interface GrChangeActionsElement extends Element {
RevisionActions?: Record<string, string>;
+ ChangeActions: Record<string, string>;
+ ActionType: Record<string, string>;
+ primaryActionKeys: string[];
+ push(propName: 'primaryActionKeys', value: string): void;
+ hideQuickApproveAction(): void;
+ setActionOverflow(type: ActionType, key: string, overflow: boolean): void;
+ setActionPriority(
+ type: ActionType,
+ key: string,
+ overflow: ActionPriority
+ ): void;
+ setActionHidden(type: ActionType, key: string, hidden: boolean): void;
+ addActionButton(type: ActionType, label: string): string;
+ removeActionButton(key: string): void;
+ setActionButtonProp<T extends keyof UIActionInfo>(
+ key: string,
+ prop: T,
+ value: UIActionInfo[T]
+ ): void;
+ getActionDetails(actionName: string): ActionInfo | undefined;
+}
- ChangeActions?: Record<string, string>;
+export class GrChangeActionsInterface {
+ private _el?: GrChangeActionsElement;
- ActionType?: Record<string, string>;
+ RevisionActions = RevisionActions;
- constructor(public plugin: Plugin, el?: GrChangeActions) {
+ ChangeActions = ChangeActions;
+
+ ActionType = ActionType;
+
+ constructor(public plugin: Plugin, el?: GrChangeActionsElement) {
this.setEl(el);
}
/**
* Set gr-change-actions element to a GrChangeActionsInterface instance.
*/
- private setEl(el?: GrChangeActions) {
+ private setEl(el?: GrChangeActionsElement) {
if (!el) {
console.warn('changeActions() is not ready');
return;
}
this._el = el;
- this.RevisionActions = el.RevisionActions;
- this.ChangeActions = el.ChangeActions;
- this.ActionType = el.ActionType;
}
/**
* Ensure GrChangeActionsInterface instance has access to gr-change-actions
* element and retrieve if the interface was created before element.
*/
- private ensureEl(): GrChangeActions {
+ private ensureEl(): GrChangeActionsElement {
if (!this._el) {
const sharedApiElement = (document.createElement(
'gr-js-api-interface'
@@ -66,13 +129,13 @@
this.setEl(
(sharedApiElement.getElement(
TargetElement.CHANGE_ACTIONS
- ) as unknown) as GrChangeActions
+ ) as unknown) as GrChangeActionsElement
);
}
return this._el!;
}
- addPrimaryActionKey(key: string) {
+ addPrimaryActionKey(key: PrimaryActionKey) {
const el = this.ensureEl();
if (el.primaryActionKeys.includes(key)) {
return;
@@ -130,7 +193,7 @@
this.ensureEl().setActionButtonProp(key, 'title', text);
}
- setEnabled(key: string, enabled: string) {
+ setEnabled(key: string, enabled: boolean) {
this.ensureEl().setActionButtonProp(key, 'enabled', enabled);
}
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-js-api-interface-element.ts b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-js-api-interface-element.ts
index 4fc6f9f..8c26d4a 100644
--- a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-js-api-interface-element.ts
+++ b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-js-api-interface-element.ts
@@ -20,9 +20,13 @@
import {getPluginLoader} from './gr-plugin-loader';
import {patchNumEquals} from '../../../utils/patch-set-util';
import {customElement} from '@polymer/decorators';
-import {ChangeInfo, RevisionInfo} from '../../../types/common';
+import {
+ ChangeInfo,
+ LabelNameToValuesMap,
+ RevisionInfo,
+} from '../../../types/common';
import {GrAnnotationActionsInterface} from './gr-annotation-actions-js-api';
-import {GrAdminApi} from '../../plugins/gr-admin-api/gr-admin-api';
+import {GrAdminApi, MenuLink} from '../../plugins/gr-admin-api/gr-admin-api';
import {
JsApiService,
EventCallback,
@@ -87,7 +91,7 @@
eventCallbacks[eventName].push(callback);
}
- canSubmitChange(change: ChangeInfo, revision: RevisionInfo) {
+ canSubmitChange(change: ChangeInfo, revision?: RevisionInfo | null) {
const submitCallbacks = this._getEventCallbacks(EventType.SUBMIT_CHANGE);
const cancelSubmit = submitCallbacks.some(callback => {
try {
@@ -290,8 +294,8 @@
);
}
- getAdminMenuLinks() {
- const links = [];
+ getAdminMenuLinks(): MenuLink[] {
+ const links: MenuLink[] = [];
for (const cb of this._getEventCallbacks(EventType.ADMIN_MENU_LINKS)) {
const adminApi = (cb as unknown) as GrAdminApi;
links.push(...adminApi.getMenuLinks());
@@ -299,8 +303,8 @@
return links;
}
- getLabelValuesPostRevert(change: ChangeInfo) {
- let labels = {};
+ getLabelValuesPostRevert(change?: ChangeInfo): LabelNameToValuesMap {
+ let labels: LabelNameToValuesMap = {};
for (const cb of this._getEventCallbacks(EventType.POST_REVERT)) {
try {
labels = cb(change);
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-js-api-types.ts b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-js-api-types.ts
index 505e62e..261298b 100644
--- a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-js-api-types.ts
+++ b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-js-api-types.ts
@@ -18,6 +18,7 @@
import {EventType, TargetElement} from '../../plugins/gr-plugin-types';
import {DiffLayer} from '../../../types/types';
import {GrAnnotationActionsInterface} from './gr-annotation-actions-js-api';
+import {MenuLink} from '../../plugins/gr-admin-api/gr-admin-api';
export interface ShowChangeDetail {
change: ChangeInfo;
@@ -50,5 +51,6 @@
getDiffLayers(path: string, changeNum: number): DiffLayer[];
disposeDiffLayers(path: string): void;
getCoverageAnnotationApi(): Promise<GrAnnotationActionsInterface | undefined>;
+ getAdminMenuLinks(): MenuLink[];
// TODO(TS): Add more methods when needed for the TS conversion.
}
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-action-context.ts b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-action-context.ts
index b3f4987..ffdf710 100644
--- a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-action-context.ts
+++ b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-action-context.ts
@@ -15,13 +15,9 @@
* limitations under the License.
*/
-import {
- RevisionInfo,
- ChangeInfo,
- RequestPayload,
- ActionInfo,
-} from '../../../types/common';
+import {RevisionInfo, ChangeInfo, RequestPayload} from '../../../types/common';
import {PluginApi} from '../../plugins/gr-plugin-types';
+import {UIActionInfo} from './gr-change-actions-js-api';
interface GrPopupInterface {
close(): void;
@@ -36,7 +32,7 @@
constructor(
public readonly plugin: PluginApi,
- public readonly action: ActionInfo,
+ public readonly action: UIActionInfo,
public readonly change: ChangeInfo,
public readonly revision: RevisionInfo
) {}
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-public-js-api.ts b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-public-js-api.ts
index 043293f..0625f67 100644
--- a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-public-js-api.ts
+++ b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-public-js-api.ts
@@ -45,7 +45,7 @@
import {RequestPayload} from '../../../types/common';
import {HttpMethod} from '../../../constants/constants';
import {JsApiService} from './gr-js-api-types';
-import {GrChangeActions} from '../../../services/services/gr-rest-api/gr-rest-api';
+import {GrChangeActions} from '../../change/gr-change-actions/gr-change-actions';
/**
* Plugin-provided custom components can affect content in extension
diff --git a/polygerrit-ui/app/elements/shared/gr-label-info/gr-label-info.ts b/polygerrit-ui/app/elements/shared/gr-label-info/gr-label-info.ts
index b6e664e..c9e448e6 100644
--- a/polygerrit-ui/app/elements/shared/gr-label-info/gr-label-info.ts
+++ b/polygerrit-ui/app/elements/shared/gr-label-info/gr-label-info.ts
@@ -33,13 +33,14 @@
ChangeInfo,
AccountInfo,
LabelInfo,
- DetailedLabelInfo,
- QuickLabelInfo,
ApprovalInfo,
AccountId,
+ isQuickLabelInfo,
+ isDetailedLabelInfo,
} from '../../../types/common';
import {RestApiService} from '../../../services/services/gr-rest-api/gr-rest-api';
import {GrButton} from '../gr-button/gr-button';
+import {getVotingRangeOrDefault} from '../../../utils/label-util';
export interface GrLabelInfo {
$: {
@@ -66,11 +67,6 @@
value: string;
}
-// type guard to check if label is QuickLabelInfo
-function isQuickLabelInfo(label: LabelInfo): label is QuickLabelInfo {
- return !(label as DetailedLabelInfo).values;
-}
-
@customElement('gr-label-info')
export class GrLabelInfo extends GestureEventListeners(
LegacyElementMixin(PolymerElement)
@@ -106,8 +102,11 @@
if (!labelInfo || !account) {
return result;
}
- if (isQuickLabelInfo(labelInfo)) {
- if (labelInfo.rejected || labelInfo.approved) {
+ if (!isDetailedLabelInfo(labelInfo)) {
+ if (
+ isQuickLabelInfo(labelInfo) &&
+ (labelInfo.rejected || labelInfo.approved)
+ ) {
const ok = labelInfo.approved || !labelInfo.rejected;
return [
{
@@ -125,23 +124,24 @@
const votes = (labelInfo.all || []).sort(
(a, b) => (a.value || 0) - (b.value || 0)
);
- const values = Object.keys(labelInfo.values || {});
+ const votingRange = getVotingRangeOrDefault(labelInfo);
for (const label of votes) {
- if (label.value && label.value !== labelInfo.default_value) {
+ if (
+ label.value &&
+ (!isQuickLabelInfo(labelInfo) ||
+ label.value !== labelInfo.default_value)
+ ) {
let labelClassName;
let labelValPrefix = '';
if (label.value > 0) {
labelValPrefix = '+';
- if (
- parseInt(`${label.value}`, 10) ===
- parseInt(values[values.length - 1], 10)
- ) {
+ if (label.value === votingRange.max) {
labelClassName = LabelClassName.MAX;
} else {
labelClassName = LabelClassName.POSITIVE;
}
} else if (label.value < 0) {
- if (parseInt(`${label.value}`, 10) === parseInt(values[0], 10)) {
+ if (label.value === votingRange.min) {
labelClassName = LabelClassName.MIN;
} else {
labelClassName = LabelClassName.NEGATIVE;
@@ -228,8 +228,8 @@
_computeValueTooltip(labelInfo: LabelInfo, score: string) {
if (
!labelInfo ||
- isQuickLabelInfo(labelInfo) ||
- !labelInfo.values?.[score]
+ !isDetailedLabelInfo(labelInfo) ||
+ !labelInfo.values[score]
) {
return '';
}
@@ -240,19 +240,25 @@
* This method also listens change.labels.* in
* order to trigger computation when a label is removed from the change.
*/
- _computeShowPlaceholder(labelInfo: LabelInfo) {
+ _computeShowPlaceholder(labelInfo?: LabelInfo) {
+ if (!labelInfo) {
+ return '';
+ }
if (
- labelInfo &&
+ !isDetailedLabelInfo(labelInfo) &&
isQuickLabelInfo(labelInfo) &&
(labelInfo.rejected || labelInfo.approved)
) {
return 'hidden';
}
- // TODO(TS): might replace with hasOwnProperty instead
- if (labelInfo && (labelInfo as DetailedLabelInfo).all) {
- for (const label of (labelInfo as DetailedLabelInfo).all || []) {
- if (label.value && label.value !== labelInfo.default_value) {
+ if (isDetailedLabelInfo(labelInfo) && labelInfo.all) {
+ for (const label of labelInfo.all) {
+ if (
+ label.value &&
+ (!isQuickLabelInfo(labelInfo) ||
+ label.value !== labelInfo.default_value)
+ ) {
return 'hidden';
}
}
diff --git a/polygerrit-ui/app/elements/shared/gr-label-info/gr-label-info_test.js b/polygerrit-ui/app/elements/shared/gr-label-info/gr-label-info_test.js
index be7878b..3a2cc39 100644
--- a/polygerrit-ui/app/elements/shared/gr-label-info/gr-label-info_test.js
+++ b/polygerrit-ui/app/elements/shared/gr-label-info/gr-label-info_test.js
@@ -204,25 +204,32 @@
});
test('placeholder', () => {
+ const values = {
+ '0': 'No score',
+ '+1': 'good',
+ '+2': 'excellent',
+ '-1': 'bad',
+ '-2': 'terrible',
+ };
element.labelInfo = {};
assert.isFalse(isHidden(element.shadowRoot
.querySelector('.placeholder')));
- element.labelInfo = {all: []};
+ element.labelInfo = {all: [], values};
assert.isFalse(isHidden(element.shadowRoot
.querySelector('.placeholder')));
- element.labelInfo = {all: [{value: 1}]};
+ element.labelInfo = {all: [{value: 1}], values};
assert.isTrue(isHidden(element.shadowRoot
.querySelector('.placeholder')));
element.labelInfo = {rejected: []};
assert.isTrue(isHidden(element.shadowRoot
.querySelector('.placeholder')));
- element.labelInfo = {values: [], rejected: [], all: [{value: 1}]};
+ element.labelInfo = {values: [], rejected: [], all: [{value: 1}, values]};
assert.isTrue(isHidden(element.shadowRoot
.querySelector('.placeholder')));
element.labelInfo = {approved: []};
assert.isTrue(isHidden(element.shadowRoot
.querySelector('.placeholder')));
- element.labelInfo = {values: [], approved: [], all: [{value: 1}]};
+ element.labelInfo = {values: [], approved: [], all: [{value: 1}, values]};
assert.isTrue(isHidden(element.shadowRoot
.querySelector('.placeholder')));
});
diff --git a/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-api-interface.ts b/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-api-interface.ts
index 5b9f360..15cdac4 100644
--- a/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-api-interface.ts
+++ b/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-api-interface.ts
@@ -134,6 +134,11 @@
FilePathToDiffInfoMap,
ChangeViewChangeInfo,
BlameInfo,
+ ActionNameToActionInfoMap,
+ RevisionId,
+ GroupName,
+ Hashtag,
+ TopMenuEntryInfo,
} from '../../../types/common';
import {
CancelConditionCallback,
@@ -194,7 +199,7 @@
endpoint: string;
anonymizedEndpoint?: string;
changeNum: NumericChangeId;
- method: HttpMethod;
+ method: HttpMethod | undefined;
errFn?: ErrorCallback;
headers?: Record<string, string>;
contentType?: string;
@@ -515,7 +520,7 @@
}
getGroupConfig(
- group: GroupId,
+ group: GroupId | GroupName,
errFn?: ErrorCallback
): Promise<GroupInfo | undefined> {
return this._restApiHelper.fetchJSON({
@@ -649,7 +654,7 @@
});
}
- getIsGroupOwner(groupName: GroupId): Promise<boolean> {
+ getIsGroupOwner(groupName: GroupName): Promise<boolean> {
const encodeName = encodeURIComponent(groupName);
const req = {
url: `/groups/?owned&g=${encodeName}`,
@@ -661,7 +666,7 @@
}
getGroupMembers(
- groupName: GroupId,
+ groupName: GroupId | GroupName,
errFn?: ErrorCallback
): Promise<AccountInfo[] | undefined> {
const encodeName = encodeURIComponent(groupName);
@@ -672,14 +677,16 @@
}) as Promise<AccountInfo[] | undefined>;
}
- getIncludedGroup(groupName: GroupId): Promise<GroupInfo[] | undefined> {
+ getIncludedGroup(
+ groupName: GroupId | GroupName
+ ): Promise<GroupInfo[] | undefined> {
return this._restApiHelper.fetchJSON({
url: `/groups/${encodeURIComponent(groupName)}/groups/`,
anonymizedUrl: '/groups/*/groups',
}) as Promise<GroupInfo[] | undefined>;
}
- saveGroupName(groupId: GroupId, name: string): Promise<Response> {
+ saveGroupName(groupId: GroupId | GroupName, name: string): Promise<Response> {
const encodeId = encodeURIComponent(groupId);
return this._restApiHelper.send({
method: HttpMethod.PUT,
@@ -689,7 +696,10 @@
});
}
- saveGroupOwner(groupId: GroupId, ownerId: string): Promise<Response> {
+ saveGroupOwner(
+ groupId: GroupId | GroupName,
+ ownerId: string
+ ): Promise<Response> {
const encodeId = encodeURIComponent(groupId);
return this._restApiHelper.send({
method: HttpMethod.PUT,
@@ -700,7 +710,7 @@
}
saveGroupDescription(
- groupId: GroupId,
+ groupId: GroupId | GroupName,
description: string
): Promise<Response> {
const encodeId = encodeURIComponent(groupId);
@@ -713,7 +723,7 @@
}
saveGroupOptions(
- groupId: GroupId,
+ groupId: GroupId | GroupName,
options: GroupOptionsInput
): Promise<Response> {
const encodeId = encodeURIComponent(groupId);
@@ -737,7 +747,7 @@
}
saveGroupMember(
- groupName: GroupId,
+ groupName: GroupId | GroupName,
groupMember: AccountId
): Promise<AccountInfo> {
const encodeName = encodeURIComponent(groupName);
@@ -751,7 +761,7 @@
}
saveIncludedGroup(
- groupName: GroupId,
+ groupName: GroupId | GroupName,
includedGroup: GroupId,
errFn?: ErrorCallback
): Promise<GroupInfo | undefined> {
@@ -774,7 +784,7 @@
}
deleteGroupMember(
- groupName: GroupId,
+ groupName: GroupId | GroupName,
groupMember: AccountId
): Promise<Response> {
const encodeName = encodeURIComponent(groupName);
@@ -788,7 +798,7 @@
deleteIncludedGroup(
groupName: GroupId,
- includedGroup: GroupId
+ includedGroup: GroupId | GroupName
): Promise<Response> {
const encodeName = encodeURIComponent(groupName);
const encodeIncludedGroup = encodeURIComponent(includedGroup);
@@ -1192,11 +1202,11 @@
);
}
- getDefaultPreferences() {
+ getDefaultPreferences(): Promise<PreferencesInfo | undefined> {
return this._fetchSharedCacheURL({
url: '/config/server/preferences',
reportUrlAsIs: true,
- });
+ }) as Promise<PreferencesInfo | undefined>;
}
getPreferences(): Promise<PreferencesInfo | undefined> {
@@ -1374,10 +1384,12 @@
getChangeActionURL(
changeNum: NumericChangeId,
- patchNum: PatchSetNum | undefined,
+ revisionId: RevisionId | undefined,
endpoint: string
): Promise<string> {
- return this._changeBaseURL(changeNum, patchNum).then(url => url + endpoint);
+ return this._changeBaseURL(changeNum, revisionId).then(
+ url => url + endpoint
+ );
}
getChangeDetail(
@@ -1592,7 +1604,10 @@
}) as Promise<string[] | undefined>;
}
- getChangeOrEditFiles(changeNum: NumericChangeId, patchRange: PatchRange) {
+ getChangeOrEditFiles(
+ changeNum: NumericChangeId,
+ patchRange: PatchRange
+ ): Promise<FileNameToFileInfoMap | undefined> {
if (patchNumEquals(patchRange.patchNum, EditPatchSetNum)) {
return this.getChangeEditFiles(changeNum, patchRange).then(
res => res && res.files
@@ -1601,14 +1616,19 @@
return this.getChangeFiles(changeNum, patchRange);
}
- getChangeRevisionActions(changeNum: NumericChangeId, patchNum: PatchSetNum) {
+ getChangeRevisionActions(
+ changeNum: NumericChangeId,
+ patchNum: PatchSetNum
+ ): Promise<ActionNameToActionInfoMap | undefined> {
const req: FetchChangeJSON = {
changeNum,
endpoint: '/actions',
patchNum,
reportEndpointAsIs: true,
};
- return this._getChangeURLAndFetch(req);
+ return this._getChangeURLAndFetch(req) as Promise<
+ ActionNameToActionInfoMap | undefined
+ >;
}
getChangeSuggestedReviewers(
@@ -2070,13 +2090,16 @@
}) as Promise<ChangeInfo[] | undefined>;
}
- getReviewedFiles(changeNum: NumericChangeId, patchNum: PatchSetNum) {
+ getReviewedFiles(
+ changeNum: NumericChangeId,
+ patchNum: PatchSetNum
+ ): Promise<string[] | undefined> {
return this._getChangeURLAndFetch({
changeNum,
endpoint: '/files?reviewed',
patchNum,
reportEndpointAsIs: true,
- });
+ }) as Promise<string[] | undefined>;
}
saveFileReviewed(
@@ -2409,7 +2432,10 @@
});
}
- saveChangeStarred(changeNum: NumericChangeId, starred: boolean) {
+ saveChangeStarred(
+ changeNum: NumericChangeId,
+ starred: boolean
+ ): Promise<Response> {
// Some servers may require the project name to be provided
// alongside the change number, so resolve the project name
// first.
@@ -2765,7 +2791,7 @@
_getDiffCommentsFetchURL(
changeNum: NumericChangeId,
endpoint: string,
- patchNum?: PatchSetNum
+ patchNum?: RevisionId
) {
return this._changeBaseURL(changeNum, patchNum).then(url => url + endpoint);
}
@@ -2901,7 +2927,7 @@
getB64FileContents(
changeId: NumericChangeId,
- patchNum: PatchSetNum,
+ patchNum: RevisionId,
path: string,
parentIndex?: number
) {
@@ -2974,7 +3000,7 @@
_changeBaseURL(
changeNum: NumericChangeId,
- patchNum?: PatchSetNum,
+ revisionId?: RevisionId,
project?: RepoName
): Promise<string> {
// TODO(kaspern): For full slicer migration, app should warn with a call
@@ -2987,8 +3013,8 @@
let url = `/changes/${encodeURIComponent(
project as RepoName
)}~${changeNum}`;
- if (patchNum) {
- url += `/revisions/${patchNum}`;
+ if (revisionId) {
+ url += `/revisions/${revisionId}`;
}
return url;
});
@@ -3022,26 +3048,32 @@
});
}
- setChangeTopic(changeNum: NumericChangeId, topic: string | null) {
- return this._getChangeURLAndSend({
+ setChangeTopic(
+ changeNum: NumericChangeId,
+ topic: string | null
+ ): Promise<string> {
+ return (this._getChangeURLAndSend({
changeNum,
method: HttpMethod.PUT,
endpoint: '/topic',
body: {topic},
parseResponse: true,
reportUrlAsIs: true,
- });
+ }) as unknown) as Promise<string>;
}
- setChangeHashtag(changeNum: NumericChangeId, hashtag: HashtagsInput) {
- return this._getChangeURLAndSend({
+ setChangeHashtag(
+ changeNum: NumericChangeId,
+ hashtag: HashtagsInput
+ ): Promise<Hashtag[]> {
+ return (this._getChangeURLAndSend({
changeNum,
method: HttpMethod.POST,
endpoint: '/hashtags',
body: hashtag,
parseResponse: true,
reportUrlAsIs: true,
- });
+ }) as unknown) as Promise<Hashtag[]>;
}
deleteAccountHttpPassword() {
@@ -3165,7 +3197,7 @@
});
}
- confirmEmail(token: string) {
+ confirmEmail(token: string): Promise<string | null> {
const req = {
method: HttpMethod.PUT,
url: '/config/server/email.confirm',
@@ -3190,25 +3222,29 @@
}) as Promise<CapabilityInfoMap | undefined>;
}
- getTopMenus(errFn?: ErrorCallback) {
+ getTopMenus(errFn?: ErrorCallback): Promise<TopMenuEntryInfo[] | undefined> {
return this._fetchSharedCacheURL({
url: '/config/server/top-menus',
errFn,
reportUrlAsIs: true,
- });
+ }) as Promise<TopMenuEntryInfo[] | undefined>;
}
- setAssignee(changeNum: NumericChangeId, assignee: AssigneeInput) {
+ setAssignee(
+ changeNum: NumericChangeId,
+ assignee: AccountId
+ ): Promise<Response> {
+ const body: AssigneeInput = {assignee};
return this._getChangeURLAndSend({
changeNum,
method: HttpMethod.PUT,
endpoint: '/assignee',
- body: {assignee},
+ body,
reportUrlAsIs: true,
});
}
- deleteAssignee(changeNum: NumericChangeId) {
+ deleteAssignee(changeNum: NumericChangeId): Promise<Response> {
return this._getChangeURLAndSend({
changeNum,
method: HttpMethod.DELETE,
@@ -3279,7 +3315,7 @@
* Given a changeNum, gets the change.
*/
getChange(
- changeNum: NumericChangeId,
+ changeNum: ChangeId | NumericChangeId,
errFn: ErrorCallback
): Promise<ChangeInfo | null> {
// Cannot use _changeBaseURL, as this function is used by _projectLookup.
@@ -3416,7 +3452,7 @@
executeChangeAction(
changeNum: NumericChangeId,
- method: HttpMethod,
+ method: HttpMethod | undefined,
endpoint: string,
patchNum?: PatchSetNum,
payload?: RequestPayload
@@ -3424,7 +3460,7 @@
executeChangeAction(
changeNum: NumericChangeId,
- method: HttpMethod,
+ method: HttpMethod | undefined,
endpoint: string,
patchNum: PatchSetNum | undefined,
payload: RequestPayload | undefined,
@@ -3436,7 +3472,7 @@
*/
executeChangeAction(
changeNum: NumericChangeId,
- method: HttpMethod,
+ method: HttpMethod | undefined,
endpoint: string,
patchNum?: PatchSetNum,
payload?: RequestPayload,
@@ -3549,11 +3585,12 @@
});
}
- deleteDraftComments(query: DeleteDraftCommentsInput) {
+ deleteDraftComments(query: string): Promise<Response> {
+ const body: DeleteDraftCommentsInput = {query};
return this._restApiHelper.send({
method: HttpMethod.POST,
url: '/accounts/self/drafts:delete',
- body: {query},
+ body,
});
}
}
diff --git a/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-apis/gr-rest-api-helper.ts b/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-apis/gr-rest-api-helper.ts
index 6354aab..eee48d2 100644
--- a/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-apis/gr-rest-api-helper.ts
+++ b/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-apis/gr-rest-api-helper.ts
@@ -168,7 +168,7 @@
};
interface SendRequestBase {
- method: HttpMethod;
+ method: HttpMethod | undefined;
body?: RequestPayload;
contentType?: string;
headers?: Record<string, string>;
diff --git a/polygerrit-ui/app/mixins/keyboard-shortcut-mixin/keyboard-shortcut-mixin.ts b/polygerrit-ui/app/mixins/keyboard-shortcut-mixin/keyboard-shortcut-mixin.ts
index 80df630..b255ea5 100644
--- a/polygerrit-ui/app/mixins/keyboard-shortcut-mixin/keyboard-shortcut-mixin.ts
+++ b/polygerrit-ui/app/mixins/keyboard-shortcut-mixin/keyboard-shortcut-mixin.ts
@@ -1082,7 +1082,7 @@
_shortcut_v_key_last_pressed: number | null;
_shortcut_go_table: Map<string, string>;
_shortcut_v_table: Map<string, string>;
- keyboardShortcuts(): {[key: string]: string};
+ keyboardShortcuts(): {[key: string]: string | null};
createTitle(name: Shortcut, section: ShortcutSection): string;
bindShortcut(shortcut: Shortcut, ...bindings: string[]): void;
shouldSuppressKeyboardShortcut(event: CustomKeyboardEvent): boolean;
diff --git a/polygerrit-ui/app/scripts/util.ts b/polygerrit-ui/app/scripts/util.ts
index 59ade33..bf7120f 100644
--- a/polygerrit-ui/app/scripts/util.ts
+++ b/polygerrit-ui/app/scripts/util.ts
@@ -49,8 +49,7 @@
// True if the promise is either resolved or reject (possibly cancelled)
let isDone = false;
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
- let rejectPromise: (reason?: any) => void;
+ let rejectPromise: (reason?: unknown) => void;
const wrappedPromise: CancelablePromise<T> = new Promise(
(resolve, reject) => {
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 10715cf..6b93082 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
@@ -22,7 +22,6 @@
NumericChangeId,
ServerInfo,
ProjectInfo,
- ActionInfo,
AccountCapabilityInfo,
SuggestedReviewerInfo,
GroupNameToGroupInfoMap,
@@ -91,6 +90,14 @@
BlameInfo,
PatchRange,
ImagesForDiff,
+ ActionNameToActionInfoMap,
+ RevisionId,
+ GroupName,
+ DashboardId,
+ HashtagsInput,
+ Hashtag,
+ FileNameToFileInfoMap,
+ TopMenuEntryInfo,
} from '../../../types/common';
import {ParsedChangeInfo} from '../../../elements/shared/gr-rest-api-interface/gr-reviewer-updates-parser';
import {HttpMethod, IgnoreWhitespaceType} from '../../../constants/constants';
@@ -121,27 +128,6 @@
REVISION = 1,
}
-// TODO(TS) remove interface when GrChangeActions is converted to typescript
-export interface GrChangeActions extends Element {
- RevisionActions?: Record<string, string>;
- ChangeActions: Record<string, string>;
- ActionType: Record<string, string>;
- primaryActionKeys: string[];
- push(propName: 'primaryActionKeys', value: string): void;
- hideQuickApproveAction(): void;
- setActionOverflow(type: ActionType, key: string, overflow: boolean): void;
- setActionPriority(
- type: ActionType,
- key: string,
- overflow: ActionPriority
- ): void;
- setActionHidden(type: ActionType, key: string, hidden: boolean): void;
- addActionButton(type: ActionType, label: string): string;
- removeActionButton(key: string): void;
- setActionButtonProp(key: string, prop: string, value: string): void;
- getActionDetails(actionName: string): ActionInfo;
-}
-
export interface GetDiffCommentsOutput {
baseComments: CommentInfo[];
comments: CommentInfo[];
@@ -214,7 +200,7 @@
): Promise<GroupNameToGroupInfoMap | undefined>;
executeChangeAction(
changeNum: NumericChangeId,
- method: HttpMethod,
+ method: HttpMethod | undefined,
endpoint: string,
patchNum?: PatchSetNum,
payload?: RequestPayload,
@@ -234,6 +220,11 @@
opt_cancelCondition?: Function
): Promise<ParsedChangeInfo | null | undefined>;
+ getChange(
+ changeNum: ChangeId | NumericChangeId,
+ errFn: ErrorCallback
+ ): Promise<ChangeInfo | null>;
+
savePreferences(prefs: PreferencesInput): Promise<Response>;
getDiffPreferences(): Promise<DiffPreferencesInfo | undefined>;
@@ -376,17 +367,23 @@
): Promise<GroupNameToGroupInfoMap | undefined>;
getGroupConfig(
- group: GroupId,
+ group: GroupId | GroupName,
errFn?: ErrorCallback
): Promise<GroupInfo | undefined>;
getIsAdmin(): Promise<boolean | undefined>;
- getIsGroupOwner(groupName: GroupId): Promise<boolean>;
+ getIsGroupOwner(groupName: GroupName): Promise<boolean>;
- saveGroupName(groupId: GroupId, name: string): Promise<Response>;
+ saveGroupName(
+ groupId: GroupId | GroupName,
+ name: GroupName
+ ): Promise<Response>;
- saveGroupOwner(groupId: GroupId, ownerId: string): Promise<Response>;
+ saveGroupOwner(
+ groupId: GroupId | GroupName,
+ ownerId: string
+ ): Promise<Response>;
saveGroupDescription(
groupId: GroupId,
@@ -399,19 +396,19 @@
): Promise<Response>;
saveChangeReview(
- changeNum: NumericChangeId,
- patchNum: PatchSetNum,
+ changeNum: ChangeId | NumericChangeId,
+ patchNum: RevisionId,
review: ReviewInput
): Promise<Response>;
saveChangeReview(
- changeNum: NumericChangeId,
- patchNum: PatchSetNum,
+ changeNum: ChangeId | NumericChangeId,
+ patchNum: RevisionId,
review: ReviewInput,
errFn: ErrorCallback
): Promise<Response | undefined>;
saveChangeReview(
- changeNum: NumericChangeId,
- patchNum: PatchSetNum,
+ changeNum: ChangeId | NumericChangeId,
+ patchNum: RevisionId,
review: ReviewInput,
errFn?: ErrorCallback
): Promise<Response>;
@@ -421,6 +418,12 @@
downloadCommands?: boolean
): Promise<false | EditInfo | undefined>;
+ getChangeActionURL(
+ changeNum: NumericChangeId,
+ patchNum: PatchSetNum | undefined,
+ endpoint: string
+ ): Promise<string>;
+
createChange(
project: RepoName,
branch: BranchName,
@@ -646,30 +649,32 @@
): Promise<GroupAuditEventInfo[] | undefined>;
getGroupMembers(
- groupName: GroupId,
+ groupName: GroupId | GroupName,
errFn?: ErrorCallback
): Promise<AccountInfo[] | undefined>;
- getIncludedGroup(groupName: GroupId): Promise<GroupInfo[] | undefined>;
+ getIncludedGroup(
+ groupName: GroupId | GroupName
+ ): Promise<GroupInfo[] | undefined>;
saveGroupMember(
- groupName: GroupId,
+ groupName: GroupId | GroupName,
groupMember: AccountId
): Promise<AccountInfo>;
saveIncludedGroup(
- groupName: GroupId,
+ groupName: GroupId | GroupName,
includedGroup: GroupId,
errFn?: ErrorCallback
): Promise<GroupInfo | undefined>;
deleteGroupMember(
- groupName: GroupId,
+ groupName: GroupId | GroupName,
groupMember: AccountId
): Promise<Response>;
deleteIncludedGroup(
- groupName: GroupId,
+ groupName: GroupId | GroupName,
includedGroup: GroupId
): Promise<Response>;
@@ -761,4 +766,82 @@
diff: DiffInfo,
patchRange: PatchRange
): Promise<ImagesForDiff>;
+
+ getChangeRevisionActions(
+ changeNum: NumericChangeId,
+ patchNum: PatchSetNum
+ ): Promise<ActionNameToActionInfoMap | undefined>;
+
+ confirmEmail(token: string): Promise<string | null>;
+
+ getDefaultPreferences(): Promise<PreferencesInfo | undefined>;
+
+ addAccountEmail(email: string): Promise<Response>;
+
+ addAccountEmail(
+ email: string,
+ errFn?: ErrorCallback
+ ): Promise<Response | undefined>;
+
+ saveChangeReviewed(
+ changeNum: NumericChangeId,
+ reviewed: boolean
+ ): Promise<Response | undefined>;
+
+ saveChangeStarred(
+ changeNum: NumericChangeId,
+ starred: boolean
+ ): Promise<Response>;
+
+ getDashboard(
+ project: RepoName,
+ dashboard: DashboardId,
+ errFn?: ErrorCallback
+ ): Promise<DashboardInfo | undefined>;
+
+ deleteDraftComments(query: string): Promise<Response>;
+
+ setAssignee(
+ changeNum: NumericChangeId,
+ assignee: AccountId
+ ): Promise<Response>;
+
+ deleteAssignee(changeNum: NumericChangeId): Promise<Response>;
+
+ setChangeHashtag(
+ changeNum: NumericChangeId,
+ hashtag: HashtagsInput
+ ): Promise<Hashtag[]>;
+
+ setChangeTopic(
+ changeNum: NumericChangeId,
+ topic: string | null
+ ): Promise<string>;
+
+ getChangeOrEditFiles(
+ changeNum: NumericChangeId,
+ patchRange: PatchRange
+ ): Promise<FileNameToFileInfoMap | undefined>;
+
+ getReviewedFiles(
+ changeNum: NumericChangeId,
+ patchNum: PatchSetNum
+ ): Promise<string[] | undefined>;
+
+ saveFileReviewed(
+ changeNum: NumericChangeId,
+ patchNum: PatchSetNum,
+ path: string,
+ reviewed: boolean
+ ): Promise<Response>;
+
+ saveFileReviewed(
+ changeNum: NumericChangeId,
+ patchNum: PatchSetNum,
+ path: string,
+ reviewed: boolean,
+ errFn: ErrorCallback
+ ): Promise<Response | undefined>;
+
+ getTopMenus(errFn?: ErrorCallback): Promise<TopMenuEntryInfo[] | undefined>;
}
diff --git a/polygerrit-ui/app/styles/gr-change-list-styles.ts b/polygerrit-ui/app/styles/gr-change-list-styles.ts
index 1dbc917..da62936 100644
--- a/polygerrit-ui/app/styles/gr-change-list-styles.ts
+++ b/polygerrit-ui/app/styles/gr-change-list-styles.ts
@@ -32,6 +32,13 @@
gr-change-list-item:focus {
background-color: var(--selection-background-color);
}
+ gr-change-list-item[highlight] {
+ background-color: var(--assignee-highlight-color);
+ }
+ gr-change-list-item[highlight][selected],
+ gr-change-list-item[highlight]:focus {
+ background-color: var(--assignee-highlight-selection-color);
+ }
.groupTitle td,
.cell {
vertical-align: middle;
@@ -84,6 +91,8 @@
.owner,
.assignee,
.updated,
+ .submitted,
+ .since,
.size,
.status,
.repo {
@@ -163,6 +172,8 @@
.repo,
.branch,
.updated,
+ .submitted,
+ .since,
.label,
.assignee,
.groupHeader .star,
diff --git a/polygerrit-ui/app/styles/themes/app-theme.ts b/polygerrit-ui/app/styles/themes/app-theme.ts
index 835ef0a..2a3e91b 100644
--- a/polygerrit-ui/app/styles/themes/app-theme.ts
+++ b/polygerrit-ui/app/styles/themes/app-theme.ts
@@ -71,6 +71,9 @@
--view-background-color: var(--background-color-primary);
/* unique background colors */
--assignee-highlight-color: #fcfad6;
+ /* TODO: Find a nicer way to combine the --assignee-highlight-color and the
+ --selection-background-color than to just invent another unique color. */
+ --assignee-highlight-selection-color: #f6f4d0;
--chip-selected-background-color: #e8f0fe;
--edit-mode-background-color: #ebf5fb;
--emphasis-color: #fff9c4;
diff --git a/polygerrit-ui/app/styles/themes/dark-theme.ts b/polygerrit-ui/app/styles/themes/dark-theme.ts
index 3032984..03a3ab5 100644
--- a/polygerrit-ui/app/styles/themes/dark-theme.ts
+++ b/polygerrit-ui/app/styles/themes/dark-theme.ts
@@ -55,6 +55,7 @@
/* empty, because inheriting from app-theme is just fine
/* unique background colors */
--assignee-highlight-color: #3a361c;
+ --assignee-highlight-selection-color: #423e24;
--chip-selected-background-color: #3c4455;
--edit-mode-background-color: #5c0a36;
--emphasis-color: #383f4a;
diff --git a/polygerrit-ui/app/types/common.ts b/polygerrit-ui/app/types/common.ts
index caa5f7f..5bcf0b8 100644
--- a/polygerrit-ui/app/types/common.ts
+++ b/polygerrit-ui/app/types/common.ts
@@ -42,7 +42,10 @@
DiffViewMode,
DraftsAction,
NotifyType,
+ EmailFormat,
+ AuthType,
} from '../constants/constants';
+import {PolymerDeepPropertyChange} from '@polymer/polymer/interfaces';
export type BrandType<T, BrandName extends string> = T &
{[__brand in BrandName]: never};
@@ -53,6 +56,13 @@
export type RequireProperties<T, K extends keyof T> = Omit<T, K> &
Required<Pick<T, K>>;
+export type PropertyType<T, K extends keyof T> = ReturnType<() => T[K]>;
+
+export type ElementPropertyDeepChange<
+ T,
+ K extends keyof T
+> = PolymerDeepPropertyChange<PropertyType<T, K>, PropertyType<T, K>>;
+
/**
* Type alias for parsed json object to make code cleaner
*/
@@ -80,6 +90,10 @@
export type RobotId = BrandType<string, '_robotId'>;
export type RobotRunId = BrandType<string, '_robotRunId'>;
+// RevisionId '0' is the same as 'current'. However, we want to avoid '0'
+// in our code, so it is not added here as a possible value.
+export type RevisionId = 'current' | CommitId | PatchSetNum;
+
// The UUID of the suggested fix.
export type FixId = BrandType<string, '_fixId'>;
export type EmailAddress = BrandType<string, '_emailAddress'>;
@@ -120,6 +134,7 @@
export type StarLabel = BrandType<string, '_startLabel'>;
export type CommitId = BrandType<string, '_commitId'>;
export type LabelName = BrandType<string, '_labelName'>;
+export type GroupName = BrandType<string, '_groupName'>;
// The UUID of the group
export type GroupId = BrandType<string, '_groupId'>;
@@ -145,10 +160,14 @@
export type LabelValueToDescriptionMap = {[labelValue: string]: string};
/**
- * The LabelInfo entity contains information about a label on a change, always corresponding to the current patch set.
+ * The LabelInfo entity contains information about a label on a change, always
+ * corresponding to the current patch set.
* https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#label-info
*/
-export type LabelInfo = QuickLabelInfo | DetailedLabelInfo;
+export type LabelInfo =
+ | QuickLabelInfo
+ | DetailedLabelInfo
+ | (QuickLabelInfo & DetailedLabelInfo);
interface LabelCommonInfo {
optional?: boolean; // not set if false
@@ -164,12 +183,39 @@
default_value?: number;
}
+/**
+ * LabelInfo when DETAILED_LABELS are requested.
+ * https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#_fields_set_by_code_detailed_labels_code
+ */
export interface DetailedLabelInfo extends LabelCommonInfo {
+ // This is not set when the change has no reviewers.
all?: ApprovalInfo[];
- values?: LabelValueToDescriptionMap; // A map of all values that are allowed for this label
+ // Docs claim that 'values' is optional, but it is actually always set.
+ values: LabelValueToDescriptionMap; // A map of all values that are allowed for this label
default_value?: number;
}
+export function isQuickLabelInfo(
+ l: LabelInfo
+): l is QuickLabelInfo | (QuickLabelInfo & DetailedLabelInfo) {
+ const quickLabelInfo = l as QuickLabelInfo;
+ return (
+ quickLabelInfo.approved !== undefined ||
+ quickLabelInfo.rejected !== undefined ||
+ quickLabelInfo.recommended !== undefined ||
+ quickLabelInfo.disliked !== undefined ||
+ quickLabelInfo.blocking !== undefined ||
+ quickLabelInfo.blocking !== undefined ||
+ quickLabelInfo.value !== undefined
+ );
+}
+
+export function isDetailedLabelInfo(
+ label: LabelInfo
+): label is DetailedLabelInfo | (QuickLabelInfo & DetailedLabelInfo) {
+ return !!(label as DetailedLabelInfo).values;
+}
+
// https://gerrit-review.googlesource.com/Documentation/rest-api-accounts.html#contributor-agreement-input
export interface ContributorAgreementInput {
name?: string;
@@ -212,9 +258,10 @@
deletions: number; // Number of deleted lines
total_comment_count?: number;
unresolved_comment_count?: number;
+ // TODO(TS): Use changed_id everywhere in code instead of (legacy) _number
_number: NumericChangeId;
owner: AccountInfo;
- actions?: ActionInfo[];
+ actions?: ActionNameToActionInfoMap;
requirements?: Requirement[];
labels?: LabelNameToInfoMap;
permitted_labels?: LabelNameToValueMap;
@@ -325,17 +372,17 @@
*/
export interface GroupBaseInfo {
id: GroupId;
- name: string;
+ name: GroupName;
}
/**
* The GroupInfo entity contains information about a group. This can be a
* Gerrit internal group, or an external group that is known to Gerrit.
- * https://gerrit-review.googlesource.com/Documentation/rest-api-groups.html
+ * https://gerrit-review.googlesource.com/Documentation/rest-api-groups.html#group-info
*/
export interface GroupInfo {
id: GroupId;
- name?: string;
+ name?: GroupName;
url?: string;
options?: GroupOptionsInfo;
description?: string;
@@ -353,10 +400,10 @@
/**
* The 'GroupInput' entity contains information for the creation of a new
* internal group.
- * https://gerrit-review.googlesource.com/Documentation/rest-api-groups.html
+ * https://gerrit-review.googlesource.com/Documentation/rest-api-groups.html#group-input
*/
export interface GroupInput {
- name?: string;
+ name?: GroupName;
uuid?: string;
description?: string;
visible_to_all?: string;
@@ -407,14 +454,34 @@
* https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#action-info
*/
export interface ActionInfo {
- __key?: string;
- __url?: string;
method?: HttpMethod; // Most actions use POST, PUT or DELETE to cause state changes.
label?: string; // Short title to display to a user describing the action
title?: string; // Longer text to display describing the action
enabled?: boolean; // not set if false
}
+export interface ActionNameToActionInfoMap {
+ [actionType: string]: ActionInfo | undefined;
+ // List of actions explicitly used in code:
+ wip?: ActionInfo;
+ publishEdit?: ActionInfo;
+ rebaseEdit?: ActionInfo;
+ deleteEdit?: ActionInfo;
+ edit?: ActionInfo;
+ stopEdit?: ActionInfo;
+ download?: ActionInfo;
+ rebase?: ActionInfo;
+ cherrypick?: ActionInfo;
+ move?: ActionInfo;
+ revert?: ActionInfo;
+ revert_submission?: ActionInfo;
+ abandon?: ActionInfo;
+ submit?: ActionInfo;
+ topic?: ActionInfo;
+ hashtags?: ActionInfo;
+ assignee?: ActionInfo;
+}
+
/**
* The Requirement entity contains information about a requirement relative to
* a change.
@@ -677,10 +744,10 @@
/**
* The AuthInfo entity contains information about the authentication
* configuration of the Gerrit server.
- * https://gerrit-review.googlesource.com/Documentation/rest-api-config.html
+ * https://gerrit-review.googlesource.com/Documentation/rest-api-config.html#auth-info
*/
export interface AuthInfo {
- type: string;
+ auth_type: AuthType; // docs incorrectly names it 'type'
use_contributor_agreements: boolean;
contributor_agreements?: ContributorAgreementInfo;
editable_account_fields: string;
@@ -982,15 +1049,24 @@
download: DownloadInfo;
gerrit: GerritInfo;
index: IndexConfigInfo;
- note_db_enabled: boolean;
+ note_db_enabled?: boolean;
plugin: PluginConfigInfo;
receive?: ReceiveInfo;
+ sshd?: SshdInfo;
suggest: SuggestInfo;
user: UserConfigInfo;
default_theme?: string;
}
/**
+ * The SshdInfo entity contains information about Gerrit configuration from the sshd section.
+ * https://gerrit-review.googlesource.com/Documentation/rest-api-config.html#sshd-info
+ * This entity doesn’t contain any data, but the presence of this (empty) entity
+ * in the ServerInfo entity means that SSHD is enabled on the server.
+ */
+export type SshdInfo = {};
+
+/**
* The SuggestInfo entity contains information about Gerritconfiguration from
* the suggest section.
* https://gerrit-review.googlesource.com/Documentation/rest-api-config.html#suggest-info
@@ -1049,17 +1125,17 @@
/**
* The TopMenuEntryInfo entity contains information about a top menu entry.
- * https://gerrit-review.googlesource.com/Documentation/rest-api-config.html
+ * https://gerrit-review.googlesource.com/Documentation/rest-api-config.html#top-menu-entry-info
*/
export interface TopMenuEntryInfo {
name: string;
- items: string;
+ items: TopMenuItemInfo[];
}
/**
* The TopMenuItemInfo entity contains information about a menu item ina top
* menu entry.
- * https://gerrit-review.googlesource.com/Documentation/rest-api-config.html
+ * https://gerrit-review.googlesource.com/Documentation/rest-api-config.html#top-menu-item-info
*/
export interface TopMenuItemInfo {
url: string;
@@ -1081,7 +1157,7 @@
* The CommentInfo entity contains information about an inline comment.
* https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#comment-info
*/
-export interface CommentInfo extends CommentInput {
+export interface CommentInfo {
patch_set?: PatchSetNum;
id: UrlEncodedCommentId;
path?: string;
@@ -1455,11 +1531,12 @@
project: RepoName;
defining_project: RepoName;
ref: string; // The name of the ref in which the dashboard is defined, without the refs/meta/dashboards/ prefix
+ path: string;
description?: string;
foreach?: string;
url: string;
is_default?: boolean;
- title?: boolean;
+ title?: string;
sections: DashboardSectionInfo[];
}
@@ -1683,25 +1760,11 @@
/**
* The PreferencesInput entity contains information for setting the user preferences. Fields which are not set will not be updated
* https://gerrit-review.googlesource.com/Documentation/rest-api-accounts.html#preferences-input
+ *
+ * Note: the doc missed several properties. Java code uses the same class (GeneralPreferencesInfo)
+ * both for input data and for response data.
*/
-export interface PreferencesInput {
- changes_per_page?: 10 | 25 | 50 | 100;
- theme?: AppTheme;
- expand_inline_diffs?: boolean;
- download_scheme?: string;
- date_format?: DateFormat;
- time_format?: TimeFormat;
- relative_date_in_change_table?: boolean;
- diff_view?: DiffViewMode;
- size_bar_in_change_table?: boolean;
- legacycid_in_change_table?: boolean;
- mute_common_path_prefixes?: boolean;
- signed_off_by?: boolean;
- my?: TopMenuItemInfo[];
- change_table?: string[];
- email_strategy?: EmailStrategy;
- default_base_for_merges?: DefaultBase;
-}
+export type PreferencesInput = Partial<PreferencesInfo>;
/**
* The DiffPreferencesInput entity contains information for setting the diff preferences of a user. Fields which are not set will not be updated
@@ -1798,6 +1861,8 @@
default_base_for_merges: DefaultBase;
publish_comments_on_push?: boolean;
work_in_progress_by_default?: boolean;
+ // The email_format doesn't mentioned in doc, but exists in Java class GeneralPreferencesInfo
+ email_format?: EmailFormat;
// The following property doesn't exist in RestAPI, it is added by GrRestApiInterface
default_diff_view?: DiffViewMode;
}
@@ -2143,3 +2208,28 @@
changes: ChangeInfo[];
non_visible_changes: number;
}
+
+/**
+ * The RevertSubmissionInfo entity describes the revert changes.
+ * https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#revert-submission-info
+ */
+export interface RevertSubmissionInfo {
+ revert_changes: ChangeInfo[];
+}
+
+/**
+ * The CherryPickInput entity contains information for cherry-picking a change to a new branch.
+ * https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#cherrypick-input
+ */
+export interface CherryPickInput {
+ message?: string;
+ destination: BranchName;
+ base?: CommitId;
+ parent?: number;
+ notify?: NotifyType;
+ notify_details: RecipientTypeToNotifyInfoMap;
+ keep_reviewers?: boolean;
+ allow_conflicts?: boolean;
+ topic?: TopicName;
+ allow_empty?: boolean;
+}
diff --git a/polygerrit-ui/app/types/globals.ts b/polygerrit-ui/app/types/globals.ts
index 645c991..1b5f330 100644
--- a/polygerrit-ui/app/types/globals.ts
+++ b/polygerrit-ui/app/types/globals.ts
@@ -52,6 +52,10 @@
};
STATIC_RESOURCE_PATH?: string;
+ PRELOADED_QUERIES?: {
+ dashboardQuery?: string[];
+ };
+
/** Enhancements on Gr elements or utils */
// TODO(TS): should clean up those and removing them may break certain plugin behaviors
// TODO(TS): as @brohlfs suggested, to avoid importing anything from elements/ to types/
diff --git a/polygerrit-ui/app/utils/account-util.ts b/polygerrit-ui/app/utils/account-util.ts
index 4504ffd..7e425f8 100644
--- a/polygerrit-ui/app/utils/account-util.ts
+++ b/polygerrit-ui/app/utils/account-util.ts
@@ -15,13 +15,7 @@
* limitations under the License.
*/
-import {
- AccountId,
- AccountInfo,
- ChangeInfo,
- EmailAddress,
- ServerInfo,
-} from '../types/common';
+import {AccountId, AccountInfo, EmailAddress} from '../types/common';
import {AccountTag} from '../constants/constants';
export function accountKey(account: AccountInfo): AccountId | EmailAddress {
@@ -37,24 +31,3 @@
export function removeServiceUsers(accounts?: AccountInfo[]): AccountInfo[] {
return accounts?.filter(a => !isServiceUser(a)) || [];
}
-
-export function isAttentionSetEnabled(config: ServerInfo): boolean {
- return !!config?.change?.enable_attention_set;
-}
-
-export function canHaveAttention(account: AccountInfo): boolean {
- return !!account && !!account._account_id && !isServiceUser(account);
-}
-
-export function hasAttention(
- config: ServerInfo,
- account: AccountInfo,
- change: ChangeInfo
-): boolean {
- return (
- isAttentionSetEnabled(config) &&
- canHaveAttention(account) &&
- !!account._account_id &&
- !!change?.attention_set?.hasOwnProperty(account._account_id)
- );
-}
diff --git a/polygerrit-ui/app/utils/admin-nav-util.ts b/polygerrit-ui/app/utils/admin-nav-util.ts
index bb95066..06f4e3a 100644
--- a/polygerrit-ui/app/utils/admin-nav-util.ts
+++ b/polygerrit-ui/app/utils/admin-nav-util.ts
@@ -24,7 +24,7 @@
RepoName,
GroupId,
AccountDetailInfo,
- CapabilityInfo,
+ AccountCapabilityInfo,
} from '../types/common';
import {MenuLink} from '../elements/plugins/gr-admin-api/gr-admin-api';
import {hasOwnProperty} from './common-util';
@@ -54,12 +54,27 @@
},
];
+export interface AdminLink {
+ url: string;
+ text: string;
+ capability: string | null;
+ noBaseUrl: boolean;
+ view: null;
+ viewableToAll: boolean;
+ target: '_blank' | null;
+}
+
+export interface AdminLinks {
+ links: NavLink[];
+ expandedSection?: SubsectionInterface;
+}
+
export function getAdminLinks(
- account: AccountDetailInfo,
- getAccountCapabilities: (params?: string[]) => Promise<CapabilityInfo>,
+ account: AccountDetailInfo | undefined,
+ getAccountCapabilities: () => Promise<AccountCapabilityInfo>,
getAdminMenuLinks: () => MenuLink[],
options?: AdminNavLinksOption
-) {
+): Promise<AdminLinks> {
if (!account) {
return Promise.resolve(
_filterLinks(link => !!link.viewableToAll, getAdminMenuLinks, options)
@@ -78,9 +93,9 @@
filterFn: (link: NavLink) => boolean,
getAdminMenuLinks: () => MenuLink[],
options?: AdminNavLinksOption
-) {
- let links = ADMIN_LINKS.slice(0);
- let expandedSection;
+): AdminLinks {
+ let links: NavLink[] = ADMIN_LINKS.slice(0);
+ let expandedSection: SubsectionInterface | undefined = undefined;
const isExternalLink = (link: MenuLink) => link.url[0] !== '/';
@@ -90,18 +105,18 @@
return {
url: link.url,
name: link.text,
- capability: link.capability || null,
+ capability: link.capability || undefined,
noBaseUrl: !isExternalLink(link),
view: null,
viewableToAll: !link.capability,
target: isExternalLink(link) ? '_blank' : null,
- } as NavLink;
+ };
})
);
links = links.filter(filterFn);
- const filteredLinks = [];
+ const filteredLinks: NavLink[] = [];
const repoName = options && options.repoName;
const groupId = options && options.groupId;
const groupName = options && options.groupName;
diff --git a/polygerrit-ui/app/utils/async-util.ts b/polygerrit-ui/app/utils/async-util.ts
index 7e63f70..119b09b 100644
--- a/polygerrit-ui/app/utils/async-util.ts
+++ b/polygerrit-ui/app/utils/async-util.ts
@@ -26,7 +26,7 @@
*/
export function asyncForeach<T>(
array: T[],
- fn: (item: T, stopCallback: () => void) => Promise<T>
+ fn: (item: T, stopCallback: () => void) => Promise<unknown>
): Promise<T | void> {
if (!array.length) {
return Promise.resolve();
diff --git a/polygerrit-ui/app/utils/attention-set-util.ts b/polygerrit-ui/app/utils/attention-set-util.ts
new file mode 100644
index 0000000..b0aefcb
--- /dev/null
+++ b/polygerrit-ui/app/utils/attention-set-util.ts
@@ -0,0 +1,63 @@
+/**
+ * @license
+ * 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.
+ */
+
+import {AccountInfo, ChangeInfo} from '../types/common';
+import {isServiceUser} from './account-util';
+
+// You would typically use a ServerInfo here, but this utility does not care
+// about all the other parameters in that object.
+interface SimpleServerInfo {
+ change?: {
+ enable_attention_set?: boolean;
+ };
+}
+
+const CONFIG_ENABLED: SimpleServerInfo = {
+ change: {enable_attention_set: true},
+};
+
+export function isAttentionSetEnabled(config?: SimpleServerInfo): boolean {
+ return !!config?.change?.enable_attention_set;
+}
+
+export function canHaveAttention(account?: AccountInfo): boolean {
+ return !!account?._account_id && !isServiceUser(account);
+}
+
+export function hasAttention(
+ config?: SimpleServerInfo,
+ account?: AccountInfo,
+ change?: ChangeInfo
+): boolean {
+ return (
+ isAttentionSetEnabled(config) &&
+ canHaveAttention(account) &&
+ !!change?.attention_set?.hasOwnProperty(account!._account_id!)
+ );
+}
+
+export function getReason(account?: AccountInfo, change?: ChangeInfo) {
+ if (!hasAttention(CONFIG_ENABLED, account, change)) return '';
+ const entry = change!.attention_set![account!._account_id!];
+ return entry?.reason ? entry.reason : '';
+}
+
+export function getLastUpdate(account?: AccountInfo, change?: ChangeInfo) {
+ if (!hasAttention(CONFIG_ENABLED, account, change)) return '';
+ const entry = change!.attention_set![account!._account_id!];
+ return entry?.last_update ? entry.last_update : '';
+}
diff --git a/polygerrit-ui/app/utils/attention-set-util_test.js b/polygerrit-ui/app/utils/attention-set-util_test.js
new file mode 100644
index 0000000..71735d5
--- /dev/null
+++ b/polygerrit-ui/app/utils/attention-set-util_test.js
@@ -0,0 +1,57 @@
+/**
+ * @license
+ * 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.
+ */
+
+import '../test/common-test-setup-karma.js';
+import {
+ hasAttention, getReason,
+} from './attention-set-util.js';
+
+const KERMIT = {
+ email: 'kermit@gmail.com',
+ username: 'kermit',
+ name: 'Kermit The Frog',
+ _account_id: '31415926535',
+};
+
+suite('attention-set-util', () => {
+ test('hasAttention', () => {
+ const config = {
+ change: {enable_attention_set: true},
+ };
+ const change = {
+ attention_set: {
+ 31415926535: {
+ reason: 'a good reason',
+ },
+ },
+ };
+
+ assert.isTrue(hasAttention(config, KERMIT, change));
+ });
+
+ test('getReason', () => {
+ const change = {
+ attention_set: {
+ 31415926535: {
+ reason: 'a good reason',
+ },
+ },
+ };
+
+ assert.equal(getReason(KERMIT, change), 'a good reason');
+ });
+});
diff --git a/polygerrit-ui/app/utils/change-util.ts b/polygerrit-ui/app/utils/change-util.ts
index 2c9d9c3..47924e6 100644
--- a/polygerrit-ui/app/utils/change-util.ts
+++ b/polygerrit-ui/app/utils/change-util.ts
@@ -16,7 +16,12 @@
*/
import {getBaseUrl} from './url-util';
import {ChangeStatus} from '../constants/constants';
-import {NumericChangeId, PatchSetNum, ChangeInfo} from '../types/common';
+import {
+ NumericChangeId,
+ PatchSetNum,
+ ChangeInfo,
+ AccountInfo,
+} from '../types/common';
import {ParsedChangeInfo} from '../elements/shared/gr-rest-api-interface/gr-reviewer-updates-parser';
// This can be wrong! See WARNING above
@@ -167,6 +172,23 @@
return states;
}
+export function isOwner(change?: ChangeInfo, account?: AccountInfo) {
+ if (!change || !account) return false;
+ return change.owner?._account_id === account._account_id;
+}
+
export function changeStatusString(change: ChangeInfo) {
return changeStatuses(change).join(', ');
}
+
+export function isRemovableReviewer(
+ change?: ChangeInfo,
+ reviewer?: AccountInfo
+): boolean {
+ if (!change?.removable_reviewers || !reviewer) return false;
+ return change.removable_reviewers.some(
+ account =>
+ account._account_id === reviewer._account_id ||
+ (!reviewer._account_id && account.email === reviewer.email)
+ );
+}
diff --git a/polygerrit-ui/app/utils/change-util_test.js b/polygerrit-ui/app/utils/change-util_test.js
index 20b9578..fd181fe 100644
--- a/polygerrit-ui/app/utils/change-util_test.js
+++ b/polygerrit-ui/app/utils/change-util_test.js
@@ -21,6 +21,7 @@
changePath,
changeStatuses,
changeStatusString,
+ isRemovableReviewer,
} from './change-util.js';
suite('change-util tests', () => {
@@ -198,5 +199,19 @@
assert.deepEqual(statuses, ['Merge Conflict', 'WIP', 'Private']);
assert.equal(statusString, 'Merge Conflict, WIP, Private');
});
+
+ test('isRemovableReviewer', () => {
+ let change = {
+ removable_reviewers: [{_account_id: 1}],
+ };
+ const reviewer = {_account_id: 1};
+
+ assert.equal(isRemovableReviewer(change, reviewer), true);
+
+ change = {
+ removable_reviewers: [{_account_id: 2}],
+ };
+ assert.equal(isRemovableReviewer(change, reviewer), false);
+ });
});
diff --git a/polygerrit-ui/app/utils/comment-util.ts b/polygerrit-ui/app/utils/comment-util.ts
new file mode 100644
index 0000000..e598e2b
--- /dev/null
+++ b/polygerrit-ui/app/utils/comment-util.ts
@@ -0,0 +1,140 @@
+/**
+ * @license
+ * 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.
+ */
+import {
+ CommentBasics,
+ CommentInfo,
+ PatchSetNum,
+ RobotCommentInfo,
+ Timestamp,
+ UrlEncodedCommentId,
+} from '../types/common';
+import {CommentSide, Side} from '../constants/constants';
+import {parseDate} from './date-util';
+
+export interface DraftCommentProps {
+ __draft?: boolean;
+ __draftID?: string;
+ __date?: Date;
+}
+
+export type DraftInfo = CommentBasics & DraftCommentProps;
+
+/**
+ * Each of the type implements or extends CommentBasics.
+ */
+export type Comment = DraftInfo | CommentInfo | RobotCommentInfo;
+
+export interface UIStateCommentProps {
+ // The `side` of the comment is PARENT or REVISION, but this is LEFT or RIGHT.
+ // TODO(TS): Remove the naming confusion of commentSide being of type of Side,
+ // but side being of type CommentSide. :-)
+ __commentSide?: Side;
+ // TODO(TS): Remove this. Seems to be exactly the same as `path`??
+ __path?: string;
+ collapsed?: boolean;
+ // TODO(TS): Consider allowing this only for drafts.
+ __editing?: boolean;
+ __otherEditing?: boolean;
+}
+
+export type UIDraft = DraftInfo & UIStateCommentProps;
+
+export type UIHuman = CommentInfo & UIStateCommentProps;
+
+export type UIRobot = RobotCommentInfo & UIStateCommentProps;
+
+export type UIComment = UIHuman | UIRobot | UIDraft;
+
+export type CommentMap = {[path: string]: boolean};
+
+export function isRobot<T extends CommentInfo>(
+ x: T | DraftInfo | RobotCommentInfo | undefined
+): x is RobotCommentInfo {
+ return !!x && !!(x as RobotCommentInfo).robot_id;
+}
+
+export function isDraft<T extends CommentInfo>(
+ x: T | UIDraft | undefined
+): x is UIDraft {
+ return !!x && !!(x as UIDraft).__draft;
+}
+
+export interface PatchSetFile {
+ path: string;
+ basePath?: string;
+ patchNum?: PatchSetNum;
+}
+
+export interface PatchNumOnly {
+ patchNum: PatchSetNum;
+}
+
+export function isPatchSetFile(
+ x: PatchSetFile | PatchNumOnly
+): x is PatchSetFile {
+ return !!(x as PatchSetFile).path;
+}
+
+interface SortableComment {
+ __draft?: boolean;
+ __date?: Date;
+ updated?: Timestamp;
+ id?: UrlEncodedCommentId;
+}
+
+export function sortComments<T extends SortableComment>(comments: T[]): T[] {
+ return comments.slice(0).sort((c1, c2) => {
+ const d1 = !!c1.__draft;
+ const d2 = !!c2.__draft;
+ if (d1 !== d2) return d1 ? 1 : -1;
+
+ const date1 = (c1.updated && parseDate(c1.updated)) || c1.__date;
+ const date2 = (c2.updated && parseDate(c2.updated)) || c2.__date;
+ const dateDiff = date1!.valueOf() - date2!.valueOf();
+ if (dateDiff !== 0) return dateDiff;
+
+ const id1 = c1.id ?? '';
+ const id2 = c2.id ?? '';
+ return id1.localeCompare(id2);
+ });
+}
+
+export interface CommentThread {
+ comments: UIComment[];
+ patchNum?: PatchSetNum;
+ path: string;
+ // TODO(TS): It would be nice to use LineNumber here, but the comment thread
+ // element actually relies on line to be undefined for file comments. Be
+ // aware of element attribute getters and setters, if you try to refactor
+ // this. :-) Still worthwhile to do ...
+ line?: number;
+ rootId: UrlEncodedCommentId;
+ commentSide?: CommentSide;
+}
+
+export function getLastComment(thread?: CommentThread): UIComment | undefined {
+ const len = thread?.comments.length;
+ return thread && len ? thread.comments[len - 1] : undefined;
+}
+
+export function isUnresolved(thread?: CommentThread): boolean {
+ return !!getLastComment(thread)?.unresolved;
+}
+
+export function isDraftThread(thread?: CommentThread): boolean {
+ return isDraft(getLastComment(thread));
+}
diff --git a/polygerrit-ui/app/utils/comment-util_test.js b/polygerrit-ui/app/utils/comment-util_test.js
new file mode 100644
index 0000000..ad19974
--- /dev/null
+++ b/polygerrit-ui/app/utils/comment-util_test.js
@@ -0,0 +1,34 @@
+/**
+ * @license
+ * 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.
+ */
+
+import '../test/common-test-setup-karma.js';
+import {
+ isUnresolved,
+} from './comment-util.js';
+
+suite('comment-util', () => {
+ test('isUnresolved', () => {
+ assert.isFalse(isUnresolved(undefined));
+ assert.isFalse(isUnresolved({comments: []}));
+ assert.isTrue(isUnresolved({comments: [{unresolved: true}]}));
+ assert.isFalse(isUnresolved({comments: [{unresolved: false}]}));
+ assert.isTrue(isUnresolved(
+ {comments: [{unresolved: false}, {unresolved: true}]}));
+ assert.isFalse(isUnresolved(
+ {comments: [{unresolved: true}, {unresolved: false}]}));
+ });
+});
diff --git a/polygerrit-ui/app/utils/common-util.ts b/polygerrit-ui/app/utils/common-util.ts
index d144272..183e56c 100644
--- a/polygerrit-ui/app/utils/common-util.ts
+++ b/polygerrit-ui/app/utils/common-util.ts
@@ -44,3 +44,25 @@
console.error(msg, obj);
throw new Error(msg);
}
+
+/**
+ * Returns true, if both sets contain the same members.
+ */
+export function areSetsEqual<T>(a: Set<T>, b: Set<T>): boolean {
+ if (a.size !== b.size) {
+ return false;
+ }
+ return containsAll(a, b);
+}
+
+/**
+ * Returns true, if 'set' contains 'subset'.
+ */
+export function containsAll<T>(set: Set<T>, subSet: Set<T>): boolean {
+ for (const value of subSet) {
+ if (!set.has(value)) {
+ return false;
+ }
+ }
+ return true;
+}
diff --git a/polygerrit-ui/app/utils/common-util_test.js b/polygerrit-ui/app/utils/common-util_test.js
index 60c0b0a..917d652b 100644
--- a/polygerrit-ui/app/utils/common-util_test.js
+++ b/polygerrit-ui/app/utils/common-util_test.js
@@ -16,7 +16,7 @@
*/
import '../test/common-test-setup-karma.js';
-import {hasOwnProperty} from './common-util.js';
+import {hasOwnProperty, areSetsEqual, containsAll} from './common-util.js';
suite('common-util tests', () => {
suite('hasOwnProperty', () => {
@@ -41,4 +41,29 @@
assert.isFalse(hasOwnProperty(obj, 'def'));
});
});
+
+ test('areSetsEqual', () => {
+ assert.isTrue(areSetsEqual(new Set(), new Set()));
+ assert.isTrue(areSetsEqual(new Set([1]), new Set([1])));
+ assert.isTrue(areSetsEqual(new Set([1, 1, 1, 1]), new Set([1])));
+ assert.isTrue(areSetsEqual(new Set([1, 1, 2, 2]), new Set([2, 1, 2, 1])));
+ assert.isTrue(areSetsEqual(new Set([1, 2, 3, 4]), new Set([4, 3, 2, 1])));
+ assert.isFalse(areSetsEqual(new Set(), new Set([1])));
+ assert.isFalse(areSetsEqual(new Set([1]), new Set([2])));
+ assert.isFalse(areSetsEqual(new Set([1, 2, 4]), new Set([1, 2, 3])));
+ });
+
+ test('containsAll', () => {
+ assert.isTrue(containsAll(new Set(), new Set()));
+ assert.isTrue(containsAll(new Set([1]), new Set()));
+ assert.isTrue(containsAll(new Set([1]), new Set([1])));
+ assert.isTrue(containsAll(new Set([1, 2]), new Set([1])));
+ assert.isTrue(containsAll(new Set([1, 2]), new Set([2])));
+ assert.isTrue(containsAll(new Set([1, 2, 3, 4]), new Set([1, 4])));
+ assert.isTrue(containsAll(new Set([1, 2, 3, 4]), new Set([1, 2, 3, 4])));
+ assert.isFalse(containsAll(new Set(), new Set([2])));
+ assert.isFalse(containsAll(new Set([1]), new Set([2])));
+ assert.isFalse(containsAll(new Set([1, 2, 3, 4]), new Set([5])));
+ assert.isFalse(containsAll(new Set([1, 2, 3, 4]), new Set([1, 2, 3, 5])));
+ });
});
diff --git a/polygerrit-ui/app/utils/date-util.ts b/polygerrit-ui/app/utils/date-util.ts
index 7748b9d..3cad21a 100644
--- a/polygerrit-ui/app/utils/date-util.ts
+++ b/polygerrit-ui/app/utils/date-util.ts
@@ -36,25 +36,29 @@
}
// similar to fromNow from moment.js
-export function fromNow(date: Date) {
+export function fromNow(date: Date, noAgo = false) {
const now = new Date();
+ const ago = noAgo ? '' : ' ago';
const secondsAgo = Math.round((now.valueOf() - date.valueOf()) / 1000);
- if (secondsAgo <= 44) return 'just now';
- if (secondsAgo <= 89) return 'a minute ago';
+ if (secondsAgo <= 59) return 'just now';
+ if (secondsAgo <= 119) return `1 minute${ago}`;
const minutesAgo = Math.round(secondsAgo / 60);
- if (minutesAgo <= 44) return `${minutesAgo} minutes ago`;
- if (minutesAgo <= 89) return 'an hour ago';
+ if (minutesAgo <= 59) return `${minutesAgo} minutes${ago}`;
+ if (minutesAgo === 60) return `1 hour${ago}`;
+ if (minutesAgo <= 119) return `1 hour ${minutesAgo - 60} min${ago}`;
const hoursAgo = Math.round(minutesAgo / 60);
- if (hoursAgo <= 21) return `${hoursAgo} hours ago`;
- if (hoursAgo <= 35) return 'a day ago';
+ if (hoursAgo <= 23) return `${hoursAgo} hours${ago}`;
+ if (hoursAgo === 24) return `1 day${ago}`;
+ if (hoursAgo <= 47) return `1 day ${hoursAgo - 24} hr${ago}`;
const daysAgo = Math.round(hoursAgo / 24);
- if (daysAgo <= 25) return `${daysAgo} days ago`;
- if (daysAgo <= 45) return 'a month ago';
+ if (daysAgo <= 30) return `${daysAgo} days${ago}`;
+ if (daysAgo <= 60) return `1 month${ago}`;
const monthsAgo = Math.round(daysAgo / 30);
- if (daysAgo <= 319) return `${monthsAgo} months ago`;
- if (daysAgo <= 547) return 'a year ago';
+ if (monthsAgo <= 11) return `${monthsAgo} months${ago}`;
+ if (monthsAgo === 12) return `1 year${ago}`;
+ if (monthsAgo <= 24) return `1 year ${monthsAgo - 12} m${ago}`;
const yearsAgo = Math.round(daysAgo / 365);
- return `${yearsAgo} years ago`;
+ return `${yearsAgo} years${ago}`;
}
/**
diff --git a/polygerrit-ui/app/utils/date-util_test.js b/polygerrit-ui/app/utils/date-util_test.js
index 7b22cc6..a003c65 100644
--- a/polygerrit-ui/app/utils/date-util_test.js
+++ b/polygerrit-ui/app/utils/date-util_test.js
@@ -39,15 +39,18 @@
const fakeNow = new Date('May 08 2020 12:00:00');
sinon.useFakeTimers(fakeNow.getTime());
assert.equal('just now', fromNow(new Date('May 08 2020 11:59:30')));
- assert.equal('a minute ago', fromNow(new Date('May 08 2020 11:59:00')));
+ assert.equal('1 minute ago', fromNow(new Date('May 08 2020 11:59:00')));
assert.equal('5 minutes ago', fromNow(new Date('May 08 2020 11:55:00')));
- assert.equal('an hour ago', fromNow(new Date('May 08 2020 11:00:00')));
+ assert.equal('1 hour ago', fromNow(new Date('May 08 2020 11:00:00')));
+ assert.equal(
+ '1 hour 5 min ago', fromNow(new Date('May 08 2020 10:55:00')));
assert.equal('3 hours ago', fromNow(new Date('May 08 2020 9:00:00')));
- assert.equal('a day ago', fromNow(new Date('May 07 2020 12:00:00')));
+ assert.equal('1 day ago', fromNow(new Date('May 07 2020 12:00:00')));
+ assert.equal('1 day 2 hr ago', fromNow(new Date('May 07 2020 10:00:00')));
assert.equal('3 days ago', fromNow(new Date('May 05 2020 12:00:00')));
- assert.equal('a month ago', fromNow(new Date('Apr 05 2020 12:00:00')));
+ assert.equal('1 month ago', fromNow(new Date('Apr 05 2020 12:00:00')));
assert.equal('2 months ago', fromNow(new Date('Mar 05 2020 12:00:00')));
- assert.equal('a year ago', fromNow(new Date('May 05 2019 12:00:00')));
+ assert.equal('1 year ago', fromNow(new Date('May 05 2019 12:00:00')));
assert.equal('10 years ago', fromNow(new Date('May 05 2010 12:00:00')));
});
});
@@ -118,4 +121,4 @@
formatDate(new Date('Jul 03 2013 00:15:00'), timeFormat));
});
});
-});
\ No newline at end of file
+});
diff --git a/polygerrit-ui/app/utils/display-name-util.ts b/polygerrit-ui/app/utils/display-name-util.ts
index 21c4e09..7114f98 100644
--- a/polygerrit-ui/app/utils/display-name-util.ts
+++ b/polygerrit-ui/app/utils/display-name-util.ts
@@ -84,5 +84,5 @@
export const _testOnly_accountEmail = _accountEmail;
export function getGroupDisplayName(group: GroupInfo) {
- return (group.name || '') + ' (group)';
+ return `${group.name || ''} (group)`;
}
diff --git a/polygerrit-ui/app/utils/dom-util.ts b/polygerrit-ui/app/utils/dom-util.ts
index ae6d616..76db40b 100644
--- a/polygerrit-ui/app/utils/dom-util.ts
+++ b/polygerrit-ui/app/utils/dom-util.ts
@@ -211,13 +211,13 @@
export function descendedFromClass(
element: Element,
className: string,
- opt_stopElement: Element
+ stopElement?: Element
) {
let isDescendant = element.classList.contains(className);
while (
!isDescendant &&
element.parentElement &&
- (!opt_stopElement || element.parentElement !== opt_stopElement)
+ (!stopElement || element.parentElement !== stopElement)
) {
isDescendant = element.classList.contains(className);
element = element.parentElement;
diff --git a/polygerrit-ui/app/utils/label-util.ts b/polygerrit-ui/app/utils/label-util.ts
new file mode 100644
index 0000000..3a2556f
--- /dev/null
+++ b/polygerrit-ui/app/utils/label-util.ts
@@ -0,0 +1,44 @@
+/**
+ * @license
+ * 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.
+ */
+import {
+ ApprovalInfo,
+ isDetailedLabelInfo,
+ LabelInfo,
+ VotingRangeInfo,
+} from '../types/common';
+
+// Name of the standard Code-Review label.
+export const CODE_REVIEW = 'Code-Review';
+
+export function getVotingRange(label?: LabelInfo): VotingRangeInfo | undefined {
+ if (!label || !isDetailedLabelInfo(label)) return undefined;
+ const values = Object.keys(label.values).map(v => parseInt(v, 10));
+ values.sort((a, b) => a - b);
+ if (!values.length) return undefined;
+ return {min: values[0], max: values[values.length - 1]};
+}
+
+export function getVotingRangeOrDefault(label?: LabelInfo): VotingRangeInfo {
+ const range = getVotingRange(label);
+ return range ? range : {min: 0, max: 0};
+}
+
+export function getMaxAccounts(label?: LabelInfo): ApprovalInfo[] {
+ if (!label || !isDetailedLabelInfo(label) || !label.all) return [];
+ const votingRange = getVotingRangeOrDefault(label);
+ return label.all.filter(account => account.value === votingRange.max);
+}
diff --git a/polygerrit-ui/app/utils/label-util_test.js b/polygerrit-ui/app/utils/label-util_test.js
new file mode 100644
index 0000000..d6f7b3e
--- /dev/null
+++ b/polygerrit-ui/app/utils/label-util_test.js
@@ -0,0 +1,90 @@
+/**
+ * @license
+ * 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.
+ */
+
+import '../test/common-test-setup-karma.js';
+import {
+ getVotingRange,
+ getVotingRangeOrDefault,
+ getMaxAccounts,
+} from './label-util.js';
+
+const VALUES_1 = {
+ '-1': 'bad',
+ '0': 'neutral',
+ '+1': 'good',
+};
+
+const VALUES_2 = {
+ '-1': 'bad',
+ '+2': 'perfect',
+ '0': 'neutral',
+ '-2': 'blocking',
+ '+1': 'good',
+};
+
+suite('label-util', () => {
+ test('getVotingRange -1 to +1', () => {
+ const label = {values: VALUES_1};
+ const expectedRange = {min: -1, max: 1};
+ assert.deepEqual(getVotingRange(label), expectedRange);
+ assert.deepEqual(getVotingRangeOrDefault(label), expectedRange);
+ });
+
+ test('getVotingRange -2 to +2', () => {
+ const label = {values: VALUES_2};
+ const expectedRange = {min: -2, max: 2};
+ assert.deepEqual(getVotingRange(label), expectedRange);
+ assert.deepEqual(getVotingRangeOrDefault(label), expectedRange);
+ });
+
+ test('getVotingRange empty values', () => {
+ const label = {
+ values: {},
+ };
+ const expectedRange = {min: 0, max: 0};
+ assert.isUndefined(getVotingRange(label));
+ assert.deepEqual(getVotingRangeOrDefault(label), expectedRange);
+ });
+
+ test('getVotingRange no values', () => {
+ const label = {};
+ const expectedRange = {min: 0, max: 0};
+ assert.isUndefined(getVotingRange(label));
+ assert.deepEqual(getVotingRangeOrDefault(label), expectedRange);
+ });
+
+ test('getMaxAccounts', () => {
+ const label = {
+ values: VALUES_2,
+ all: [
+ {value: 2, _account_id: 314},
+ {value: 1, _account_id: 777},
+ ],
+ };
+
+ const maxAccounts = getMaxAccounts(label);
+
+ assert.equal(maxAccounts.length, 1);
+ assert.equal(maxAccounts[0]._account_id, 314);
+ });
+
+ test('getMaxAccounts unset parameters', () => {
+ assert.isEmpty(getMaxAccounts());
+ assert.isEmpty(getMaxAccounts({}));
+ assert.isEmpty(getMaxAccounts({values: VALUES_2}));
+ });
+});
diff --git a/polygerrit-ui/app/utils/url-util.ts b/polygerrit-ui/app/utils/url-util.ts
index 612f05c..0c6fabc 100644
--- a/polygerrit-ui/app/utils/url-util.ts
+++ b/polygerrit-ui/app/utils/url-util.ts
@@ -29,13 +29,10 @@
/**
* Get the docs base URL from either the server config or by probing.
*
- * @param config The server config.
- * @param restApi A REST API instance
- * @return A promise that resolves with the docs base
- * URL.
+ * @return A promise that resolves with the docs base URL.
*/
export function getDocsBaseUrl(
- config: ServerInfo,
+ config: ServerInfo | undefined,
restApi: RestApiService
): Promise<string | null> {
if (!getDocsBaseUrlCachedPromise) {
diff --git a/proto/cache.proto b/proto/cache.proto
index 7924cbd..121cdb8 100644
--- a/proto/cache.proto
+++ b/proto/cache.proto
@@ -76,7 +76,7 @@
// Instead, we just take the tedious yet simple approach of having a "has_foo"
// field for each nullable field "foo", indicating whether or not foo is null.
//
-// Next ID: 24
+// Next ID: 25
message ChangeNotesStateProto {
// Effectively required, even though the corresponding ChangeNotesState field
// is optional, since the field is only absent when NoteDb is disabled, in
@@ -218,7 +218,11 @@
string operation = 3;
string reason = 4;
}
+ // Only includes the most recent attention set update for each user.
repeated AttentionSetUpdateProto attention_set_update = 23;
+
+ // Includes all attention set updates.
+ repeated AttentionSetUpdateProto all_attention_set_update = 24;
}
// Serialized form of com.google.gerrit.server.query.change.ConflictKey
@@ -500,3 +504,25 @@
bytes global_config_revision = 3; // Hash of All-Projects-projects.config. This
// will only be populated for All-Projects.
}
+
+// Serialized form of com.google.gerrit.server.comment.CommentContextCacheImpl.Key
+// Next ID: 6
+message CommentContextKeyProto {
+ string project = 1;
+ string change_id = 2;
+ int32 patchset = 3;
+ string commentId = 4;
+
+ // hashed with the murmur3_128 hash function
+ string path_hash = 5;
+}
+
+// Serialized form of a list of com.google.gerrit.extensions.common.ContextLineInfo
+// Next ID: 2
+message AllCommentContextProto {
+ message CommentContextProto {
+ int32 line_number = 1;
+ string context_line = 2;
+ }
+ repeated CommentContextProto context = 1;
+}
diff --git a/resources/com/google/gerrit/server/mail/HeaderHtml.soy b/resources/com/google/gerrit/server/mail/ChangeHeader.soy
similarity index 62%
copy from resources/com/google/gerrit/server/mail/HeaderHtml.soy
copy to resources/com/google/gerrit/server/mail/ChangeHeader.soy
index 4710d8c..fde69f1 100644
--- a/resources/com/google/gerrit/server/mail/HeaderHtml.soy
+++ b/resources/com/google/gerrit/server/mail/ChangeHeader.soy
@@ -1,5 +1,5 @@
/**
- * Copyright (C) 2016 The Android Open Source Project
+ * 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.
@@ -16,5 +16,17 @@
{namespace com.google.gerrit.server.mail.template}
-{template .HeaderHtml}
+{template .ChangeHeader kind="text"}
+ {@param attentionSet: ?}
+ {if $attentionSet}
+ Attention is currently required from:{sp}
+ {for $attentionSetUser in $attentionSet}
+ {$attentionSetUser}
+ // add commas or dot.
+ {if isLast($attentionSetUser)}.
+ {else},{sp}
+ {/if}
+ {/for}
+ {\n}
+ {/if}
{/template}
diff --git a/resources/com/google/gerrit/server/mail/HeaderHtml.soy b/resources/com/google/gerrit/server/mail/ChangeHeaderHtml.soy
similarity index 62%
rename from resources/com/google/gerrit/server/mail/HeaderHtml.soy
rename to resources/com/google/gerrit/server/mail/ChangeHeaderHtml.soy
index 4710d8c..ea12455 100644
--- a/resources/com/google/gerrit/server/mail/HeaderHtml.soy
+++ b/resources/com/google/gerrit/server/mail/ChangeHeaderHtml.soy
@@ -1,5 +1,5 @@
/**
- * Copyright (C) 2016 The Android Open Source Project
+ * 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.
@@ -14,7 +14,20 @@
* limitations under the License.
*/
+
{namespace com.google.gerrit.server.mail.template}
-{template .HeaderHtml}
+{template .ChangeHeaderHtml}
+ {@param attentionSet: ?}
+ {if $attentionSet}
+ <p> Attention is currently required from:{sp}
+ {for $attentionSetUser in $attentionSet}
+ {$attentionSetUser}
+ //add commas or dot.
+ {if isLast($attentionSetUser)}.
+ {else},{sp}
+ {/if}
+ {/for} </p>
+ {\n}
+ {/if}
{/template}
diff --git a/tools/BUILD b/tools/BUILD
index 5159177..be12735 100644
--- a/tools/BUILD
+++ b/tools/BUILD
@@ -17,6 +17,54 @@
visibility = ["//visibility:public"],
)
+JDK11_JVM_OPTS = select({
+ "@bazel_tools//src/conditions:openbsd": ["-Xbootclasspath/p:$(location @bazel_tools//tools/jdk:javac_jar)"],
+ "//conditions:default": [
+ "--add-exports=jdk.compiler/com.sun.tools.javac.api=ALL-UNNAMED",
+ "--add-exports=jdk.compiler/com.sun.tools.javac.code=ALL-UNNAMED",
+ "--add-exports=jdk.compiler/com.sun.tools.javac.comp=ALL-UNNAMED",
+ "--add-exports=jdk.compiler/com.sun.tools.javac.file=ALL-UNNAMED",
+ "--add-exports=jdk.compiler/com.sun.tools.javac.main=ALL-UNNAMED",
+ "--add-exports=jdk.compiler/com.sun.tools.javac.tree=ALL-UNNAMED",
+ "--add-exports=jdk.compiler/com.sun.tools.javac.util=ALL-UNNAMED",
+ "--add-opens=jdk.compiler/com.sun.tools.javac.file=ALL-UNNAMED",
+ "--patch-module=java.compiler=$(location @bazel_tools//tools/jdk:java_compiler_jar)",
+ "--patch-module=jdk.compiler=$(location @bazel_tools//tools/jdk:jdk_compiler_jar)",
+ "--add-opens=java.base/java.nio=ALL-UNNAMED",
+ "--add-opens=java.base/java.lang=ALL-UNNAMED",
+ ],
+})
+
+default_java_toolchain(
+ name = "error_prone_warnings_toolchain_java11",
+ bootclasspath = ["@bazel_tools//tools/jdk:platformclasspath.jar"],
+ forcibly_disable_header_compilation = False,
+ genclass = ["@bazel_tools//tools/jdk:genclass"],
+ header_compiler = ["@bazel_tools//tools/jdk:turbine"],
+ header_compiler_direct = ["@bazel_tools//tools/jdk:turbine_direct"],
+ ijar = ["@bazel_tools//tools/jdk:ijar"],
+ javabuilder = ["@bazel_tools//tools/jdk:javabuilder"],
+ javac = ["@bazel_tools//tools/jdk:javac_jar"],
+ javac_supports_workers = True,
+ jvm_opts = JDK11_JVM_OPTS,
+ misc = [
+ "-XDskipDuplicateBridges=true",
+ "-g",
+ "-parameters",
+ ],
+ package_configuration = [
+ ":error_prone",
+ ],
+ singlejar = ["@bazel_tools//tools/jdk:singlejar"],
+ source_version = "11",
+ target_version = "11",
+ tools = [
+ "@bazel_tools//tools/jdk:java_compiler_jar",
+ "@bazel_tools//tools/jdk:jdk_compiler_jar",
+ ],
+ visibility = ["//visibility:public"],
+)
+
# Error Prone errors enabled by default; see ../.bazelrc for how this is
# enabled. This warnings list is originally based on:
# https://github.com/bazelbuild/BUILD_file_generator/blob/master/tools/bazel_defs/java.bzl
diff --git a/tools/maven/gerrit-acceptance-framework_pom.xml b/tools/maven/gerrit-acceptance-framework_pom.xml
index 14c726e..2f8c5bc 100644
--- a/tools/maven/gerrit-acceptance-framework_pom.xml
+++ b/tools/maven/gerrit-acceptance-framework_pom.xml
@@ -2,7 +2,7 @@
<modelVersion>4.0.0</modelVersion>
<groupId>com.google.gerrit</groupId>
<artifactId>gerrit-acceptance-framework</artifactId>
- <version>3.3.0-SNAPSHOT</version>
+ <version>3.4.0-SNAPSHOT</version>
<packaging>jar</packaging>
<name>Gerrit Code Review - Acceptance Test Framework</name>
<description>Framework for Gerrit's acceptance tests</description>
diff --git a/tools/maven/gerrit-extension-api_pom.xml b/tools/maven/gerrit-extension-api_pom.xml
index bd323ba..31cce3b 100644
--- a/tools/maven/gerrit-extension-api_pom.xml
+++ b/tools/maven/gerrit-extension-api_pom.xml
@@ -2,7 +2,7 @@
<modelVersion>4.0.0</modelVersion>
<groupId>com.google.gerrit</groupId>
<artifactId>gerrit-extension-api</artifactId>
- <version>3.3.0-SNAPSHOT</version>
+ <version>3.4.0-SNAPSHOT</version>
<packaging>jar</packaging>
<name>Gerrit Code Review - Extension API</name>
<description>API for Gerrit Extensions</description>
diff --git a/tools/maven/gerrit-plugin-api_pom.xml b/tools/maven/gerrit-plugin-api_pom.xml
index 3b059e5..5689009 100644
--- a/tools/maven/gerrit-plugin-api_pom.xml
+++ b/tools/maven/gerrit-plugin-api_pom.xml
@@ -2,7 +2,7 @@
<modelVersion>4.0.0</modelVersion>
<groupId>com.google.gerrit</groupId>
<artifactId>gerrit-plugin-api</artifactId>
- <version>3.3.0-SNAPSHOT</version>
+ <version>3.4.0-SNAPSHOT</version>
<packaging>jar</packaging>
<name>Gerrit Code Review - Plugin API</name>
<description>API for Gerrit Plugins</description>
diff --git a/tools/maven/gerrit-war_pom.xml b/tools/maven/gerrit-war_pom.xml
index b8fa132..4e0d545 100644
--- a/tools/maven/gerrit-war_pom.xml
+++ b/tools/maven/gerrit-war_pom.xml
@@ -2,7 +2,7 @@
<modelVersion>4.0.0</modelVersion>
<groupId>com.google.gerrit</groupId>
<artifactId>gerrit-war</artifactId>
- <version>3.3.0-SNAPSHOT</version>
+ <version>3.4.0-SNAPSHOT</version>
<packaging>war</packaging>
<name>Gerrit Code Review - WAR</name>
<description>Gerrit WAR</description>
diff --git a/tools/nongoogle.bzl b/tools/nongoogle.bzl
index ff8116b..a8f5af0 100644
--- a/tools/nongoogle.bzl
+++ b/tools/nongoogle.bzl
@@ -102,8 +102,8 @@
maven_jar(
name = "jackson-core",
- artifact = "com.fasterxml.jackson.core:jackson-core:2.11.2",
- sha1 = "bc022ab0f0c83c07f9c52c5ab9a6a4932b15cc35",
+ artifact = "com.fasterxml.jackson.core:jackson-core:2.11.3",
+ sha1 = "c2351800432bdbdd8284c3f5a7f0782a352aa84a",
)
# Google internal dependencies: these are developed at Google, so there is
diff --git a/version.bzl b/version.bzl
index 78b286b..066d07e 100644
--- a/version.bzl
+++ b/version.bzl
@@ -2,4 +2,4 @@
# Used by :api_install and :api_deploy targets
# when talking to the destination repository.
#
-GERRIT_VERSION = "3.3.0-SNAPSHOT"
+GERRIT_VERSION = "3.4.0-SNAPSHOT"