Merge "ChangeInfo: Remove outdated hint about counts possibly not being populated"
diff --git a/Documentation/access-control.txt b/Documentation/access-control.txt
index 3e75a53..7d531ff 100644
--- a/Documentation/access-control.txt
+++ b/Documentation/access-control.txt
@@ -97,7 +97,7 @@
groups are created on Gerrit site initialization and unique UUIDs are assigned
to those groups. These UUIDs are different on different Gerrit sites.
-Gerrit comes with two predefined groups:
+Gerrit comes with three predefined groups:
* link:#administrators[Administrators]
* link:#service_users[Service Users]
diff --git a/Documentation/dev-plugins.txt b/Documentation/dev-plugins.txt
index bcc96b4..4fa5e68 100644
--- a/Documentation/dev-plugins.txt
+++ b/Documentation/dev-plugins.txt
@@ -623,6 +623,105 @@
.to(MyListener.class);
----
+Blocking inside onStop() is a good choice for QOS limits which are
+attempting to restrict total usage of resources as might be done to
+to prevent a server overload. In these cases, when a server's resources
+are being exhausted, it is important to throttle all `Tasks`, and blocking
+the current thread from being used by any Task makes sense. However,
+Task parking (see below) is more appropriate if it desirable to limit a
+specific resource usage in favor of other resources, such as when
+prioritization or fairness policies are desired.
+
+[[taskParker]]
+== TaskParkers
+
+It is possible to park `com.google.gerrit.server.git.WorkQueue$Task`s
+before they run without depriving other `Tasks` of a thread. Parking is
+particularly useful for (de-)prioritizing certain `Tasks` based on resource
+quotas without blocking `Tasks` not using those resources. For example,
+when there is a desire to limit how many commands a single user can run
+concurrently it is typically also desirable to not limit the total amount
+of concurrently running commands to the same limit. The Task parking
+mechanism is useful for such Task limiting scenarios.
+
+The `TaskParker` interface works well with a Semaphore's `tryAcquire()`
+method even when the Semaphore is unavailable. However, blocking should
+not be done with a `TaskParker` and if it is desired, such as when using a
+Semaphore's `acquire()` method, use a `TaskListener` interface instead.
+
+To make use of Task parking, implement a
+`com.google.gerrit.server.git.WorkQueue$TaskParker` and register the
+TaskParker (as a TaskListener) from a plugin like this:
+
+[source,java]
+----
+ public class MyParker implements TaskParker {
+ Semaphore semaphore = new Semaphore(3);
+
+ @Override
+ public boolean isReadyToStart(Task<?> task) {
+ try {
+ return semaphore.tryAcquire();
+ } catch (InterruptedException e) {
+ return false;
+ }
+ }
+
+ @Override
+ public void onNotReadyToStart(Task<?> task) {
+ semaphore.release();
+ }
+
+ @Override
+ public void onStart(Task<?> task) {}
+
+ @Override
+ public void onStop(Task<?> task) {
+ semaphore.release();
+ }
+ }
+
+ bind(TaskListener.class)
+ .annotatedWith(Exports.named("MyParker"))
+ .to(MyParker.class);
+----
+
+Before running a Task, the executor will query each `TaskParker` to see
+if the Task may be run by calling `isReadyToStart()`. If any `TaskParker`
+returns `false` from `isReadyToStart()`, then the Task will get parked
+and the executor will wait until another Task completes before
+attempting to run a parked task again.
+
+Since parked `Tasks` are not actually running and consuming resources,
+they generally should also not be contributing towards those resource
+quotas which caused the task to be parked. For this reason, once it is
+determined that a Task will be parked, the executor will call
+`onNotReadyToStart()` on every `TaskParker` that previously returned `true`
+from `isReadyToStart()`. This allows those TaskParkers to reduce their
+resource usage counts which they bumped up in `isReadyToStart()` with
+the expectation that the Task may run. Since the Task is not running and
+the resource is not being used, reducing the resource usage count allows
+other `Tasks` needing that resource to run while the Task is parked.
+
+Once a running Task completes, the executor will attempt to run
+parked `Tasks` (in the order in which they were parked) by again calling
+`isReadyToStart()` on the TaskParkers, even the TaskParkers which
+previously returned a `true` before the Task was parked. This is
+necessary because although a Task may not have exceeded a specific
+resource limit before it was parked, another Task may since have been
+allowed to run and its usage of that resource may now cause the parked
+task under evaluation to need to be throttled and parked again.
+
+Note, the reason that it is important to not block inside the
+`isReadyToStart()` method is to avoid delaying the executor from calling
+`onNotReadyToStart()` on other TaskParkers holding resources, as this
+would prevent them from freeing those resources. Also, just as it is
+important to later release any resources acquired within
+`isReadyToStart()` in `onStop()`, it is even more important to release
+those resources in `onNotReadyToStart()` since `isReadyToStart()` may
+be called many times per `TaskParker`, but `onStop()` will only ever be
+be called once.
+
[[change-message-modifier]]
== Change Message Modifier
diff --git a/Documentation/js_licenses.txt b/Documentation/js_licenses.txt
index 8b6049e..ab77d1b 100644
--- a/Documentation/js_licenses.txt
+++ b/Documentation/js_licenses.txt
@@ -1137,6 +1137,45 @@
----
+[[highlightjs-epp]]
+highlightjs-epp
+
+* highlightjs-epp
+
+[[highlightjs-epp_license]]
+----
+BSD 3-Clause License
+
+Copyright (c) 2024, highlight.js
+
+Redistribution and use in source and binary forms, with or without
+modification, are permitted provided that the following conditions are met:
+
+1. Redistributions of source code must retain the above copyright notice, this
+ list of conditions and the following disclaimer.
+
+2. Redistributions in binary form must reproduce the above copyright notice,
+ this list of conditions and the following disclaimer in the documentation
+ and/or other materials provided with the distribution.
+
+3. Neither the name of the copyright holder nor the names of its
+ contributors may be used to endorse or promote products derived from
+ this software without specific prior written permission.
+
+THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
+DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
+FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
+DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
+SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
+CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
+OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+
+----
+
+
[[highlightjs-structured-text]]
highlightjs-structured-text
diff --git a/Documentation/licenses.txt b/Documentation/licenses.txt
index 7e3fa9a..91b6227 100644
--- a/Documentation/licenses.txt
+++ b/Documentation/licenses.txt
@@ -4021,6 +4021,45 @@
----
+[[highlightjs-epp]]
+highlightjs-epp
+
+* highlightjs-epp
+
+[[highlightjs-epp_license]]
+----
+BSD 3-Clause License
+
+Copyright (c) 2024, highlight.js
+
+Redistribution and use in source and binary forms, with or without
+modification, are permitted provided that the following conditions are met:
+
+1. Redistributions of source code must retain the above copyright notice, this
+ list of conditions and the following disclaimer.
+
+2. Redistributions in binary form must reproduce the above copyright notice,
+ this list of conditions and the following disclaimer in the documentation
+ and/or other materials provided with the distribution.
+
+3. Neither the name of the copyright holder nor the names of its
+ contributors may be used to endorse or promote products derived from
+ this software without specific prior written permission.
+
+THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
+DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
+FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
+DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
+SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
+CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
+OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+
+----
+
+
[[highlightjs-structured-text]]
highlightjs-structured-text
diff --git a/Documentation/rest-api-accounts.txt b/Documentation/rest-api-accounts.txt
index d0c4553..384585f 100644
--- a/Documentation/rest-api-accounts.txt
+++ b/Documentation/rest-api-accounts.txt
@@ -1317,6 +1317,7 @@
"work_in_progress_by_default": true,
"allow_browser_notifications": true,
"allow_suggest_code_while_commenting": true,
+ "allow_autocompleting_comments": true,
"diff_page_sidebar": "plugin-foo",
"default_base_for_merges": "FIRST_PARENT",
"my": [
@@ -1372,6 +1373,7 @@
"disable_token_highlighting": true,
"allow_browser_notifications": false,
"allow_suggest_code_while_commenting": false,
+ "allow_autocompleting_comments": false,
"diff_page_sidebar": "NONE",
"diff_view": "SIDE_BY_SIDE",
"mute_common_path_prefixes": true,
@@ -2723,6 +2725,9 @@
|`allow_suggest_code_while_commenting` |not set if `false`|
Whether to receive suggested code while writing comments. This feature needs
a plugin implementation.
+|`allow_autocompleting_comments` |not set if `false`|
+Whether to receive autocompletions while writing comments. This feature needs
+a plugin implementation.
|`diff_page_sidebar` |optional|
String indicating which sidebar should be open on the diff page. Set to "NONE"
if no sidebars should be open. Plugin-supplied sidebars will be prefixed with
@@ -2801,6 +2806,9 @@
|`allow_suggest_code_while_commenting` |not set if `false`|
Whether to receive suggested code while writing comments. This feature needs
a plugin implementation.
+|`allow_autocompleting_comments` |not set if `false`|
+Whether to receive autocompletions while writing comments. This feature needs
+a plugin implementation.
|`diff_page_sidebar` |optional|
String indicating which sidebar should be open on the diff page. Set to "NONE"
if no sidebars should be open. Plugin-supplied sidebars will be prefixed with
diff --git a/Documentation/user-notify.txt b/Documentation/user-notify.txt
index f420fe7..2da3802 100644
--- a/Documentation/user-notify.txt
+++ b/Documentation/user-notify.txt
@@ -245,11 +245,11 @@
comments had been posted in that notification using "Yes" or "No", for
example `Gerrit-HasComments: Yes`.
-[[Gerrit-HasLabels]]Gerrit-HasLabels::
+[[Gerrit-Has-Labels]]Gerrit-Has-Labels::
In comment emails, the has-labels footer states whether label votes had
been posted in that notification using "Yes" or "No", for
-example `Gerrit-HasLabels: No`.
+example `Gerrit-Has-Labels: No`.
[[Gerrit-Comment-In-Reply-To]]Gerrit-Comment-In-Reply-To::
diff --git a/java/com/google/gerrit/entities/Account.java b/java/com/google/gerrit/entities/Account.java
index efbac97..4b7694a 100644
--- a/java/com/google/gerrit/entities/Account.java
+++ b/java/com/google/gerrit/entities/Account.java
@@ -22,6 +22,7 @@
import com.google.common.base.MoreObjects;
import com.google.common.primitives.Ints;
import com.google.errorprone.annotations.CanIgnoreReturnValue;
+import com.google.gerrit.common.ConvertibleToProto;
import com.google.gerrit.common.Nullable;
import com.google.gerrit.extensions.client.DiffPreferencesInfo;
import java.time.Instant;
@@ -58,6 +59,7 @@
/** Key local to Gerrit to identify a user. */
@AutoValue
+ @ConvertibleToProto
public abstract static class Id implements Comparable<Id> {
/** Parse an Account.Id out of a string representation. */
public static Optional<Id> tryParse(String str) {
diff --git a/java/com/google/gerrit/entities/Change.java b/java/com/google/gerrit/entities/Change.java
index fad3aa8..51585f3 100644
--- a/java/com/google/gerrit/entities/Change.java
+++ b/java/com/google/gerrit/entities/Change.java
@@ -18,6 +18,7 @@
import com.google.auto.value.AutoValue;
import com.google.common.primitives.Ints;
+import com.google.gerrit.common.ConvertibleToProto;
import com.google.gerrit.common.Nullable;
import com.google.gerrit.extensions.client.ChangeStatus;
import com.google.gson.Gson;
@@ -104,6 +105,7 @@
/** The numeric change ID */
@AutoValue
+ @ConvertibleToProto
public abstract static class Id {
/**
* Parse a Change.Id out of a string representation.
@@ -271,6 +273,7 @@
* "Ixxxxxx...", and is stored in the Change-Id footer of a commit.
*/
@AutoValue
+ @ConvertibleToProto
public abstract static class Key {
// TODO(dborowitz): This hardly seems worth it: why would someone pass a URL-encoded change key?
// Ideally the standard key() factory method would enforce the format and throw IAE.
@@ -434,7 +437,7 @@
private Id changeId;
/** ServerId of the Gerrit instance that has created the change */
- private String serverId;
+ @Nullable private String serverId;
/** Globally assigned unique identifier of the change */
private Key changeKey;
@@ -545,7 +548,8 @@
* ServerId of the Gerrit instance that created the change. It could be null when the change is
* not fetched from NoteDb but obtained through protobuf deserialisation.
*/
- public @Nullable String getServerId() {
+ @Nullable
+ public String getServerId() {
return serverId;
}
@@ -607,6 +611,7 @@
return originalSubject != null ? originalSubject : subject;
}
+ @Nullable
public String getOriginalSubjectOrNull() {
return originalSubject;
}
@@ -652,6 +657,7 @@
originalSubject = null;
}
+ @Nullable
public String getSubmissionId() {
return submissionId;
}
@@ -684,6 +690,7 @@
return isAbandoned() || isMerged();
}
+ @Nullable
public String getTopic() {
return topic;
}
@@ -720,10 +727,12 @@
this.revertOf = revertOf;
}
+ @Nullable
public Id getRevertOf() {
return this.revertOf;
}
+ @Nullable
public PatchSet.Id getCherryPickOf() {
return cherryPickOf;
}
diff --git a/java/com/google/gerrit/entities/ChangeMessage.java b/java/com/google/gerrit/entities/ChangeMessage.java
index dea070f..c8fc7d2 100644
--- a/java/com/google/gerrit/entities/ChangeMessage.java
+++ b/java/com/google/gerrit/entities/ChangeMessage.java
@@ -15,6 +15,7 @@
package com.google.gerrit.entities;
import com.google.auto.value.AutoValue;
+import com.google.gerrit.common.ConvertibleToProto;
import com.google.gerrit.common.Nullable;
import java.time.Instant;
import java.util.Objects;
@@ -34,6 +35,7 @@
}
@AutoValue
+ @ConvertibleToProto
public abstract static class Key {
public abstract Change.Id changeId();
diff --git a/java/com/google/gerrit/entities/HumanComment.java b/java/com/google/gerrit/entities/HumanComment.java
index 1e48f11..325bd6c 100644
--- a/java/com/google/gerrit/entities/HumanComment.java
+++ b/java/com/google/gerrit/entities/HumanComment.java
@@ -14,6 +14,7 @@
package com.google.gerrit.entities;
+import com.google.gerrit.common.ConvertibleToProto;
import java.time.Instant;
import java.util.Objects;
@@ -26,6 +27,7 @@
*
* <p>Consider updating {@link #getApproximateSize()} when adding/changing fields.
*/
+@ConvertibleToProto
public class HumanComment extends Comment {
public boolean unresolved;
diff --git a/java/com/google/gerrit/entities/LabelId.java b/java/com/google/gerrit/entities/LabelId.java
index 2426818..e3b3024 100644
--- a/java/com/google/gerrit/entities/LabelId.java
+++ b/java/com/google/gerrit/entities/LabelId.java
@@ -15,8 +15,10 @@
package com.google.gerrit.entities;
import com.google.auto.value.AutoValue;
+import com.google.gerrit.common.ConvertibleToProto;
@AutoValue
+@ConvertibleToProto
public abstract class LabelId {
public static final String LEGACY_SUBMIT_NAME = "SUBM";
public static final String CODE_REVIEW = "Code-Review";
diff --git a/java/com/google/gerrit/entities/PatchSet.java b/java/com/google/gerrit/entities/PatchSet.java
index e8759fa..6f71874 100644
--- a/java/com/google/gerrit/entities/PatchSet.java
+++ b/java/com/google/gerrit/entities/PatchSet.java
@@ -23,6 +23,7 @@
import com.google.common.collect.ImmutableList;
import com.google.common.primitives.Ints;
import com.google.errorprone.annotations.InlineMe;
+import com.google.gerrit.common.ConvertibleToProto;
import com.google.gerrit.common.Nullable;
import java.time.Instant;
import java.util.List;
@@ -31,6 +32,7 @@
/** A single revision of a {@link Change}. */
@AutoValue
+@ConvertibleToProto
public abstract class PatchSet {
/** Is the reference name a change reference? */
public static boolean isChangeRef(String name) {
@@ -67,6 +69,7 @@
}
@AutoValue
+ @ConvertibleToProto
public abstract static class Id implements Comparable<Id> {
/** Parse a PatchSet.Id out of a string representation. */
public static Id parse(String str) {
diff --git a/java/com/google/gerrit/entities/PatchSetApproval.java b/java/com/google/gerrit/entities/PatchSetApproval.java
index 608cf0d..f78167b 100644
--- a/java/com/google/gerrit/entities/PatchSetApproval.java
+++ b/java/com/google/gerrit/entities/PatchSetApproval.java
@@ -16,6 +16,7 @@
import com.google.auto.value.AutoValue;
import com.google.common.primitives.Shorts;
+import com.google.gerrit.common.ConvertibleToProto;
import java.time.Instant;
import java.util.Optional;
@@ -27,6 +28,7 @@
}
@AutoValue
+ @ConvertibleToProto
public abstract static class Key {
public abstract PatchSet.Id patchSetId();
diff --git a/java/com/google/gerrit/entities/Project.java b/java/com/google/gerrit/entities/Project.java
index 9c2866c..7b02597 100644
--- a/java/com/google/gerrit/entities/Project.java
+++ b/java/com/google/gerrit/entities/Project.java
@@ -20,6 +20,7 @@
import com.google.common.collect.ImmutableMap;
import com.google.errorprone.annotations.CanIgnoreReturnValue;
import com.google.errorprone.annotations.Immutable;
+import com.google.gerrit.common.ConvertibleToProto;
import com.google.gerrit.common.Nullable;
import com.google.gerrit.extensions.client.InheritableBoolean;
import com.google.gerrit.extensions.client.ProjectState;
@@ -57,6 +58,7 @@
* <p>This class is immutable and thread safe.
*/
@Immutable
+ @ConvertibleToProto
public static class NameKey implements Serializable, Comparable<NameKey> {
private static final long serialVersionUID = 1L;
diff --git a/java/com/google/gerrit/entities/converter/AccountIdProtoConverter.java b/java/com/google/gerrit/entities/converter/AccountIdProtoConverter.java
index 1e846fb..6106090 100644
--- a/java/com/google/gerrit/entities/converter/AccountIdProtoConverter.java
+++ b/java/com/google/gerrit/entities/converter/AccountIdProtoConverter.java
@@ -20,7 +20,7 @@
import com.google.protobuf.Parser;
@Immutable
-public enum AccountIdProtoConverter implements ProtoConverter<Entities.Account_Id, Account.Id> {
+public enum AccountIdProtoConverter implements SafeProtoConverter<Entities.Account_Id, Account.Id> {
INSTANCE;
@Override
@@ -37,4 +37,14 @@
public Parser<Entities.Account_Id> getParser() {
return Entities.Account_Id.parser();
}
+
+ @Override
+ public Class<Entities.Account_Id> getProtoClass() {
+ return Entities.Account_Id.class;
+ }
+
+ @Override
+ public Class<Account.Id> getEntityClass() {
+ return Account.Id.class;
+ }
}
diff --git a/java/com/google/gerrit/entities/converter/ChangeIdProtoConverter.java b/java/com/google/gerrit/entities/converter/ChangeIdProtoConverter.java
index 0d4ec70..909b4d3 100644
--- a/java/com/google/gerrit/entities/converter/ChangeIdProtoConverter.java
+++ b/java/com/google/gerrit/entities/converter/ChangeIdProtoConverter.java
@@ -17,10 +17,11 @@
import com.google.errorprone.annotations.Immutable;
import com.google.gerrit.entities.Change;
import com.google.gerrit.proto.Entities;
+import com.google.gerrit.proto.Entities.Change_Id;
import com.google.protobuf.Parser;
@Immutable
-public enum ChangeIdProtoConverter implements ProtoConverter<Entities.Change_Id, Change.Id> {
+public enum ChangeIdProtoConverter implements SafeProtoConverter<Entities.Change_Id, Change.Id> {
INSTANCE;
@Override
@@ -37,4 +38,14 @@
public Parser<Entities.Change_Id> getParser() {
return Entities.Change_Id.parser();
}
+
+ @Override
+ public Class<Change_Id> getProtoClass() {
+ return Change_Id.class;
+ }
+
+ @Override
+ public Class<Change.Id> getEntityClass() {
+ return Change.Id.class;
+ }
}
diff --git a/java/com/google/gerrit/entities/converter/ChangeKeyProtoConverter.java b/java/com/google/gerrit/entities/converter/ChangeKeyProtoConverter.java
index f3ccdfa..0620c70 100644
--- a/java/com/google/gerrit/entities/converter/ChangeKeyProtoConverter.java
+++ b/java/com/google/gerrit/entities/converter/ChangeKeyProtoConverter.java
@@ -17,10 +17,11 @@
import com.google.errorprone.annotations.Immutable;
import com.google.gerrit.entities.Change;
import com.google.gerrit.proto.Entities;
+import com.google.gerrit.proto.Entities.Change_Key;
import com.google.protobuf.Parser;
@Immutable
-public enum ChangeKeyProtoConverter implements ProtoConverter<Entities.Change_Key, Change.Key> {
+public enum ChangeKeyProtoConverter implements SafeProtoConverter<Entities.Change_Key, Change.Key> {
INSTANCE;
@Override
@@ -37,4 +38,14 @@
public Parser<Entities.Change_Key> getParser() {
return Entities.Change_Key.parser();
}
+
+ @Override
+ public Class<Change_Key> getProtoClass() {
+ return Change_Key.class;
+ }
+
+ @Override
+ public Class<Change.Key> getEntityClass() {
+ return Change.Key.class;
+ }
}
diff --git a/java/com/google/gerrit/entities/converter/ChangeMessageKeyProtoConverter.java b/java/com/google/gerrit/entities/converter/ChangeMessageKeyProtoConverter.java
index 3e93c5a..a76ab98 100644
--- a/java/com/google/gerrit/entities/converter/ChangeMessageKeyProtoConverter.java
+++ b/java/com/google/gerrit/entities/converter/ChangeMessageKeyProtoConverter.java
@@ -18,11 +18,12 @@
import com.google.gerrit.entities.Change;
import com.google.gerrit.entities.ChangeMessage;
import com.google.gerrit.proto.Entities;
+import com.google.gerrit.proto.Entities.ChangeMessage_Key;
import com.google.protobuf.Parser;
@Immutable
public enum ChangeMessageKeyProtoConverter
- implements ProtoConverter<Entities.ChangeMessage_Key, ChangeMessage.Key> {
+ implements SafeProtoConverter<Entities.ChangeMessage_Key, ChangeMessage.Key> {
INSTANCE;
private final ProtoConverter<Entities.Change_Id, Change.Id> changeIdConverter =
@@ -45,4 +46,14 @@
public Parser<Entities.ChangeMessage_Key> getParser() {
return Entities.ChangeMessage_Key.parser();
}
+
+ @Override
+ public Class<ChangeMessage_Key> getProtoClass() {
+ return ChangeMessage_Key.class;
+ }
+
+ @Override
+ public Class<ChangeMessage.Key> getEntityClass() {
+ return ChangeMessage.Key.class;
+ }
}
diff --git a/java/com/google/gerrit/entities/converter/HumanCommentProtoConverter.java b/java/com/google/gerrit/entities/converter/HumanCommentProtoConverter.java
index 6e8c907..316a042 100644
--- a/java/com/google/gerrit/entities/converter/HumanCommentProtoConverter.java
+++ b/java/com/google/gerrit/entities/converter/HumanCommentProtoConverter.java
@@ -35,7 +35,7 @@
*/
@Immutable
public enum HumanCommentProtoConverter
- implements ProtoConverter<Entities.HumanComment, HumanComment> {
+ implements SafeProtoConverter<Entities.HumanComment, HumanComment> {
INSTANCE;
private final ProtoConverter<Entities.Account_Id, Account.Id> accountIdConverter =
@@ -142,4 +142,14 @@
public Parser<Entities.HumanComment> getParser() {
return Entities.HumanComment.parser();
}
+
+ @Override
+ public Class<Entities.HumanComment> getProtoClass() {
+ return Entities.HumanComment.class;
+ }
+
+ @Override
+ public Class<HumanComment> getEntityClass() {
+ return HumanComment.class;
+ }
}
diff --git a/java/com/google/gerrit/entities/converter/LabelIdProtoConverter.java b/java/com/google/gerrit/entities/converter/LabelIdProtoConverter.java
index a1894ac..e6e1be7f 100644
--- a/java/com/google/gerrit/entities/converter/LabelIdProtoConverter.java
+++ b/java/com/google/gerrit/entities/converter/LabelIdProtoConverter.java
@@ -20,7 +20,7 @@
import com.google.protobuf.Parser;
@Immutable
-public enum LabelIdProtoConverter implements ProtoConverter<Entities.LabelId, LabelId> {
+public enum LabelIdProtoConverter implements SafeProtoConverter<Entities.LabelId, LabelId> {
INSTANCE;
@Override
@@ -37,4 +37,14 @@
public Parser<Entities.LabelId> getParser() {
return Entities.LabelId.parser();
}
+
+ @Override
+ public Class<Entities.LabelId> getProtoClass() {
+ return Entities.LabelId.class;
+ }
+
+ @Override
+ public Class<LabelId> getEntityClass() {
+ return LabelId.class;
+ }
}
diff --git a/java/com/google/gerrit/entities/converter/NotifyInfoProtoConverter.java b/java/com/google/gerrit/entities/converter/NotifyInfoProtoConverter.java
index 201dd78..fc963df 100644
--- a/java/com/google/gerrit/entities/converter/NotifyInfoProtoConverter.java
+++ b/java/com/google/gerrit/entities/converter/NotifyInfoProtoConverter.java
@@ -26,7 +26,8 @@
* com.google.gerrit.proto.Entities.NotifyInfo}.
*/
@Immutable
-public enum NotifyInfoProtoConverter implements ProtoConverter<Entities.NotifyInfo, NotifyInfo> {
+public enum NotifyInfoProtoConverter
+ implements SafeProtoConverter<Entities.NotifyInfo, NotifyInfo> {
INSTANCE;
@Override
@@ -47,4 +48,14 @@
public Parser<Entities.NotifyInfo> getParser() {
return Entities.NotifyInfo.parser();
}
+
+ @Override
+ public Class<Entities.NotifyInfo> getProtoClass() {
+ return Entities.NotifyInfo.class;
+ }
+
+ @Override
+ public Class<NotifyInfo> getEntityClass() {
+ return NotifyInfo.class;
+ }
}
diff --git a/java/com/google/gerrit/entities/converter/PatchSetApprovalKeyProtoConverter.java b/java/com/google/gerrit/entities/converter/PatchSetApprovalKeyProtoConverter.java
index c7d1714..3ea14e6 100644
--- a/java/com/google/gerrit/entities/converter/PatchSetApprovalKeyProtoConverter.java
+++ b/java/com/google/gerrit/entities/converter/PatchSetApprovalKeyProtoConverter.java
@@ -20,11 +20,12 @@
import com.google.gerrit.entities.PatchSet;
import com.google.gerrit.entities.PatchSetApproval;
import com.google.gerrit.proto.Entities;
+import com.google.gerrit.proto.Entities.PatchSetApproval_Key;
import com.google.protobuf.Parser;
@Immutable
public enum PatchSetApprovalKeyProtoConverter
- implements ProtoConverter<Entities.PatchSetApproval_Key, PatchSetApproval.Key> {
+ implements SafeProtoConverter<Entities.PatchSetApproval_Key, PatchSetApproval.Key> {
INSTANCE;
private final ProtoConverter<Entities.PatchSet_Id, PatchSet.Id> patchSetIdConverter =
@@ -55,4 +56,14 @@
public Parser<Entities.PatchSetApproval_Key> getParser() {
return Entities.PatchSetApproval_Key.parser();
}
+
+ @Override
+ public Class<PatchSetApproval_Key> getProtoClass() {
+ return PatchSetApproval_Key.class;
+ }
+
+ @Override
+ public Class<PatchSetApproval.Key> getEntityClass() {
+ return PatchSetApproval.Key.class;
+ }
}
diff --git a/java/com/google/gerrit/entities/converter/PatchSetIdProtoConverter.java b/java/com/google/gerrit/entities/converter/PatchSetIdProtoConverter.java
index 60c13f1..f6671cf 100644
--- a/java/com/google/gerrit/entities/converter/PatchSetIdProtoConverter.java
+++ b/java/com/google/gerrit/entities/converter/PatchSetIdProtoConverter.java
@@ -18,10 +18,12 @@
import com.google.gerrit.entities.Change;
import com.google.gerrit.entities.PatchSet;
import com.google.gerrit.proto.Entities;
+import com.google.gerrit.proto.Entities.PatchSet_Id;
import com.google.protobuf.Parser;
@Immutable
-public enum PatchSetIdProtoConverter implements ProtoConverter<Entities.PatchSet_Id, PatchSet.Id> {
+public enum PatchSetIdProtoConverter
+ implements SafeProtoConverter<Entities.PatchSet_Id, PatchSet.Id> {
INSTANCE;
private final ProtoConverter<Entities.Change_Id, Change.Id> changeIdConverter =
@@ -44,4 +46,14 @@
public Parser<Entities.PatchSet_Id> getParser() {
return Entities.PatchSet_Id.parser();
}
+
+ @Override
+ public Class<PatchSet_Id> getProtoClass() {
+ return PatchSet_Id.class;
+ }
+
+ @Override
+ public Class<PatchSet.Id> getEntityClass() {
+ return PatchSet.Id.class;
+ }
}
diff --git a/java/com/google/gerrit/entities/converter/PatchSetProtoConverter.java b/java/com/google/gerrit/entities/converter/PatchSetProtoConverter.java
index 196deca..22985d9 100644
--- a/java/com/google/gerrit/entities/converter/PatchSetProtoConverter.java
+++ b/java/com/google/gerrit/entities/converter/PatchSetProtoConverter.java
@@ -24,7 +24,7 @@
import org.eclipse.jgit.lib.ObjectId;
@Immutable
-public enum PatchSetProtoConverter implements ProtoConverter<Entities.PatchSet, PatchSet> {
+public enum PatchSetProtoConverter implements SafeProtoConverter<Entities.PatchSet, PatchSet> {
INSTANCE;
private final ProtoConverter<Entities.PatchSet_Id, PatchSet.Id> patchSetIdConverter =
@@ -103,4 +103,14 @@
public Parser<Entities.PatchSet> getParser() {
return Entities.PatchSet.parser();
}
+
+ @Override
+ public Class<Entities.PatchSet> getProtoClass() {
+ return Entities.PatchSet.class;
+ }
+
+ @Override
+ public Class<PatchSet> getEntityClass() {
+ return PatchSet.class;
+ }
}
diff --git a/java/com/google/gerrit/entities/converter/ProjectNameKeyProtoConverter.java b/java/com/google/gerrit/entities/converter/ProjectNameKeyProtoConverter.java
index 6bb0f79..320b8fc 100644
--- a/java/com/google/gerrit/entities/converter/ProjectNameKeyProtoConverter.java
+++ b/java/com/google/gerrit/entities/converter/ProjectNameKeyProtoConverter.java
@@ -16,12 +16,14 @@
import com.google.errorprone.annotations.Immutable;
import com.google.gerrit.entities.Project;
+import com.google.gerrit.entities.Project.NameKey;
import com.google.gerrit.proto.Entities;
+import com.google.gerrit.proto.Entities.Project_NameKey;
import com.google.protobuf.Parser;
@Immutable
public enum ProjectNameKeyProtoConverter
- implements ProtoConverter<Entities.Project_NameKey, Project.NameKey> {
+ implements SafeProtoConverter<Entities.Project_NameKey, Project.NameKey> {
INSTANCE;
@Override
@@ -38,4 +40,14 @@
public Parser<Entities.Project_NameKey> getParser() {
return Entities.Project_NameKey.parser();
}
+
+ @Override
+ public Class<Project_NameKey> getProtoClass() {
+ return Project_NameKey.class;
+ }
+
+ @Override
+ public Class<NameKey> getEntityClass() {
+ return NameKey.class;
+ }
}
diff --git a/java/com/google/gerrit/entities/converter/SafeProtoConverter.java b/java/com/google/gerrit/entities/converter/SafeProtoConverter.java
new file mode 100644
index 0000000..f4a66a0
--- /dev/null
+++ b/java/com/google/gerrit/entities/converter/SafeProtoConverter.java
@@ -0,0 +1,29 @@
+package com.google.gerrit.entities.converter;
+
+import com.google.errorprone.annotations.Immutable;
+import com.google.gerrit.common.ConvertibleToProto;
+import com.google.protobuf.Message;
+
+/**
+ * An extension to {@link ProtoConverter} that enforces the Entity class and the Proto class to stay
+ * in sync. The enforcement is done by {@link SafeProtoConverterTest}.
+ *
+ * <p>Requirements:
+ *
+ * <ul>
+ * <li>Implementing classes must be enums with a single value. Please prefer descriptive enum and
+ * instance names, such as {@code MyTypeConverter::MY_TYPE_CONVERTER}.
+ * <li>The Java Entity class must be annotated with {@link ConvertibleToProto}.
+ * </ul>
+ *
+ * <p>All safe converters are tested using {@link SafeProtoConverterTest}. Therefore, unless your
+ * Entity class has a {@code defaults()} method, or other methods besides simple getters and
+ * setters, there is no need to explicitly test your safe converter.
+ */
+@Immutable
+public interface SafeProtoConverter<P extends Message, C> extends ProtoConverter<P, C> {
+
+ Class<P> getProtoClass();
+
+ Class<C> getEntityClass();
+}
diff --git a/java/com/google/gerrit/extensions/api/changes/NotifyInfo.java b/java/com/google/gerrit/extensions/api/changes/NotifyInfo.java
index dd29635..21bf886 100644
--- a/java/com/google/gerrit/extensions/api/changes/NotifyInfo.java
+++ b/java/com/google/gerrit/extensions/api/changes/NotifyInfo.java
@@ -15,10 +15,12 @@
package com.google.gerrit.extensions.api.changes;
import com.google.common.base.MoreObjects;
+import com.google.gerrit.common.ConvertibleToProto;
import java.util.List;
import java.util.Objects;
/** Detailed information about who should be notified about an update. */
+@ConvertibleToProto
public class NotifyInfo {
public List<String> accounts;
diff --git a/java/com/google/gerrit/extensions/client/DiffPreferencesInfo.java b/java/com/google/gerrit/extensions/client/DiffPreferencesInfo.java
index 109afd6..ad494cb 100644
--- a/java/com/google/gerrit/extensions/client/DiffPreferencesInfo.java
+++ b/java/com/google/gerrit/extensions/client/DiffPreferencesInfo.java
@@ -15,8 +15,10 @@
package com.google.gerrit.extensions.client;
import com.google.common.base.MoreObjects;
+import com.google.gerrit.common.ConvertibleToProto;
import java.util.Objects;
+@ConvertibleToProto
public class DiffPreferencesInfo {
/** Default number of lines of context. */
diff --git a/java/com/google/gerrit/extensions/client/EditPreferencesInfo.java b/java/com/google/gerrit/extensions/client/EditPreferencesInfo.java
index 0a3ec0a..5da211e 100644
--- a/java/com/google/gerrit/extensions/client/EditPreferencesInfo.java
+++ b/java/com/google/gerrit/extensions/client/EditPreferencesInfo.java
@@ -15,9 +15,11 @@
package com.google.gerrit.extensions.client;
import com.google.common.base.MoreObjects;
+import com.google.gerrit.common.ConvertibleToProto;
import java.util.Objects;
/* This class is stored in Git config file. */
+@ConvertibleToProto
public class EditPreferencesInfo {
public Integer tabSize;
public Integer lineLength;
diff --git a/java/com/google/gerrit/extensions/client/GeneralPreferencesInfo.java b/java/com/google/gerrit/extensions/client/GeneralPreferencesInfo.java
index 1ed9793..44fc4d5 100644
--- a/java/com/google/gerrit/extensions/client/GeneralPreferencesInfo.java
+++ b/java/com/google/gerrit/extensions/client/GeneralPreferencesInfo.java
@@ -15,10 +15,12 @@
package com.google.gerrit.extensions.client;
import com.google.common.base.MoreObjects;
+import com.google.gerrit.common.ConvertibleToProto;
import java.util.List;
import java.util.Objects;
/** Preferences about a single user. */
+@ConvertibleToProto
public class GeneralPreferencesInfo {
/** Default number of items to display per page. */
@@ -143,6 +145,8 @@
public List<String> changeTable;
public Boolean allowBrowserNotifications;
public Boolean allowSuggestCodeWhileCommenting;
+ public Boolean allowAutocompletingComments;
+
/**
* The sidebar section that the user prefers to have open on the diff page, or "NONE" if all
* sidebars should be closed.
@@ -214,6 +218,9 @@
&& Objects.equals(this.my, other.my)
&& Objects.equals(this.changeTable, other.changeTable)
&& Objects.equals(this.allowBrowserNotifications, other.allowBrowserNotifications)
+ && Objects.equals(
+ this.allowSuggestCodeWhileCommenting, other.allowSuggestCodeWhileCommenting)
+ && Objects.equals(this.allowAutocompletingComments, other.allowAutocompletingComments)
&& Objects.equals(this.diffPageSidebar, other.diffPageSidebar);
}
@@ -242,6 +249,8 @@
my,
changeTable,
allowBrowserNotifications,
+ allowSuggestCodeWhileCommenting,
+ allowAutocompletingComments,
diffPageSidebar);
}
@@ -270,6 +279,8 @@
.add("my", my)
.add("changeTable", changeTable)
.add("allowBrowserNotifications", allowBrowserNotifications)
+ .add("allowSuggestCodeWhileCommenting", allowSuggestCodeWhileCommenting)
+ .add("allowAutocompletingComments", allowAutocompletingComments)
.add("diffPageSidebar", diffPageSidebar)
.toString();
}
@@ -296,8 +307,9 @@
p.disableTokenHighlighting = false;
p.workInProgressByDefault = false;
p.allowBrowserNotifications = true;
- p.diffPageSidebar = "NONE";
p.allowSuggestCodeWhileCommenting = true;
+ p.allowAutocompletingComments = true;
+ p.diffPageSidebar = "NONE";
return p;
}
}
diff --git a/java/com/google/gerrit/server/account/AccountsUpdate.java b/java/com/google/gerrit/server/account/AccountsUpdate.java
index 5951a73..45ff128 100644
--- a/java/com/google/gerrit/server/account/AccountsUpdate.java
+++ b/java/com/google/gerrit/server/account/AccountsUpdate.java
@@ -20,6 +20,8 @@
import static java.lang.annotation.ElementType.PARAMETER;
import static java.lang.annotation.RetentionPolicy.RUNTIME;
+import com.google.common.annotations.VisibleForTesting;
+import com.google.common.base.MoreObjects;
import com.google.common.collect.ImmutableList;
import com.google.errorprone.annotations.CanIgnoreReturnValue;
import com.google.gerrit.entities.Account;
@@ -97,6 +99,14 @@
this.accountId = accountId;
this.configureDeltaFromState = configureDeltaFromState;
}
+
+ @Override
+ public String toString() {
+ return MoreObjects.toStringHelper(this)
+ .add("message", message)
+ .add("accountId", accountId)
+ .toString();
+ }
}
/**
@@ -145,7 +155,7 @@
* would only contain the account ID).
*/
@CanIgnoreReturnValue
- public AccountState insert(
+ public final AccountState insert(
String message, Account.Id accountId, Consumer<AccountDelta.Builder> init)
throws IOException, ConfigInvalidException {
return insert(message, accountId, AccountsUpdate.fromConsumer(init));
@@ -171,7 +181,7 @@
* instead, i.e. the update does not depend on the current account state.
*/
@CanIgnoreReturnValue
- public Optional<AccountState> update(
+ public final Optional<AccountState> update(
String message, Account.Id accountId, Consumer<AccountDelta.Builder> update)
throws IOException, ConfigInvalidException {
return update(message, accountId, AccountsUpdate.fromConsumer(update));
@@ -193,7 +203,7 @@
* @throws ConfigInvalidException if any of the account fields has an invalid value
*/
@CanIgnoreReturnValue
- public Optional<AccountState> update(
+ public final Optional<AccountState> update(
String message, Account.Id accountId, ConfigureDeltaFromState configureDeltaFromState)
throws IOException, ConfigInvalidException {
return updateBatch(
@@ -212,7 +222,7 @@
* together have this property) will always prevent the entire batch from being executed.
*/
@CanIgnoreReturnValue
- public ImmutableList<Optional<AccountState>> updateBatch(List<UpdateArguments> updates)
+ public final ImmutableList<Optional<AccountState>> updateBatch(List<UpdateArguments> updates)
throws IOException, ConfigInvalidException {
checkArgument(
updates.stream().map(u -> u.accountId.get()).distinct().count() == updates.size(),
@@ -231,7 +241,8 @@
public abstract void delete(String message, Account.Id accountId)
throws IOException, ConfigInvalidException;
- protected abstract ImmutableList<Optional<AccountState>> executeUpdates(
+ @VisibleForTesting // productionVisibility: protected
+ public abstract ImmutableList<Optional<AccountState>> executeUpdates(
List<UpdateArguments> updates) throws ConfigInvalidException, IOException;
private static PersonIdent createPersonIdent(
diff --git a/java/com/google/gerrit/server/account/storage/notedb/AccountsUpdateNoteDbImpl.java b/java/com/google/gerrit/server/account/storage/notedb/AccountsUpdateNoteDbImpl.java
index ad3681d..27a43ba 100644
--- a/java/com/google/gerrit/server/account/storage/notedb/AccountsUpdateNoteDbImpl.java
+++ b/java/com/google/gerrit/server/account/storage/notedb/AccountsUpdateNoteDbImpl.java
@@ -380,7 +380,8 @@
}
@Override
- protected ImmutableList<Optional<AccountState>> executeUpdates(List<UpdateArguments> updates)
+ @VisibleForTesting
+ public ImmutableList<Optional<AccountState>> executeUpdates(List<UpdateArguments> updates)
throws ConfigInvalidException, IOException {
return execute(updates.stream().map(this::createExecutableUpdate).collect(toImmutableList()));
}
diff --git a/java/com/google/gerrit/server/config/UserPreferencesConverter.java b/java/com/google/gerrit/server/config/UserPreferencesConverter.java
index 912abc4..4b2f6d2 100644
--- a/java/com/google/gerrit/server/config/UserPreferencesConverter.java
+++ b/java/com/google/gerrit/server/config/UserPreferencesConverter.java
@@ -17,7 +17,7 @@
import static com.google.common.collect.ImmutableList.toImmutableList;
import com.google.gerrit.common.Nullable;
-import com.google.gerrit.entities.converter.ProtoConverter;
+import com.google.gerrit.entities.converter.SafeProtoConverter;
import com.google.gerrit.extensions.client.DiffPreferencesInfo;
import com.google.gerrit.extensions.client.EditPreferencesInfo;
import com.google.gerrit.extensions.client.GeneralPreferencesInfo;
@@ -36,7 +36,8 @@
*/
public final class UserPreferencesConverter {
public enum GeneralPreferencesInfoConverter
- implements ProtoConverter<UserPreferences.GeneralPreferencesInfo, GeneralPreferencesInfo> {
+ implements
+ SafeProtoConverter<UserPreferences.GeneralPreferencesInfo, GeneralPreferencesInfo> {
GENERAL_PREFERENCES_INFO_CONVERTER;
@Override
@@ -118,6 +119,14 @@
builder =
setIfNotNull(
builder, builder::setAllowBrowserNotifications, info.allowBrowserNotifications);
+ builder =
+ setIfNotNull(
+ builder,
+ builder::setAllowSuggestCodeWhileCommenting,
+ info.allowSuggestCodeWhileCommenting);
+ builder =
+ setIfNotNull(
+ builder, builder::setAllowAutocompletingComments, info.allowAutocompletingComments);
builder = setIfNotNull(builder, builder::setDiffPageSidebar, info.diffPageSidebar);
return builder.build();
}
@@ -180,6 +189,12 @@
res.changeTable = proto.getChangeTableCount() != 0 ? proto.getChangeTableList() : null;
res.allowBrowserNotifications =
proto.hasAllowBrowserNotifications() ? proto.getAllowBrowserNotifications() : null;
+ res.allowSuggestCodeWhileCommenting =
+ proto.hasAllowSuggestCodeWhileCommenting()
+ ? proto.getAllowSuggestCodeWhileCommenting()
+ : null;
+ res.allowAutocompletingComments =
+ proto.hasAllowAutocompletingComments() ? proto.getAllowAutocompletingComments() : null;
res.diffPageSidebar = proto.hasDiffPageSidebar() ? proto.getDiffPageSidebar() : null;
return res;
}
@@ -212,10 +227,20 @@
proto.hasTarget() ? proto.getTarget().trim() : null,
proto.hasId() ? proto.getId().trim() : null);
}
+
+ @Override
+ public Class<UserPreferences.GeneralPreferencesInfo> getProtoClass() {
+ return UserPreferences.GeneralPreferencesInfo.class;
+ }
+
+ @Override
+ public Class<GeneralPreferencesInfo> getEntityClass() {
+ return GeneralPreferencesInfo.class;
+ }
}
public enum DiffPreferencesInfoConverter
- implements ProtoConverter<UserPreferences.DiffPreferencesInfo, DiffPreferencesInfo> {
+ implements SafeProtoConverter<UserPreferences.DiffPreferencesInfo, DiffPreferencesInfo> {
DIFF_PREFERENCES_INFO_CONVERTER;
@Override
@@ -295,10 +320,20 @@
public Parser<UserPreferences.DiffPreferencesInfo> getParser() {
return UserPreferences.DiffPreferencesInfo.parser();
}
+
+ @Override
+ public Class<UserPreferences.DiffPreferencesInfo> getProtoClass() {
+ return UserPreferences.DiffPreferencesInfo.class;
+ }
+
+ @Override
+ public Class<DiffPreferencesInfo> getEntityClass() {
+ return DiffPreferencesInfo.class;
+ }
}
public enum EditPreferencesInfoConverter
- implements ProtoConverter<UserPreferences.EditPreferencesInfo, EditPreferencesInfo> {
+ implements SafeProtoConverter<UserPreferences.EditPreferencesInfo, EditPreferencesInfo> {
EDIT_PREFERENCES_INFO_CONVERTER;
@Override
@@ -347,6 +382,16 @@
public Parser<UserPreferences.EditPreferencesInfo> getParser() {
return UserPreferences.EditPreferencesInfo.parser();
}
+
+ @Override
+ public Class<UserPreferences.EditPreferencesInfo> getProtoClass() {
+ return UserPreferences.EditPreferencesInfo.class;
+ }
+
+ @Override
+ public Class<EditPreferencesInfo> getEntityClass() {
+ return EditPreferencesInfo.class;
+ }
}
private static <ValueT, BuilderT extends Message.Builder> BuilderT setIfNotNull(
diff --git a/java/com/google/gerrit/server/git/ChangesByProjectCacheImpl.java b/java/com/google/gerrit/server/git/ChangesByProjectCacheImpl.java
index 2bbd261..17cc5a0 100644
--- a/java/com/google/gerrit/server/git/ChangesByProjectCacheImpl.java
+++ b/java/com/google/gerrit/server/git/ChangesByProjectCacheImpl.java
@@ -301,7 +301,9 @@
size += JavaWeights.REFERENCE + (c.getTopic() == null ? 0 : c.getTopic().length());
size +=
JavaWeights.REFERENCE
- + (c.getOriginalSubject().equals(c.getSubject()) ? 0 : c.getSubject().length());
+ + (c.getOriginalSubject().equals(c.getSubject())
+ ? 0
+ : c.getOriginalSubject().length());
size +=
JavaWeights.REFERENCE + (c.getSubmissionId() == null ? 0 : c.getSubmissionId().length());
size += JavaWeights.REFERENCE + JavaWeights.BOOLEAN; // isPrivate;
@@ -309,7 +311,7 @@
size += JavaWeights.REFERENCE + JavaWeights.BOOLEAN; // reviewStarted;
size += JavaWeights.REFERENCE + (c.getRevertOf() == null ? 0 : GerritWeights.CHANGE_NUM);
size +=
- JavaWeights.REFERENCE + (c.getCherryPickOf() == null ? 0 : GerritWeights.PACTCH_SET_ID);
+ JavaWeights.REFERENCE + (c.getCherryPickOf() == null ? 0 : GerritWeights.PATCH_SET_ID);
return size;
}
@@ -343,7 +345,7 @@
public static final int KEY_INT = JavaWeights.OBJECT + JavaWeights.INT; // IntKey
public static final int CHANGE_NUM = KEY_INT;
public static final int ACCOUNT_ID = KEY_INT;
- public static final int PACTCH_SET_ID =
+ public static final int PATCH_SET_ID =
JavaWeights.OBJECT
+ (JavaWeights.REFERENCE + GerritWeights.CHANGE_NUM) // PatchSet.Id.changeId
+ JavaWeights.INT; // PatchSet.Id patch_num;
diff --git a/java/com/google/gerrit/server/git/WorkQueue.java b/java/com/google/gerrit/server/git/WorkQueue.java
index 307ec5c..6f904ea 100644
--- a/java/com/google/gerrit/server/git/WorkQueue.java
+++ b/java/com/google/gerrit/server/git/WorkQueue.java
@@ -39,15 +39,20 @@
import java.time.Instant;
import java.util.ArrayList;
import java.util.Collection;
+import java.util.HashSet;
import java.util.List;
+import java.util.Optional;
+import java.util.Set;
import java.util.concurrent.Callable;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.CopyOnWriteArrayList;
+import java.util.concurrent.CountDownLatch;
import java.util.concurrent.Delayed;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;
import java.util.concurrent.FutureTask;
+import java.util.concurrent.PriorityBlockingQueue;
import java.util.concurrent.RunnableScheduledFuture;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.ScheduledFuture;
@@ -56,7 +61,9 @@
import java.util.concurrent.TimeUnit;
import java.util.concurrent.TimeoutException;
import java.util.concurrent.atomic.AtomicInteger;
+import java.util.concurrent.atomic.AtomicLong;
import java.util.concurrent.atomic.AtomicReference;
+import org.apache.commons.lang3.mutable.MutableBoolean;
import org.eclipse.jgit.lib.Config;
/** Delayed execution of tasks using a background thread pool. */
@@ -88,6 +95,54 @@
void onStop(Task<?> task);
}
+ /**
+ * Register a TaskParker from a plugin like this:
+ *
+ * <p>bind(TaskListener.class).annotatedWith(Exports.named("MyParker")).to(MyParker.class);
+ */
+ public interface TaskParker extends TaskListener {
+ class NoOp extends TaskListener.NoOp implements TaskParker {
+ @Override
+ public boolean isReadyToStart(Task<?> task) {
+ return true;
+ }
+
+ @Override
+ public void onNotReadyToStart(Task<?> task) {}
+ }
+
+ /**
+ * Determine whether a {@link Task} is ready to run or whether it should get parked.
+ *
+ * <p>Tasks that are not ready to run will get parked and will not run until all {@link
+ * TaskParker}s return {@code true} from this method for the {@link Task}. This method may be
+ * called more than once, but will always be followed by a call to {@link
+ * #onNotReadyToStart(Task)} before being called again.
+ *
+ * <p>Resources should be acquired in this method via non-blocking means to avoid delaying the
+ * executor from calling {@link #onNotReadyToStart(Task)} on other {@link TaskParker}s holding
+ * resources.
+ *
+ * @param task the {@link Task} being considered for starting/parking
+ * @return a boolean indicating if the given {@link Task} is ready to run ({@code true}) or
+ * should be parked ({@code false})
+ */
+ boolean isReadyToStart(Task<?> task);
+
+ /**
+ * This method will be called after this {@link TaskParker} returns {@code true} from {@link
+ * #isReadyToStart(Task)} and another {@link TaskParker} returns {@code false}, thus preventing
+ * the start.
+ *
+ * <p>Implementors should use this method to free any resources acquired in {@link
+ * #isReadyToStart(Task)} based on the expectation that the task would start. Those resources
+ * can be re-acquired when {@link #isReadyToStart(Task)} is called again later.
+ *
+ * @param task the {@link Task} that was prevented from starting by another {@link TaskParker}
+ */
+ void onNotReadyToStart(Task<?> task);
+ }
+
public static class Lifecycle implements LifecycleListener {
private final WorkQueue workQueue;
@@ -288,9 +343,75 @@
/** An isolated queue. */
private class Executor extends ScheduledThreadPoolExecutor {
+ private class ParkedTask implements Comparable<ParkedTask> {
+ public final CancellableCountDownLatch latch = new CancellableCountDownLatch(1);
+ public final Task<?> task;
+ private final Long priority = priorityGenerator.getAndIncrement();
+
+ public ParkedTask(Task<?> task) {
+ this.task = task;
+ }
+
+ @Override
+ public int compareTo(ParkedTask o) {
+ return priority.compareTo(o.priority);
+ }
+
+ /**
+ * Cancel a parked {@link Task}.
+ *
+ * <p>Tasks awaiting in {@link #onStart(Task)} to be un-parked can be interrupted using this
+ * method.
+ */
+ public void cancel() {
+ latch.cancel();
+ }
+
+ public boolean isEqualTo(Task task) {
+ return this.task.taskId == task.taskId;
+ }
+ }
+
+ private class CancellableCountDownLatch extends CountDownLatch {
+ protected volatile boolean cancelled = false;
+
+ public CancellableCountDownLatch(int count) {
+ super(count);
+ }
+
+ /**
+ * Unblocks threads which are waiting until the latch has counted down to zero.
+ *
+ * <p>If the current count is zero, then this method returns immediately.
+ *
+ * <p>If the current count is greater than zero, then it decrements until the count reaches
+ * zero and causes all threads waiting on the latch using {@link CountDownLatch#await()} to
+ * throw an {@link InterruptedException}.
+ */
+ public void cancel() {
+ if (getCount() == 0) {
+ return;
+ }
+ this.cancelled = true;
+ while (getCount() > 0) {
+ countDown();
+ }
+ }
+
+ @Override
+ public void await() throws InterruptedException {
+ super.await();
+ if (cancelled) {
+ throw new InterruptedException();
+ }
+ }
+ }
+
private final ConcurrentHashMap<Integer, Task<?>> all;
private final ConcurrentHashMap<Runnable, Long> nanosPeriodByRunnable;
private final String queueName;
+ private final AtomicLong priorityGenerator = new AtomicLong();
+ private final PriorityBlockingQueue<ParkedTask> parked = new PriorityBlockingQueue<>();
Executor(int corePoolSize, final String queueName) {
super(
@@ -488,7 +609,17 @@
}
void remove(Task<?> task) {
- all.remove(task.getTaskId(), task);
+ boolean isRemoved = all.remove(task.getTaskId(), task);
+ if (isRemoved && !listeners.isEmpty()) {
+ cancelIfParked(task);
+ }
+ }
+
+ void cancelIfParked(Task<?> task) {
+ Optional<ParkedTask> parkedTask = parked.stream().filter(p -> p.isEqualTo(task)).findFirst();
+ if (parkedTask.isPresent()) {
+ parkedTask.get().cancel();
+ }
}
Task<?> getTask(int id) {
@@ -503,12 +634,86 @@
return all.values();
}
+ public void waitUntilReadyToStart(Task<?> task) {
+ if (!listeners.isEmpty() && !isReadyToStart(task)) {
+ incrementCorePoolSizeBy(1);
+ ParkedTask parkedTask = new ParkedTask(task);
+ parked.offer(parkedTask);
+ task.runningState.set(Task.State.PARKED);
+ try {
+ parkedTask.latch.await();
+ } catch (InterruptedException e) {
+ logger.atSevere().withCause(e).log("Parked Task(%s) Interrupted", task);
+ parked.remove(parkedTask);
+ incrementCorePoolSizeBy(-1);
+ }
+ }
+ }
+
public void onStart(Task<?> task) {
listeners.runEach(extension -> extension.get().onStart(task));
}
public void onStop(Task<?> task) {
listeners.runEach(extension -> extension.get().onStop(task));
+ updateParked();
+ }
+
+ protected boolean isReadyToStart(Task<?> task) {
+ MutableBoolean isReady = new MutableBoolean(true);
+ Set<TaskParker> readyParkers = new HashSet<>();
+ listeners.runEach(
+ extension -> {
+ if (isReady.isTrue()) {
+ TaskListener listener = extension.get();
+ if (listener instanceof TaskParker) {
+ TaskParker parker = (TaskParker) listener;
+ if (parker.isReadyToStart(task)) {
+ readyParkers.add(parker);
+ } else {
+ isReady.setFalse();
+ }
+ }
+ }
+ });
+
+ if (isReady.isFalse()) {
+ listeners.runEach(
+ extension -> {
+ TaskListener listener = extension.get();
+ if (readyParkers.contains(listener)) {
+ ((TaskParker) listener).onNotReadyToStart(task);
+ }
+ });
+ }
+ return isReady.getValue();
+ }
+
+ public void updateParked() {
+ ParkedTask ready = parked.poll();
+ if (ready == null) {
+ return;
+ }
+ List<ParkedTask> notReady = new ArrayList<>();
+ while (ready != null && !isReadyToStart(ready.task)) {
+ notReady.add(ready);
+ ready = parked.poll();
+ }
+ parked.addAll(notReady);
+
+ if (ready != null) {
+ incrementCorePoolSizeBy(-1);
+ ready.latch.countDown();
+ }
+ }
+
+ public synchronized void incrementCorePoolSizeBy(int i) {
+ super.setCorePoolSize(getCorePoolSize() + i);
+ }
+
+ @Override
+ public synchronized void setCorePoolSize(int s) {
+ super.setCorePoolSize(s);
}
}
@@ -556,13 +761,14 @@
// Ordered like this so ordinal matches the order we would
// prefer to see tasks sorted in: done before running,
// stopping before running, running before starting,
- // starting before ready, ready before sleeping.
+ // starting before parked, parked before ready, ready before sleeping.
//
DONE,
CANCELLED,
STOPPING,
RUNNING,
STARTING,
+ PARKED,
READY,
SLEEPING,
OTHER
@@ -694,12 +900,14 @@
@Override
public void run() {
- if (runningState.compareAndSet(null, State.STARTING)) {
+ if (runningState.compareAndSet(null, State.READY)) {
String oldThreadName = Thread.currentThread().getName();
try {
+ Thread.currentThread().setName(oldThreadName + "[" + this + "]");
+ executor.waitUntilReadyToStart(this); // Transitions to PARKED while not ready to start
+ runningState.set(State.STARTING);
executor.onStart(this);
runningState.set(State.RUNNING);
- Thread.currentThread().setName(oldThreadName + "[" + this + "]");
task.run();
} finally {
Thread.currentThread().setName(oldThreadName);
diff --git a/java/com/google/gerrit/server/restapi/change/GetPatch.java b/java/com/google/gerrit/server/restapi/change/GetPatch.java
index d8946a7..749a241 100644
--- a/java/com/google/gerrit/server/restapi/change/GetPatch.java
+++ b/java/com/google/gerrit/server/restapi/change/GetPatch.java
@@ -17,6 +17,7 @@
import static com.google.gerrit.git.ObjectIds.abbreviateName;
import static java.nio.charset.StandardCharsets.UTF_8;
+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.ResourceNotFoundException;
@@ -51,6 +52,10 @@
@Option(name = "--path")
private String path;
+ /** 1-based index of the parent's position in the commit object. */
+ @Option(name = "--parent", metaVar = "parent-number")
+ private Integer parentNum;
+
@Inject
GetPatch(GitRepositoryManager repoManager) {
this.repoManager = repoManager;
@@ -58,7 +63,8 @@
@Override
public Response<BinaryResult> apply(RevisionResource rsrc)
- throws ResourceConflictException, IOException, ResourceNotFoundException {
+ throws BadRequestException, ResourceConflictException, IOException,
+ ResourceNotFoundException {
final Repository repo = repoManager.openRepository(rsrc.getProject());
boolean close = true;
try {
@@ -67,12 +73,16 @@
try {
final RevCommit commit = rw.parseCommit(rsrc.getPatchSet().commitId());
RevCommit[] parents = commit.getParents();
- if (parents.length > 1) {
+ if (parentNum == null && parents.length > 1) {
throw new ResourceConflictException("Revision has more than 1 parent.");
- } else if (parents.length == 0) {
+ }
+ if (parents.length == 0) {
throw new ResourceConflictException("Revision has no parent.");
}
- final RevCommit base = parents[0];
+ if (parentNum != null && (parentNum < 1 || parentNum > parents.length)) {
+ throw new BadRequestException(String.format("invalid parent number: %d", parentNum));
+ }
+ final RevCommit base = parents[parentNum == null ? 0 : parentNum - 1];
rw.parseBody(base);
bin =
diff --git a/java/com/google/gerrit/server/restapi/config/GetSummary.java b/java/com/google/gerrit/server/restapi/config/GetSummary.java
index 77af0f3..c76f0a4 100644
--- a/java/com/google/gerrit/server/restapi/config/GetSummary.java
+++ b/java/com/google/gerrit/server/restapi/config/GetSummary.java
@@ -77,6 +77,7 @@
int tasksTotal = pending.size();
int tasksStopping = 0;
int tasksRunning = 0;
+ int tasksParked = 0;
int tasksStarting = 0;
int tasksReady = 0;
int tasksSleeping = 0;
@@ -88,6 +89,9 @@
case RUNNING:
tasksRunning++;
break;
+ case PARKED:
+ tasksParked++;
+ break;
case STARTING:
tasksStarting++;
break;
@@ -108,6 +112,7 @@
taskSummary.total = toInteger(tasksTotal);
taskSummary.stopping = toInteger(tasksStopping);
taskSummary.running = toInteger(tasksRunning);
+ taskSummary.parked = toInteger(tasksParked);
taskSummary.starting = toInteger(tasksStarting);
taskSummary.ready = toInteger(tasksReady);
taskSummary.sleeping = toInteger(tasksSleeping);
@@ -245,6 +250,7 @@
public Integer total;
public Integer stopping;
public Integer running;
+ public Integer parked;
public Integer starting;
public Integer ready;
public Integer sleeping;
diff --git a/java/com/google/gerrit/sshd/commands/ShowQueue.java b/java/com/google/gerrit/sshd/commands/ShowQueue.java
index 00361ad..14915bf 100644
--- a/java/com/google/gerrit/sshd/commands/ShowQueue.java
+++ b/java/com/google/gerrit/sshd/commands/ShowQueue.java
@@ -134,6 +134,7 @@
switch (task.state) {
case DONE:
case CANCELLED:
+ case PARKED:
case STARTING:
case RUNNING:
case STOPPING:
@@ -212,6 +213,8 @@
return "";
case STARTING:
return "starting ...";
+ case PARKED:
+ return "parked .....";
case READY:
return "waiting ....";
case SLEEPING:
diff --git a/javatests/com/google/gerrit/acceptance/api/accounts/GeneralPreferencesIT.java b/javatests/com/google/gerrit/acceptance/api/accounts/GeneralPreferencesIT.java
index 0b28f6f..afa9bca 100644
--- a/javatests/com/google/gerrit/acceptance/api/accounts/GeneralPreferencesIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/accounts/GeneralPreferencesIT.java
@@ -85,6 +85,7 @@
i.signedOffBy ^= true;
i.allowBrowserNotifications ^= false;
i.allowSuggestCodeWhileCommenting ^= false;
+ i.allowAutocompletingComments ^= false;
i.diffPageSidebar = "plugin-insight";
i.diffView = DiffView.UNIFIED_DIFF;
i.my = new ArrayList<>();
@@ -99,6 +100,7 @@
assertThat(o.theme).isEqualTo(i.theme);
assertThat(o.allowBrowserNotifications).isEqualTo(i.allowBrowserNotifications);
assertThat(o.allowSuggestCodeWhileCommenting).isEqualTo(i.allowSuggestCodeWhileCommenting);
+ assertThat(o.allowAutocompletingComments).isEqualTo(i.allowAutocompletingComments);
assertThat(o.diffPageSidebar).isEqualTo(i.diffPageSidebar);
assertThat(o.disableKeyboardShortcuts).isEqualTo(i.disableKeyboardShortcuts);
}
diff --git a/javatests/com/google/gerrit/acceptance/rest/change/CreateChangeIT.java b/javatests/com/google/gerrit/acceptance/rest/change/CreateChangeIT.java
index df7cd8f..1e03f2d 100644
--- a/javatests/com/google/gerrit/acceptance/rest/change/CreateChangeIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/change/CreateChangeIT.java
@@ -1186,6 +1186,52 @@
}
@Test
+ public void changePatch_multipleParents_success() throws Exception {
+ changeInTwoBranches("branchA", "a.txt", "branchB", "b.txt");
+ ChangeInput in = newMergeChangeInput("branchA", "branchB", "");
+ ChangeInfo change = assertCreateSucceeds(in);
+
+ RestResponse patchResp =
+ userRestSession.get("/changes/" + change.id + "/revisions/current/patch?parent=1");
+ patchResp.assertOK();
+ assertThat(new String(Base64.decode(patchResp.getEntityContent()), UTF_8))
+ .contains("+B content");
+
+ patchResp = userRestSession.get("/changes/" + change.id + "/revisions/current/patch?parent=2");
+ patchResp.assertOK();
+ assertThat(new String(Base64.decode(patchResp.getEntityContent()), UTF_8))
+ .contains("+A content");
+ }
+
+ @Test
+ public void changePatch_multipleParents_failure() throws Exception {
+ changeInTwoBranches("branchA", "a.txt", "branchB", "b.txt");
+ ChangeInput in = newMergeChangeInput("branchA", "branchB", "");
+ ChangeInfo change = assertCreateSucceeds(in);
+
+ RestResponse patchResp =
+ userRestSession.get("/changes/" + change.id + "/revisions/current/patch");
+ // Maintaining historic logic of failing with 409 Conflict in this case.
+ patchResp.assertConflict();
+ }
+
+ @Test
+ public void changePatch_parent_badRequest() throws Exception {
+ changeInTwoBranches("branchA", "a.txt", "branchB", "b.txt");
+ ChangeInput in = newMergeChangeInput("branchA", "branchB", "");
+ ChangeInfo change = assertCreateSucceeds(in);
+
+ RestResponse patchResp =
+ userRestSession.get("/changes/" + change.id + "/revisions/current/patch?parent=3");
+ // Parent 3 does not exist.
+ patchResp.assertBadRequest();
+
+ patchResp = userRestSession.get("/changes/" + change.id + "/revisions/current/patch?parent=0");
+ // Parent 0 does not exist.
+ patchResp.assertBadRequest();
+ }
+
+ @Test
@UseSystemTime
public void sha1sOfTwoNewChangesDiffer() throws Exception {
ChangeInput changeInput = newChangeInput(ChangeStatus.NEW);
diff --git a/javatests/com/google/gerrit/acceptance/server/util/TaskListenerIT.java b/javatests/com/google/gerrit/acceptance/server/util/TaskListenerIT.java
index 809cee9..9e9e1c2 100644
--- a/javatests/com/google/gerrit/acceptance/server/util/TaskListenerIT.java
+++ b/javatests/com/google/gerrit/acceptance/server/util/TaskListenerIT.java
@@ -34,109 +34,147 @@
public class TaskListenerIT extends AbstractDaemonTest {
/**
* Use a LatchedMethod in a method to allow another thread to await the method's call. Once
- * called, the Latch.call() method will block until another thread calls its LatchedMethods's
- * complete() method.
+ * called, the call() method will block until another thread calls the complete() method or until
+ * a preset timeout is reached.
*/
- private static class LatchedMethod {
- private static final int AWAIT_TIMEOUT = 20;
- private static final TimeUnit AWAIT_TIMEUNIT = TimeUnit.MILLISECONDS;
-
- /** API class meant be used by the class whose method is being latched */
- private class Latch {
- /** Ensure that the latched method calls this on entry */
- public void call() {
- called.countDown();
- await(complete);
- }
- }
-
- public Latch latch = new Latch();
+ public static class LatchedMethod<T> {
+ private volatile T value;
private final CountDownLatch called = new CountDownLatch(1);
private final CountDownLatch complete = new CountDownLatch(1);
- /** Assert that the Latch's call() method has not yet been called */
+ /** Assert that the call() method has not yet been called */
public void assertUncalled() {
assertThat(called.getCount()).isEqualTo(1);
}
/**
- * Assert that a timeout does not occur while awaiting Latch's call() method to be called. Fails
- * if the waiting time elapses before Latch's call() method is called, otherwise passes.
+ * Assert that a timeout does not occur while awaiting the call() to be called. Fails if the
+ * waiting time elapses before the call() method is called, otherwise passes.
*/
- public void assertAwait() {
+ public void assertCalledEventually() {
assertThat(await(called)).isEqualTo(true);
}
- /** Unblock the Latch's call() method so that it can complete */
+ public T call() {
+ called.countDown();
+ await(complete);
+ return getValue();
+ }
+
+ public T call(T val) {
+ set(val);
+ return call();
+ }
+
+ public T callAndAwaitComplete() throws InterruptedException {
+ called.countDown();
+ complete.await();
+ return getValue();
+ }
+
public void complete() {
complete.countDown();
}
- @CanIgnoreReturnValue
- private static boolean await(CountDownLatch latch) {
- try {
- return latch.await(AWAIT_TIMEOUT, AWAIT_TIMEUNIT);
- } catch (InterruptedException e) {
- return false;
- }
+ public void set(T val) {
+ value = val;
+ }
+
+ public void complete(T val) {
+ set(val);
+ complete();
+ }
+
+ public void assertCalledEventuallyThenComplete(T val) {
+ assertCalledEventually();
+ complete(val);
+ }
+
+ protected T getValue() {
+ return value;
}
}
- private static class LatchedRunnable implements Runnable {
- public LatchedMethod run = new LatchedMethod();
+ public static class LatchedRunnable implements Runnable {
+ public LatchedMethod<?> run = new LatchedMethod<>();
+ public String name = "latched-runnable";
+
+ public LatchedRunnable(String name) {
+ this.name = name;
+ }
+
+ public LatchedRunnable() {}
@Override
public void run() {
- run.latch.call();
+ run.call();
+ }
+
+ @Override
+ public String toString() {
+ return name;
}
}
- private static class ForwardingListener implements TaskListener {
- public volatile TaskListener delegate;
+ public static class ForwardingListener<T extends TaskListener> implements TaskListener {
+ public volatile T delegate;
public volatile Task<?> task;
- public void resetDelegate(TaskListener listener) {
+ public void resetDelegate(T listener) {
delegate = listener;
task = null;
}
@Override
public void onStart(Task<?> task) {
- if (delegate != null) {
- if (this.task == null || this.task == task) {
- this.task = task;
- delegate.onStart(task);
- }
+ if (isDelegatable(task)) {
+ delegate.onStart(task);
}
}
@Override
public void onStop(Task<?> task) {
+ if (isDelegatable(task)) {
+ delegate.onStop(task);
+ }
+ }
+
+ protected boolean isDelegatable(Task<?> task) {
if (delegate != null) {
if (this.task == task) {
- delegate.onStop(task);
+ return true;
+ }
+ if (this.task == null) {
+ this.task = task;
+ return true;
}
}
+ return false;
}
}
- private static class LatchedListener implements TaskListener {
- public LatchedMethod onStart = new LatchedMethod();
- public LatchedMethod onStop = new LatchedMethod();
+ public static class LatchedListener implements TaskListener {
+ public LatchedMethod<?> onStart = new LatchedMethod<>();
+ public LatchedMethod<?> onStop = new LatchedMethod<>();
@Override
public void onStart(Task<?> task) {
- onStart.latch.call();
+ onStart.call();
}
@Override
public void onStop(Task<?> task) {
- onStop.latch.call();
+ onStop.call();
}
}
- private static ForwardingListener forwarder;
+ private static final int AWAIT_TIMEOUT = 20;
+ private static final TimeUnit AWAIT_TIMEUNIT = TimeUnit.MILLISECONDS;
+ private static final long MS_EMPTY_QUEUE =
+ TimeUnit.MILLISECONDS.convert(50, TimeUnit.MILLISECONDS);
+
+ private static ForwardingListener<TaskListener> forwarder;
@Inject private WorkQueue workQueue;
private ScheduledExecutorService executor;
@@ -149,9 +187,9 @@
return new AbstractModule() {
@Override
public void configure() {
- // Forwarder.delegate is empty on start to protect test listener from non test tasks
- // (such as the "Log File Manager") interference
- forwarder = new ForwardingListener(); // Only gets bound once for all tests
+ // Forwarder.delegate is empty on start to protect test listener from non-test tasks (such
+ // as the "Log File Manager") interference
+ forwarder = new ForwardingListener<>(); // Only gets bound once for all tests
bind(TaskListener.class).annotatedWith(Exports.named("listener")).toInstance(forwarder);
}
};
@@ -184,23 +222,23 @@
int size = assertQueueBlockedOnExecution(runnable);
// onStartThenRunThenOnStopAreCalled -> onStart...Called
- listener.onStart.assertAwait();
+ listener.onStart.assertCalledEventually();
assertQueueSize(size);
runnable.run.assertUncalled();
listener.onStop.assertUncalled();
listener.onStart.complete();
// onStartThenRunThenOnStopAreCalled -> ...ThenRun...Called
- runnable.run.assertAwait();
+ runnable.run.assertCalledEventually();
listener.onStop.assertUncalled();
runnable.run.complete();
// onStartThenRunThenOnStopAreCalled -> ...ThenOnStop...Called
- listener.onStop.assertAwait();
+ listener.onStop.assertCalledEventually();
assertQueueSize(size);
listener.onStop.complete();
- assertAwaitQueueSize(--size);
+ assertTaskCountIsEventually(--size);
}
@Test
@@ -208,7 +246,7 @@
int size = assertQueueBlockedOnExecution(runnable);
// firstBlocksSecond -> first...
- listener.onStart.assertAwait();
+ listener.onStart.assertCalledEventually();
assertQueueSize(size);
LatchedRunnable runnable2 = new LatchedRunnable();
@@ -219,35 +257,35 @@
assertQueueSize(size); // waiting on first
listener.onStart.complete();
- runnable.run.assertAwait();
+ runnable.run.assertCalledEventually();
assertQueueSize(size); // waiting on first
runnable2.run.assertUncalled();
runnable.run.complete();
- listener.onStop.assertAwait();
+ listener.onStop.assertCalledEventually();
assertQueueSize(size); // waiting on first
runnable2.run.assertUncalled();
listener.onStop.complete();
- runnable2.run.assertAwait();
+ runnable2.run.assertCalledEventually();
assertQueueSize(--size);
runnable2.run.complete();
- assertAwaitQueueSize(--size);
+ assertTaskCountIsEventually(--size);
}
@Test
public void states() throws Exception {
executor.execute(runnable);
- listener.onStart.assertAwait();
+ listener.onStart.assertCalledEventually();
assertStateIs(Task.State.STARTING);
listener.onStart.complete();
- runnable.run.assertAwait();
+ runnable.run.assertCalledEventually();
assertStateIs(Task.State.RUNNING);
runnable.run.complete();
- listener.onStop.assertAwait();
+ listener.onStop.assertCalledEventually();
assertStateIs(Task.State.STOPPING);
listener.onStop.complete();
@@ -255,8 +293,40 @@
assertStateIs(Task.State.DONE);
}
+ /** Fails if the waiting time elapses before the count is reached, otherwise passes */
+ public static void assertTaskCountIsEventually(WorkQueue workQueue, int count)
+ throws InterruptedException {
+ long ms = 0;
+ while (count != workQueue.getTasks().size()) {
+ assertThat(ms++).isLessThan(MS_EMPTY_QUEUE);
+ TimeUnit.MILLISECONDS.sleep(1);
+ }
+ }
+
+ public static void assertQueueSize(WorkQueue workQueue, int size) {
+ assertThat(workQueue.getTasks().size()).isEqualTo(size);
+ }
+
+ @CanIgnoreReturnValue
+ public static boolean await(CountDownLatch latch) {
+ try {
+ return latch.await(AWAIT_TIMEOUT, AWAIT_TIMEUNIT);
+ } catch (InterruptedException e) {
+ return false;
+ }
+ }
+
+ public void assertTaskCountIsEventually(int count) throws InterruptedException {
+ TaskListenerIT.assertTaskCountIsEventually(workQueue, count);
+ }
+
+ public static void assertStateIs(Task<?> task, Task.State state) {
+ assertThat(task).isNotNull();
+ assertThat(task.getState()).isEqualTo(state);
+ }
+
private void assertStateIs(Task.State state) {
- assertThat(forwarder.task.getState()).isEqualTo(state);
+ assertStateIs(forwarder.task, state);
}
private int assertQueueBlockedOnExecution(Runnable runnable) {
@@ -267,19 +337,10 @@
}
private void assertQueueSize(int size) {
- assertThat(workQueue.getTasks().size()).isEqualTo(size);
+ assertQueueSize(workQueue, size);
}
private void assertAwaitQueueIsEmpty() throws InterruptedException {
- assertAwaitQueueSize(0);
- }
-
- /** Fails if the waiting time elapses before the count is reached, otherwise passes */
- private void assertAwaitQueueSize(int size) throws InterruptedException {
- long i = 0;
- do {
- TimeUnit.NANOSECONDS.sleep(100);
- assertThat(i++).isLessThan(1000);
- } while (size != workQueue.getTasks().size());
+ assertTaskCountIsEventually(0);
}
}
diff --git a/javatests/com/google/gerrit/acceptance/server/util/TaskParkerIT.java b/javatests/com/google/gerrit/acceptance/server/util/TaskParkerIT.java
new file mode 100644
index 0000000..3b82ebe
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/server/util/TaskParkerIT.java
@@ -0,0 +1,548 @@
+// Copyright (C) 2022 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.server.util;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.acceptance.server.util.TaskListenerIT.LatchedMethod;
+import com.google.gerrit.acceptance.server.util.TaskListenerIT.LatchedRunnable;
+import com.google.gerrit.extensions.annotations.Exports;
+import com.google.gerrit.server.git.WorkQueue;
+import com.google.gerrit.server.git.WorkQueue.Task;
+import com.google.gerrit.server.git.WorkQueue.Task.State;
+import com.google.gerrit.server.git.WorkQueue.TaskListener;
+import com.google.gerrit.server.git.WorkQueue.TaskParker;
+import com.google.inject.AbstractModule;
+import com.google.inject.Inject;
+import com.google.inject.Module;
+import java.util.concurrent.ScheduledExecutorService;
+import java.util.concurrent.ScheduledThreadPoolExecutor;
+import java.util.concurrent.Semaphore;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicInteger;
+import org.junit.After;
+import org.junit.Before;
+import org.junit.Test;
+
+public class TaskParkerIT extends AbstractDaemonTest {
+ private static class ForwardingParker extends TaskListenerIT.ForwardingListener<LatchedParker>
+ implements TaskParker {
+ public AtomicInteger isReadyToStartCounter = new AtomicInteger(0);
+ public AtomicInteger onNotReadyToStartCounter = new AtomicInteger(0);
+
+ @Override
+ public boolean isReadyToStart(Task<?> task) {
+ isReadyToStartCounter.incrementAndGet();
+ if (isDelegatable(task)) {
+ return delegate.isReadyToStart(task);
+ }
+ return true;
+ }
+
+ @Override
+ public void onNotReadyToStart(Task<?> task) {
+ onNotReadyToStartCounter.incrementAndGet();
+ if (isDelegatable(task)) {
+ delegate.onNotReadyToStart(task);
+ }
+ }
+
+ public void resetCounters() {
+ isReadyToStartCounter.getAndSet(0);
+ onNotReadyToStartCounter.getAndSet(0);
+ }
+ }
+
+ public static class LatchedParker extends TaskListenerIT.LatchedListener implements TaskParker {
+ private static final String EXPENSIVE_TASK = "expensive-task";
+ private final Semaphore expensiveTaskSemaphore = new Semaphore(1, true);
+ public volatile LatchedMethod<Boolean> isReadyToStart = new LatchedMethod<>();
+ public volatile LatchedMethod<?> onNotReadyToStart = new LatchedMethod<>();
+
+ @Override
+ public boolean isReadyToStart(Task<?> task) {
+ Boolean rtn = isReadyToStart.call();
+ if (EXPENSIVE_TASK.equals(task.toString()) && !expensiveTaskSemaphore.tryAcquire()) {
+ return false;
+ }
+ isReadyToStart = new LatchedMethod<>();
+ if (rtn != null) {
+ return rtn;
+ }
+ return true;
+ }
+
+ @Override
+ public void onNotReadyToStart(Task<?> task) {
+ onNotReadyToStart.call();
+ onNotReadyToStart = new LatchedMethod<>();
+ }
+
+ @Override
+ public void onStop(Task<?> task) {
+ if (EXPENSIVE_TASK.equals(task.toString())) {
+ expensiveTaskSemaphore.release();
+ }
+ super.onStop(task);
+ }
+ }
+
+ public static class LatchedForeverRunnable extends LatchedRunnable {
+ public LatchedForeverRunnable(String name) {
+ super(name);
+ }
+
+ @Override
+ public void run() {
+ try {
+ run.callAndAwaitComplete();
+ } catch (InterruptedException e) {
+ throw new RuntimeException(e);
+ }
+ }
+ }
+
+ private static ForwardingParker forwarder;
+ private static ForwardingParker forwarder2;
+ public static final long TIMEOUT = TimeUnit.MILLISECONDS.convert(200, TimeUnit.MILLISECONDS);
+
+ private final LatchedParker parker = new LatchedParker();
+
+ @Inject private WorkQueue workQueue;
+ private ScheduledExecutorService executor;
+
+ @Before
+ public void setupExecutorAndForwarder() throws InterruptedException {
+ executor = workQueue.createQueue(1, "TaskParkers");
+ // "Log File Manager"s are likely running and will interfere with tests
+ while (0 != workQueue.getTasks().size()) {
+ for (Task<?> t : workQueue.getTasks()) {
+ t.cancel(true);
+ }
+ TimeUnit.MILLISECONDS.sleep(1);
+ }
+ forwarder.delegate = parker;
+ forwarder.task = null;
+ forwarder.resetCounters();
+ forwarder2.delegate = null; // load only if test needs it
+ forwarder2.task = null;
+ forwarder2.resetCounters();
+ }
+
+ @After
+ public void shutdownExecutor() throws InterruptedException {
+ executor.shutdownNow();
+ executor.awaitTermination(1, TimeUnit.SECONDS);
+ }
+
+ @Override
+ public Module createModule() {
+ return new AbstractModule() {
+ @Override
+ public void configure() {
+ // Forwarder.delegate is empty on start to protect test parker from non-test tasks (such as
+ // the "Log File Manager") interference
+ forwarder = new ForwardingParker(); // Only gets bound once for all tests
+ bind(TaskListener.class).annotatedWith(Exports.named("parker")).toInstance(forwarder);
+ forwarder2 = new ForwardingParker();
+ bind(TaskListener.class).annotatedWith(Exports.named("parker2")).toInstance(forwarder2);
+ }
+ };
+ }
+
+ @Test
+ public void noParkFlow() throws Exception {
+ LatchedRunnable runnable = new LatchedRunnable();
+
+ assertTaskCountIs(0);
+ assertThat(forwarder.task).isEqualTo(null);
+ parker.isReadyToStart.assertUncalled();
+ parker.onNotReadyToStart.assertUncalled();
+ parker.onStart.assertUncalled();
+ runnable.run.assertUncalled();
+ parker.onStop.assertUncalled();
+ assertCorePoolSizeIs(1);
+
+ executor.execute(runnable);
+ assertTaskCountIs(1);
+
+ parker.isReadyToStart.assertCalledEventually();
+ assertTaskCountIs(1);
+ assertStateIs(State.READY);
+ parker.onNotReadyToStart.assertUncalled();
+ parker.onStart.assertUncalled();
+ runnable.run.assertUncalled();
+ parker.onStop.assertUncalled();
+
+ parker.isReadyToStart.complete();
+ parker.onStart.assertCalledEventually();
+ assertStateIs(State.STARTING);
+ assertTaskCountIs(1);
+ parker.onNotReadyToStart.assertUncalled();
+ runnable.run.assertUncalled();
+ parker.onStop.assertUncalled();
+
+ parker.onStart.complete();
+ runnable.run.assertCalledEventually();
+ assertStateIs(State.RUNNING);
+ assertTaskCountIs(1);
+ parker.onNotReadyToStart.assertUncalled();
+ parker.onStop.assertUncalled();
+
+ runnable.run.complete();
+ parker.onStop.assertCalledEventually();
+ assertStateIs(State.STOPPING);
+ assertTaskCountIs(1);
+ parker.onNotReadyToStart.assertUncalled();
+
+ parker.onStop.complete();
+ assertTaskCountIsEventually(0);
+ assertStateIs(State.DONE);
+ parker.onNotReadyToStart.assertUncalled();
+ assertCorePoolSizeIs(1);
+ assertCounterIsEventually(forwarder.isReadyToStartCounter, 1);
+ assertCounter(forwarder.onNotReadyToStartCounter, 0);
+ }
+
+ @Test
+ public void parkFirstSoSecondRuns() throws Exception {
+ LatchedRunnable runnable1 = new LatchedRunnable();
+ LatchedRunnable runnable2 = new LatchedRunnable();
+ assertCorePoolSizeIs(1);
+
+ executor.execute(runnable1);
+ parker.isReadyToStart.assertCalledEventually();
+ Task<?> task1 = forwarder.task; // task for runnable1
+ assertCounterIsEventually(forwarder.isReadyToStartCounter, 1);
+ assertCounter(forwarder.onNotReadyToStartCounter, 0);
+ executor.execute(runnable2);
+ assertTaskCountIs(2);
+ parker.onNotReadyToStart.assertUncalled();
+ parker.onStart.assertUncalled();
+ runnable1.run.assertUncalled();
+ parker.onStop.assertUncalled();
+
+ // park runnable1
+ parker.isReadyToStart.complete(false);
+ assertCorePoolSizeIsEventually(2);
+ assertStateIs(task1, State.PARKED);
+
+ runnable2.run.assertCalledEventually();
+ assertTaskCountIs(2);
+ parker.onNotReadyToStart.assertUncalled();
+ parker.onStart.assertUncalled();
+ runnable1.run.assertUncalled();
+ parker.onStop.assertUncalled();
+ assertStateIs(task1, State.PARKED);
+
+ assertCounterIsEventually(forwarder.isReadyToStartCounter, 2);
+ assertCounter(forwarder.onNotReadyToStartCounter, 0);
+ runnable2.run.complete();
+
+ parker.isReadyToStart.assertCalledEventually();
+ parker.onNotReadyToStart.assertUncalled();
+ parker.onStart.assertUncalled();
+ runnable1.run.assertUncalled();
+ parker.onStop.assertUncalled();
+
+ parker.isReadyToStart.complete(true);
+ parker.onStart.assertCalledEventually();
+ assertStateIs(task1, State.STARTING);
+ assertTaskCountIsEventually(1);
+ parker.onNotReadyToStart.assertUncalled();
+ runnable1.run.assertUncalled();
+ parker.onStop.assertUncalled();
+
+ parker.onStart.complete();
+ runnable1.run.assertCalledEventually();
+ assertStateIs(task1, State.RUNNING);
+ assertTaskCountIs(1);
+ parker.onNotReadyToStart.assertUncalled();
+ parker.onStop.assertUncalled();
+
+ runnable1.run.complete();
+ parker.onStop.assertCalledEventually();
+ assertStateIs(task1, State.STOPPING);
+ assertTaskCountIs(1);
+ parker.onNotReadyToStart.assertUncalled();
+
+ parker.onStop.complete();
+ assertCorePoolSizeIsEventually(1);
+ assertTaskCountIsEventually(0);
+ assertStateIs(task1, State.DONE);
+ parker.onNotReadyToStart.assertUncalled();
+ assertCounterIsEventually(forwarder.isReadyToStartCounter, 3);
+ assertCounter(forwarder.onNotReadyToStartCounter, 0);
+ }
+
+ @Test
+ public void unParkPriorityOrder() throws Exception {
+ LatchedRunnable runnable1 = new LatchedRunnable();
+ LatchedRunnable runnable2 = new LatchedRunnable();
+ LatchedRunnable runnable3 = new LatchedRunnable();
+
+ // park runnable1
+ assertCorePoolSizeIs(1);
+ executor.execute(runnable1);
+ parker.isReadyToStart.assertCalledEventuallyThenComplete(false);
+ Task<?> task1 = forwarder.task; // task for runnable1
+ assertStateIsEventually(task1, State.PARKED);
+ assertCounterIsEventually(forwarder.isReadyToStartCounter, 1);
+ assertCounter(forwarder.onNotReadyToStartCounter, 0);
+ assertTaskCountIsEventually(1);
+ assertCorePoolSizeIsEventually(2);
+
+ // park runnable2
+ forwarder.resetDelegate(parker);
+ executor.execute(runnable2);
+ parker.isReadyToStart.assertCalledEventuallyThenComplete(false);
+ Task<?> task2 = forwarder.task; // task for runnable2
+ assertStateIsEventually(task2, State.PARKED);
+
+ assertCounterIsEventually(forwarder.isReadyToStartCounter, 2);
+ assertCounter(forwarder.onNotReadyToStartCounter, 0);
+ assertTaskCountIsEventually(2);
+ assertCorePoolSizeIsEventually(3);
+
+ // set parker to ready and execute runnable3
+ forwarder.resetDelegate(parker);
+ executor.execute(runnable3);
+
+ // assert runnable3 finishes executing and runnable1, runnable2 stay parked
+ assertCounterIsEventually(forwarder.isReadyToStartCounter, 3);
+ assertCounter(forwarder.onNotReadyToStartCounter, 0);
+ parker.isReadyToStart.assertCalledEventually();
+ Task<?> task3 = forwarder.task; // task for runnable3
+ assertStateIs(task3, State.READY);
+ parker.isReadyToStart.complete(true);
+ parker.onStart.assertCalledEventually();
+ assertStateIs(task3, State.STARTING);
+ parker.onStart.complete();
+ runnable3.run.assertCalledEventually();
+ assertStateIs(task3, State.RUNNING);
+ runnable1.run.assertUncalled();
+ runnable2.run.assertUncalled();
+ runnable3.run.complete();
+ parker.onStop.assertCalledEventually();
+ assertStateIs(task3, State.STOPPING);
+ parker.onStop.complete();
+ assertTaskCountIsEventually(2);
+ assertStateIs(task3, State.DONE);
+
+ // assert runnable1 finishes executing and runnable2 stays parked
+ runnable1.run.assertCalledEventually();
+ assertStateIs(task1, State.RUNNING);
+ assertCounterIsEventually(forwarder.isReadyToStartCounter, 4);
+ assertCounter(forwarder.onNotReadyToStartCounter, 0);
+ runnable2.run.assertUncalled();
+ assertStateIs(task2, State.PARKED);
+ runnable1.run.complete();
+ assertCorePoolSizeIsEventually(2);
+ assertTaskCountIsEventually(1);
+ assertStateIs(task1, State.DONE);
+
+ // assert runnable2 finishes executing
+ runnable2.run.assertCalledEventually();
+ assertStateIs(task2, State.RUNNING);
+ assertCounterIsEventually(forwarder.isReadyToStartCounter, 5);
+ assertCounter(forwarder.onNotReadyToStartCounter, 0);
+ runnable2.run.complete();
+ assertCorePoolSizeIsEventually(1);
+ assertTaskCountIsEventually(0);
+ assertStateIs(task2, State.DONE);
+ }
+
+ @Test
+ public void notReadyToStartIsCalledOnReadyListenerWhenAnotherListenerIsNotReady()
+ throws InterruptedException {
+ LatchedRunnable runnable1 = new LatchedRunnable();
+ LatchedRunnable runnable2 = new LatchedRunnable();
+
+ LatchedParker parker2 = new LatchedParker();
+ forwarder2.delegate = parker2;
+
+ // park runnable1 (parker1 is ready and parker2 is not ready)
+ assertCorePoolSizeIs(1);
+ executor.execute(runnable1);
+ parker2.isReadyToStart.complete(false);
+
+ assertTaskCountIsEventually(1);
+ assertCorePoolSizeIsEventually(2);
+
+ assertCounterIsEventually(forwarder.isReadyToStartCounter, 1);
+ assertCounterIsEventually(forwarder.onNotReadyToStartCounter, 1);
+ assertCounterIsEventually(forwarder2.isReadyToStartCounter, 1);
+ assertCounter(forwarder2.onNotReadyToStartCounter, 0);
+ Task<?> task1 = forwarder.task; // task for runnable1
+ assertStateIsEventually(task1, State.PARKED);
+
+ // set parker2 to ready and execute runnable-2
+ parker2.isReadyToStart.set(true);
+ forwarder.resetDelegate(parker);
+ forwarder2.resetDelegate(parker2);
+ executor.execute(runnable2);
+
+ assertCounterIsEventually(forwarder.isReadyToStartCounter, 2);
+ assertCounterIsEventually(forwarder.onNotReadyToStartCounter, 1);
+ assertCounterIsEventually(forwarder2.isReadyToStartCounter, 2);
+ assertCounter(forwarder2.onNotReadyToStartCounter, 0);
+ Task<?> task2 = forwarder.task; // task for runnable2
+
+ assertCorePoolSizeIsEventually(1);
+ runnable2.run.assertCalledEventually();
+ runnable2.run.complete();
+ assertTaskCountIsEventually(1);
+ assertStateIs(task2, State.DONE);
+
+ assertCounterIsEventually(forwarder.isReadyToStartCounter, 3);
+ assertCounterIsEventually(forwarder.onNotReadyToStartCounter, 1);
+ assertCounterIsEventually(forwarder2.isReadyToStartCounter, 3);
+ assertCounter(forwarder2.onNotReadyToStartCounter, 0);
+
+ runnable1.run.assertCalledEventually();
+ runnable1.run.complete();
+ assertTaskCountIsEventually(0);
+ assertStateIs(task1, State.DONE);
+ }
+
+ @Test
+ public void runFirstParkSecondUsingTaskName() throws InterruptedException {
+ LatchedForeverRunnable runnable1 = new LatchedForeverRunnable("expensive-task");
+ LatchedRunnable runnable2 = new LatchedRunnable("expensive-task");
+ LatchedParker parker = new LatchedParker();
+ executor = workQueue.createQueue(2, "TaskParkers");
+ assertCorePoolSizeIs(2);
+
+ forwarder.resetDelegate(parker);
+ executor.execute(runnable1);
+ parker.isReadyToStart.complete();
+ parker.onStart.complete();
+ runnable1.run.assertCalledEventually();
+ assertTaskCountIsEventually(1);
+ assertCorePoolSizeIs(2);
+ Task<?> task1 = forwarder.task; // task for runnable1
+ assertStateIs(task1, State.RUNNING);
+
+ forwarder.resetDelegate(parker);
+ executor.execute(runnable2);
+ parker.isReadyToStart.assertCalledEventually();
+ assertCorePoolSizeIsEventually(3);
+ Task<?> task2 = forwarder.task; // task for runnable2
+ assertStateIs(task2, State.PARKED);
+
+ forwarder.resetDelegate(parker);
+ runnable1.run.complete(); // unblock runnable1
+
+ assertCorePoolSizeIsEventually(2);
+ assertTaskCountIsEventually(0); // assert both tasks finish
+ }
+
+ @Test
+ public void interruptingParkedTaskDecrementsCorePoolSize() throws InterruptedException {
+ String taskName = "to-be-parked";
+ LatchedRunnable runnable1 = new LatchedRunnable(taskName);
+ assertCorePoolSizeIs(1);
+
+ // park runnable1
+ executor.execute(runnable1);
+ parker.isReadyToStart.assertCalledEventuallyThenComplete(false);
+ assertCorePoolSizeIsEventually(2);
+ assertStateIsEventually(forwarder.task, State.PARKED);
+
+ // interrupt the thread with parked task
+ for (Thread t : Thread.getAllStackTraces().keySet()) {
+ if (t.getName().contains(taskName)) {
+ t.interrupt();
+ break;
+ }
+ }
+
+ assertCorePoolSizeIsEventually(1);
+ }
+
+ @Test
+ public void canCancelParkedTask() throws InterruptedException {
+ LatchedRunnable runnable1 = new LatchedRunnable();
+ assertCorePoolSizeIs(1);
+
+ // park runnable1
+ executor.execute(runnable1);
+ parker.isReadyToStart.assertCalledEventuallyThenComplete(false);
+ assertCorePoolSizeIsEventually(2);
+ Task<?> task = forwarder.task;
+ assertStateIsEventually(task, State.PARKED);
+
+ // cancel parked task
+ task.cancel(true);
+
+ // assert core pool size is reduced and task is cancelled
+ assertCorePoolSizeIsEventually(1);
+ assertTaskCountIsEventually(0);
+ assertStateIs(State.CANCELLED);
+ }
+
+ private void assertTaskCountIs(int size) {
+ TaskListenerIT.assertQueueSize(workQueue, size);
+ }
+
+ private void assertTaskCountIsEventually(int count) throws InterruptedException {
+ TaskListenerIT.assertTaskCountIsEventually(workQueue, count);
+ }
+
+ private void assertCorePoolSizeIs(int count) {
+ assertThat(count).isEqualTo(((ScheduledThreadPoolExecutor) executor).getCorePoolSize());
+ }
+
+ private void assertCorePoolSizeIsEventually(int count) throws InterruptedException {
+ ScheduledThreadPoolExecutor scheduledThreadPoolExecutor =
+ (ScheduledThreadPoolExecutor) executor;
+ long ms = 0;
+ while (count != scheduledThreadPoolExecutor.getCorePoolSize()) {
+ assertThat(ms++).isLessThan(TIMEOUT);
+ TimeUnit.MILLISECONDS.sleep(1);
+ }
+ }
+
+ private void assertCounter(AtomicInteger counter, int desiredCount) {
+ assertThat(counter.get()).isEqualTo(desiredCount);
+ }
+
+ private void assertCounterIsEventually(AtomicInteger counter, int desiredCount)
+ throws InterruptedException {
+ long ms = 0;
+ while (desiredCount != counter.get()) {
+ assertThat(ms++).isLessThan(TIMEOUT);
+ TimeUnit.MILLISECONDS.sleep(1);
+ }
+ }
+
+ private void assertStateIs(Task.State state) {
+ TaskListenerIT.assertStateIs(forwarder.task, state);
+ }
+
+ private void assertStateIs(Task<?> task, Task.State state) {
+ TaskListenerIT.assertStateIs(task, state);
+ }
+
+ private void assertStateIsEventually(Task<?> task, Task.State state) throws InterruptedException {
+ long ms = 0;
+ assertThat(task).isNotNull();
+ while (!task.getState().equals(state)) {
+ assertThat(ms++).isLessThan(TIMEOUT);
+ TimeUnit.MILLISECONDS.sleep(1);
+ }
+ }
+}
diff --git a/javatests/com/google/gerrit/entities/converter/BUILD b/javatests/com/google/gerrit/entities/converter/BUILD
index 0ca9478..804397b 100644
--- a/javatests/com/google/gerrit/entities/converter/BUILD
+++ b/javatests/com/google/gerrit/entities/converter/BUILD
@@ -4,16 +4,19 @@
name = "proto_converter_tests",
srcs = glob(["*.java"]),
deps = [
+ "//java/com/google/gerrit/common:annotations",
"//java/com/google/gerrit/entities",
"//java/com/google/gerrit/extensions:api",
"//java/com/google/gerrit/proto/testing",
"//java/com/google/gerrit/server",
"//lib:guava",
+ "//lib:guava-testlib",
"//lib:jgit",
"//lib:protobuf",
"//lib/guice",
"//lib/truth",
"//lib/truth:truth-proto-extension",
"//proto:entities_java_proto",
+ "@commons-lang3//jar",
],
)
diff --git a/javatests/com/google/gerrit/entities/converter/SafeProtoConverterTest.java b/javatests/com/google/gerrit/entities/converter/SafeProtoConverterTest.java
new file mode 100644
index 0000000..eb69d53
--- /dev/null
+++ b/javatests/com/google/gerrit/entities/converter/SafeProtoConverterTest.java
@@ -0,0 +1,286 @@
+package com.google.gerrit.entities.converter;
+
+import static com.google.common.collect.ImmutableList.toImmutableList;
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.common.truth.extensions.proto.ProtoTruth.assertThat;
+
+import com.google.common.collect.ImmutableList;
+import com.google.common.primitives.Primitives;
+import com.google.common.reflect.ClassPath;
+import com.google.common.reflect.ClassPath.ClassInfo;
+import com.google.common.testing.ArbitraryInstances;
+import com.google.gerrit.common.ConvertibleToProto;
+import com.google.gerrit.common.Nullable;
+import com.google.protobuf.Descriptors.FieldDescriptor;
+import com.google.protobuf.Message;
+import com.google.protobuf.MessageLite;
+import java.lang.reflect.Constructor;
+import java.lang.reflect.Field;
+import java.lang.reflect.Modifier;
+import java.lang.reflect.ParameterizedType;
+import java.lang.reflect.Type;
+import java.sql.Timestamp;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collection;
+import java.util.Comparator;
+import java.util.List;
+import java.util.Map;
+import java.util.Optional;
+import java.util.stream.Stream;
+import javax.annotation.Nonnull;
+import org.apache.commons.lang3.builder.EqualsBuilder;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.junit.runners.Parameterized;
+import org.junit.runners.Parameterized.Parameter;
+import org.junit.runners.Parameterized.Parameters;
+import org.junit.runners.Suite;
+
+@RunWith(Suite.class)
+@Suite.SuiteClasses({
+ SafeProtoConverterTest.ListSafeProtoConverterTest.class, //
+ SafeProtoConverterTest.PerTypeSafeProtoConverterTest.class, //
+})
+public class SafeProtoConverterTest {
+ public static class ListSafeProtoConverterTest {
+ @Test
+ public void areAllConvertersEnums() throws Exception {
+ Stream<? extends Class<?>> safeConverters =
+ ClassPath.from(ClassLoader.getSystemClassLoader()).getAllClasses().stream()
+ .filter(type -> type.getPackageName().contains("gerrit"))
+ .map(ClassInfo::load)
+ .filter(SafeProtoConverter.class::isAssignableFrom)
+ .filter(clz -> !SafeProtoConverter.class.equals(clz));
+ // Safe converters must be enums. See also `isConverterAValidEnum` test below.
+ assertThat(safeConverters.allMatch(Class::isEnum)).isTrue();
+ }
+ }
+
+ @RunWith(Parameterized.class)
+ public static class PerTypeSafeProtoConverterTest {
+ @Parameter(0)
+ public SafeProtoConverter<Message, Object> converter;
+
+ @Parameter(1)
+ public String converterName;
+
+ @Parameters(name = "PerTypeSafeProtoConverterTest${1}")
+ public static ImmutableList<Object[]> listSafeConverters() throws Exception {
+ return ClassPath.from(ClassLoader.getSystemClassLoader()).getAllClasses().stream()
+ .filter(type -> type.getPackageName().contains("gerrit"))
+ .map(ClassInfo::load)
+ .filter(SafeProtoConverter.class::isAssignableFrom)
+ .filter(clz -> !SafeProtoConverter.class.equals(clz))
+ .filter(Class::isEnum)
+ .map(clz -> (SafeProtoConverter<Message, Object>) clz.getEnumConstants()[0])
+ .map(clz -> new Object[] {clz, clz.getClass().getSimpleName()})
+ .collect(toImmutableList());
+ }
+
+ /**
+ * For rising visibility, all Java Entity classes which have a {@link SafeProtoConverter}, must
+ * be annotated with {@link ConvertibleToProto}.
+ */
+ @Test
+ public void isJavaClassMarkedAsConvertibleToProto() {
+ assertThat(converter.getEntityClass().getDeclaredAnnotation(ConvertibleToProto.class))
+ .isNotNull();
+ }
+
+ /**
+ * All {@link SafeProtoConverter} implementations must be enums with a single instance. Please
+ * prefer descriptive enum and instance names, such as {@code
+ * MyTypeConverter::MY_TYPE_CONVERTER}.
+ */
+ @Test
+ public void isConverterAValidEnum() {
+ assertThat(converter.getClass().isEnum()).isTrue();
+ assertThat(converter.getClass().getEnumConstants().length).isEqualTo(1);
+ }
+
+ /**
+ * If this test fails, it's likely that you added a field to a Java class that has a {@link
+ * SafeProtoConverter} set, or that you have changed the default value for such a field. Please
+ * update the corresponding proto accordingly.
+ */
+ @Test
+ public void javaDefaultsKeptOnDoubleConversion() {
+ Object orig;
+ try {
+ orig = buildObjectWithFullFieldsOrThrow(converter.getEntityClass());
+ } catch (Exception e) {
+ throw new IllegalStateException(
+ String.format(
+ "Failed to build object for type %s, this likely means the buildObjectWithFullFieldsOrThrow should be adapted.",
+ converter.getEntityClass().getName()),
+ e);
+ }
+ Object res = converter.fromProto(converter.toProto(converter.getEntityClass().cast(orig)));
+ assertThat(orig).isEqualTo(res);
+ // If this assertion fails, it's likely that you forgot to update the `equals` method to
+ // include your new field.
+ assertThat(EqualsBuilder.reflectionEquals(orig, res)).isTrue();
+ }
+
+ /**
+ * If this test fails, it's likely that you added a field to a proto that has a {@link
+ * SafeProtoConverter} set, or that you have changed the default value for such a field. Please
+ * update the corresponding Java class accordingly.
+ */
+ @Test
+ public void protoDefaultsKeptOnDoubleConversion() {
+ Message defaultInstance = getProtoDefaultInstance(converter.getProtoClass());
+ Message preFilled = explicitlyFillProtoDefaults(defaultInstance);
+ Message resFromDefault =
+ converter.toProto(converter.fromProto(converter.getProtoClass().cast(preFilled)));
+ Message resFromPrefilled =
+ converter.toProto(converter.fromProto(converter.getProtoClass().cast(preFilled)));
+ assertThat(resFromDefault).isEqualTo(preFilled);
+ assertThat(resFromPrefilled).isEqualTo(preFilled);
+ }
+
+ @Nullable
+ private static Object buildObjectWithFullFieldsOrThrow(Class<?> clz) throws Exception {
+ if (clz == null) {
+ return null;
+ }
+ Object obj = construct(clz);
+ if (isSimple(clz)) {
+ return obj;
+ }
+ for (Field field : clz.getDeclaredFields()) {
+ if (Modifier.isStatic(field.getModifiers())) {
+ continue;
+ }
+ Class<?> parameterizedType = getParameterizedType(field);
+ if (!field.getType().isArray()
+ && !Map.class.isAssignableFrom(field.getType())
+ && !Collection.class.isAssignableFrom(field.getType())) {
+ if (!field.trySetAccessible()) {
+ return null;
+ }
+ field.set(obj, buildObjectWithFullFieldsOrThrow(field.getType()));
+ } else if (Collection.class.isAssignableFrom(field.getType())
+ && parameterizedType != null) {
+ field.set(obj, ImmutableList.of(buildObjectWithFullFieldsOrThrow(parameterizedType)));
+ }
+ }
+ return obj;
+ }
+
+ /**
+ * AutoValue annotations are not retained on runtime. We can only find out if a class is an
+ * AutoValue, by trying to load the expected AutoValue class.
+ *
+ * <p>For the class {@code package.Clz}, the AutoValue class name is {@code
+ * package.AutoValue_Clz}, for {@code package.Enclosing$Clz}, it is {@code
+ * package.AutoValue_Enclosing_Clz}
+ */
+ static Optional<Class<?>> toRepresentingAutoValueClass(Class<?> clz) {
+ String origClzName = clz.getName();
+ String autoValueClzName =
+ origClzName.substring(0, origClzName.lastIndexOf("."))
+ + ".AutoValue_"
+ + origClzName.substring(origClzName.lastIndexOf(".") + 1);
+ autoValueClzName = autoValueClzName.replace('$', '_');
+ try {
+ return Optional.of(clz.getClassLoader().loadClass(autoValueClzName));
+ } catch (Exception e) {
+ return Optional.empty();
+ }
+ }
+
+ @Nullable
+ private static Class<?> getParameterizedType(Field field) {
+ if (!Collection.class.isAssignableFrom(field.getType())) {
+ return null;
+ }
+ Type genericType = field.getGenericType();
+ if (genericType instanceof ParameterizedType) {
+ return (Class<?>) ((ParameterizedType) genericType).getActualTypeArguments()[0];
+ }
+ return null;
+ }
+
+ @Nonnull
+ static Object construct(@Nonnull Class<?> clz) {
+ try {
+ Object arbitrary = ArbitraryInstances.get(clz);
+ if (arbitrary != null) {
+ return arbitrary;
+ }
+ Optional<Class<?>> optionalAutoValueRepresentation = toRepresentingAutoValueClass(clz);
+ if (optionalAutoValueRepresentation.isPresent()) {
+ return construct(optionalAutoValueRepresentation.get());
+ }
+ Constructor<?> constructor =
+ Arrays.stream(clz.getDeclaredConstructors())
+ // Filter out copy constructors
+ .filter(
+ c ->
+ c.getParameterCount() != 1
+ || !c.getParameterTypes()[0].isAssignableFrom(clz))
+ // Filter out private constructors which cannot be set accessible.
+ .filter(c -> c.canAccess(null) || c.trySetAccessible())
+ .min(Comparator.comparingInt(Constructor::getParameterCount))
+ .get();
+ List<Object> args = new ArrayList<>();
+ for (Class<?> f : constructor.getParameterTypes()) {
+ args.add(construct(f));
+ }
+ return constructor.newInstance(args.toArray());
+ } catch (Exception e) {
+ throw new IllegalStateException("Failed to construct class " + clz.getName(), e);
+ }
+ }
+
+ static boolean isSimple(Class<?> c) {
+ return c.isPrimitive()
+ || c.isEnum()
+ || Primitives.isWrapperType(c)
+ || String.class.isAssignableFrom(c)
+ || Timestamp.class.isAssignableFrom(c);
+ }
+
+ /**
+ * Returns the default instance for the given MessageLite class, if it has the {@code
+ * getDefaultInstance} static method.
+ *
+ * @param type the protobuf message class
+ * @throws IllegalArgumentException if the given class doesn't have the static {@code
+ * getDefaultInstance} method
+ */
+ public static <T extends MessageLite> T getProtoDefaultInstance(Class<T> type) {
+ try {
+ return type.cast(type.getMethod("getDefaultInstance").invoke(null));
+ } catch (ReflectiveOperationException | ClassCastException e) {
+ throw new IllegalStateException("Cannot get default instance for " + type, e);
+ }
+ }
+
+ private static Message explicitlyFillProtoDefaults(Message defaultInstance) {
+ Message.Builder res = defaultInstance.toBuilder();
+ for (FieldDescriptor f : defaultInstance.getDescriptorForType().getFields()) {
+ try {
+ if (f.getType().equals(FieldDescriptor.Type.MESSAGE)) {
+ if (f.isRepeated()) {
+ res.addRepeatedField(
+ f,
+ explicitlyFillProtoDefaults(
+ explicitlyFillProtoDefaults(
+ getProtoDefaultInstance(res.newBuilderForField(f).build().getClass()))));
+ } else {
+ res.setField(f, explicitlyFillProtoDefaults((Message) defaultInstance.getField(f)));
+ }
+ } else {
+ res.setField(f, defaultInstance.getField(f));
+ }
+ } catch (Exception e) {
+ throw new IllegalStateException("Failed to fill default instance for " + f.getName(), e);
+ }
+ }
+ return res.build();
+ }
+ }
+}
diff --git a/lib/highlightjs/BUILD b/lib/highlightjs/BUILD
index 4105d85..d55a273 100644
--- a/lib/highlightjs/BUILD
+++ b/lib/highlightjs/BUILD
@@ -12,6 +12,7 @@
srcs = [
"@ui_npm//highlight.js",
"@ui_npm//highlightjs-closure-templates",
+ "@ui_npm//highlightjs-epp",
"@ui_npm//highlightjs-structured-text",
],
config_file = "rollup.config.js",
diff --git a/lib/highlightjs/index.js b/lib/highlightjs/index.js
index c2d048d..811dec7 100644
--- a/lib/highlightjs/index.js
+++ b/lib/highlightjs/index.js
@@ -17,9 +17,11 @@
import hljs from 'highlight.js';
import soy from 'highlightjs-closure-templates';
+import epp from 'highlightjs-epp';
import iecst from 'highlightjs-structured-text';
hljs.registerLanguage('soy', soy);
+hljs.registerLanguage('epp', epp);
hljs.registerLanguage('iecst', iecst);
export default hljs;
diff --git a/lib/nongoogle_test.sh b/lib/nongoogle_test.sh
index 6865340..37cee04 100755
--- a/lib/nongoogle_test.sh
+++ b/lib/nongoogle_test.sh
@@ -11,6 +11,11 @@
grep 'name = "[^"]*"' ${bzl} | sed 's|^[^"]*"||g;s|".*$||g' | sort > $TMP/names
cat << EOF > $TMP/want
+auto-common
+auto-factory
+auto-service-annotations
+auto-value
+auto-value-annotations
cglib-3_2
commons-io
dropwizard-core
@@ -20,6 +25,7 @@
flogger-google-extensions
flogger-log4j-backend
flogger-system-backend
+gson
guava
guava-testlib
guice-assistedinject
diff --git a/modules/jgit b/modules/jgit
index c0b415f..d06722a 160000
--- a/modules/jgit
+++ b/modules/jgit
@@ -1 +1 @@
-Subproject commit c0b415fb028b4c1f29b6df749323bbb11599495d
+Subproject commit d06722a15998eeee04cfefd7c3c7e79fe67f1494
diff --git a/polygerrit-ui/app/api/embed.ts b/polygerrit-ui/app/api/embed.ts
index 2faeeda..d6425be 100644
--- a/polygerrit-ui/app/api/embed.ts
+++ b/polygerrit-ui/app/api/embed.ts
@@ -45,6 +45,7 @@
/** <gr-textarea> event when showing a hint */
export declare interface HintShownEventDetail {
hint: string;
+ oldValue: string;
}
/** <gr-textarea> event when a hint was dismissed */
diff --git a/polygerrit-ui/app/api/suggestions.ts b/polygerrit-ui/app/api/suggestions.ts
index c3089a9..b952180 100644
--- a/polygerrit-ui/app/api/suggestions.ts
+++ b/polygerrit-ui/app/api/suggestions.ts
@@ -72,6 +72,7 @@
export declare interface AutocompleteCommentResponse {
responseCode: ResponseCode;
completion?: string;
+ modelVersion?: string;
}
export declare interface SuggestCodeResponse {
diff --git a/polygerrit-ui/app/constants/constants.ts b/polygerrit-ui/app/constants/constants.ts
index 0fa58f4..b21663a 100644
--- a/polygerrit-ui/app/constants/constants.ts
+++ b/polygerrit-ui/app/constants/constants.ts
@@ -264,6 +264,7 @@
default_base_for_merges: DefaultBase.AUTO_MERGE,
allow_browser_notifications: false,
allow_suggest_code_while_commenting: true,
+ allow_autocompleting_comments: true,
diff_page_sidebar: 'NONE',
};
}
diff --git a/polygerrit-ui/app/constants/reporting.ts b/polygerrit-ui/app/constants/reporting.ts
index 37a17ba..643f8aa 100644
--- a/polygerrit-ui/app/constants/reporting.ts
+++ b/polygerrit-ui/app/constants/reporting.ts
@@ -102,6 +102,8 @@
APPLY_FIX_LOAD = 'ApplyFixLoad',
// Time to copy target to clipboard
COPY_TO_CLIPBOARD = 'CopyToClipboard',
+ // Time to autocomplete a comment
+ COMMENT_COMPLETION = 'CommentCompletion',
}
export enum Interaction {
@@ -156,4 +158,9 @@
// The very first reporting event with `ChangeId` set when visiting a change
// related page. Can be used as a starting point for user journeys.
CHANGE_ID_CHANGED = 'change-id-changed',
+
+ COMMENT_COMPLETION_SUGGESTION_SHOWN = 'comment-completion-suggestion-shown',
+ COMMENT_COMPLETION_SUGGESTION_ACCEPTED = 'comment-completion-suggestion-accepted',
+ COMMENT_COMPLETION_SAVE_DRAFT = 'comment-completion-save-draft',
+ COMMENT_COMPLETION_SUGGESTION_FETCHED = 'comment-completion-suggestion-fetched',
}
diff --git a/polygerrit-ui/app/elements/admin/gr-repo/gr-repo.ts b/polygerrit-ui/app/elements/admin/gr-repo/gr-repo.ts
index 4e8841e..2251321 100644
--- a/polygerrit-ui/app/elements/admin/gr-repo/gr-repo.ts
+++ b/polygerrit-ui/app/elements/admin/gr-repo/gr-repo.ts
@@ -815,7 +815,7 @@
config.max_object_size_limit.configured_value = '';
}
this.repoConfig = config;
- this.originalConfig = deepClone(config) as ConfigInfo;
+ this.originalConfig = deepClone<ConfigInfo>(config);
this.loading = false;
};
promises.push(repoConfigHelper());
@@ -920,7 +920,7 @@
this.repo,
this.formatRepoConfigForSave(this.repoConfig)
);
- this.originalConfig = deepClone(this.repoConfig) as ConfigInfo;
+ this.originalConfig = deepClone<ConfigInfo>(this.repoConfig);
this.pluginConfigChanged = false;
return;
}
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
index 50ec339..836d86a 100644
--- 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
@@ -485,7 +485,7 @@
@state() pluginsLoaded = false;
- @state() threadsWithSuggestions?: CommentThread[];
+ @state() threadsWithUnappliedSuggestions?: CommentThread[];
private readonly restApiService = getAppContext().restApiService;
@@ -579,8 +579,8 @@
);
subscribe(
this,
- () => this.getCommentsModel().threadsWithSuggestions$,
- x => (this.threadsWithSuggestions = x)
+ () => this.getCommentsModel().threadsWithUnappliedSuggestions$,
+ x => (this.threadsWithUnappliedSuggestions = x)
);
}
@@ -820,11 +820,11 @@
<div class="header" slot="header">Publish Change Edit</div>
<div class="main" slot="main">
${when(
- this.numberOfThreadsWithSuggestions() > 0,
+ this.numberOfThreadsWithUnappliedSuggestions() > 0,
() => html`<p class="info">
<gr-icon id="icon" icon="info" small></gr-icon>
- Heads Up! ${this.numberOfThreadsWithSuggestions()} comments have
- suggestions you can apply before publishing
+ Heads Up! ${this.numberOfThreadsWithUnappliedSuggestions()}
+ comments have suggestions you can apply before publishing
</p>`
)}
Do you really want to publish the edit?
@@ -2105,8 +2105,16 @@
}
private handlePublishEditTap() {
- assertIsDefined(this.confirmPublishEditDialog, 'confirmPublishEditDialog');
- this.showActionDialog(this.confirmPublishEditDialog);
+ if (this.numberOfThreadsWithUnappliedSuggestions() > 0) {
+ assertIsDefined(
+ this.confirmPublishEditDialog,
+ 'confirmPublishEditDialog'
+ );
+ this.showActionDialog(this.confirmPublishEditDialog);
+ } else {
+ // Skip confirmation dialog and publish immediately.
+ this.handlePublishEditConfirm();
+ }
}
private handleRebaseEditTap() {
@@ -2264,9 +2272,9 @@
fireNoBubbleNoCompose(this, 'stop-edit-tap', {});
}
- private numberOfThreadsWithSuggestions() {
- if (!this.threadsWithSuggestions) return 0;
- return this.threadsWithSuggestions.length;
+ private numberOfThreadsWithUnappliedSuggestions() {
+ if (!this.threadsWithUnappliedSuggestions) return 0;
+ return this.threadsWithUnappliedSuggestions.length;
}
}
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 b2b045a..c76e928 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
@@ -123,7 +123,7 @@
import {Key, Modifier, whenVisible} from '../../../utils/dom-util';
import {GrThreadList} from '../gr-thread-list/gr-thread-list';
import {userModelToken} from '../../../models/user/user-model';
-import {accountsModelToken} from '../../../models/accounts-model/accounts-model';
+import {accountsModelToken} from '../../../models/accounts/accounts-model';
import {pluginLoaderToken} from '../../shared/gr-js-api-interface/gr-plugin-loader';
import {modalStyles} from '../../../styles/gr-modal-styles';
import {ironAnnouncerRequestAvailability} from '../../polymer-util';
diff --git a/polygerrit-ui/app/elements/change/gr-revision-parents/gr-revision-parents.ts b/polygerrit-ui/app/elements/change/gr-revision-parents/gr-revision-parents.ts
index 9cf5423..2dbbd26 100644
--- a/polygerrit-ui/app/elements/change/gr-revision-parents/gr-revision-parents.ts
+++ b/polygerrit-ui/app/elements/change/gr-revision-parents/gr-revision-parents.ts
@@ -3,6 +3,8 @@
* Copyright 2023 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
+import '../gr-commit-info/gr-commit-info';
+import '../../shared/gr-button/gr-button';
import {customElement, state} from 'lit/decorators.js';
import {css, html, HTMLTemplateResult, LitElement} from 'lit';
import {resolve} from '../../../models/dependency';
diff --git a/polygerrit-ui/app/elements/change/gr-revision-parents/gr-revision-parents_test.ts b/polygerrit-ui/app/elements/change/gr-revision-parents/gr-revision-parents_test.ts
index b9aa63d..d3b46ff 100644
--- a/polygerrit-ui/app/elements/change/gr-revision-parents/gr-revision-parents_test.ts
+++ b/polygerrit-ui/app/elements/change/gr-revision-parents/gr-revision-parents_test.ts
@@ -167,7 +167,7 @@
The diff below may not be meaningful and may<br/>
even be hiding relevant changes.
<a href="/Documentation/user-review-ui.html#hazardous-rebases">Learn more</a>
- </p><p><gr-button link="">Show details</gr-button></p></div></div>`
+ </p><p><gr-button aria-disabled="false" link="" role="button" tabindex="0">Show details</gr-button></p></div></div>`
);
});
@@ -183,7 +183,7 @@
The diff below may not be meaningful and may<br/>
even be hiding relevant changes.
<a href="/Documentation/user-review-ui.html#hazardous-rebases">Learn more</a>
- </p><p><gr-button link="">Show details</gr-button></p></div></div>`
+ </p><p><gr-button aria-disabled="false" link="" role="button" tabindex="0">Show details</gr-button></p></div></div>`
);
});
@@ -235,7 +235,7 @@
The diff below may not be meaningful and may<br/>
even be hiding relevant changes.
<a href="/Documentation/user-review-ui.html#hazardous-rebases">Learn more</a>
- </p><p><gr-button link="">Show details</gr-button></p></div></div>`
+ </p><p><gr-button aria-disabled="false" link="" role="button" tabindex="0">Show details</gr-button></p></div></div>`
);
});
});
diff --git a/polygerrit-ui/app/elements/core/gr-account-dropdown/gr-account-dropdown.ts b/polygerrit-ui/app/elements/core/gr-account-dropdown/gr-account-dropdown.ts
index e63ac8f..644871a4 100644
--- a/polygerrit-ui/app/elements/core/gr-account-dropdown/gr-account-dropdown.ts
+++ b/polygerrit-ui/app/elements/core/gr-account-dropdown/gr-account-dropdown.ts
@@ -32,6 +32,9 @@
@property({type: Object})
account?: AccountInfo;
+ @property({type: Boolean})
+ showMobile?: boolean;
+
// Private but used in test
@state()
config?: ServerInfo;
@@ -45,6 +48,9 @@
@state()
private switchAccountUrl = '';
+ // private but used in test
+ @state() feedbackURL = '';
+
// Private but used in test
readonly getConfigModel = resolve(this, configModelToken);
@@ -56,6 +62,10 @@
cfg => {
this.config = cfg;
+ if (cfg?.gerrit?.report_bug_url) {
+ this.feedbackURL = cfg?.gerrit.report_bug_url;
+ }
+
if (cfg && cfg.auth && cfg.auth.switch_account_url) {
this.switchAccountUrl = cfg.auth.switch_account_url;
} else {
@@ -103,7 +113,11 @@
@tap-item-shortcuts=${this.handleShortcutsTap}
.horizontalAlign=${'right'}
>
- <span ?hidden=${this.hasAvatars}>${this.accountName(this.account)}</span>
+ ${this.showMobile && !this.hasAvatars
+ ? html`<gr-icon icon="account_circle" filled></gr-icon>`
+ : html`<span ?hidden=${this.hasAvatars}
+ >${this.accountName(this.account)}</span
+ >`}
<gr-avatar
.account=${this.account}
?hidden=${!this.hasAvatars}
@@ -135,6 +149,15 @@
const url = this.interpolateUrl(switchAccountUrl, replacements);
links.push({name: 'Switch account', url, external: true});
}
+ if (this.showMobile && this.feedbackURL) {
+ links.push({
+ name: 'Feedback',
+ id: 'feedback',
+ url: this.feedbackURL,
+ external: true,
+ target: '_blank',
+ });
+ }
links.push({name: 'Sign out', id: 'signout', url: '/logout'});
return links;
}
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
index ae59fb6..3be77db 100644
--- 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
@@ -22,13 +22,14 @@
import {getAppContext} from '../../../services/app-context';
import {sharedStyles} from '../../../styles/shared-styles';
import {LitElement, PropertyValues, html, css} from 'lit';
-import {customElement, property, state} from 'lit/decorators.js';
+import {customElement, property, query, state} from 'lit/decorators.js';
import {fire} from '../../../utils/event-util';
import {resolve} from '../../../models/dependency';
import {configModelToken} from '../../../models/config/config-model';
import {userModelToken} from '../../../models/user/user-model';
import {pluginLoaderToken} from '../../shared/gr-js-api-interface/gr-plugin-loader';
import {subscribe} from '../../lit/subscription-controller';
+import {ifDefined} from 'lit/directives/if-defined.js';
type MainHeaderLink = RequireProperties<DropdownLink, 'url' | 'name'>;
@@ -104,6 +105,9 @@
AuthType.CUSTOM_EXTENSION,
]);
+const REL_NOOPENER = 'noopener';
+const REL_EXTERNAL = 'external';
+
declare global {
interface HTMLElementTagNameMap {
'gr-main-header': GrMainHeader;
@@ -149,6 +153,15 @@
// private but used in test
@state() feedbackURL = '';
+ @state() hamburgerClose? = false;
+
+ @query('.nav-sidebar') navSidebar?: HTMLDivElement;
+
+ @query('.modelBackground') modelBackground?: HTMLDivElement;
+
+ @query('.has-collapsible.active')
+ hasCollapsibleActive?: HTMLLIElement;
+
private readonly restApiService = getAppContext().restApiService;
private readonly getPluginLoader = resolve(this, pluginLoaderToken);
@@ -202,10 +215,29 @@
:host {
display: block;
}
- nav {
+ .hideOnDesktop {
+ display: none;
+ }
+
+ nav.hideOnMobile {
align-items: center;
display: flex;
}
+ nav.hideOnMobile ul {
+ list-style: none;
+ padding-left: var(--spacing-l);
+ }
+ nav.hideOnMobile .links > li {
+ cursor: default;
+ display: inline-block;
+ padding: 0;
+ position: relative;
+ }
+
+ .mobileTitle {
+ display: none;
+ }
+
.bigTitle {
color: var(--header-text-color);
font-size: var(--header-title-font-size);
@@ -215,7 +247,8 @@
.bigTitle:hover {
text-decoration: underline;
}
- .titleText {
+ .titleText,
+ .mobileTitleText {
/* Vertical alignment of icons and text with just block/inline display is too troublesome. */
display: flex;
align-items: center;
@@ -239,16 +272,45 @@
content: var(--header-title-content);
white-space: nowrap;
}
- ul {
- list-style: none;
- padding-left: var(--spacing-l);
+
+ .mobileTitleText::before {
+ --icon-width: var(
+ --header-icon-width,
+ var(--header-mobile-icon-size, var(--header-icon-size, 0))
+ );
+ --icon-height: var(
+ --header-icon-height,
+ var(--header-mobile-icon-size, var(--header-icon-size, 0))
+ );
+ background-image: var(--header-mobile-icon, var(--header-icon));
+ background-size: var(--mobile-icon-width, var(--icon-width))
+ var(--mobile-icon-height, var(--icon-height));
+ background-repeat: no-repeat;
+ content: '';
+ /* Any direct child of a flex element implicitly has 'display: block', but let's make that explicit here. */
+ display: block;
+ width: var(--mobile-icon-width, var(--icon-width));
+ height: var(--mobile-icon-height, var(--icon-height));
+ /* If size or height are set, then use 'spacing-m', 0px otherwise. */
+ margin-right: clamp(
+ 0px,
+ var(--mobile-icon-height, var(--icon-height)),
+ var(--spacing-m)
+ );
}
- .links > li {
- cursor: default;
- display: inline-block;
- padding: 0;
- position: relative;
+ .mobileTitleText::after {
+ /* The height will be determined by the line-height of the .bigTitle element. */
+ content: var(
+ --header-mobile-title-content,
+ var(--header-title-content)
+ );
+ white-space: nowrap;
+ text-overflow: ellipsis;
+ flex: 1;
+ overflow: hidden;
+ min-width: 0;
}
+
.linksTitle {
display: inline-block;
font-weight: var(--font-weight-bold);
@@ -264,7 +326,24 @@
flex: 1;
justify-content: flex-end;
}
- .rightItems gr-endpoint-decorator:not(:empty) {
+ .mobileRightItems {
+ align-items: center;
+ justify-content: flex-end;
+
+ display: inline-block;
+ vertical-align: middle;
+ cursor: pointer;
+ position: relative;
+ top: 0px;
+ right: 0px;
+ margin-right: 0;
+ margin-left: auto;
+ min-height: 50px;
+ padding-top: 12px;
+ }
+
+ .rightItems gr-endpoint-decorator:not(:empty),
+ .mobileRightItems gr-endpoint-decorator:not(:empty) {
margin-left: var(--spacing-l);
}
gr-smart-search {
@@ -299,13 +378,17 @@
}
:host([loading]) .accountContainer,
:host([loggedIn]) .loginButton,
- :host([loggedIn]) .registerButton {
+ :host([loggedIn]) .registerButton,
+ :host([loggedIn]) .moreMenu {
display: none;
}
:host([loggedIn]) .settingsButton,
:host([loggedIn]) gr-account-dropdown {
display: inline;
}
+ :host:not([loggedIn]) .moreMenu {
+ display: inline;
+ }
.accountContainer {
flex: 0 0 auto;
align-items: center;
@@ -363,7 +446,158 @@
margin-left: var(--spacing-m) !important;
}
gr-dropdown {
- padding: var(--spacing-m) 0 var(--spacing-m) var(--spacing-m);
+ padding: 0 var(--spacing-m);
+ }
+ .nav-sidebar {
+ background: var(--table-header-background-color);
+ width: 200px;
+ height: 100%;
+ display: block;
+ position: fixed;
+ left: -200px;
+ top: 0px;
+ transition: left 0.25s ease;
+ margin: 0;
+ border: 0;
+ overflow-y: auto;
+ overflow-x: hidden;
+ height: 100%;
+ margin-bottom: 15px 0;
+ box-shadow: 0 2px 5px 0 rgba(0, 0, 0, 0.26);
+ border-radius: 3px;
+ z-index: 2;
+ }
+ .nav-sidebar.visible {
+ left: 0px;
+ transition: left 0.25s ease;
+ width: 80%;
+ z-index: 200;
+ }
+ .mobileTitle {
+ position: relative;
+ display: block;
+ top: 10px;
+ font-size: 20px;
+ left: 100px;
+ right: 100px;
+ text-align: center;
+ text-overflow: ellipsis;
+ overflow: hidden;
+ width: 50%;
+ }
+ .nav-header {
+ display: flex;
+ }
+ .hamburger {
+ display: inline-block;
+ vertical-align: middle;
+ height: 50px;
+ cursor: pointer;
+ margin: 0;
+ position: absolute;
+ top: 0;
+ left: 0;
+ padding: 12px;
+ z-index: 200;
+ }
+ .nav-sidebar ul {
+ list-style-type: none;
+ margin: 0;
+ padding: 0;
+ display: block;
+ padding-top: 50px;
+ }
+ .nav-sidebar li {
+ list-style-type: none;
+ margin: 0;
+ padding: 0;
+ display: inline-block;
+ position: relative;
+ font-size: 14;
+ color: var(--primary-text-color);
+ display: block;
+ }
+ .cover {
+ background: rgba(0, 0, 0, 0.5);
+ position: fixed;
+ top: 0;
+ bottom: 0;
+ left: 0;
+ right: 0;
+ overflow: none;
+ z-index: 199;
+ }
+ .hideOnDesktop {
+ display: block;
+ }
+ nav.hideOnMobile {
+ display: none;
+ }
+ .nav-sidebar .menu ul {
+ list-style-type: none;
+ margin: 0;
+ padding: 0;
+ display: block;
+ padding-top: 50px;
+ }
+ .nav-sidebar .menu li {
+ list-style-type: none;
+ margin: 0;
+ padding: 0;
+ display: inline-block;
+ position: relative;
+ font-size: 14;
+ color: var(--primary-text-color);
+ display: block;
+ }
+ .nav-sidebar .menu li a {
+ padding: 15px 20px;
+ font-size: 14;
+ outline: 0;
+ display: block;
+ color: var(--primary-text-color);
+ font-weight: 600;
+ }
+ .nav-sidebar .menu li.active ul.dropdown {
+ display: block;
+ }
+ .nav-sidebar .menu li ul.dropdown {
+ position: absolute;
+ display: none;
+ width: 100%;
+ box-shadow: 0 2px 5px 0 rgba(0, 0, 0, 0.26);
+ padding-top: 0;
+ position: relative;
+ }
+ .nav-sidebar .menu li ul.dropdown li {
+ display: block;
+ list-style-type: none;
+ }
+ .nav-sidebar .menu li ul.dropdown li a {
+ padding: 15px 20px;
+ font-size: 15px;
+ display: block;
+ font-weight: 400;
+ border-bottom: none;
+ padding: 10px 10px 10px 30px;
+ }
+ .nav-sidebar .menu li ul.dropdown li:last-child a {
+ border-bottom: none;
+ }
+ .nav-sidebar .menu a {
+ text-decoration: none;
+ }
+ .nav-sidebar .menu li.active:first-child a {
+ border-radius: 3px 0 0 3px;
+ border-radius: 0;
+ }
+ .nav-sidebar .menu li ul.dropdown li.active:first-child a {
+ border-radius: 0;
+ }
+ .arrow-down {
+ position: absolute;
+ top: 10px;
+ right: 10px;
}
}
`,
@@ -371,35 +605,130 @@
}
override render() {
+ return html` ${this.renderDesktop()} ${this.renderMobile()} `;
+ }
+
+ private renderDesktop() {
return html`
- <nav>
- <a href=${`//${window.location.host}${getBaseUrl()}/`} class="bigTitle">
- <gr-endpoint-decorator name="header-title">
- <div class="titleText"></div>
- </gr-endpoint-decorator>
- </a>
- <ul class="links">
- ${this.computeLinks(this.userLinks, this.adminLinks, this.topMenus).map(
- linkGroup => this.renderLinkGroup(linkGroup)
- )}
- </ul>
- <div class="rightItems">
- <gr-endpoint-decorator
- class="hideOnMobile"
- name="header-small-banner"
- ></gr-endpoint-decorator>
- <gr-smart-search id="search"></gr-smart-search>
- <gr-endpoint-decorator
- class="hideOnMobile"
- name="header-top-right"
- ></gr-endpoint-decorator>
- <gr-endpoint-decorator class="feedbackButton" name="header-feedback">
- ${this.renderFeedback()}
- </gr-endpoint-decorator>
- </div>
- ${this.renderAccount()}
- </div>
- </nav>
+ <nav class="hideOnMobile">
+ <a href=${`//${window.location.host}${getBaseUrl()}/`} class="bigTitle">
+ <gr-endpoint-decorator name="header-title">
+ <div class="titleText"></div>
+ </gr-endpoint-decorator>
+ </a>
+ <ul class="links">
+ ${this.computeLinks(
+ this.userLinks,
+ this.adminLinks,
+ this.topMenus
+ ).map(linkGroup => this.renderLinkGroup(linkGroup))}
+ </ul>
+ <div class="rightItems">
+ <gr-endpoint-decorator
+ class="hideOnMobile"
+ name="header-small-banner"
+ ></gr-endpoint-decorator>
+ <gr-smart-search id="search"></gr-smart-search>
+ <gr-endpoint-decorator
+ class="hideOnMobile"
+ name="header-top-right"
+ ></gr-endpoint-decorator>
+ <gr-endpoint-decorator class="feedbackButton" name="header-feedback">
+ ${this.renderFeedback()}
+ </gr-endpoint-decorator>
+ </div>
+ ${this.renderAccount()}
+ </div>
+ </nav>
+ `;
+ }
+
+ private renderMobile() {
+ const moreMenu: MainHeaderLink[] = [
+ {
+ name: this.registerText,
+ url: this.registerURL,
+ },
+ {
+ name: this.loginText,
+ url: this.loginUrl,
+ },
+ ];
+ if (!this.registerURL) {
+ moreMenu.shift();
+ }
+ if (this.feedbackURL) {
+ moreMenu.push({
+ name: 'Feedback',
+ url: this.feedbackURL,
+ external: true,
+ target: '_blank',
+ });
+ }
+
+ return html`
+ <nav class="hideOnDesktop">
+ <div class="nav-sidebar">
+ <ul class="menu">
+ ${this.computeLinks(
+ this.userLinks,
+ this.adminLinks,
+ this.topMenus
+ ).map(linkGroup => this.renderLinkGroupMobile(linkGroup))}
+ </ul>
+ </div>
+ <div class="nav-header">
+ <a
+ class="hamburger"
+ href=""
+ title="Hamburger"
+ aria-label="${!this.hamburgerClose ? 'Open' : 'Close'} hamburger"
+ role="button"
+ @click=${() => {
+ this.handleSidebar();
+ }}
+ >
+ ${!this.hamburgerClose
+ ? html`<gr-icon icon="menu" filled></gr-icon>`
+ : html`<gr-icon icon="menu_open" filled></gr-icon>`}
+ </a>
+ <a
+ href=${`//${window.location.host}${getBaseUrl()}/`}
+ class="mobileTitle bigTitle"
+ >
+ <gr-endpoint-decorator name="header-mobile-title">
+ <div class="mobileTitleText"></div>
+ </gr-endpoint-decorator>
+ </a>
+ <div class="mobileRightItems">
+ <a
+ class="searchButton"
+ title="Search"
+ @click=${(e: Event) => {
+ this.onMobileSearchTap(e);
+ }}
+ role="button"
+ aria-label=${this.mobileSearchHidden
+ ? 'Show Searchbar'
+ : 'Hide Searchbar'}
+ >
+ <gr-icon icon="search" filled></gr-icon>
+ </a>
+ <gr-dropdown
+ class="moreMenu"
+ link=""
+ .items=${moreMenu}
+ horizontal-align="center"
+ >
+ <span class="linksTitle">
+ <gr-icon icon="more_horiz" filled></gr-icon>
+ </span>
+ </gr-dropdown>
+ ${this.renderAccountDropdown(true)}
+ </div>
+ </div>
+ </nav>
+ <div class="modelBackground" @click=${() => this.handleSidebar()}></div>
`;
}
@@ -420,6 +749,41 @@
`;
}
+ private renderLinkGroupMobile(linkGroup: MainHeaderLinkGroup) {
+ return html`
+ <li class="has-collapsible" @click=${this.handleCollapsible}>
+ <a class="main" href="" data-title=${linkGroup.title}
+ >${linkGroup.title}<gr-icon
+ icon="arrow_drop_down"
+ class="arrow-down"
+ ></gr-icon
+ ></a>
+ <ul class="dropdown">
+ ${linkGroup.links.map(link => this.renderLinkMobile(link))}
+ </ul>
+ </li>
+ `;
+ }
+
+ private renderLinkMobile(link: DropdownLink) {
+ return html`
+ <li tabindex="-1">
+ <span ?hidden=${!!link.url} tabindex="-1">${link.name}</span>
+ <a
+ class="itemAction"
+ href=${this.computeLinkURL(link)}
+ ?download=${!!link.download}
+ rel=${ifDefined(this.computeLinkRel(link) ?? undefined)}
+ target=${ifDefined(link.target ?? undefined)}
+ ?hidden=${!link.url}
+ tabindex="-1"
+ @click=${() => this.handleSidebar()}
+ >${link.name}</a
+ >
+ </li>
+ `;
+ }
+
private renderFeedback() {
if (!this.feedbackURL) return;
@@ -483,11 +847,14 @@
`;
}
- private renderAccountDropdown() {
+ private renderAccountDropdown(showOnMobile?: boolean) {
if (!this.account) return;
return html`
- <gr-account-dropdown .account=${this.account}></gr-account-dropdown>
+ <gr-account-dropdown
+ .account=${this.account}
+ ?showMobile=${showOnMobile}
+ ></gr-account-dropdown>
`;
}
@@ -533,7 +900,6 @@
links.push({
title: 'Documentation',
links: docLinks,
- class: 'hideOnMobile',
});
}
links.push({
@@ -637,4 +1003,96 @@
e.stopPropagation();
fire(this, 'mobile-search', {});
}
+
+ /**
+ * Build a URL for the given host and path. The base URL will be only added,
+ * if it is not already included in the path.
+ *
+ * TODO: Move to util handler to remove duplication.
+ * @return The scheme-relative URL.
+ */
+ private computeURLHelper(host: string, path: string) {
+ const base = path.startsWith(getBaseUrl()) ? '' : getBaseUrl();
+ return '//' + host + base + path;
+ }
+
+ /**
+ * Build a scheme-relative URL for the current host. Will include the base
+ * URL if one is present. Note: the URL will be scheme-relative but absolute
+ * with regard to the host.
+ *
+ * TODO: Move to util handler to remove duplication.
+ * @param path The path for the URL.
+ * @return The scheme-relative URL.
+ */
+ private computeRelativeURL(path: string) {
+ const host = window.location.host;
+ return this.computeURLHelper(host, path);
+ }
+
+ /**
+ * Compute the URL for a link object.
+ *
+ * Private but used in tests.
+ *
+ * TODO: Move to util handler to remove duplication.
+ */
+ private computeLinkURL(link: DropdownLink) {
+ if (typeof link.url === 'undefined') {
+ return '';
+ }
+ if (link.target || !link.url.startsWith('/')) {
+ return link.url;
+ }
+ return this.computeRelativeURL(link.url);
+ }
+
+ /**
+ * Compute the value for the rel attribute of an anchor for the given link
+ * object. If the link has a target value, then the rel must be "noopener"
+ * for security reasons.
+ * Private but used in tests.
+ *
+ * TODO: Move to util handler to remove duplication.
+ */
+ private computeLinkRel(link: DropdownLink) {
+ // Note: noopener takes precedence over external.
+ if (link.target) {
+ return REL_NOOPENER;
+ }
+ if (link.external) {
+ return REL_EXTERNAL;
+ }
+ return null;
+ }
+
+ private handleCollapsible(e: MouseEvent) {
+ const target = e.target as HTMLSpanElement;
+ if (target.hasAttribute('data-title')) {
+ if (target.parentElement?.classList.contains('active')) {
+ target.parentElement.classList.remove('active');
+ } else {
+ if (this.hasCollapsibleActive) {
+ this.hasCollapsibleActive.classList.remove('active');
+ }
+ target.parentElement?.classList.toggle('active');
+ }
+ }
+ }
+
+ private handleSidebar() {
+ this.navSidebar?.classList.toggle('visible');
+ if (!this.modelBackground?.classList.contains('cover')) {
+ if (document.getElementsByTagName('html')) {
+ document.getElementsByTagName('html')[0].style.overflow = 'hidden';
+ }
+ } else {
+ if (document.getElementsByTagName('html')) {
+ document.getElementsByTagName('html')[0].style.overflow = '';
+ }
+ }
+ this.modelBackground?.classList.toggle('cover');
+ this.hasCollapsibleActive?.classList.remove('active');
+ this.hamburgerClose = !this.hamburgerClose;
+ }
}
diff --git a/polygerrit-ui/app/elements/core/gr-main-header/gr-main-header_test.ts b/polygerrit-ui/app/elements/core/gr-main-header/gr-main-header_test.ts
index 40430fb..b4a0600 100644
--- a/polygerrit-ui/app/elements/core/gr-main-header/gr-main-header_test.ts
+++ b/polygerrit-ui/app/elements/core/gr-main-header/gr-main-header_test.ts
@@ -39,7 +39,7 @@
assert.shadowDom.equal(
element,
/* HTML */ `
- <nav>
+ <nav class="hideOnMobile">
<a class="bigTitle" href="//localhost:9876/">
<gr-endpoint-decorator name="header-title">
<div class="titleText"></div>
@@ -51,7 +51,7 @@
<span class="linksTitle" id="Changes"> Changes </span>
</gr-dropdown>
</li>
- <li class="hideOnMobile">
+ <li>
<gr-dropdown down-arrow="" horizontal-align="left" link="">
<span class="linksTitle" id="Documentation">Documentation</span>
</gr-dropdown>
@@ -101,6 +101,169 @@
</a>
</div>
</nav>
+ <nav class="hideOnDesktop">
+ <div class="nav-sidebar">
+ <ul class="menu">
+ <li class="has-collapsible">
+ <a class="main" data-title="Changes" href="">
+ Changes
+ <gr-icon class="arrow-down" icon="arrow_drop_down"> </gr-icon>
+ </a>
+ <ul class="dropdown">
+ <li tabindex="-1">
+ <span hidden="" tabindex="-1"> Open </span>
+ <a
+ class="itemAction"
+ href="//localhost:9876/q/status:open+-is:wip"
+ tabindex="-1"
+ >
+ Open
+ </a>
+ </li>
+ <li tabindex="-1">
+ <span hidden="" tabindex="-1"> Merged </span>
+ <a
+ class="itemAction"
+ href="//localhost:9876/q/status:merged"
+ tabindex="-1"
+ >
+ Merged
+ </a>
+ </li>
+ <li tabindex="-1">
+ <span hidden="" tabindex="-1"> Abandoned </span>
+ <a
+ class="itemAction"
+ href="//localhost:9876/q/status:abandoned"
+ tabindex="-1"
+ >
+ Abandoned
+ </a>
+ </li>
+ </ul>
+ </li>
+ <li class="has-collapsible">
+ <a class="main" data-title="Documentation" href="">
+ Documentation
+ <gr-icon class="arrow-down" icon="arrow_drop_down"> </gr-icon>
+ </a>
+ <ul class="dropdown">
+ <li tabindex="-1">
+ <span hidden="" tabindex="-1"> Table of Contents </span>
+ <a
+ class="itemAction"
+ href="https://gerrit-review.googlesource.com/Documentation/index.html"
+ rel="noopener"
+ tabindex="-1"
+ target="_blank"
+ >
+ Table of Contents
+ </a>
+ </li>
+ <li tabindex="-1">
+ <span hidden="" tabindex="-1"> Searching </span>
+ <a
+ class="itemAction"
+ href="https://gerrit-review.googlesource.com/Documentation/user-search.html"
+ rel="noopener"
+ tabindex="-1"
+ target="_blank"
+ >
+ Searching
+ </a>
+ </li>
+ <li tabindex="-1">
+ <span hidden="" tabindex="-1"> Uploading </span>
+ <a
+ class="itemAction"
+ href="https://gerrit-review.googlesource.com/Documentation/user-upload.html"
+ rel="noopener"
+ tabindex="-1"
+ target="_blank"
+ >
+ Uploading
+ </a>
+ </li>
+ <li tabindex="-1">
+ <span hidden="" tabindex="-1"> Access Control </span>
+ <a
+ class="itemAction"
+ href="https://gerrit-review.googlesource.com/Documentation/access-control.html"
+ rel="noopener"
+ tabindex="-1"
+ target="_blank"
+ >
+ Access Control
+ </a>
+ </li>
+ <li tabindex="-1">
+ <span hidden="" tabindex="-1"> REST API </span>
+ <a
+ class="itemAction"
+ href="https://gerrit-review.googlesource.com/Documentation/rest-api.html"
+ rel="noopener"
+ tabindex="-1"
+ target="_blank"
+ >
+ REST API
+ </a>
+ </li>
+ <li tabindex="-1">
+ <span hidden="" tabindex="-1"> Project Owner Guide </span>
+ <a
+ class="itemAction"
+ href="https://gerrit-review.googlesource.com/Documentation/intro-project-owner.html"
+ rel="noopener"
+ tabindex="-1"
+ target="_blank"
+ >
+ Project Owner Guide
+ </a>
+ </li>
+ </ul>
+ </li>
+ <li class="has-collapsible">
+ <a class="main" data-title="Browse" href="">
+ Browse
+ <gr-icon class="arrow-down" icon="arrow_drop_down"> </gr-icon>
+ </a>
+ <ul class="dropdown"></ul>
+ </li>
+ </ul>
+ </div>
+ <div class="nav-header">
+ <a
+ aria-label="Open hamburger"
+ class="hamburger"
+ href=""
+ role="button"
+ title="Hamburger"
+ >
+ <gr-icon filled="" icon="menu"> </gr-icon>
+ </a>
+ <a class="bigTitle mobileTitle" href="//localhost:9876/">
+ <gr-endpoint-decorator name="header-mobile-title">
+ <div class="mobileTitleText"></div>
+ </gr-endpoint-decorator>
+ </a>
+ <div class="mobileRightItems">
+ <a
+ aria-label="Hide Searchbar"
+ class="searchButton"
+ role="button"
+ title="Search"
+ >
+ <gr-icon filled="" icon="search"> </gr-icon>
+ </a>
+ <gr-dropdown class="moreMenu" horizontal-align="center" link="">
+ <span class="linksTitle">
+ <gr-icon filled="" icon="more_horiz"> </gr-icon>
+ </span>
+ </gr-dropdown>
+ </div>
+ </div>
+ </nav>
+ <div class="modelBackground"></div>
`
);
});
diff --git a/polygerrit-ui/app/elements/settings/gr-email-editor/gr-email-editor.ts b/polygerrit-ui/app/elements/settings/gr-email-editor/gr-email-editor.ts
index 9c99ae0..2220d2f 100644
--- a/polygerrit-ui/app/elements/settings/gr-email-editor/gr-email-editor.ts
+++ b/polygerrit-ui/app/elements/settings/gr-email-editor/gr-email-editor.ts
@@ -3,20 +3,23 @@
* Copyright 2016 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
-import '@polymer/iron-input/iron-input';
import '../../shared/gr-button/gr-button';
import {EmailInfo} from '../../../types/common';
import {getAppContext} from '../../../services/app-context';
import {LitElement, css, html} from 'lit';
-import {customElement, property, state} from 'lit/decorators.js';
+import {customElement, state} from 'lit/decorators.js';
import {sharedStyles} from '../../../styles/shared-styles';
import {grFormStyles} from '../../../styles/gr-form-styles';
import {ValueChangedEvent} from '../../../types/events';
import {fire} from '../../../utils/event-util';
+import {deepClone} from '../../../utils/deep-util';
+import {userModelToken} from '../../../models/user/user-model';
+import {resolve} from '../../../models/dependency';
+import {subscribe} from '../../lit/subscription-controller';
@customElement('gr-email-editor')
export class GrEmailEditor extends LitElement {
- @property({type: Boolean}) hasUnsavedChanges = false;
+ @state() private originalEmails: EmailInfo[] = [];
/* private but used in test */
@state() emails: EmailInfo[] = [];
@@ -29,6 +32,21 @@
readonly restApiService = getAppContext().restApiService;
+ private readonly getUserModel = resolve(this, userModelToken);
+
+ constructor() {
+ super();
+ subscribe(
+ this,
+ () => this.getUserModel().emails$,
+ x => {
+ if (!x) return;
+ this.originalEmails = deepClone<EmailInfo[]>(x);
+ this.emails = deepClone<EmailInfo[]>(x);
+ }
+ );
+ }
+
static override get styles() {
return [
sharedStyles,
@@ -82,24 +100,20 @@
return html`<tr>
<td class="emailColumn">${email.email}</td>
<td class="preferredControl" @click=${this.handlePreferredControlClick}>
- <iron-input
+ <!-- We have to use \`.checked\` rather then \`?checked\` as there
+ appears to be an issue when deleting, checked doesn't work correctly. -->
+ <input
class="preferredRadio"
+ type="radio"
+ name="preferred"
+ .value=${email.email}
+ .checked=${email.preferred}
@change=${this.handlePreferredChange}
- .bindValue=${email.email}
- >
- <input
- class="preferredRadio"
- type="radio"
- @change=${this.handlePreferredChange}
- name="preferred"
- ?checked=${email.preferred}
- />
- </iron-input>
+ />
</td>
<td>
<gr-button
- data-index=${index}
- @click=${this.handleDeleteButton}
+ @click=${() => this.handleDeleteButton(index)}
?disabled=${this.checkPreferred(email.preferred)}
class="remove-button"
>Delete</gr-button
@@ -108,12 +122,6 @@
</tr>`;
}
- loadData() {
- return this.restApiService.getAccountEmails().then(emails => {
- this.emails = emails ?? [];
- });
- }
-
save() {
const promises: Promise<unknown>[] = [];
@@ -127,24 +135,27 @@
);
}
- return Promise.all(promises).then(() => {
+ return Promise.all(promises).then(async () => {
this.emailsToRemove = [];
this.newPreferred = '';
- this.setHasUnsavedChanges(false);
+ await this.getUserModel().loadEmails(true);
+ this.setHasUnsavedChanges();
});
}
- private handleDeleteButton(e: Event) {
- const target = e.target;
- if (!(target instanceof Element)) return;
- const indexStr = target.getAttribute('data-index');
- if (indexStr === null) return;
- const index = Number(indexStr);
+ private handleDeleteButton(index: number) {
const email = this.emails[index];
- this.emailsToRemove = [...this.emailsToRemove, email];
+ // Don't add project to emailsToRemove if it wasn't in
+ // emails.
+ // We have to use JSON.stringify as we cloned the array
+ // so the reference is not the same.
+ const emails = this.emails.some(
+ x => JSON.stringify(email) === JSON.stringify(x)
+ );
+ if (emails) this.emailsToRemove.push(email);
this.emails.splice(index, 1);
this.requestUpdate();
- this.setHasUnsavedChanges(true);
+ this.setHasUnsavedChanges();
}
private handlePreferredControlClick(e: Event) {
@@ -165,9 +176,10 @@
this.emails[i].preferred = true;
this.requestUpdate();
this.newPreferred = preferred;
- this.setHasUnsavedChanges(true);
+ this.setHasUnsavedChanges();
} else if (this.emails[i].preferred) {
- this.emails[i].preferred = false;
+ delete this.emails[i].preferred;
+ this.setHasUnsavedChanges();
this.requestUpdate();
}
}
@@ -177,9 +189,11 @@
return preferred ?? false;
}
- private setHasUnsavedChanges(value: boolean) {
- this.hasUnsavedChanges = value;
- fire(this, 'has-unsaved-changes-changed', {value});
+ private setHasUnsavedChanges() {
+ const hasUnsavedChanges =
+ JSON.stringify(this.originalEmails) !== JSON.stringify(this.emails) ||
+ this.emailsToRemove.length > 0;
+ fire(this, 'has-unsaved-changes-changed', {value: hasUnsavedChanges});
}
}
diff --git a/polygerrit-ui/app/elements/settings/gr-email-editor/gr-email-editor_test.ts b/polygerrit-ui/app/elements/settings/gr-email-editor/gr-email-editor_test.ts
index 25c9b97..84ed8dc 100644
--- a/polygerrit-ui/app/elements/settings/gr-email-editor/gr-email-editor_test.ts
+++ b/polygerrit-ui/app/elements/settings/gr-email-editor/gr-email-editor_test.ts
@@ -11,6 +11,7 @@
suite('gr-email-editor tests', () => {
let element: GrEmailEditor;
+ let accountEmailStub: sinon.SinonStub;
setup(async () => {
const emails = [
@@ -19,13 +20,14 @@
{email: 'email@three.com'},
];
- stubRestApi('getAccountEmails').returns(Promise.resolve(emails));
+ accountEmailStub = stubRestApi('getAccountEmails').returns(
+ Promise.resolve(emails)
+ );
element = await fixture<GrEmailEditor>(
html`<gr-email-editor></gr-email-editor>`
);
- await element.loadData();
await element.updateComplete;
});
@@ -45,20 +47,17 @@
<tr>
<td class="emailColumn">email@one.com</td>
<td class="preferredControl">
- <iron-input class="preferredRadio">
- <input
- class="preferredRadio"
- name="preferred"
- type="radio"
- value="email@one.com"
- />
- </iron-input>
+ <input
+ class="preferredRadio"
+ name="preferred"
+ type="radio"
+ value="email@one.com"
+ />
</td>
<td>
<gr-button
aria-disabled="false"
class="remove-button"
- data-index="0"
role="button"
tabindex="0"
>
@@ -69,21 +68,17 @@
<tr>
<td class="emailColumn">email@two.com</td>
<td class="preferredControl">
- <iron-input class="preferredRadio">
- <input
- checked=""
- class="preferredRadio"
- name="preferred"
- type="radio"
- value="email@two.com"
- />
- </iron-input>
+ <input
+ class="preferredRadio"
+ name="preferred"
+ type="radio"
+ value="email@two.com"
+ />
</td>
<td>
<gr-button
aria-disabled="true"
class="remove-button"
- data-index="1"
disabled=""
role="button"
tabindex="-1"
@@ -95,20 +90,17 @@
<tr>
<td class="emailColumn">email@three.com</td>
<td class="preferredControl">
- <iron-input class="preferredRadio">
- <input
- class="preferredRadio"
- name="preferred"
- type="radio"
- value="email@three.com"
- />
- </iron-input>
+ <input
+ class="preferredRadio"
+ name="preferred"
+ type="radio"
+ value="email@three.com"
+ />
</td>
<td>
<gr-button
aria-disabled="false"
class="remove-button"
- data-index="2"
role="button"
tabindex="0"
>
@@ -123,6 +115,12 @@
});
test('renders', () => {
+ const hasUnsavedChangesSpy = sinon.spy();
+ element.addEventListener(
+ 'has-unsaved-changes-changed',
+ hasUnsavedChangesSpy
+ );
+
const rows = element
.shadowRoot!.querySelector('table')!
.querySelectorAll('tbody tr');
@@ -144,15 +142,21 @@
);
assert.isNotOk(rows[2].querySelector('gr-button')!.disabled);
- assert.isFalse(element.hasUnsavedChanges);
+ assert.isFalse(hasUnsavedChangesSpy.called);
});
test('edit preferred', () => {
+ const hasUnsavedChangesSpy = sinon.spy();
+ element.addEventListener(
+ 'has-unsaved-changes-changed',
+ hasUnsavedChangesSpy
+ );
+
const radios = element
.shadowRoot!.querySelector('table')!
.querySelectorAll<HTMLInputElement>('input[type=radio]');
- assert.isFalse(element.hasUnsavedChanges);
+ assert.isFalse(hasUnsavedChangesSpy.called);
assert.isNotOk(element.newPreferred);
assert.equal(element.emailsToRemove.length, 0);
assert.equal(element.emails.length, 3);
@@ -162,7 +166,7 @@
radios[0].click();
- assert.isTrue(element.hasUnsavedChanges);
+ assert.isTrue(hasUnsavedChangesSpy.called);
assert.isOk(element.newPreferred);
assert.equal(element.emailsToRemove.length, 0);
assert.equal(element.emails.length, 3);
@@ -172,18 +176,24 @@
});
test('delete email', () => {
+ const hasUnsavedChangesSpy = sinon.spy();
+ element.addEventListener(
+ 'has-unsaved-changes-changed',
+ hasUnsavedChangesSpy
+ );
+
const buttons = element
.shadowRoot!.querySelector('table')!
.querySelectorAll('gr-button');
- assert.isFalse(element.hasUnsavedChanges);
+ assert.isFalse(hasUnsavedChangesSpy.called);
assert.isNotOk(element.newPreferred);
assert.equal(element.emailsToRemove.length, 0);
assert.equal(element.emails.length, 3);
buttons[2].click();
- assert.isTrue(element.hasUnsavedChanges);
+ assert.isTrue(hasUnsavedChangesSpy.called);
assert.isNotOk(element.newPreferred);
assert.equal(element.emailsToRemove.length, 1);
assert.equal(element.emails.length, 2);
@@ -192,6 +202,12 @@
});
test('save changes', async () => {
+ const hasUnsavedChangesSpy = sinon.spy();
+ element.addEventListener(
+ 'has-unsaved-changes-changed',
+ hasUnsavedChangesSpy
+ );
+
const deleteEmailSpy = spyRestApi('deleteAccountEmail');
const setPreferredSpy = spyRestApi('setPreferredAccountEmail');
@@ -199,7 +215,7 @@
.shadowRoot!.querySelector('table')!
.querySelectorAll('tbody tr');
- assert.isFalse(element.hasUnsavedChanges);
+ assert.isFalse(hasUnsavedChangesSpy.called);
assert.isNotOk(element.newPreferred);
assert.equal(element.emailsToRemove.length, 0);
assert.equal(element.emails.length, 3);
@@ -208,16 +224,24 @@
rows[0].querySelector('gr-button')!.click();
rows[2].querySelector<HTMLInputElement>('input[type=radio]')!.click();
- assert.isTrue(element.hasUnsavedChanges);
+ assert.isTrue(hasUnsavedChangesSpy.called);
+ assert.isTrue(hasUnsavedChangesSpy.lastCall.args[0].detail.value);
assert.equal(element.newPreferred, 'email@three.com');
assert.equal(element.emailsToRemove.length, 1);
assert.equal(element.emailsToRemove[0].email, 'email@one.com');
assert.equal(element.emails.length, 2);
+ accountEmailStub.restore();
+
+ accountEmailStub = stubRestApi('getAccountEmails').returns(
+ Promise.resolve(element.emails)
+ );
+
await element.save();
assert.equal(deleteEmailSpy.callCount, 1);
assert.equal(deleteEmailSpy.getCall(0).args[0], 'email@one.com');
assert.isTrue(setPreferredSpy.called);
assert.equal(setPreferredSpy.getCall(0).args[0], 'email@three.com');
+ assert.isFalse(hasUnsavedChangesSpy.lastCall.args[0].detail.value);
});
});
diff --git a/polygerrit-ui/app/elements/settings/gr-preferences/gr-preferences.ts b/polygerrit-ui/app/elements/settings/gr-preferences/gr-preferences.ts
index bb0f1e9..fdb4f22 100644
--- a/polygerrit-ui/app/elements/settings/gr-preferences/gr-preferences.ts
+++ b/polygerrit-ui/app/elements/settings/gr-preferences/gr-preferences.ts
@@ -57,6 +57,9 @@
@query('#allowSuggestCodeWhileCommenting')
allowSuggestCodeWhileCommenting?: HTMLInputElement;
+ @query('#allowAiCommentAutocompletion')
+ allowAiCommentAutocompletion?: HTMLInputElement;
+
@query('#defaultBaseForMergesSelect')
defaultBaseForMergesSelect!: HTMLInputElement;
@@ -284,6 +287,7 @@
</section>
${this.renderBrowserNotifications()}
${this.renderGenerateSuggestionWhenCommenting()}
+ ${this.renderAiCommentAutocompletion()}
${this.renderDefaultBaseForMerges()}
<section>
<label class="title" for="relativeDateInChangeTable"
@@ -519,6 +523,37 @@
`;
}
+ // When the experiment is over, move this back to render(),
+ // removing this function.
+ private renderAiCommentAutocompletion() {
+ if (
+ !this.flagsService.isEnabled(KnownExperimentId.COMMENT_AUTOCOMPLETION) ||
+ !this.suggestionsProvider
+ )
+ return nothing;
+ return html`
+ <section id="allowAiCommentAutocompletionSection">
+ <div class="title">
+ <label for="allowAiCommentAutocompletion"
+ >AI suggested text completions while commenting</label
+ >
+ </div>
+ <span class="value">
+ <input
+ id="allowAiCommentAutocompletion"
+ type="checkbox"
+ ?checked=${this.prefs?.allow_autocompleting_comments}
+ @change=${() => {
+ this.prefs!.allow_autocompleting_comments =
+ this.allowAiCommentAutocompletion!.checked;
+ this.requestUpdate();
+ }}
+ />
+ </span>
+ </section>
+ `;
+ }
+
// When this is fixed and can be re-enabled, move this back to render()
// and remove function.
private renderDefaultBaseForMerges() {
diff --git a/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-item.ts b/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-item.ts
deleted file mode 100644
index c7118f7..0000000
--- a/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-item.ts
+++ /dev/null
@@ -1,40 +0,0 @@
-/**
- * @license
- * Copyright 2017 Google LLC
- * SPDX-License-Identifier: Apache-2.0
- */
-import {LitElement, css, html} from 'lit';
-import {customElement, property} from 'lit/decorators.js';
-import {grFormStyles} from '../../../styles/gr-form-styles';
-
-declare global {
- interface HTMLElementTagNameMap {
- 'gr-settings-item': GrSettingsItem;
- }
-}
-
-@customElement('gr-settings-item')
-export class GrSettingsItem extends LitElement {
- @property({type: String})
- anchor?: string;
-
- @property({type: String})
- override title = '';
-
- static override get styles() {
- return [
- grFormStyles,
- css`
- :host {
- display: block;
- margin-bottom: var(--spacing-xxl);
- }
- `,
- ];
- }
-
- override render() {
- const anchor = this.anchor ?? '';
- return html`<h2 id=${anchor} class="heading-2">${this.title}</h2>`;
- }
-}
diff --git a/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-menu-item.ts b/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-menu-item.ts
deleted file mode 100644
index 6c83bea..0000000
--- a/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-menu-item.ts
+++ /dev/null
@@ -1,35 +0,0 @@
-/**
- * @license
- * Copyright 2017 Google LLC
- * SPDX-License-Identifier: Apache-2.0
- */
-import {pageNavStyles} from '../../../styles/gr-page-nav-styles';
-import {sharedStyles} from '../../../styles/shared-styles';
-import {LitElement, html} from 'lit';
-import {customElement, property} from 'lit/decorators.js';
-
-declare global {
- interface HTMLElementTagNameMap {
- 'gr-settings-menu-item': GrSettingsMenuItem;
- }
-}
-
-@customElement('gr-settings-menu-item')
-export class GrSettingsMenuItem extends LitElement {
- @property({type: String})
- href?: string;
-
- @property({type: String})
- override title = '';
-
- static override get styles() {
- return [sharedStyles, pageNavStyles];
- }
-
- override render() {
- const href = this.href ?? '';
- return html` <div class="navStyles">
- <li><a href=${href}>${this.title}</a></li>
- </div>`;
- }
-}
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
index fd7d67b..7626e14 100644
--- 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
@@ -38,7 +38,7 @@
import {GrSshEditor} from '../gr-ssh-editor/gr-ssh-editor';
import {GrGpgEditor} from '../gr-gpg-editor/gr-gpg-editor';
import {GrEmailEditor} from '../gr-email-editor/gr-email-editor';
-import {fireAlert, fireTitleChange} from '../../../utils/event-util';
+import {fire, fireAlert, fireTitleChange} from '../../../utils/event-util';
import {getAppContext} from '../../../services/app-context';
import {BindValueChangeEvent, ValueChangedEvent} from '../../../types/events';
import {LitElement, css, html} from 'lit';
@@ -190,7 +190,7 @@
const message = await this.restApiService.confirmEmail(this.emailToken);
if (message) fireAlert(this, message);
this.getViewModel().clearToken();
- await this.emailEditor.loadData();
+ await this.getUserModel().loadEmails(true);
}
override connectedCallback() {
@@ -230,8 +230,6 @@
})
);
- promises.push(this.emailEditor.loadData());
-
this._testOnly_loadingPromise = Promise.all(promises).then(() => {
this.loading = false;
@@ -252,6 +250,7 @@
css`
:host {
color: var(--primary-text-color);
+ overflow: auto;
}
h2 {
font-family: var(--header-font-family);
@@ -340,6 +339,9 @@
@unsaved-changes-changed=${(e: ValueChangedEvent<boolean>) => {
this.accountInfoChanged = e.detail.value;
}}
+ @account-detail-update=${() => {
+ fire(this, 'account-detail-update', {});
+ }}
></gr-account-info>
<gr-button
@click=${() => {
@@ -437,7 +439,6 @@
</h2>
<fieldset id="watchedProjects">
<gr-watched-projects-editor
- ?hasUnsavedChanges=${this.watchedProjectsChanged}
@has-unsaved-changes-changed=${(
e: ValueChangedEvent<boolean>
) => {
@@ -463,7 +464,6 @@
<fieldset id="email">
<gr-email-editor
id="emailEditor"
- ?hasUnsavedChanges=${this.emailsChanged}
@has-unsaved-changes-changed=${(
e: ValueChangedEvent<boolean>
) => {
@@ -471,8 +471,8 @@
}}
></gr-email-editor>
<gr-button
- @click=${() => {
- this.emailEditor.save();
+ @click=${async () => {
+ await this.emailEditor.save();
}}
?disabled=${!this.emailsChanged}
>Save changes</gr-button
@@ -605,7 +605,7 @@
};
reloadAccountDetail() {
- Promise.all([this.accountInfo.loadData(), this.emailEditor.loadData()]);
+ Promise.all([this.accountInfo.loadData()]);
}
// private but used in test
@@ -643,7 +643,7 @@
if (!this.isNewEmailValid(this.newEmail)) return;
this.addingEmail = true;
- this.restApiService.addAccountEmail(this.newEmail).then(response => {
+ this.restApiService.addAccountEmail(this.newEmail).then(async response => {
this.addingEmail = false;
// If it was unsuccessful.
@@ -653,6 +653,8 @@
this.lastSentVerificationEmail = this.newEmail;
this.newEmail = '';
+
+ await this.getUserModel().loadEmails(true);
});
}
diff --git a/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-view_test.ts b/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-view_test.ts
index b6690b6..74bcca9 100644
--- a/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-view_test.ts
+++ b/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-view_test.ts
@@ -331,12 +331,6 @@
assert.isNotOk(element.lastSentVerificationEmail);
});
- test('emails are loaded without emailToken', () => {
- const emailEditorLoadDataStub = sinon.stub(element.emailEditor, 'loadData');
- element.firstUpdated();
- assert.isTrue(emailEditorLoadDataStub.calledOnce);
- });
-
test('handleSaveChangeTable', () => {
let newColumns = ['Owner', 'Project', 'Branch'];
element.localChangeTableColumns = newColumns.slice(0);
@@ -387,10 +381,8 @@
value: string | PromiseLike<string | null> | null
) => void;
let confirmEmailStub: sinon.SinonStub;
- let emailEditorLoadDataStub: sinon.SinonStub;
setup(() => {
- emailEditorLoadDataStub = sinon.stub(element.emailEditor, 'loadData');
confirmEmailStub = stubRestApi('confirmEmail').returns(
new Promise(resolve => {
resolveConfirm = resolve;
@@ -406,16 +398,6 @@
assert.isTrue(confirmEmailStub.calledWith('foo'));
});
- test('emails are not loaded initially', () => {
- assert.isFalse(emailEditorLoadDataStub.called);
- });
-
- test('user emails are loaded after email confirmed', async () => {
- resolveConfirm('bar');
- await element._testOnly_loadingPromise;
- assert.isTrue(emailEditorLoadDataStub.calledOnce);
- });
-
test('show-alert is fired when email is confirmed', async () => {
const dispatchEventSpy = sinon.spy(element, 'dispatchEvent');
resolveConfirm('bar');
diff --git a/polygerrit-ui/app/elements/settings/gr-watched-projects-editor/gr-watched-projects-editor.ts b/polygerrit-ui/app/elements/settings/gr-watched-projects-editor/gr-watched-projects-editor.ts
index f0f0f2f..583798c 100644
--- a/polygerrit-ui/app/elements/settings/gr-watched-projects-editor/gr-watched-projects-editor.ts
+++ b/polygerrit-ui/app/elements/settings/gr-watched-projects-editor/gr-watched-projects-editor.ts
@@ -6,7 +6,7 @@
import '@polymer/iron-input/iron-input';
import '../../shared/gr-autocomplete/gr-autocomplete';
import '../../shared/gr-button/gr-button';
-import {customElement, property, query} from 'lit/decorators.js';
+import {customElement, query, state} from 'lit/decorators.js';
import {
AutocompleteQuery,
GrAutocomplete,
@@ -22,6 +22,7 @@
import {fire} from '../../../utils/event-util';
import {PropertiesOfType} from '../../../utils/type-util';
import {throwingErrorCallback} from '../../shared/gr-rest-api-interface/gr-rest-apis/gr-rest-api-helper';
+import {notDeepEqual} from '../../../utils/deep-util';
type NotificationKey = PropertiesOfType<Required<ProjectWatchInfo>, boolean>;
@@ -43,13 +44,13 @@
@query('#newProject')
newProject?: GrAutocomplete;
- @property({type: Boolean})
- hasUnsavedChanges = false;
+ @state()
+ originalProjects?: ProjectWatchInfo[];
- @property({type: Array})
+ @state()
projects?: ProjectWatchInfo[];
- @property({type: Array})
+ @state()
projectsToRemove: ProjectWatchInfo[] = [];
private readonly query: AutocompleteQuery = input =>
@@ -163,7 +164,8 @@
loadData() {
return this.restApiService.getWatchedProjects().then(projs => {
- this.projects = projs;
+ this.originalProjects = projs;
+ this.projects = projs ? [...projs] : [];
});
}
@@ -186,9 +188,10 @@
}
})
.then(projects => {
- this.projects = projects;
+ this.originalProjects = projects;
+ this.projects = projects ? [...projects] : [];
this.projectsToRemove = [];
- this.setHasUnsavedChanges(false);
+ this.setHasUnsavedChanges();
});
}
@@ -206,13 +209,16 @@
}
private handleRemoveProject(project: ProjectWatchInfo) {
- if (!this.projects) return;
+ if (!this.projects || !this.originalProjects) return;
const index = this.projects.indexOf(project);
if (index < 0) return;
this.projects.splice(index, 1);
- this.projectsToRemove.push(project);
+ // Don't add project to projectsToRemove if it wasn't in
+ // originalProjects.
+ if (this.originalProjects.includes(project))
+ this.projectsToRemove.push(project);
this.requestUpdate();
- this.setHasUnsavedChanges(true);
+ this.setHasUnsavedChanges();
}
// private but used in tests.
@@ -288,7 +294,7 @@
this.newProject.clear();
this.newFilter.value = '';
- this.setHasUnsavedChanges(true);
+ this.setHasUnsavedChanges();
}
private handleCheckboxChange(
@@ -300,7 +306,7 @@
const checked = el.checked;
project[key] = !!checked;
this.requestUpdate();
- this.setHasUnsavedChanges(true);
+ this.setHasUnsavedChanges();
}
private handleNotifCellClick(e: Event) {
@@ -311,9 +317,11 @@
}
}
- private setHasUnsavedChanges(value: boolean) {
- this.hasUnsavedChanges = value;
- fire(this, 'has-unsaved-changes-changed', {value});
+ private setHasUnsavedChanges() {
+ const hasUnsavedChanges =
+ notDeepEqual(this.originalProjects, this.projects) ||
+ this.projectsToRemove.length > 0;
+ fire(this, 'has-unsaved-changes-changed', {value: hasUnsavedChanges});
}
isFilterDefined(filter: string | null | undefined) {
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 d77bec9..704cecf 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
@@ -10,9 +10,13 @@
import '../../plugins/gr-endpoint-param/gr-endpoint-param';
import {getAppContext} from '../../../services/app-context';
import {getDisplayName} from '../../../utils/display-name-util';
-import {isSelf, isServiceUser} from '../../../utils/account-util';
+import {
+ isDetailedAccount,
+ isSelf,
+ isServiceUser,
+} from '../../../utils/account-util';
import {ChangeInfo, AccountInfo, ServerInfo} from '../../../types/common';
-import {assertIsDefined, hasOwnProperty} from '../../../utils/common-util';
+import {hasOwnProperty} from '../../../utils/common-util';
import {fire} from '../../../utils/event-util';
import {isInvolved} from '../../../utils/change-util';
import {LitElement, css, html, TemplateResult} from 'lit';
@@ -21,7 +25,7 @@
import {getRemovedByIconClickReason} from '../../../utils/attention-set-util';
import {ifDefined} from 'lit/directives/if-defined.js';
import {createSearchUrl} from '../../../models/views/search';
-import {accountsModelToken} from '../../../models/accounts-model/accounts-model';
+import {accountsModelToken} from '../../../models/accounts/accounts-model';
import {resolve} from '../../../models/dependency';
import {configModelToken} from '../../../models/config/config-model';
import {userModelToken} from '../../../models/user/user-model';
@@ -198,15 +202,29 @@
];
}
- override async updated() {
- assertIsDefined(this.account, 'account');
+ override updated() {
+ this.computeDetailedAccount();
+ }
+
+ private async computeDetailedAccount() {
+ if (!this.account) return;
+ // If this.account is already a detailed object, then there is no need to fill it.
+ if (isDetailedAccount(this.account)) return;
const account = await this.getAccountsModel().fillDetails(this.account);
- // AccountInfo returned by fillDetails has the email property set
- // to the primary email of the account. This poses a problem in
- // cases where a secondary email is used as the committer or author
- // email. Therefore, only fill in the missing details to avoid
- // displaying incorrect author or committer email.
- if (account) this.account = Object.assign(account, this.account);
+ if (
+ account &&
+ // If we were not able to get a detailed object, then there is no point in updating the
+ // account.
+ isDetailedAccount(account) &&
+ account !== this.account &&
+ account._account_id === this.account._account_id
+ ) {
+ // AccountInfo returned by fillDetails has the email property set
+ // to the primary email of the account. This poses a problem in
+ // cases where a secondary email is used as the committer or author
+ // email. Therefore, only fill in the *missing* properties.
+ this.account = {...account, ...this.account};
+ }
}
override render() {
diff --git a/polygerrit-ui/app/elements/shared/gr-avatar/gr-avatar-stack.ts b/polygerrit-ui/app/elements/shared/gr-avatar/gr-avatar-stack.ts
index 863ee90..b445f75 100644
--- a/polygerrit-ui/app/elements/shared/gr-avatar/gr-avatar-stack.ts
+++ b/polygerrit-ui/app/elements/shared/gr-avatar/gr-avatar-stack.ts
@@ -17,7 +17,7 @@
import {configModelToken} from '../../../models/config/config-model';
import {subscribe} from '../../lit/subscription-controller';
import {getDisplayName} from '../../../utils/display-name-util';
-import {accountsModelToken} from '../../../models/accounts-model/accounts-model';
+import {accountsModelToken} from '../../../models/accounts/accounts-model';
import {isDefined} from '../../../types/types';
import {when} from 'lit/directives/when.js';
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 c27ca5b..68ab132 100644
--- a/polygerrit-ui/app/elements/shared/gr-comment/gr-comment.ts
+++ b/polygerrit-ui/app/elements/shared/gr-comment/gr-comment.ts
@@ -79,8 +79,12 @@
commentModelToken,
} from '../gr-comment-model/gr-comment-model';
import {formStyles} from '../../../styles/form-styles';
-import {Interaction} from '../../../constants/reporting';
-import {Suggestion, SuggestionsProvider} from '../../../api/suggestions';
+import {Interaction, Timing} from '../../../constants/reporting';
+import {
+ AutocompleteCommentResponse,
+ Suggestion,
+ SuggestionsProvider,
+} from '../../../api/suggestions';
import {when} from 'lit/directives/when.js';
import {getDocUrl} from '../../../utils/url-util';
import {configModelToken} from '../../../models/config/config-model';
@@ -89,6 +93,12 @@
import {deepEqual} from '../../../utils/deep-util';
import {GrSuggestionDiffPreview} from '../gr-suggestion-diff-preview/gr-suggestion-diff-preview';
import {waitUntil} from '../../../utils/async-util';
+import {
+ AutocompleteCache,
+ AutocompletionContext,
+} from '../../../utils/autocomplete-cache';
+import {HintAppliedEventDetail, HintShownEventDetail} from '../../../api/embed';
+import {levenshteinDistance} from '../../../utils/string-util';
// visible for testing
export const AUTO_SAVE_DEBOUNCE_DELAY_MS = 2000;
@@ -224,7 +234,14 @@
* An hint for autocompleting the comment message from plugin suggestion
* providers.
*/
- @state() autocompleteHint = '';
+ @state() autocompleteHint?: AutocompletionContext;
+
+ private autocompleteAcceptedHints: string[] = [];
+
+ /** Based on user preferences. */
+ @state() autocompleteEnabled = true;
+
+ readonly autocompleteCache = new AutocompleteCache();
/* The 'dirty' state of !comment.unresolved, which will be saved on demand. */
@state()
@@ -440,6 +457,7 @@
this,
() => this.getUserModel().preferences$,
prefs => {
+ this.autocompleteEnabled = !!prefs.allow_autocompleting_comments;
if (
this.generateSuggestion !==
!!prefs.allow_suggest_code_while_commenting
@@ -671,6 +689,10 @@
/* Making up for the 2px reduced height above. */
top: 1px;
}
+ gr-suggestion-diff-preview,
+ gr-fix-suggestions {
+ margin-top: var(--spacing-s);
+ }
`,
];
}
@@ -893,12 +915,70 @@
rows="4"
.placeholder=${this.messagePlaceholder}
text=${this.messageText}
- autocompleteHint=${this.autocompleteHint}
+ autocompleteHint=${this.autocompleteHint?.commentCompletion ?? ''}
@text-changed=${this.handleTextChanged}
+ @hintShown=${this.handleHintShown}
+ @hintApplied=${this.handleHintApplied}
></gr-suggestion-textarea>
`;
}
+ private handleHintShown(e: CustomEvent<HintShownEventDetail>) {
+ const context = this.autocompleteCache.get(e.detail.oldValue);
+ if (context?.commentCompletion !== e.detail.hint) return;
+
+ this.reportHintInteraction(
+ Interaction.COMMENT_COMPLETION_SUGGESTION_SHOWN,
+ context
+ );
+ }
+
+ private handleHintApplied(e: CustomEvent<HintAppliedEventDetail>) {
+ const context = this.autocompleteCache.get(e.detail.oldValue);
+ if (context?.commentCompletion !== e.detail.hint) return;
+
+ this.autocompleteAcceptedHints.push(e.detail.hint);
+ this.reportHintInteraction(
+ Interaction.COMMENT_COMPLETION_SUGGESTION_ACCEPTED,
+ context
+ );
+ }
+
+ private reportHintInteractionSaved() {
+ const content = this.messageText.trimEnd();
+ const acceptedHintsConcatenated = this.autocompleteAcceptedHints.join('');
+ const numExtraCharacters =
+ content.length - acceptedHintsConcatenated.length;
+ let distance = levenshteinDistance(acceptedHintsConcatenated, content);
+ if (numExtraCharacters > 0) {
+ distance -= numExtraCharacters;
+ }
+ const context = {
+ ...this.createAutocompletionBaseContext(),
+ similarCharacters: acceptedHintsConcatenated.length - distance,
+ maxSimilarCharacters: acceptedHintsConcatenated.length,
+ acceptedSuggestionsCount: this.autocompleteAcceptedHints.length,
+ totalAcceptedCharacters: acceptedHintsConcatenated.length,
+ savedDraftLength: content.length,
+ };
+ this.reportHintInteraction(
+ Interaction.COMMENT_COMPLETION_SAVE_DRAFT,
+ context
+ );
+ }
+
+ private reportHintInteraction(
+ interaction: Interaction,
+ context: Partial<AutocompletionContext>
+ ) {
+ context = {
+ ...context,
+ draftContent: '[REDACTED]',
+ commentCompletion: '[REDACTED]',
+ };
+ this.reporting.reportInteraction(interaction, context);
+ }
+
private handleTextChanged(e: ValueChangedEvent) {
const oldValue = this.messageText;
const newValue = e.detail.value;
@@ -909,33 +989,20 @@
// of the textare instead of needing a dedicated property.
this.messageText = newValue;
- this.handleTextChangedForAutocomplete(oldValue, newValue);
+ this.handleTextChangedForAutocomplete();
this.autoSaveTrigger$.next();
this.generateSuggestionTrigger$.next();
}
// visible for testing
- handleTextChangedForAutocomplete(oldValue: string, newValue: string) {
- if (oldValue === newValue) return;
- // As soon as the user changes the text the hint for autocompletion
- // is invalidated, *if* what the user typed does not match the
- // autocompletion!
- const charsAdded = newValue.length - oldValue.length;
- if (
- charsAdded > 0 &&
- newValue.startsWith(oldValue) &&
- this.autocompleteHint.startsWith(newValue.substring(oldValue.length))
- ) {
- // What the user typed matches the hint, so we keep the hint, but shorten
- // it accordingly.
- this.autocompleteHint = this.autocompleteHint.substring(charsAdded);
- return;
+ handleTextChangedForAutocomplete() {
+ const cachedHint = this.autocompleteCache.get(this.messageText);
+ if (cachedHint) {
+ this.autocompleteHint = cachedHint;
+ } else {
+ this.autocompleteHint = undefined;
+ this.autocompleteTrigger$.next();
}
-
- // The default behavior is to reset the hint and to generate a new
- // autocomplete suggestion.
- this.autocompleteHint = '';
- this.autocompleteTrigger$.next();
}
private renderCommentMessage() {
@@ -1349,6 +1416,7 @@
const change = this.getChangeModel().getChange();
if (
!enabled ||
+ !this.autocompleteEnabled ||
!suggestionsProvider?.autocompleteComment ||
!change ||
!this.comment?.patch_set ||
@@ -1358,6 +1426,7 @@
return;
}
const commentText = this.messageText;
+ this.reporting.time(Timing.COMMENT_COMPLETION);
const response = await suggestionsProvider.autocompleteComment({
id: id(this.comment),
commentText,
@@ -1367,11 +1436,51 @@
range: this.comment.range,
lineNumber: this.comment.line,
});
- // If between request and response the user has changed the message, then
- // ignore the suggestion for the old message text.
- if (this.messageText !== commentText) return;
+ const elapsed = this.reporting.timeEnd(Timing.COMMENT_COMPLETION);
+ const context = this.createAutocompletionContext(
+ commentText,
+ response,
+ elapsed
+ );
+ this.reportHintInteraction(
+ Interaction.COMMENT_COMPLETION_SUGGESTION_FETCHED,
+ context
+ );
if (!response?.completion) return;
- this.autocompleteHint = response.completion;
+ // Note that we are setting the cache value for `commentText` and getting the value
+ // for `this.messageText`.
+ this.autocompleteCache.set(context);
+ this.autocompleteHint = this.autocompleteCache.get(this.messageText);
+ }
+
+ private createAutocompletionBaseContext(): Partial<AutocompletionContext> {
+ return {
+ commentId: id(this.comment!),
+ commentNumber: this.comments?.length ?? 0,
+ filePath: this.comment!.path,
+ fileExtension: getFileExtension(this.comment!.path ?? ''),
+ };
+ }
+
+ private createAutocompletionContext(
+ draftContent: string,
+ response: AutocompleteCommentResponse,
+ requestDurationMs: number
+ ): AutocompletionContext {
+ const commentCompletion = response.completion ?? '';
+ return {
+ ...this.createAutocompletionBaseContext(),
+
+ draftContent,
+ draftContentLength: draftContent.length,
+ commentCompletion,
+ commentCompletionLength: commentCompletion.length,
+
+ isFullCommentPrediction: draftContent.length === 0,
+ draftInSyncWithSuggestionLength: 0,
+ modelVersion: response.modelVersion ?? '',
+ requestDurationMs,
+ };
}
private renderRobotActions() {
@@ -1800,6 +1909,7 @@
if (this.isFixSuggestionChanged()) {
draft.fix_suggestions = this.getFixSuggestions();
}
+ this.reportHintInteractionSaved();
return this.getCommentsModel().saveDraft(draft, options.showToast);
}
diff --git a/polygerrit-ui/app/elements/shared/gr-comment/gr-comment_test.ts b/polygerrit-ui/app/elements/shared/gr-comment/gr-comment_test.ts
index 7b4f63a..444a43f 100644
--- a/polygerrit-ui/app/elements/shared/gr-comment/gr-comment_test.ts
+++ b/polygerrit-ui/app/elements/shared/gr-comment/gr-comment_test.ts
@@ -898,27 +898,39 @@
suite('handleTextChangedForAutocomplete', () => {
test('foo -> foo with asdf', async () => {
- element.autocompleteHint = 'asdf';
- element.handleTextChangedForAutocomplete('foo', 'foo');
- assert.equal(element.autocompleteHint, 'asdf');
+ const ctx = {draftContent: 'foo', commentCompletion: 'asdf'};
+ element.autocompleteHint = ctx;
+ element.autocompleteCache.set(ctx);
+ element.messageText = 'foo';
+ element.handleTextChangedForAutocomplete();
+ assert.equal(element.autocompleteHint.commentCompletion, 'asdf');
});
test('foo -> bar with asdf', async () => {
- element.autocompleteHint = 'asdf';
- element.handleTextChangedForAutocomplete('foo', 'bar');
- assert.equal(element.autocompleteHint, '');
+ const ctx = {draftContent: 'foo', commentCompletion: 'asdf'};
+ element.autocompleteHint = ctx;
+ element.autocompleteCache.set(ctx);
+ element.messageText = 'bar';
+ element.handleTextChangedForAutocomplete();
+ assert.isUndefined(element.autocompleteHint);
});
test('foo -> foofoo with asdf', async () => {
- element.autocompleteHint = 'asdf';
- element.handleTextChangedForAutocomplete('foo', 'foofoo');
- assert.equal(element.autocompleteHint, '');
+ const ctx = {draftContent: 'foo', commentCompletion: 'asdf'};
+ element.autocompleteHint = ctx;
+ element.autocompleteCache.set(ctx);
+ element.messageText = 'foofoo';
+ element.handleTextChangedForAutocomplete();
+ assert.isUndefined(element.autocompleteHint);
});
test('foo -> foofoo with foomore', async () => {
- element.autocompleteHint = 'foomore';
- element.handleTextChangedForAutocomplete('foo', 'foofoo');
- assert.equal(element.autocompleteHint, 'more');
+ const ctx = {draftContent: 'foo', commentCompletion: 'foomore'};
+ element.autocompleteHint = ctx;
+ element.autocompleteCache.set(ctx);
+ element.messageText = 'foofoo';
+ element.handleTextChangedForAutocomplete();
+ assert.equal(element.autocompleteHint.commentCompletion, 'more');
});
});
diff --git a/polygerrit-ui/app/elements/shared/gr-fix-suggestions/gr-fix-suggestions.ts b/polygerrit-ui/app/elements/shared/gr-fix-suggestions/gr-fix-suggestions.ts
index 63c3832..195eeb6 100644
--- a/polygerrit-ui/app/elements/shared/gr-fix-suggestions/gr-fix-suggestions.ts
+++ b/polygerrit-ui/app/elements/shared/gr-fix-suggestions/gr-fix-suggestions.ts
@@ -87,6 +87,9 @@
static override get styles() {
return [
css`
+ :host {
+ display: block;
+ }
.header {
background-color: var(--background-color-primary);
border: 1px solid var(--border-color);
diff --git a/polygerrit-ui/app/elements/shared/gr-suggestion-diff-preview/gr-suggestion-diff-preview.ts b/polygerrit-ui/app/elements/shared/gr-suggestion-diff-preview/gr-suggestion-diff-preview.ts
index c533a5e..e1d4f89 100644
--- a/polygerrit-ui/app/elements/shared/gr-suggestion-diff-preview/gr-suggestion-diff-preview.ts
+++ b/polygerrit-ui/app/elements/shared/gr-suggestion-diff-preview/gr-suggestion-diff-preview.ts
@@ -152,9 +152,16 @@
static override get styles() {
return [
css`
+ :host {
+ display: block;
+ }
.buttons {
text-align: right;
}
+ .diff-container {
+ border: 1px solid var(--border-color);
+ border-top: none;
+ }
code {
max-width: var(--gr-formatted-text-prose-max-width, none);
background-color: var(--background-color-secondary);
@@ -221,14 +228,16 @@
if (!anyLineTooLong(diff)) {
this.syntaxLayer.process(diff);
}
- return html`<gr-diff
- .prefs=${this.overridePartialDiffPrefs()}
- .path=${this.preview.filepath}
- .diff=${diff}
- .layers=${this.layers}
- .renderPrefs=${this.renderPrefs}
- .viewMode=${DiffViewMode.UNIFIED}
- ></gr-diff>`;
+ return html`<div class="diff-container">
+ <gr-diff
+ .prefs=${this.overridePartialDiffPrefs()}
+ .path=${this.preview.filepath}
+ .diff=${diff}
+ .layers=${this.layers}
+ .renderPrefs=${this.renderPrefs}
+ .viewMode=${DiffViewMode.UNIFIED}
+ ></gr-diff>
+ </div>`;
}
private async fetchFixPreview() {
diff --git a/polygerrit-ui/app/elements/shared/gr-suggestion-diff-preview/gr-suggestion-diff-preview_test.ts b/polygerrit-ui/app/elements/shared/gr-suggestion-diff-preview/gr-suggestion-diff-preview_test.ts
index 86be868..2630aad 100644
--- a/polygerrit-ui/app/elements/shared/gr-suggestion-diff-preview/gr-suggestion-diff-preview_test.ts
+++ b/polygerrit-ui/app/elements/shared/gr-suggestion-diff-preview/gr-suggestion-diff-preview_test.ts
@@ -103,10 +103,12 @@
assert.shadowDom.equal(
element,
/* HTML */ `
- <gr-diff
- class="disable-context-control-buttons hide-line-length-indicator"
- >
- </gr-diff>
+ <div class="diff-container">
+ <gr-diff
+ class="disable-context-control-buttons hide-line-length-indicator"
+ >
+ </gr-diff>
+ </div>
`,
{ignoreAttributes: ['style']}
);
diff --git a/polygerrit-ui/app/elements/shared/gr-suggestion-textarea/gr-suggestion-textarea.ts b/polygerrit-ui/app/elements/shared/gr-suggestion-textarea/gr-suggestion-textarea.ts
index 2d22f6a..a8a779d 100644
--- a/polygerrit-ui/app/elements/shared/gr-suggestion-textarea/gr-suggestion-textarea.ts
+++ b/polygerrit-ui/app/elements/shared/gr-suggestion-textarea/gr-suggestion-textarea.ts
@@ -348,6 +348,12 @@
position: relative;
--gr-textarea-padding: var(--spacing-s);
--gr-textarea-border-width: 0px;
+ --gr-textarea-border-color: var(--border-color);
+ --input-field-bg: var(--view-background-color);
+ --input-field-disabled-bg: var(--view-background-color);
+ --secondary-bg-color: var(--background-color-secondary);
+ --text-default: var(--primary-text-color);
+ --text-disabled: var(--deemphasized-text-color);
--text-secondary: var(--deemphasized-text-color);
--iron-autogrow-textarea_-_padding: var(--spacing-s);
}
diff --git a/polygerrit-ui/app/embed/diff/gr-syntax-layer/gr-syntax-layer-worker.ts b/polygerrit-ui/app/embed/diff/gr-syntax-layer/gr-syntax-layer-worker.ts
index 5074290..9337951 100644
--- a/polygerrit-ui/app/embed/diff/gr-syntax-layer/gr-syntax-layer-worker.ts
+++ b/polygerrit-ui/app/embed/diff/gr-syntax-layer/gr-syntax-layer-worker.ts
@@ -23,6 +23,7 @@
['application/typescript', 'typescript'],
['application/xml', 'xml'],
['application/xquery', 'xquery'],
+ ['application/x-epp', 'epp'],
['application/x-erb', 'erb'],
['text/css', 'css'],
['text/html', 'html'],
diff --git a/polygerrit-ui/app/embed/gr-textarea.ts b/polygerrit-ui/app/embed/gr-textarea.ts
index 628b65b..9b88e9c 100644
--- a/polygerrit-ui/app/embed/gr-textarea.ts
+++ b/polygerrit-ui/app/embed/gr-textarea.ts
@@ -456,13 +456,7 @@
const value = await this.getValue();
this.innerValue = value;
- this.dispatchEvent(
- new CustomEvent('input', {
- detail: {
- value: this.value,
- },
- })
- );
+ this.fire('input', {value: this.value});
}
private onFocus(event: Event) {
@@ -492,7 +486,7 @@
(event.ctrlKey || event.metaKey)
) {
event.preventDefault();
- this.dispatchEvent(new CustomEvent('saveShortcut'));
+ this.fire('saveShortcut');
}
await this.toggleHintVisibilityIfAny();
}
@@ -507,7 +501,13 @@
}
private handleScroll() {
- this.dispatchEvent(new CustomEvent('scroll'));
+ this.fire('scroll');
+ }
+
+ private fire<T>(type: string, detail?: T) {
+ this.dispatchEvent(
+ new CustomEvent(type, {detail, bubbles: true, composed: true})
+ );
}
private async handleTabKeyPress(event: KeyboardEvent) {
@@ -529,14 +529,7 @@
await this.putCursorAtEnd();
await this.onInput(event);
- this.dispatchEvent(
- new CustomEvent('hintApplied', {
- detail: {
- hint,
- oldValue,
- },
- })
- );
+ this.fire('hintApplied', {hint, oldValue});
}
private async toggleHintVisibilityIfAny() {
@@ -572,6 +565,7 @@
}
private addHintSpanAtEndOfContent(editableDivElement: Node, hint: string) {
+ const oldValue = this.value ?? '';
const hintSpan = document.createElement('span');
hintSpan.classList.add(AUTOCOMPLETE_HINT_CLASS);
hintSpan.setAttribute('role', 'alert');
@@ -581,26 +575,16 @@
);
hintSpan.dataset['hint'] = hint;
editableDivElement.appendChild(hintSpan);
- this.dispatchEvent(
- new CustomEvent('hintShown', {
- detail: {
- hint,
- },
- })
- );
+ this.fire('hintShown', {hint, oldValue});
}
private removeHintSpanIfShown() {
const hintSpan = this.hintSpan();
if (hintSpan) {
hintSpan.remove();
- this.dispatchEvent(
- new CustomEvent('hintDismissed', {
- detail: {
- hint: (hintSpan as HTMLElement).dataset['hint'],
- },
- })
- );
+ this.fire('hintDismissed', {
+ hint: (hintSpan as HTMLElement).dataset['hint'],
+ });
}
}
@@ -616,13 +600,7 @@
event?.preventDefault();
event?.stopImmediatePropagation();
- this.dispatchEvent(
- new CustomEvent('cursorPositionChange', {
- detail: {
- position: this.getCursorPosition(),
- },
- })
- );
+ this.fire('cursorPositionChange', {position: this.getCursorPosition()});
}
private async updateValueInDom() {
diff --git a/polygerrit-ui/app/models/accounts-model/accounts-model_test.ts b/polygerrit-ui/app/models/accounts-model/accounts-model_test.ts
deleted file mode 100644
index 53c90a6..0000000
--- a/polygerrit-ui/app/models/accounts-model/accounts-model_test.ts
+++ /dev/null
@@ -1,42 +0,0 @@
-/**
- * @license
- * Copyright 2022 Google LLC
- * SPDX-License-Identifier: Apache-2.0
- */
-
-import '../../test/common-test-setup';
-import {EmailAddress} from '../../api/rest-api';
-import {getAppContext} from '../../services/app-context';
-import {stubRestApi} from '../../test/test-utils';
-import {AccountsModel} from './accounts-model';
-import {assert} from '@open-wc/testing';
-
-suite('accounts-model tests', () => {
- let model: AccountsModel;
-
- setup(() => {
- model = new AccountsModel(getAppContext().restApiService);
- });
-
- teardown(() => {
- model.finalize();
- });
-
- test('invalid account makes only one request', () => {
- const response = {...new Response(), status: 404};
- const getAccountDetails = stubRestApi('getAccountDetails').callsFake(
- (_, errFn) => {
- if (errFn !== undefined) {
- errFn(response);
- }
- return Promise.resolve(undefined);
- }
- );
-
- model.fillDetails({email: 'Invalid_email@def.com' as EmailAddress});
- assert.equal(getAccountDetails.callCount, 1);
-
- model.fillDetails({email: 'Invalid_email@def.com' as EmailAddress});
- assert.equal(getAccountDetails.callCount, 1);
- });
-});
diff --git a/polygerrit-ui/app/models/accounts-model/accounts-model.ts b/polygerrit-ui/app/models/accounts/accounts-model.ts
similarity index 95%
rename from polygerrit-ui/app/models/accounts-model/accounts-model.ts
rename to polygerrit-ui/app/models/accounts/accounts-model.ts
index 0802f06..6eedcbe8 100644
--- a/polygerrit-ui/app/models/accounts-model/accounts-model.ts
+++ b/polygerrit-ui/app/models/accounts/accounts-model.ts
@@ -42,7 +42,7 @@
): Promise<AccountDetailInfo | AccountInfo> {
const current = this.getState();
const id = getUserId(partialAccount);
- if (hasOwnProperty(current.accounts, id)) return current.accounts[id];
+ if (hasOwnProperty(current.accounts, id)) return {...current.accounts[id]};
// It is possible to add emails to CC when they don't have a Gerrit
// account. In this case getAccountDetails will return a 404 error then
// we at least use what is in partialAccount.
diff --git a/polygerrit-ui/app/models/accounts/accounts-model_test.ts b/polygerrit-ui/app/models/accounts/accounts-model_test.ts
new file mode 100644
index 0000000..e84723c
--- /dev/null
+++ b/polygerrit-ui/app/models/accounts/accounts-model_test.ts
@@ -0,0 +1,71 @@
+/**
+ * @license
+ * Copyright 2022 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import '../../test/common-test-setup';
+import {
+ AccountDetailInfo,
+ AccountId,
+ EmailAddress,
+ Timestamp,
+} from '../../api/rest-api';
+import {getAppContext} from '../../services/app-context';
+import {stubRestApi} from '../../test/test-utils';
+import {AccountsModel} from './accounts-model';
+import {assert} from '@open-wc/testing';
+
+const KERMIT: AccountDetailInfo = {
+ _account_id: 1 as AccountId,
+ name: 'Kermit',
+ registered_on: '2015-03-12 18:32:08.000000000' as Timestamp,
+};
+
+suite('accounts-model tests', () => {
+ let model: AccountsModel;
+
+ setup(() => {
+ model = new AccountsModel(getAppContext().restApiService);
+ });
+
+ teardown(() => {
+ model.finalize();
+ });
+
+ test('basic lookup', async () => {
+ const stub = stubRestApi('getAccountDetails').returns(
+ Promise.resolve(KERMIT)
+ );
+
+ let filled = await model.fillDetails({_account_id: 1 as AccountId});
+ assert.equal(filled.name, 'Kermit');
+ assert.equal(filled, KERMIT);
+ assert.equal(stub.callCount, 1);
+
+ filled = await model.fillDetails({_account_id: 1 as AccountId});
+ assert.equal(filled.name, 'Kermit');
+ // Cache objects are cloned on lookup, so this is a different object.
+ assert.notEqual(filled, KERMIT);
+ // Did not have to call the REST API again.
+ assert.equal(stub.callCount, 1);
+ });
+
+ test('invalid account makes only one request', () => {
+ const response = {...new Response(), status: 404};
+ const getAccountDetails = stubRestApi('getAccountDetails').callsFake(
+ (_, errFn) => {
+ if (errFn !== undefined) {
+ errFn(response);
+ }
+ return Promise.resolve(undefined);
+ }
+ );
+
+ model.fillDetails({email: 'Invalid_email@def.com' as EmailAddress});
+ assert.equal(getAccountDetails.callCount, 1);
+
+ model.fillDetails({email: 'Invalid_email@def.com' as EmailAddress});
+ assert.equal(getAccountDetails.callCount, 1);
+ });
+});
diff --git a/polygerrit-ui/app/models/comments/comments-model.ts b/polygerrit-ui/app/models/comments/comments-model.ts
index f9a8401..497962f 100644
--- a/polygerrit-ui/app/models/comments/comments-model.ts
+++ b/polygerrit-ui/app/models/comments/comments-model.ts
@@ -56,7 +56,7 @@
import {Deduping} from '../../api/reporting';
import {extractMentionedUsers, getUserId} from '../../utils/account-util';
import {SpecialFilePath} from '../../constants/constants';
-import {AccountsModel} from '../accounts-model/accounts-model';
+import {AccountsModel} from '../accounts/accounts-model';
import {
distinctUntilChanged,
map,
@@ -432,7 +432,7 @@
threads.filter(t => !isNewThread(t) && isDraftThread(t))
);
- public readonly threadsWithSuggestions$ = select(
+ public readonly threadsWithUnappliedSuggestions$ = select(
combineLatest([this.threads$, this.changeModel.latestPatchNum$]),
([threads, latestPs]) =>
threads.filter(
diff --git a/polygerrit-ui/app/models/comments/comments-model_test.ts b/polygerrit-ui/app/models/comments/comments-model_test.ts
index 0d3df42..b3dbc08 100644
--- a/polygerrit-ui/app/models/comments/comments-model_test.ts
+++ b/polygerrit-ui/app/models/comments/comments-model_test.ts
@@ -28,7 +28,7 @@
import {changeModelToken} from '../change/change-model';
import {assert} from '@open-wc/testing';
import {testResolver} from '../../test/common-test-setup';
-import {accountsModelToken} from '../accounts-model/accounts-model';
+import {accountsModelToken} from '../accounts/accounts-model';
import {ChangeComments} from '../../elements/diff/gr-comment-api/gr-comment-api';
import {changeViewModelToken} from '../views/change';
import {navigationToken} from '../../elements/core/gr-navigation/gr-navigation';
diff --git a/polygerrit-ui/app/models/user/user-model.ts b/polygerrit-ui/app/models/user/user-model.ts
index cd6a66a..4973307 100644
--- a/polygerrit-ui/app/models/user/user-model.ts
+++ b/polygerrit-ui/app/models/user/user-model.ts
@@ -4,7 +4,7 @@
* SPDX-License-Identifier: Apache-2.0
*/
import {from, of, Observable} from 'rxjs';
-import {filter, switchMap} from 'rxjs/operators';
+import {filter, switchMap, tap} from 'rxjs/operators';
import {
DiffPreferencesInfo as DiffPreferencesInfoAPI,
DiffViewMode,
@@ -13,6 +13,7 @@
AccountCapabilityInfo,
AccountDetailInfo,
EditPreferencesInfo,
+ EmailInfo,
PreferencesInfo,
TopMenuItemInfo,
} from '../../types/common';
@@ -48,6 +49,7 @@
* `account` is known, then use `accountLoaded` below.
*/
account?: AccountDetailInfo;
+ emails?: EmailInfo[];
/**
* Starts as `false` and switches to `true` after the first `getAccount` call.
* A common use case for this is to wait with loading or doing something until
@@ -82,6 +84,15 @@
userState => userState.account
);
+ readonly emails$: Observable<EmailInfo[] | undefined> = select(
+ this.state$,
+ userState => userState.emails
+ ).pipe(
+ tap(emails => {
+ if (emails === undefined) this.loadEmails();
+ })
+ );
+
/**
* Only emits once we have tried to actually load the account. Note that
* this does not initially emit a value.
@@ -148,12 +159,8 @@
super({
accountLoaded: false,
});
+ this.loadAccount();
this.subscriptions = [
- from(this.restApiService.getAccount()).subscribe(
- (account?: AccountDetailInfo) => {
- this.setAccount(account);
- }
- ),
this.loadedAccount$
.pipe(
switchMap(account => {
@@ -261,4 +268,22 @@
setAccount(account?: AccountDetailInfo) {
this.updateState({account, accountLoaded: true});
}
+
+ private setAccountEmails(emails?: EmailInfo[]) {
+ this.updateState({emails});
+ }
+
+ loadAccount(noCache?: boolean) {
+ if (noCache) this.restApiService.invalidateAccountsDetailCache();
+ return this.restApiService.getAccount().then(account => {
+ this.setAccount(account);
+ });
+ }
+
+ loadEmails(noCache?: boolean) {
+ if (noCache) this.restApiService.invalidateAccountsEmailCache();
+ return this.restApiService.getAccountEmails().then(emails => {
+ this.setAccountEmails(emails);
+ });
+ }
}
diff --git a/polygerrit-ui/app/node_modules_licenses/licenses.ts b/polygerrit-ui/app/node_modules_licenses/licenses.ts
index 9fa2e89..21fab53 100644
--- a/polygerrit-ui/app/node_modules_licenses/licenses.ts
+++ b/polygerrit-ui/app/node_modules_licenses/licenses.ts
@@ -454,6 +454,14 @@
},
},
{
+ name: 'highlightjs-epp',
+ license: {
+ name: 'highlightjs-epp',
+ type: LicenseTypes.Bsd3,
+ packageLicenseFile: 'LICENSE',
+ },
+ },
+ {
name: 'highlightjs-structured-text',
license: {
name: 'highlightjs-structured-text',
diff --git a/polygerrit-ui/app/package.json b/polygerrit-ui/app/package.json
index 73816d2..dea0e8b 100644
--- a/polygerrit-ui/app/package.json
+++ b/polygerrit-ui/app/package.json
@@ -35,8 +35,9 @@
"@webcomponents/shadycss": "^1.11.2",
"@webcomponents/webcomponentsjs": "^1.3.3",
"highlight.js": "^11.9.0",
- "highlightjs-closure-templates": "https://github.com/highlightjs/highlightjs-closure-templates",
- "highlightjs-structured-text": "https://github.com/highlightjs/highlightjs-structured-text",
+ "highlightjs-closure-templates": "https://github.com/highlightjs/highlightjs-closure-templates#02fb0646e0499084f96a99b8c6f4a0d7bd1d33ba",
+ "highlightjs-epp": "https://github.com/highlightjs/highlightjs-epp#9f9e1a92f37c217c68899c7d3bdccb4d134681b9",
+ "highlightjs-structured-text": "https://github.com/highlightjs/highlightjs-structured-text#e68dd7aa829529fb6c40d6287585f43273605a9e",
"immer": "^9.0.21",
"lit": "^3.1.2",
"polymer-bridges": "file:../../polymer-bridges",
diff --git a/polygerrit-ui/app/services/app-context-init.ts b/polygerrit-ui/app/services/app-context-init.ts
index 40517ac..8536492 100644
--- a/polygerrit-ui/app/services/app-context-init.ts
+++ b/polygerrit-ui/app/services/app-context-init.ts
@@ -34,7 +34,7 @@
import {
AccountsModel,
accountsModelToken,
-} from '../models/accounts-model/accounts-model';
+} from '../models/accounts/accounts-model';
import {
DashboardViewModel,
dashboardViewModelToken,
diff --git a/polygerrit-ui/app/services/gr-reporting/gr-reporting.ts b/polygerrit-ui/app/services/gr-reporting/gr-reporting.ts
index 6df2c67..e175228 100644
--- a/polygerrit-ui/app/services/gr-reporting/gr-reporting.ts
+++ b/polygerrit-ui/app/services/gr-reporting/gr-reporting.ts
@@ -55,7 +55,7 @@
/**
* Finish named timer and report it to server.
*/
- timeEnd(name: Timing, eventDetails?: EventDetails): void;
+ timeEnd(name: Timing, eventDetails?: EventDetails): number;
/**
* Get a timer object for reporting a user timing. The start time will be
* the time that the object has been created, and the end time will be the
diff --git a/polygerrit-ui/app/services/gr-reporting/gr-reporting_impl.ts b/polygerrit-ui/app/services/gr-reporting/gr-reporting_impl.ts
index 1eb3bc2..781f370 100644
--- a/polygerrit-ui/app/services/gr-reporting/gr-reporting_impl.ts
+++ b/polygerrit-ui/app/services/gr-reporting/gr-reporting_impl.ts
@@ -770,23 +770,26 @@
/**
* Finish named timer and report it to server.
*/
- timeEnd(name: Timing, eventDetails?: EventDetails) {
+ timeEnd(name: Timing, eventDetails?: EventDetails): number {
if (!hasOwnProperty(this._baselines, name)) {
- return;
+ return 0;
}
- const baseTime = this._baselines[name];
+ const begin = this._baselines[name];
delete this._baselines[name];
- this._reportTiming(name, now() - baseTime, eventDetails);
+ const end = now();
+ const elapsed = end - begin;
+ this._reportTiming(name, elapsed, eventDetails);
// Finalize the interval. Either from a registered start mark or
// the navigation start time (if baseTime is 0).
- if (baseTime !== 0) {
+ if (begin !== 0) {
window.performance.measure(name, `${name}-start`);
} else {
// Microsoft Edge does not handle the 2nd param correctly
// (if undefined).
window.performance.measure(name);
}
+ return elapsed;
}
/**
diff --git a/polygerrit-ui/app/services/gr-reporting/gr-reporting_mock.ts b/polygerrit-ui/app/services/gr-reporting/gr-reporting_mock.ts
index fb1f0c3..7cb777a 100644
--- a/polygerrit-ui/app/services/gr-reporting/gr-reporting_mock.ts
+++ b/polygerrit-ui/app/services/gr-reporting/gr-reporting_mock.ts
@@ -71,5 +71,5 @@
setRepoName: () => {},
setChangeId: () => {},
time: () => {},
- timeEnd: () => {},
+ timeEnd: () => 0,
};
diff --git a/polygerrit-ui/app/services/gr-rest-api/gr-rest-api-impl.ts b/polygerrit-ui/app/services/gr-rest-api/gr-rest-api-impl.ts
index 0be3ab5..7a12e7a 100644
--- a/polygerrit-ui/app/services/gr-rest-api/gr-rest-api-impl.ts
+++ b/polygerrit-ui/app/services/gr-rest-api/gr-rest-api-impl.ts
@@ -1565,6 +1565,10 @@
this._restApiHelper.invalidateFetchPromisesPrefix('/accounts/self/detail');
}
+ invalidateAccountsEmailCache() {
+ this._restApiHelper.invalidateFetchPromisesPrefix('/accounts/self/emails');
+ }
+
getGroups(filter: string, groupsPerPage: number, offset?: number) {
const url = this._getGroupsUrl(filter, groupsPerPage, offset);
diff --git a/polygerrit-ui/app/services/gr-rest-api/gr-rest-api.ts b/polygerrit-ui/app/services/gr-rest-api/gr-rest-api.ts
index 947952c..814e97a 100644
--- a/polygerrit-ui/app/services/gr-rest-api/gr-rest-api.ts
+++ b/polygerrit-ui/app/services/gr-rest-api/gr-rest-api.ts
@@ -542,6 +542,7 @@
invalidateReposCache(): void;
invalidateAccountsCache(): void;
invalidateAccountsDetailCache(): void;
+ invalidateAccountsEmailCache(): void;
removeFromAttentionSet(
changeNum: NumericChangeId,
user: AccountId,
diff --git a/polygerrit-ui/app/test/mocks/gr-rest-api_mock.ts b/polygerrit-ui/app/test/mocks/gr-rest-api_mock.ts
index 77f2498..570b50a 100644
--- a/polygerrit-ui/app/test/mocks/gr-rest-api_mock.ts
+++ b/polygerrit-ui/app/test/mocks/gr-rest-api_mock.ts
@@ -442,6 +442,7 @@
invalidateGroupsCache(): void {},
invalidateReposCache(): void {},
invalidateAccountsDetailCache(): void {},
+ invalidateAccountsEmailCache(): void {},
probePath(): Promise<boolean> {
return Promise.resolve(true);
},
diff --git a/polygerrit-ui/app/test/test-data-generators.ts b/polygerrit-ui/app/test/test-data-generators.ts
index 3829f57..7d666e8 100644
--- a/polygerrit-ui/app/test/test-data-generators.ts
+++ b/polygerrit-ui/app/test/test-data-generators.ts
@@ -712,6 +712,7 @@
email_strategy: EmailStrategy.ENABLED,
allow_browser_notifications: true,
allow_suggest_code_while_commenting: true,
+ allow_autocompleting_comments: true,
};
}
diff --git a/polygerrit-ui/app/types/common.ts b/polygerrit-ui/app/types/common.ts
index 4dd965c..072a872 100644
--- a/polygerrit-ui/app/types/common.ts
+++ b/polygerrit-ui/app/types/common.ts
@@ -1344,6 +1344,7 @@
email_format?: EmailFormat;
allow_browser_notifications?: boolean;
allow_suggest_code_while_commenting?: boolean;
+ allow_autocompleting_comments?: boolean;
diff_page_sidebar?: DiffPageSidebar;
}
diff --git a/polygerrit-ui/app/utils/account-util.ts b/polygerrit-ui/app/utils/account-util.ts
index 0853941..b93acc6 100644
--- a/polygerrit-ui/app/utils/account-util.ts
+++ b/polygerrit-ui/app/utils/account-util.ts
@@ -140,10 +140,10 @@
export function isDetailedAccount(account?: AccountInfo) {
// In case ChangeInfo is requested without DetailedAccount option, the
- // reviewer entry is returned as just {_account_id: 123}
- // This object should also be treated as not detailed account if they have
- // an AccountId and no email
- return !!account?.email && !!account?._account_id;
+ // reviewer entry is returned as just {_account_id: 123}.
+ // At least a name or an email must be set for the account to be treated as
+ // "detailed".
+ return (!!account?.email || !!account?.name) && !!account?._account_id;
}
/**
diff --git a/polygerrit-ui/app/utils/account-util_test.ts b/polygerrit-ui/app/utils/account-util_test.ts
index 72fa791..b1ee50e 100644
--- a/polygerrit-ui/app/utils/account-util_test.ts
+++ b/polygerrit-ui/app/utils/account-util_test.ts
@@ -263,6 +263,7 @@
test('isDetailedAccount', () => {
assert.isFalse(isDetailedAccount({_account_id: 12345 as AccountId}));
assert.isFalse(isDetailedAccount({email: 'abcd' as EmailAddress}));
+ assert.isFalse(isDetailedAccount({name: 'Kermit'}));
assert.isTrue(
isDetailedAccount({
@@ -270,6 +271,12 @@
email: 'abcd' as EmailAddress,
})
);
+ assert.isTrue(
+ isDetailedAccount({
+ _account_id: 12345 as AccountId,
+ name: 'Kermit',
+ })
+ );
});
test('fails gracefully when all is not included', async () => {
diff --git a/polygerrit-ui/app/utils/autocomplete-cache.ts b/polygerrit-ui/app/utils/autocomplete-cache.ts
new file mode 100644
index 0000000..c8077ab
--- /dev/null
+++ b/polygerrit-ui/app/utils/autocomplete-cache.ts
@@ -0,0 +1,79 @@
+/**
+ * @license
+ * Copyright 2024 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+export interface AutocompletionContext {
+ draftContent: string;
+ draftContentLength?: number;
+ commentCompletion: string;
+ commentCompletionLength?: number;
+
+ isFullCommentPrediction?: boolean;
+ draftInSyncWithSuggestionLength?: number;
+ modelVersion?: string;
+ requestDurationMs?: number;
+
+ commentId?: string;
+ commentNumber?: number;
+ filePath?: string;
+ fileExtension?: string;
+
+ similarCharacters?: number;
+ maxSimilarCharacters?: number;
+ acceptedSuggestionsCount?: number;
+ totalAcceptedCharacters?: number;
+ savedDraftLength?: number;
+}
+
+/**
+ * Caching for autocompleting text, e.g. comments.
+ *
+ * If the user continues typing text that matches the completion hint, then keep the hint.
+ *
+ * If the user backspaces, then continue using previous hint.
+ */
+export class AutocompleteCache {
+ /**
+ * We are using an ordered list instead of a map here, because we want to evict the oldest
+ * entries, if the capacity is exceeded. And we want to prefer newer entries over older
+ * entries, if both match the criteria for being reused.
+ */
+ private cache: AutocompletionContext[] = [];
+
+ constructor(private readonly capacity = 10) {}
+
+ get(content: string): AutocompletionContext | undefined {
+ if (content === '') return undefined;
+ for (let i = this.cache.length - 1; i >= 0; i--) {
+ const cachedContext = this.cache[i];
+ const completionContent = cachedContext.draftContent;
+ const completionHint = cachedContext.commentCompletion;
+ const completionFull = completionContent + completionHint;
+ if (completionContent.length > content.length) continue;
+ if (!completionFull.startsWith(content)) continue;
+ if (completionFull === content) continue;
+ const hint = completionFull.substring(content.length);
+ return {
+ ...cachedContext,
+ draftContent: content,
+ commentCompletion: hint,
+ draftInSyncWithSuggestionLength:
+ content.length - completionContent.length,
+ };
+ }
+ return undefined;
+ }
+
+ set(context: AutocompletionContext) {
+ const index = this.cache.findIndex(
+ c => c.draftContent === context.draftContent
+ );
+ if (index !== -1) {
+ this.cache.splice(index, 1);
+ } else if (this.cache.length >= this.capacity) {
+ this.cache.shift();
+ }
+ this.cache.push(context);
+ }
+}
diff --git a/polygerrit-ui/app/utils/autocomplete-cache_test.ts b/polygerrit-ui/app/utils/autocomplete-cache_test.ts
new file mode 100644
index 0000000..970436b
--- /dev/null
+++ b/polygerrit-ui/app/utils/autocomplete-cache_test.ts
@@ -0,0 +1,89 @@
+/**
+ * @license
+ * Copyright 2024 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import {AutocompleteCache} from './autocomplete-cache';
+import {assert} from '@open-wc/testing';
+
+suite('AutocompleteCache', () => {
+ let cache: AutocompleteCache;
+
+ setup(() => {
+ cache = new AutocompleteCache();
+ });
+
+ const cacheSet = (draftContent: string, commentCompletion: string) => {
+ cache.set({draftContent, commentCompletion});
+ };
+
+ const assertCacheEqual = (
+ draftContent: string,
+ expectedCommentCompletion?: string
+ ) => {
+ assert.equal(
+ cache.get(draftContent)?.commentCompletion,
+ expectedCommentCompletion
+ );
+ };
+
+ test('should get and set values', () => {
+ cacheSet('foo', 'bar');
+ assertCacheEqual('foo', 'bar');
+ });
+
+ test('should return undefined for empty content string', () => {
+ cacheSet('foo', 'bar');
+ assertCacheEqual('', undefined);
+ });
+
+ test('should return a value, if completion content+hint start with content', () => {
+ cacheSet('foo', 'bar');
+ assertCacheEqual('foo', 'bar');
+ assertCacheEqual('foob', 'ar');
+ assertCacheEqual('fooba', 'r');
+ assertCacheEqual('foobar', undefined);
+ });
+
+ test('should not return a value, if content is shorter than completion content', () => {
+ cacheSet('foo', 'bar');
+ assertCacheEqual('f', undefined);
+ assertCacheEqual('fo', undefined);
+ });
+
+ test('should not get values that are not set', () => {
+ assertCacheEqual('foo', undefined);
+ });
+
+ test('should not return an empty completion, if content equals completion content+hint', () => {
+ cacheSet('foo', 'bar');
+ assertCacheEqual('foobar', undefined);
+ });
+
+ test('skips over the first entry, but returns the second entry', () => {
+ cacheSet('foobar', 'bang');
+ cacheSet('foo', 'bar');
+ assertCacheEqual('foobar', 'bang');
+ });
+
+ test('replaces entries', () => {
+ cacheSet('foo', 'bar');
+ cacheSet('foo', 'baz');
+ assertCacheEqual('foo', 'baz');
+ });
+
+ test('prefers newer entries, but also returns older entries', () => {
+ cacheSet('foo', 'bar');
+ assertCacheEqual('foob', 'ar');
+ cacheSet('foob', 'arg');
+ assertCacheEqual('foob', 'arg');
+ assertCacheEqual('foo', 'bar');
+ });
+
+ test('capacity', () => {
+ cache = new AutocompleteCache(1);
+ cacheSet('foo', 'bar');
+ cacheSet('boom', 'bang');
+ assertCacheEqual('foo', undefined);
+ });
+});
diff --git a/polygerrit-ui/app/utils/deep-util.ts b/polygerrit-ui/app/utils/deep-util.ts
index eca528e..3e41e61 100644
--- a/polygerrit-ui/app/utils/deep-util.ts
+++ b/polygerrit-ui/app/utils/deep-util.ts
@@ -82,7 +82,7 @@
/**
* @param obj Object
*/
-export function deepClone(obj?: object) {
- if (!obj) return undefined;
- return JSON.parse(JSON.stringify(obj));
+export function deepClone<T>(obj: T): T {
+ if (!obj) throw new Error('undefined object for deepClone');
+ return JSON.parse(JSON.stringify(obj)) as T;
}
diff --git a/polygerrit-ui/app/utils/string-util.ts b/polygerrit-ui/app/utils/string-util.ts
index 81dcde1..abc5529 100644
--- a/polygerrit-ui/app/utils/string-util.ts
+++ b/polygerrit-ui/app/utils/string-util.ts
@@ -115,3 +115,42 @@
fileName: fileNameSection,
};
}
+
+/**
+ * Computes the Levenshtein edit distance between two strings.
+ */
+export function levenshteinDistance(str1: string, str2: string): number {
+ const m = str1.length;
+ const n = str2.length;
+
+ // Create a matrix to store edit distances
+ const dp: number[][] = Array.from({length: m + 1}, () =>
+ Array(n + 1).fill(0)
+ );
+
+ // Initialize first row and column with base cases
+ for (let i = 0; i <= m; i++) {
+ dp[i][0] = i;
+ }
+ for (let j = 0; j <= n; j++) {
+ dp[0][j] = j;
+ }
+
+ // Calculate edit distances for all substrings
+ for (let i = 1; i <= m; i++) {
+ for (let j = 1; j <= n; j++) {
+ if (str1[i - 1] === str2[j - 1]) {
+ dp[i][j] = dp[i - 1][j - 1];
+ } else {
+ dp[i][j] = Math.min(
+ dp[i - 1][j] + 1, // Deletion
+ dp[i][j - 1] + 1, // Insertion
+ dp[i - 1][j - 1] + 1 // Substitution
+ );
+ }
+ }
+ }
+
+ // Return the final edit distance
+ return dp[m][n];
+}
diff --git a/polygerrit-ui/app/yarn.lock b/polygerrit-ui/app/yarn.lock
index b2f722f..a8f10e2 100644
--- a/polygerrit-ui/app/yarn.lock
+++ b/polygerrit-ui/app/yarn.lock
@@ -610,18 +610,24 @@
resolved "https://registry.yarnpkg.com/highlight.js/-/highlight.js-10.7.3.tgz#697272e3991356e40c3cac566a74eef681756531"
integrity sha512-tzcUFauisWKNHaRkN4Wjl/ZA07gENAjFl3J/c480dprkGTg5EQstgaNFqBfUqCq54kZRIEcreTsAgF/m2quD7A==
-"highlight.js@^11.5.0 || ^10.4.1", highlight.js@^11.9.0:
+highlight.js@^11.9.0, "highlight.js@^11.9.0 || ^10.4.1":
version "11.9.0"
resolved "https://registry.yarnpkg.com/highlight.js/-/highlight.js-11.9.0.tgz#04ab9ee43b52a41a047432c8103e2158a1b8b5b0"
integrity sha512-fJ7cW7fQGCYAkgv4CPfwFHrfd/cLS4Hau96JuJ+ZTOWhjnhoeN1ub1tFmALm/+lW5z4WCAuAV9bm05AP0mS6Gw==
-"highlightjs-closure-templates@https://github.com/highlightjs/highlightjs-closure-templates":
+"highlightjs-closure-templates@https://github.com/highlightjs/highlightjs-closure-templates#02fb0646e0499084f96a99b8c6f4a0d7bd1d33ba":
version "0.0.1"
- resolved "https://github.com/highlightjs/highlightjs-closure-templates#7922b1e68def8b10199e186bb679600de3ebb711"
+ resolved "https://github.com/highlightjs/highlightjs-closure-templates#02fb0646e0499084f96a99b8c6f4a0d7bd1d33ba"
dependencies:
- highlight.js "^11.5.0 || ^10.4.1"
+ highlight.js "^11.9.0 || ^10.4.1"
-"highlightjs-structured-text@https://github.com/highlightjs/highlightjs-structured-text":
+"highlightjs-epp@https://github.com/highlightjs/highlightjs-epp#9f9e1a92f37c217c68899c7d3bdccb4d134681b9":
+ version "0.0.1"
+ resolved "https://github.com/highlightjs/highlightjs-epp#9f9e1a92f37c217c68899c7d3bdccb4d134681b9"
+ dependencies:
+ highlight.js "^11.9.0"
+
+"highlightjs-structured-text@https://github.com/highlightjs/highlightjs-structured-text#e68dd7aa829529fb6c40d6287585f43273605a9e":
version "1.4.9"
resolved "https://github.com/highlightjs/highlightjs-structured-text#e68dd7aa829529fb6c40d6287585f43273605a9e"
dependencies:
diff --git a/proto/entities.proto b/proto/entities.proto
index be04e97..6d5ac04 100644
--- a/proto/entities.proto
+++ b/proto/entities.proto
@@ -277,7 +277,7 @@
// Next ID: 2
message ObjectId {
// Hex string representation of the ID.
- optional string name = 1;
+ optional string name = 1 [default="0000000000000000000000000000000000000000"];
}
// Serialized form of a continuation token used for pagination.
@@ -289,7 +289,7 @@
// Proto representation of the User preferences classes
// Next ID: 4
message UserPreferences {
- // Next ID: 24
+ // Next ID: 26
message GeneralPreferencesInfo {
// Number of changes to show in a screen.
optional int32 changes_per_page = 1 [default = 25];
@@ -368,6 +368,8 @@
repeated string change_table = 18;
optional bool allow_browser_notifications = 19 [default = true];
+ optional bool allow_suggest_code_while_commenting = 24 [default = true];
+ optional bool allow_autocompleting_comments = 25 [default = true];
optional string diff_page_sidebar = 23 [default = "NONE"];
}
optional GeneralPreferencesInfo general_preferences_info = 1;
@@ -453,11 +455,11 @@
optional Side side = 2 [default = REVISION];
message Range {
// 1-based
- optional int32 start_line = 1;
+ optional int32 start_line = 1 [default = 1];
// 0-based
optional int32 start_char = 2;
// 1-based
- optional int32 end_line = 3;
+ optional int32 end_line = 3 [default = 1];
// 0-based
optional int32 end_char = 4;
}
@@ -466,7 +468,7 @@
// number is identical to the range's end line.
optional Range position_range = 3;
// 1-based
- optional int32 line_number = 4;
+ optional int32 line_number = 4 [default = 1];
}
// If not set, the comment is on the patchset level.
@@ -488,4 +490,4 @@
optional fixed64 written_on_millis = 11;
// Required.
optional string server_id = 12;
-}
\ No newline at end of file
+}
diff --git a/resources/com/google/gerrit/server/mime/mime-types.properties b/resources/com/google/gerrit/server/mime/mime-types.properties
index 642ef474..432d088 100644
--- a/resources/com/google/gerrit/server/mime/mime-types.properties
+++ b/resources/com/google/gerrit/server/mime/mime-types.properties
@@ -63,6 +63,7 @@
el = text/x-common-lisp
elm = text/x-elm
ejs = application/x-ejs
+epp = application/x-epp
erb = application/x-erb
erl = text/x-erlang
es6 = text/jsx
diff --git a/tools/bzl/pkg_war.bzl b/tools/bzl/pkg_war.bzl
index 4792de2..52fa1dd 100644
--- a/tools/bzl/pkg_war.bzl
+++ b/tools/bzl/pkg_war.bzl
@@ -14,7 +14,8 @@
# War packaging.
-load("//tools:deps.bzl", "AUTO_FACTORY_VERSION", "AUTO_VALUE_GSON_VERSION", "AUTO_VALUE_VERSION")
+load("//tools:deps.bzl", "AUTO_VALUE_GSON_VERSION")
+load("//tools:nongoogle.bzl", "AUTO_FACTORY_VERSION", "AUTO_VALUE_VERSION")
jar_filetype = [".jar"]
diff --git a/tools/deps.bzl b/tools/deps.bzl
index d056483..bc37010 100644
--- a/tools/deps.bzl
+++ b/tools/deps.bzl
@@ -11,9 +11,6 @@
MAIL_VERS = "1.6.0"
MIME4J_VERS = "0.8.1"
OW2_VERS = "9.2"
-AUTO_COMMON_VERSION = "1.2.1"
-AUTO_FACTORY_VERSION = "1.0.1"
-AUTO_VALUE_VERSION = "1.10.4"
AUTO_VALUE_GSON_VERSION = "1.3.1"
PROLOG_VERS = "1.4.4"
PROLOG_REPO = GERRIT
@@ -90,12 +87,6 @@
)
maven_jar(
- name = "gson",
- artifact = "com.google.code.gson:gson:2.9.0",
- sha1 = "8a1167e089096758b49f9b34066ef98b2f4b37aa",
- )
-
- maven_jar(
name = "caffeine",
artifact = "com.github.ben-manes.caffeine:caffeine:" + CAFFEINE_VERS,
sha1 = "0a17ed335e0ce2d337750772c0709b79af35a842",
@@ -283,36 +274,6 @@
)
maven_jar(
- name = "auto-common",
- artifact = "com.google.auto:auto-common:" + AUTO_COMMON_VERSION,
- sha1 = "f6da26895f759010f5f170c8044e84c1b17ef83e",
- )
-
- maven_jar(
- name = "auto-factory",
- artifact = "com.google.auto.factory:auto-factory:" + AUTO_FACTORY_VERSION,
- sha1 = "f81ece06b6525085da217cd900116f44caafe877",
- )
-
- maven_jar(
- name = "auto-service-annotations",
- artifact = "com.google.auto.service:auto-service-annotations:" + AUTO_FACTORY_VERSION,
- sha1 = "ac86dacc0eb9285ea9d42eee6aad8629ca3a7432",
- )
-
- maven_jar(
- name = "auto-value",
- artifact = "com.google.auto.value:auto-value:" + AUTO_VALUE_VERSION,
- sha1 = "90f9629eaa123f88551cc26a64bc386967ee24cc",
- )
-
- maven_jar(
- name = "auto-value-annotations",
- artifact = "com.google.auto.value:auto-value-annotations:" + AUTO_VALUE_VERSION,
- sha1 = "9679de8286eb0a151db6538ba297a8951c4a1224",
- )
-
- maven_jar(
name = "auto-value-gson-runtime",
artifact = "com.ryanharter.auto.value:auto-value-gson-runtime:" + AUTO_VALUE_GSON_VERSION,
sha1 = "addda2ae6cce9f855788274df5de55dde4de7b71",
diff --git a/tools/nongoogle.bzl b/tools/nongoogle.bzl
index ac3f668..e185141 100644
--- a/tools/nongoogle.bzl
+++ b/tools/nongoogle.bzl
@@ -7,6 +7,12 @@
load("@bazel_tools//tools/build_defs/repo:utils.bzl", "maybe")
load("//tools/bzl:maven_jar.bzl", "maven_jar")
+AUTO_COMMON_VERSION = "1.2.1"
+
+AUTO_FACTORY_VERSION = "1.0.1"
+
+AUTO_VALUE_VERSION = "1.10.4"
+
GUAVA_VERSION = "33.0.0-jre"
GUAVA_BIN_SHA1 = "161ba27964a62f241533807a46b8711b13c1d94b"
@@ -182,6 +188,36 @@
# no concern about version skew.
maven_jar(
+ name = "auto-common",
+ artifact = "com.google.auto:auto-common:" + AUTO_COMMON_VERSION,
+ sha1 = "f6da26895f759010f5f170c8044e84c1b17ef83e",
+ )
+
+ maven_jar(
+ name = "auto-factory",
+ artifact = "com.google.auto.factory:auto-factory:" + AUTO_FACTORY_VERSION,
+ sha1 = "f81ece06b6525085da217cd900116f44caafe877",
+ )
+
+ maven_jar(
+ name = "auto-service-annotations",
+ artifact = "com.google.auto.service:auto-service-annotations:" + AUTO_FACTORY_VERSION,
+ sha1 = "ac86dacc0eb9285ea9d42eee6aad8629ca3a7432",
+ )
+
+ maven_jar(
+ name = "auto-value",
+ artifact = "com.google.auto.value:auto-value:" + AUTO_VALUE_VERSION,
+ sha1 = "90f9629eaa123f88551cc26a64bc386967ee24cc",
+ )
+
+ maven_jar(
+ name = "auto-value-annotations",
+ artifact = "com.google.auto.value:auto-value-annotations:" + AUTO_VALUE_VERSION,
+ sha1 = "9679de8286eb0a151db6538ba297a8951c4a1224",
+ )
+
+ maven_jar(
name = "error-prone-annotations",
artifact = "com.google.errorprone:error_prone_annotations:2.22.0",
sha1 = "bfb9e4281a4cea34f0ec85b3acd47621cfab35b4",
@@ -252,6 +288,12 @@
sha1 = "6e9ccb00926325c7a9293ed05a2eaf56ea15d60e",
)
+ maven_jar(
+ name = "gson",
+ artifact = "com.google.code.gson:gson:2.10.1",
+ sha1 = "b3add478d4382b78ea20b1671390a858002feb6c",
+ )
+
# Test-only dependencies below.
maven_jar(
name = "cglib-3_2",