Merge "Move ApprovalsUtil and ApprovalInference into approval package"
* submodules:
* Update plugins/reviewnotes from branch 'master'
to 8074b2c179d31829a88c15406df51d53656f8541
- Adapt to changes in gerrit core
Depends-On: https://gerrit-review.googlesource.com/c/gerrit/+/308250
Change-Id: I5702aedd365d34289ffbbea7d9aea580abec133e
diff --git a/Documentation/intro-gerrit-walkthrough-github.txt b/Documentation/intro-gerrit-walkthrough-github.txt
index 8f3ff88..173f709 100644
--- a/Documentation/intro-gerrit-walkthrough-github.txt
+++ b/Documentation/intro-gerrit-walkthrough-github.txt
@@ -25,7 +25,7 @@
Here’s how getting code reviewed and submitted with Gerrit is different from
doing the same with GitHub:
-* You need the add a commit-msg hook script when you clone a repo for the first
+* You need to add a commit-msg hook script when you clone a repo for the first
time using a snippet you can find e.g. https://gerrit-review.googlesource.com/admin/repos/gerrit[here,role=external,window=_blank];
* Your review will be on a single commit instead of a branch. You use
`git commit --amend` to modify a code change.
diff --git a/e2e-tests/src/test/resources/hooks/commit-msg b/e2e-tests/src/test/resources/hooks/commit-msg
index b05a671..22a4b22 100644
--- a/e2e-tests/src/test/resources/hooks/commit-msg
+++ b/e2e-tests/src/test/resources/hooks/commit-msg
@@ -16,6 +16,8 @@
# See the License for the specific language governing permissions and
# limitations under the License.
+set -u
+
# avoid [[ which is not POSIX sh.
if test "$#" != 1 ; then
echo "$0 requires an argument."
@@ -32,8 +34,13 @@
exit 1
fi
-# $RANDOM will be undefined if not using bash, so don't use set -u
-random=$( (whoami ; hostname ; date; cat $1 ; echo $RANDOM) | git hash-object --stdin)
+if git rev-parse --verify HEAD >/dev/null 2>&1; then
+ refhash="$(git rev-parse HEAD)"
+else
+ refhash="$(git hash-object -t tree /dev/null)"
+fi
+
+random=$({ git var "GIT_COMMITTER_IDENT" ; echo "$refhash" ; cat "$1"; } | git hash-object --stdin)
dest="$1.tmp.${random}"
# Avoid the --in-place option which only appeared in Git 2.8
diff --git a/java/com/google/gerrit/index/query/IndexPredicate.java b/java/com/google/gerrit/index/query/IndexPredicate.java
index aac6682..7bbe70b 100644
--- a/java/com/google/gerrit/index/query/IndexPredicate.java
+++ b/java/com/google/gerrit/index/query/IndexPredicate.java
@@ -14,11 +14,30 @@
package com.google.gerrit.index.query;
+import static com.google.common.collect.ImmutableSet.toImmutableSet;
+
+import com.google.common.base.CharMatcher;
+import com.google.common.base.Splitter;
+import com.google.common.collect.ImmutableSet;
+import com.google.common.collect.Sets;
+import com.google.common.primitives.Ints;
+import com.google.common.primitives.Longs;
import com.google.gerrit.index.FieldDef;
import com.google.gerrit.index.FieldType;
+import java.util.Objects;
+import java.util.Set;
+import java.util.stream.StreamSupport;
/** Predicate that is mapped to a field in the index. */
-public abstract class IndexPredicate<I> extends OperatorPredicate<I> {
+public abstract class IndexPredicate<I> extends OperatorPredicate<I> implements Matchable<I> {
+ /**
+ * Text segmentation to be applied to both the query string and the indexed field for full-text
+ * queries. This is inspired by http://unicode.org/reports/tr29/ which is what Lucene uses, but
+ * complexity was reduced to the bare minimum at the cost of small discrepancies to the Unicode
+ * spec.
+ */
+ private static final Splitter FULL_TEXT_SPLITTER = Splitter.on(CharMatcher.anyOf(" ,.-\\/_\n"));
+
private final FieldDef<I, ?> def;
protected IndexPredicate(FieldDef<I, ?> def, String value) {
@@ -38,4 +57,70 @@
public FieldType<?> getType() {
return def.getType();
}
+
+ /**
+ * This method matches documents without calling an index subsystem. For primitive fields (e.g.
+ * integer, long) , the matching logic is consistent across this method and all known index
+ * implementations. For text fields (i.e. prefix and full-text) the semantics vary between this
+ * implementation and known index implementations:
+ * <li>Prefix: Lucene as well as {@link #match(I)} matches terms as true prefixes (prefix:foo ->
+ * `foo bar` matches, but `baz foo bar` does not match). The index implementation at Google
+ * tokenizes both the query and the indexed text and matches tokens individually (prefix:fo ba
+ * -> `baz foo bar` matches).
+ * <li>Full text: Lucene uses a {@code PhraseQuery} to search for terms in full text fields
+ * in-order. The index implementation at Google as well as {@link #match(I)} tokenizes both
+ * the query and the indexed text and matches tokens individually.
+ *
+ * @return true if the predicate matches the provided {@link I}.
+ */
+ @Override
+ public boolean match(I doc) {
+ if (getField().isRepeatable()) {
+ Iterable<Object> values = (Iterable<Object>) getField().get(doc);
+ for (Object v : values) {
+ if (matchesSingleObject(v)) {
+ return true;
+ }
+ }
+ return false;
+ } else {
+ return matchesSingleObject(getField().get(doc));
+ }
+ }
+
+ @Override
+ public int getCost() {
+ return 1;
+ }
+
+ private boolean matchesSingleObject(Object fieldValueFromObject) {
+ String fieldTypeName = getField().getType().getName();
+ if (fieldTypeName.equals(FieldType.INTEGER.getName())) {
+ return Objects.equals(fieldValueFromObject, Ints.tryParse(value));
+ } else if (fieldTypeName.equals(FieldType.EXACT.getName())) {
+ return Objects.equals(fieldValueFromObject, value);
+ } else if (fieldTypeName.equals(FieldType.LONG.getName())) {
+ return Objects.equals(fieldValueFromObject, Longs.tryParse(value));
+ } else if (fieldTypeName.equals(FieldType.PREFIX.getName())) {
+ return String.valueOf(fieldValueFromObject).startsWith(value);
+ } else if (fieldTypeName.equals(FieldType.FULL_TEXT.getName())) {
+ Set<String> tokenizedField = tokenizeString(String.valueOf(fieldValueFromObject));
+ Set<String> tokenizedValue = tokenizeString(value);
+ return !Sets.intersection(tokenizedField, tokenizedValue).isEmpty();
+ } else if (fieldTypeName.equals(FieldType.STORED_ONLY.getName())) {
+ throw new IllegalStateException("can't filter for storedOnly field " + getField().getName());
+ } else if (fieldTypeName.equals(FieldType.TIMESTAMP.getName())) {
+ throw new IllegalStateException("timestamp queries must be handled in subclasses");
+ } else if (fieldTypeName.equals(FieldType.INTEGER_RANGE.getName())) {
+ throw new IllegalStateException("integer range queries must be handled in subclasses");
+ } else {
+ throw new IllegalStateException("unrecognized field " + fieldTypeName);
+ }
+ }
+
+ private static ImmutableSet<String> tokenizeString(String value) {
+ return StreamSupport.stream(FULL_TEXT_SPLITTER.split(value.toLowerCase()).spliterator(), false)
+ .filter(s -> !s.trim().isEmpty())
+ .collect(toImmutableSet());
+ }
}
diff --git a/java/com/google/gerrit/index/query/IntegerRangePredicate.java b/java/com/google/gerrit/index/query/IntegerRangePredicate.java
index 6780867..850c4a5 100644
--- a/java/com/google/gerrit/index/query/IntegerRangePredicate.java
+++ b/java/com/google/gerrit/index/query/IntegerRangePredicate.java
@@ -31,6 +31,7 @@
protected abstract Integer getValueInt(T object);
+ @Override
public boolean match(T object) {
Integer valueInt = getValueInt(object);
if (valueInt == null) {
diff --git a/java/com/google/gerrit/server/notedb/ChangeUpdate.java b/java/com/google/gerrit/server/notedb/ChangeUpdate.java
index 9d23137..f56e933 100644
--- a/java/com/google/gerrit/server/notedb/ChangeUpdate.java
+++ b/java/com/google/gerrit/server/notedb/ChangeUpdate.java
@@ -579,10 +579,19 @@
@Override
protected boolean bypassMaxUpdates() {
- // Allow abandoning or submitting a change even if it would exceed the max update count.
+ return isAbandonChange() || isAttentionSetChangeOnly();
+ }
+
+ private boolean isAbandonChange() {
return status != null && status.isClosed();
}
+ private boolean isAttentionSetChangeOnly() {
+ return (plannedAttentionSetUpdates != null
+ && plannedAttentionSetUpdates.size() > 0
+ && comments.isEmpty());
+ }
+
@Override
protected CommitBuilder applyImpl(RevWalk rw, ObjectInserter ins, ObjectId curr)
throws IOException {
diff --git a/java/com/google/gerrit/server/notedb/OpenRepo.java b/java/com/google/gerrit/server/notedb/OpenRepo.java
index 351f31d..d02ec87 100644
--- a/java/com/google/gerrit/server/notedb/OpenRepo.java
+++ b/java/com/google/gerrit/server/notedb/OpenRepo.java
@@ -178,9 +178,9 @@
&& !update.bypassMaxUpdates()) {
throw new LimitExceededException(
String.format(
- "Change %s may not exceed %d updates. It may still be abandoned or submitted. To"
- + " continue working on this change, recreate it with a new Change-Id, then"
- + " abandon this one.",
+ "Change %s may not exceed %d updates. It may still be abandoned, submitted and you can add/remove"
+ + " reviewers to/from the attention-set. To continue working on this change, recreate it with a new"
+ + " Change-Id, then abandon this one.",
update.getId(), maxUpdates.get()));
}
curr = next;
diff --git a/java/com/google/gerrit/server/query/account/AccountPredicates.java b/java/com/google/gerrit/server/query/account/AccountPredicates.java
index e4da946..8f94089 100644
--- a/java/com/google/gerrit/server/query/account/AccountPredicates.java
+++ b/java/com/google/gerrit/server/query/account/AccountPredicates.java
@@ -21,7 +21,6 @@
import com.google.gerrit.index.FieldDef;
import com.google.gerrit.index.Schema;
import com.google.gerrit.index.query.IndexPredicate;
-import com.google.gerrit.index.query.Matchable;
import com.google.gerrit.index.query.Predicate;
import com.google.gerrit.index.query.QueryBuilder;
import com.google.gerrit.server.account.AccountState;
@@ -132,8 +131,7 @@
}
/** Predicate that is mapped to a field in the account index. */
- static class AccountPredicate extends IndexPredicate<AccountState>
- implements Matchable<AccountState> {
+ static class AccountPredicate extends IndexPredicate<AccountState> {
AccountPredicate(FieldDef<AccountState, ?> def, String value) {
super(def, value);
}
@@ -141,16 +139,6 @@
AccountPredicate(FieldDef<AccountState, ?> def, String name, String value) {
super(def, name, value);
}
-
- @Override
- public boolean match(AccountState object) {
- return true;
- }
-
- @Override
- public int getCost() {
- return 1;
- }
}
private AccountPredicates() {}
diff --git a/java/com/google/gerrit/server/query/change/ChangeData.java b/java/com/google/gerrit/server/query/change/ChangeData.java
index ce26e5d..0f1d129 100644
--- a/java/com/google/gerrit/server/query/change/ChangeData.java
+++ b/java/com/google/gerrit/server/query/change/ChangeData.java
@@ -1323,7 +1323,7 @@
}
draftsByUser = new HashMap<>();
- for (Ref ref : commentsUtil.getDraftRefs(notes.getChangeId())) {
+ for (Ref ref : commentsUtil.getDraftRefs(notes().getChangeId())) {
Account.Id account = Account.Id.fromRefSuffix(ref.getName());
if (account != null
// Double-check that any drafts exist for this user after
diff --git a/java/com/google/gerrit/server/query/change/ChangeIndexPredicate.java b/java/com/google/gerrit/server/query/change/ChangeIndexPredicate.java
index c4ca460..ccd4109 100644
--- a/java/com/google/gerrit/server/query/change/ChangeIndexPredicate.java
+++ b/java/com/google/gerrit/server/query/change/ChangeIndexPredicate.java
@@ -14,34 +14,12 @@
package com.google.gerrit.server.query.change;
-import static com.google.common.collect.ImmutableSet.toImmutableSet;
-
-import com.google.common.base.CharMatcher;
-import com.google.common.base.Splitter;
-import com.google.common.collect.ImmutableSet;
-import com.google.common.collect.Sets;
-import com.google.common.primitives.Ints;
-import com.google.common.primitives.Longs;
import com.google.gerrit.index.FieldDef;
-import com.google.gerrit.index.FieldType;
import com.google.gerrit.index.query.IndexPredicate;
-import com.google.gerrit.index.query.Matchable;
import com.google.gerrit.index.query.Predicate;
-import java.util.Objects;
-import java.util.Set;
-import java.util.stream.StreamSupport;
/** Predicate that is mapped to a field in the change index. */
-public class ChangeIndexPredicate extends IndexPredicate<ChangeData>
- implements Matchable<ChangeData> {
- /**
- * Text segmentation to be applied to both the query string and the indexed field for full-text
- * queries. This is inspired by http://unicode.org/reports/tr29/ which is what Lucene uses, but
- * complexity was reduced to the bare minimum at the cost of small discrepancies to the Unicode
- * spec.
- */
- private static final Splitter FULL_TEXT_SPLITTER = Splitter.on(CharMatcher.anyOf(" ,.-\\/_\n"));
-
+public class ChangeIndexPredicate extends IndexPredicate<ChangeData> {
/**
* Returns an index predicate that matches no changes in the index.
*
@@ -61,70 +39,4 @@
protected ChangeIndexPredicate(FieldDef<ChangeData, ?> def, String name, String value) {
super(def, name, value);
}
-
- /**
- * This method matches documents without calling an index subsystem. For primitive fields (e.g.
- * integer, long) , the matching logic is consistent across this method and all known index
- * implementations. For text fields (i.e. prefix and full-text) the semantics vary between this
- * implementation and known index implementations:
- * <li>Prefix: Lucene as well as {@link #match(ChangeData)} matches terms as true prefixes
- * (prefix:foo -> `foo bar` matches, but `baz foo bar` does not match). The index
- * implementation at Google tokenizes both the query and the indexed text and matches tokens
- * individually (prefix:fo ba -> `baz foo bar` matches).
- * <li>Full text: Lucene uses a {@code PhraseQuery} to search for terms in full text fields
- * in-order. The index implementation at Google as well as {@link #match(ChangeData)}
- * tokenizes both the query and the indexed text and matches tokens individually.
- *
- * @return true if the predicate matches the provided {@link ChangeData}.
- */
- @Override
- public boolean match(ChangeData cd) {
- if (getField().isRepeatable()) {
- Iterable<Object> values = (Iterable<Object>) getField().get(cd);
- for (Object v : values) {
- if (matchesSingleObject(v)) {
- return true;
- }
- }
- return false;
- } else {
- return matchesSingleObject(getField().get(cd));
- }
- }
-
- @Override
- public int getCost() {
- return 1;
- }
-
- private boolean matchesSingleObject(Object fieldValueFromObject) {
- String fieldTypeName = getField().getType().getName();
- if (fieldTypeName.equals(FieldType.INTEGER.getName())) {
- return Objects.equals(fieldValueFromObject, Ints.tryParse(value));
- } else if (fieldTypeName.equals(FieldType.EXACT.getName())) {
- return Objects.equals(fieldValueFromObject, value);
- } else if (fieldTypeName.equals(FieldType.LONG.getName())) {
- return Objects.equals(fieldValueFromObject, Longs.tryParse(value));
- } else if (fieldTypeName.equals(FieldType.PREFIX.getName())) {
- return String.valueOf(fieldValueFromObject).startsWith(value);
- } else if (fieldTypeName.equals(FieldType.FULL_TEXT.getName())) {
- Set<String> tokenizedField = tokenizeString(String.valueOf(fieldValueFromObject));
- Set<String> tokenizedValue = tokenizeString(value);
- return Sets.intersection(tokenizedField, tokenizedValue).size() == tokenizedValue.size();
- } else if (fieldTypeName.equals(FieldType.STORED_ONLY.getName())) {
- throw new IllegalStateException("can't filter for storedOnly field " + getField().getName());
- } else if (fieldTypeName.equals(FieldType.TIMESTAMP.getName())) {
- throw new IllegalStateException("timestamp queries must be handled in subclasses");
- } else if (fieldTypeName.equals(FieldType.INTEGER_RANGE.getName())) {
- throw new IllegalStateException("integer range queries must be handled in subclasses");
- } else {
- throw new IllegalStateException("unrecognized field " + fieldTypeName);
- }
- }
-
- private static ImmutableSet<String> tokenizeString(String value) {
- return StreamSupport.stream(FULL_TEXT_SPLITTER.split(value.toLowerCase()).spliterator(), false)
- .filter(s -> !s.trim().isEmpty())
- .collect(toImmutableSet());
- }
}
diff --git a/java/com/google/gerrit/server/query/group/GroupPredicates.java b/java/com/google/gerrit/server/query/group/GroupPredicates.java
index 9d8171c..8a2dc8d 100644
--- a/java/com/google/gerrit/server/query/group/GroupPredicates.java
+++ b/java/com/google/gerrit/server/query/group/GroupPredicates.java
@@ -19,7 +19,6 @@
import com.google.gerrit.entities.InternalGroup;
import com.google.gerrit.index.FieldDef;
import com.google.gerrit.index.query.IndexPredicate;
-import com.google.gerrit.index.query.Matchable;
import com.google.gerrit.index.query.Predicate;
import com.google.gerrit.server.index.group.GroupField;
import java.util.Locale;
@@ -45,7 +44,7 @@
}
public static Predicate<InternalGroup> name(String name) {
- return new NameGroupPredicate(name);
+ return new GroupPredicate(GroupField.NAME, name);
}
public static Predicate<InternalGroup> owner(AccountGroup.UUID ownerUuid) {
@@ -76,25 +75,5 @@
}
}
- // TODO(hiesel): This is just a one-off to make index tests work. Remove in favor of a more
- // generic solution.
- // This is required because Gerrit needs to look up groups by name on every request.
- static class NameGroupPredicate extends IndexPredicate<InternalGroup>
- implements Matchable<InternalGroup> {
- NameGroupPredicate(String value) {
- super(GroupField.NAME, value);
- }
-
- @Override
- public boolean match(InternalGroup group) {
- return group.getName().equals(getValue());
- }
-
- @Override
- public int getCost() {
- return 1;
- }
- }
-
private GroupPredicates() {}
}
diff --git a/javatests/com/google/gerrit/server/notedb/ChangeUpdateTest.java b/javatests/com/google/gerrit/server/notedb/ChangeUpdateTest.java
new file mode 100644
index 0000000..dbd1939
--- /dev/null
+++ b/javatests/com/google/gerrit/server/notedb/ChangeUpdateTest.java
@@ -0,0 +1,102 @@
+// Copyright (C) 2021 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.notedb;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import com.google.common.collect.ImmutableSet;
+import com.google.gerrit.entities.AttentionSetUpdate;
+import com.google.gerrit.entities.Change;
+import com.google.gerrit.entities.CommentRange;
+import com.google.gerrit.entities.HumanComment;
+import com.google.gerrit.server.util.time.TimeUtil;
+import org.eclipse.jgit.revwalk.RevCommit;
+import org.junit.Test;
+
+public class ChangeUpdateTest extends AbstractChangeNotesTest {
+
+ @Test
+ public void bypassMaxUpdatesShouldBeTrueWhenChangingAttentionSetOnly() throws Exception {
+ Change c = newChange();
+ ChangeUpdate update = newUpdate(c, changeOwner);
+
+ // Add to attention set
+ AttentionSetUpdate attentionSetUpdate =
+ AttentionSetUpdate.createForWrite(
+ otherUser.getAccountId(), AttentionSetUpdate.Operation.ADD, "test");
+ update.addToPlannedAttentionSetUpdates(ImmutableSet.of(attentionSetUpdate));
+
+ update.commit();
+
+ assertThat(update.bypassMaxUpdates()).isTrue();
+ }
+
+ @Test
+ public void bypassMaxUpdatesShouldBeTrueWhenClosingChange() throws Exception {
+ Change c = newChange();
+ ChangeUpdate update = newUpdate(c, changeOwner);
+
+ update.setStatus(Change.Status.ABANDONED);
+
+ update.commit();
+
+ assertThat(update.bypassMaxUpdates()).isTrue();
+ }
+
+ @Test
+ public void bypassMaxUpdatesShouldBeFalseWhenNotAbandoningChangeAndNotChangingAttentionSetOnly()
+ throws Exception {
+ Change c = newChange();
+ ChangeUpdate update = newUpdate(c, changeOwner);
+
+ update.commit();
+
+ assertThat(update.bypassMaxUpdates()).isFalse();
+ }
+
+ @Test
+ public void bypassMaxUpdatesShouldBeFalseWhenCommentsAndChangesToAttentionSetCoexist()
+ throws Exception {
+ Change c = newChange();
+ ChangeUpdate update = newUpdate(c, changeOwner);
+
+ // Add to attention set
+ AttentionSetUpdate attentionSetUpdate =
+ AttentionSetUpdate.createForWrite(
+ otherUser.getAccountId(), AttentionSetUpdate.Operation.ADD, "test");
+ update.addToPlannedAttentionSetUpdates(ImmutableSet.of(attentionSetUpdate));
+
+ // Add a comment
+ RevCommit commit = tr.commit().message("PS2").create();
+ update.putComment(
+ HumanComment.Status.PUBLISHED,
+ newComment(
+ c.currentPatchSetId(),
+ "a.txt",
+ "uuid1",
+ new CommentRange(1, 2, 3, 4),
+ 1,
+ changeOwner,
+ null,
+ TimeUtil.nowTs(),
+ "Comment",
+ (short) 1,
+ commit,
+ false));
+ update.commit();
+
+ assertThat(update.bypassMaxUpdates()).isFalse();
+ }
+}
diff --git a/javatests/com/google/gerrit/server/query/account/AbstractQueryAccountsTest.java b/javatests/com/google/gerrit/server/query/account/AbstractQueryAccountsTest.java
index f6b3317..b7be40b 100644
--- a/javatests/com/google/gerrit/server/query/account/AbstractQueryAccountsTest.java
+++ b/javatests/com/google/gerrit/server/query/account/AbstractQueryAccountsTest.java
@@ -607,7 +607,6 @@
@Test
public void reindex() throws Exception {
AccountInfo user1 = newAccountWithFullName("tester", "Test Usre");
-
// update account without reindex so that account index is stale
Account.Id accountId = Account.id(user1._accountId);
String newName = "Test User";
@@ -621,10 +620,11 @@
.setAccountDelta(AccountDelta.builder().setFullName(newName).build())
.commit(md);
}
-
- assertQuery("name:" + quote(user1.name), user1);
- assertQuery("name:" + quote(newName));
-
+ // Querying for the account here will not result in a stale document because
+ // we load AccountStates from the cache after reading documents from the index
+ // which means we always read fresh data when matching.
+ //
+ // Reindex document
gApi.accounts().id(user1.username).index();
assertQuery("name:" + quote(user1.name));
assertQuery("name:" + quote(newName), user1);
diff --git a/javatests/com/google/gerrit/server/query/account/FakeQueryAccountsTest.java b/javatests/com/google/gerrit/server/query/account/FakeQueryAccountsTest.java
index b742bd8..31d256e 100644
--- a/javatests/com/google/gerrit/server/query/account/FakeQueryAccountsTest.java
+++ b/javatests/com/google/gerrit/server/query/account/FakeQueryAccountsTest.java
@@ -14,10 +14,6 @@
package com.google.gerrit.server.query.account;
-import static org.junit.Assume.assumeFalse;
-
-import com.google.gerrit.entities.Account;
-import com.google.gerrit.server.account.AccountState;
import com.google.gerrit.server.index.account.AccountSchemaDefinitions;
import com.google.gerrit.testing.ConfigSuite;
import com.google.gerrit.testing.InMemoryModule;
@@ -25,7 +21,6 @@
import com.google.gerrit.testing.IndexVersions;
import com.google.inject.Guice;
import com.google.inject.Injector;
-import java.sql.Timestamp;
import java.util.List;
import java.util.Map;
import org.eclipse.jgit.lib.Config;
@@ -52,16 +47,4 @@
fakeConfig.setString("index", null, "type", "fake");
return Guice.createInjector(new InMemoryModule(fakeConfig));
}
-
- @Override
- protected void validateAssumptions() {
- // TODO(hiesel): Account predicates are always matching (they return true on match), so we need
- // to skip all tests here. We are doing this to document existing behavior. We want to remove
- // this assume statement and make group predicates matchable.
- assumeFalse(
- AccountPredicates.equalsName("test")
- .asMatchable()
- .match(
- AccountState.forAccount(Account.builder(Account.id(1), new Timestamp(0)).build())));
- }
}
diff --git a/javatests/com/google/gerrit/server/query/change/FakeQueryChangesTest.java b/javatests/com/google/gerrit/server/query/change/FakeQueryChangesTest.java
index 385d4b2..1e23420 100644
--- a/javatests/com/google/gerrit/server/query/change/FakeQueryChangesTest.java
+++ b/javatests/com/google/gerrit/server/query/change/FakeQueryChangesTest.java
@@ -18,8 +18,6 @@
import com.google.inject.Guice;
import com.google.inject.Injector;
import org.eclipse.jgit.lib.Config;
-import org.junit.Ignore;
-import org.junit.Test;
/**
* Test against {@link com.google.gerrit.index.testing.AbstractFakeIndex}. This test might seem
@@ -34,156 +32,4 @@
fakeConfig.setString("index", null, "type", "fake");
return Guice.createInjector(new InMemoryModule(fakeConfig));
}
-
- @Ignore
- @Test
- @Override
- public void byDefault() throws Exception {
- // TODO(hiesel): Fix bug in predicate and remove @Ignore.
- super.byDefault();
- }
-
- @Ignore
- @Test
- @Override
- public void byMergedBefore() throws Exception {
- // TODO(hiesel): Used predicate is not a matchable. Fix.
- super.byMergedBefore();
- }
-
- @Ignore
- @Test
- @Override
- public void reviewerAndCcByEmail() throws Exception {
- // TODO(hiesel): Fix bug in predicate and remove @Ignore.
- super.reviewerAndCcByEmail();
- }
-
- @Ignore
- @Test
- @Override
- public void byMessageExact() throws Exception {
- // TODO(hiesel): Existing #match function uses the index causing a StackOverflowError. Fix.
- super.byMessageExact();
- }
-
- @Ignore
- @Test
- @Override
- public void fullTextWithNumbers() throws Exception {
- // TODO(hiesel): Existing #match function uses the index causing a StackOverflowError. Fix.
- super.fullTextWithNumbers();
- }
-
- @Ignore
- @Test
- @Override
- public void byTriplet() throws Exception {
- // TODO(hiesel): Fix bug in predicate and remove @Ignore.
- super.byTriplet();
- }
-
- @Ignore
- @Test
- @Override
- public void byAge() throws Exception {
- // TODO(hiesel): Existing #match function uses the index causing a StackOverflowError. Fix.
- super.byAge();
- }
-
- @Ignore
- @Test
- @Override
- public void byMessageSubstring() throws Exception {
- // TODO(hiesel): Existing #match function uses the index causing a StackOverflowError. Fix.
- super.byMessageSubstring();
- }
-
- @Ignore
- @Test
- @Override
- public void byBeforeUntil() throws Exception {
- // TODO(hiesel): Used predicate is not a matchable. Fix.
- super.byBeforeUntil();
- }
-
- @Ignore
- @Test
- @Override
- public void byTopic() throws Exception {
- // TODO(hiesel): Existing #match function uses the index causing a StackOverflowError. Fix.
- super.byTopic();
- }
-
- @Ignore
- @Test
- @Override
- public void userQuery() throws Exception {
- // TODO(hiesel): Account name predicate is always returning true in #match. Fix.
- super.userQuery();
- }
-
- @Ignore
- @Test
- @Override
- public void visible() throws Exception {
- // TODO(hiesel): Account name predicate is always returning true in #match. Fix.
- super.visible();
- }
-
- @Ignore
- @Test
- @Override
- public void userDestination() throws Exception {
- // TODO(hiesel): Account name predicate is always returning true in #match. Fix.
- super.userDestination();
- }
-
- @Ignore
- @Test
- @Override
- public void byAfterSince() throws Exception {
- // TODO(hiesel): Used predicate is not a matchable. Fix.
- super.byAfterSince();
- }
-
- @Ignore
- @Test
- @Override
- public void byMessageMixedCase() throws Exception {
- // TODO(hiesel): Used predicate is not a matchable. Fix.
- super.byMessageMixedCase();
- }
-
- @Ignore
- @Test
- @Override
- public void byCommit() throws Exception {
- // TODO(hiesel): Existing #match function uses the index causing a StackOverflowError. Fix.
- super.byCommit();
- }
-
- @Ignore
- @Test
- @Override
- public void byComment() throws Exception {
- // TODO(hiesel): Existing #match function uses the index causing a StackOverflowError. Fix.
- super.byComment();
- }
-
- @Ignore
- @Test
- @Override
- public void byMergedAfter() throws Exception {
- // TODO(hiesel): Used predicate is not a matchable. Fix.
- super.byMergedAfter();
- }
-
- @Ignore
- @Test
- @Override
- public void byOwnerInvalidQuery() throws Exception {
- // TODO(hiesel): Account name predicate is always returning true in #match. Fix.
- super.byMergedAfter();
- }
}
diff --git a/javatests/com/google/gerrit/server/query/group/FakeQueryGroupsTest.java b/javatests/com/google/gerrit/server/query/group/FakeQueryGroupsTest.java
index 8bc1c30..e4f228a 100644
--- a/javatests/com/google/gerrit/server/query/group/FakeQueryGroupsTest.java
+++ b/javatests/com/google/gerrit/server/query/group/FakeQueryGroupsTest.java
@@ -14,8 +14,6 @@
package com.google.gerrit.server.query.group;
-import static org.junit.Assume.assumeTrue;
-
import com.google.gerrit.server.index.group.GroupSchemaDefinitions;
import com.google.gerrit.testing.ConfigSuite;
import com.google.gerrit.testing.InMemoryModule;
@@ -48,12 +46,4 @@
fakeConfig.setString("index", null, "type", "fake");
return Guice.createInjector(new InMemoryModule(fakeConfig));
}
-
- @Override
- protected void validateAssumptions() {
- // TODO(hiesel): Group predicates are not matchable, so we need to skip all tests here.
- // We are doing this to document existing behavior. We want to remove this assume statement and
- // make group predicates matchable.
- assumeTrue(GroupPredicates.inname("test").isMatchable());
- }
}
diff --git a/javatests/com/google/gerrit/server/query/project/FakeQueryProjectsTest.java b/javatests/com/google/gerrit/server/query/project/FakeQueryProjectsTest.java
index 8517ad2..6fc0568 100644
--- a/javatests/com/google/gerrit/server/query/project/FakeQueryProjectsTest.java
+++ b/javatests/com/google/gerrit/server/query/project/FakeQueryProjectsTest.java
@@ -14,8 +14,6 @@
package com.google.gerrit.server.query.project;
-import static org.junit.Assume.assumeTrue;
-
import com.google.gerrit.index.project.ProjectSchemaDefinitions;
import com.google.gerrit.testing.ConfigSuite;
import com.google.gerrit.testing.InMemoryModule;
@@ -50,12 +48,4 @@
fakeConfig.setString("index", null, "type", "fake");
return Guice.createInjector(new InMemoryModule(fakeConfig));
}
-
- @Override
- protected void validateAssumptions() {
- // TODO(hiesel): Project predicates are not matchable, so we need to skip all tests here.
- // We are doing this to document existing behavior. We want to remove this assume statement and
- // make group predicates matchable.
- assumeTrue(ProjectPredicates.inname("test").isMatchable());
- }
}
diff --git a/plugins/replication b/plugins/replication
index 321e37e..fd593ee 160000
--- a/plugins/replication
+++ b/plugins/replication
@@ -1 +1 @@
-Subproject commit 321e37e448336c2ae7495808da5fa483e755141b
+Subproject commit fd593ee1d3cb19d73c49f94c0f9b4a78856d6198
diff --git a/plugins/reviewnotes b/plugins/reviewnotes
index 5ff7832..8074b2c 160000
--- a/plugins/reviewnotes
+++ b/plugins/reviewnotes
@@ -1 +1 @@
-Subproject commit 5ff78329409f0ee8ad9502ab6bbd7882e6be1b74
+Subproject commit 8074b2c179d31829a88c15406df51d53656f8541
diff --git a/polygerrit-ui/app/api/checks.ts b/polygerrit-ui/app/api/checks.ts
index 454b3d5..2682158 100644
--- a/polygerrit-ui/app/api/checks.ts
+++ b/polygerrit-ui/app/api/checks.ts
@@ -14,6 +14,7 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
+import {CommentRange} from './core';
export declare interface ChecksPluginApi {
/**
@@ -382,6 +383,11 @@
links?: Link[];
/**
+ * Links to lines of code. The referenced path must be part of this patchset.
+ */
+ codePointers?: CodePointer[];
+
+ /**
* Callbacks to the plugin. Must be implemented individually by each
* plugin. Actions are rendered as buttons. If there are more than two actions
* per result, then further actions are put into an overflow menu. Sort order
@@ -430,6 +436,11 @@
icon: LinkIcon;
}
+export declare interface CodePointer {
+ path: string;
+ range: CommentRange;
+}
+
export enum LinkIcon {
EXTERNAL = 'external',
IMAGE = 'image',
@@ -438,4 +449,5 @@
DOWNLOAD_MOBILE = 'download_mobile',
HELP_PAGE = 'help_page',
REPORT_BUG = 'report_bug',
+ CODE = 'code',
}
diff --git a/polygerrit-ui/app/elements/change/gr-change-summary/gr-change-summary.ts b/polygerrit-ui/app/elements/change/gr-change-summary/gr-change-summary.ts
index ef8f250..bd9103e 100644
--- a/polygerrit-ui/app/elements/change/gr-change-summary/gr-change-summary.ts
+++ b/polygerrit-ui/app/elements/change/gr-change-summary/gr-change-summary.ts
@@ -20,18 +20,19 @@
import {sharedStyles} from '../../../styles/shared-styles';
import {appContext} from '../../../services/app-context';
import {
- allRunsLatest$,
+ allRunsLatestPatchsetLatestAttempt$,
aPluginHasRegistered$,
CheckResult,
CheckRun,
- errorMessage$,
- loginCallback$,
- someProvidersAreLoading$,
+ errorMessageLatest$,
+ loginCallbackLatest$,
+ someProvidersAreLoadingLatest$,
} from '../../../services/checks/checks-model';
-import {Category, Link, RunStatus} from '../../../api/checks';
+import {Category, RunStatus} from '../../../api/checks';
import {fireShowPrimaryTab} from '../../../utils/event-util';
import '../../shared/gr-avatar/gr-avatar';
import {
+ firstPrimaryLink,
getResultsOf,
hasCompletedWithoutResults,
hasResultsOf,
@@ -307,11 +308,11 @@
constructor() {
super();
- this.subscribe('runs', allRunsLatest$);
+ this.subscribe('runs', allRunsLatestPatchsetLatestAttempt$);
this.subscribe('showChecksSummary', aPluginHasRegistered$);
- this.subscribe('someProvidersAreLoading', someProvidersAreLoading$);
- this.subscribe('errorMessage', errorMessage$);
- this.subscribe('loginCallback', loginCallback$);
+ this.subscribe('someProvidersAreLoading', someProvidersAreLoadingLatest$);
+ this.subscribe('errorMessage', errorMessageLatest$);
+ this.subscribe('loginCallback', loginCallbackLatest$);
}
static get styles() {
@@ -455,13 +456,10 @@
this.detailsQuota -= runs.length;
return runs.map(run => {
this.detailsCheckNames.push(run.checkName);
- const allLinks = resultFilter(run)
- .reduce(
- (links, result) => links.concat(result.links ?? []),
- [] as Link[]
- )
- .filter(link => link.primary);
- const links = allLinks.length === 1 ? allLinks : [];
+ const allPrimaryLinks = resultFilter(run)
+ .map(firstPrimaryLink)
+ .filter(notUndefined);
+ const links = allPrimaryLinks.length === 1 ? allPrimaryLinks : [];
const text = `${run.checkName}`;
return html`<gr-checks-chip
class="${icon}"
diff --git a/polygerrit-ui/app/elements/checks/gr-checks-results.ts b/polygerrit-ui/app/elements/checks/gr-checks-results.ts
index 4b7aec5..53d25ec 100644
--- a/polygerrit-ui/app/elements/checks/gr-checks-results.ts
+++ b/polygerrit-ui/app/elements/checks/gr-checks-results.ts
@@ -17,6 +17,7 @@
import {html} from 'lit-html';
import {classMap} from 'lit-html/directives/class-map';
import {repeat} from 'lit-html/directives/repeat';
+import {ifDefined} from 'lit-html/directives/if-defined';
import {
css,
customElement,
@@ -39,12 +40,12 @@
} from '../../api/checks';
import {sharedStyles} from '../../styles/shared-styles';
import {
- allActions$,
- allLinks$,
+ topLevelActionsSelected$,
+ topLevelLinksSelected$,
CheckRun,
- checksPatchsetNumber$,
RunResult,
- someProvidersAreLoading$,
+ checksSelectedPatchsetNumber$,
+ someProvidersAreLoadingSelected$,
} from '../../services/checks/checks-model';
import {
allResults,
@@ -52,10 +53,11 @@
hasCompletedWithoutResults,
iconForCategory,
iconForLink,
- otherLinks,
- primaryLink,
+ otherPrimaryLinks,
+ firstPrimaryLink,
primaryRunAction,
tooltipForLink,
+ secondaryLinks,
} from '../../services/checks/checks-util';
import {assertIsDefined, check} from '../../utils/common-util';
import {toggleClass, whenVisible} from '../../utils/dom-util';
@@ -77,6 +79,7 @@
getRepresentativeValue,
valueString,
} from '../../utils/label-util';
+import {GerritNav} from '../core/gr-navigation/gr-navigation';
@customElement('gr-result-row')
class GrResultRow extends GrLitElement {
@@ -113,7 +116,7 @@
gr-result-expanded {
cursor: default;
}
- tr {
+ tr.container {
border-top: 1px solid var(--border-color);
}
iron-icon.link {
@@ -169,27 +172,30 @@
overflow: hidden;
text-overflow: ellipsis;
}
- tr:hover {
+ tr.container:hover {
background: var(--hover-background-color);
}
- tr td .summary-cell .links,
- tr td .summary-cell .actions,
- tr.collapsed:hover td .summary-cell .links,
- tr.collapsed:hover td .summary-cell .actions,
+ tr.container td .summary-cell .links,
+ tr.container td .summary-cell .actions,
+ tr.container.collapsed:hover td .summary-cell .links,
+ tr.container.collapsed:hover td .summary-cell .actions,
:host(.dropdown-open) tr td .summary-cell .links,
:host(.dropdown-open) tr td .summary-cell .actions {
display: inline-block;
margin-left: var(--spacing-s);
}
- tr.collapsed td .summary-cell .message {
+ tr.container.collapsed td .summary-cell .message {
color: var(--deemphasized-text-color);
}
- tr.collapsed td .summary-cell .links,
- tr.collapsed td .summary-cell .actions {
+ tr.container.collapsed td .summary-cell .links,
+ tr.container.collapsed td .summary-cell .actions {
display: none;
}
- tr.collapsed:hover .summary-cell .hoverHide.tags,
- tr.collapsed:hover .summary-cell .hoverHide.label {
+ tr.container.collapsed:hover .summary-cell .hoverHide.tags,
+ tr.container.collapsed:hover .summary-cell .hoverHide.label {
+ display: none;
+ }
+ tr.detailsRow.collapsed {
display: none;
}
td .summary-cell .tags .tag {
@@ -294,19 +300,21 @@
return html`
<tr class="${classMap({container: true, collapsed: !this.isExpanded})}">
<td class="iconCol" @click="${this.toggleExpanded}">
- <div>${this.renderIcon()}</div>
+ <div>${this.runningIcon()}</div>
</td>
<td class="nameCol" @click="${this.toggleExpanded}">
<div class="flex">
<gr-hovercard-run .run="${this.result}"></gr-hovercard-run>
- <div class="name">${this.result.checkName}</div>
+ <div class="name">
+ ${this.result.checkName} ${this.renderStatus()}
+ </div>
<div class="space"></div>
${this.renderPrimaryRunAction()}
</div>
</td>
<td class="summaryCol">
<div class="summary-cell">
- ${this.renderLink(primaryLink(this.result))}
+ ${this.renderLink(firstPrimaryLink(this.result))}
${this.renderSummary(this.result.summary)}
<div class="message" @click="${this.toggleExpanded}">
${this.isExpanded ? '' : this.result.message}
@@ -316,7 +324,6 @@
</div>
${this.renderLabel()} ${this.renderLinks()} ${this.renderActions()}
</div>
- ${this.renderExpanded()}
</td>
<td class="expanderCol" @click="${this.toggleExpanded}">
<div
@@ -338,6 +345,10 @@
</div>
</td>
</tr>
+ <tr class="${classMap({detailsRow: true, collapsed: !this.isExpanded})}">
+ <td></td>
+ <td colspan="3">${this.renderExpanded()}</td>
+ </tr>
`;
}
@@ -376,7 +387,12 @@
`;
}
- renderIcon() {
+ private renderStatus() {
+ if (this.result?.status !== RunStatus.RUNNING) return;
+ return html`<span>(Running)</span>`;
+ }
+
+ private runningIcon() {
if (this.result?.status !== RunStatus.RUNNING) return;
return html`<iron-icon icon="gr-icons:timelapse"></iron-icon>`;
}
@@ -397,12 +413,23 @@
}
renderLinks() {
- const links = otherLinks(this.result);
+ const links = otherPrimaryLinks(this.result)
+ // Showing the same icons twice without text is super confusing.
+ .filter(
+ (link: Link, index: number, array: Link[]) =>
+ array.findIndex(other => link.icon === other.icon) === index
+ )
+ // 4 is enough for the summary row. All are shown in expanded state.
+ .slice(0, 4);
if (links.length === 0) return;
- return html`<div class="links">${links.map(this.renderLink)}</div>`;
+ return html`<div class="links">
+ ${links.map(link => this.renderLink(link))}
+ </div>`;
}
renderLink(link?: Link) {
+ // The expanded state renders all links in more detail. Hide in summary.
+ if (this.isExpanded) return;
if (!link) return;
const tooltipText = link.tooltip ?? tooltipForLink(link.icon);
return html`<a href="${link.url}" target="_blank"
@@ -485,10 +512,23 @@
@property()
repoConfig?: ConfigInfo;
+ private changeService = appContext.changeService;
+
static get styles() {
return [
sharedStyles,
css`
+ .links {
+ white-space: normal;
+ padding: var(--spacing-s) 0;
+ }
+ .links a {
+ display: inline-block;
+ margin-right: var(--spacing-xl);
+ }
+ .links a iron-icon {
+ margin-right: var(--spacing-xs);
+ }
.message {
padding: var(--spacing-m) var(--spacing-m) var(--spacing-m) 0;
}
@@ -504,6 +544,8 @@
render() {
if (!this.result) return '';
return html`
+ ${this.renderFirstPrimaryLink()} ${this.renderOtherPrimaryLinks()}
+ ${this.renderSecondaryLinks()} ${this.renderCodePointers()}
<gr-endpoint-decorator name="check-result-expanded">
<gr-endpoint-param
name="run"
@@ -522,6 +564,67 @@
</gr-endpoint-decorator>
`;
}
+
+ private renderFirstPrimaryLink() {
+ const link = firstPrimaryLink(this.result);
+ if (!link) return;
+ return html`<div class="links">${this.renderLink(link)}</div>`;
+ }
+
+ private renderOtherPrimaryLinks() {
+ const links = otherPrimaryLinks(this.result);
+ if (links.length === 0) return;
+ return html`<div class="links">
+ ${links.map(link => this.renderLink(link))}
+ </div>`;
+ }
+
+ private renderSecondaryLinks() {
+ const links = secondaryLinks(this.result);
+ if (links.length === 0) return;
+ return html`<div class="links">
+ ${links.map(link => this.renderLink(link))}
+ </div>`;
+ }
+
+ private renderCodePointers() {
+ const pointers = this.result?.codePointers ?? [];
+ if (pointers.length === 0) return;
+ const links = pointers.map(pointer => {
+ let rangeText = '';
+ const start = pointer?.range?.start_line;
+ const end = pointer?.range?.end_line;
+ if (start) rangeText += `#${start}`;
+ if (end && start !== end) rangeText += `-${end}`;
+ const change = this.changeService.getChange();
+ assertIsDefined(change);
+ const path = pointer.path;
+ const patchset = this.result?.patchset as PatchSetNumber | undefined;
+ const line = pointer?.range?.start_line;
+ return {
+ icon: LinkIcon.CODE,
+ tooltip: `${path}${rangeText}`,
+ url: GerritNav.getUrlForDiff(change, path, patchset, undefined, line),
+ primary: true,
+ };
+ });
+ return links.map(
+ link => html`<div class="links">${this.renderLink(link, false)}</div>`
+ );
+ }
+
+ private renderLink(link?: Link, targetBlank = true) {
+ if (!link) return;
+ const text = link.tooltip ?? tooltipForLink(link.icon);
+ const target = targetBlank ? '_blank' : undefined;
+ return html`<a href="${link.url}" target="${ifDefined(target)}">
+ <iron-icon
+ class="link"
+ icon="gr-icons:${iconForLink(link.icon)}"
+ ></iron-icon
+ ><span>${text}</span>
+ </a>`;
+ }
}
const SHOW_ALL_THRESHOLDS: Map<Category, number> = new Map();
@@ -611,11 +714,11 @@
constructor() {
super();
- this.subscribe('actions', allActions$);
- this.subscribe('links', allLinks$);
- this.subscribe('checksPatchsetNumber', checksPatchsetNumber$);
+ this.subscribe('actions', topLevelActionsSelected$);
+ this.subscribe('links', topLevelLinksSelected$);
+ this.subscribe('checksPatchsetNumber', checksSelectedPatchsetNumber$);
this.subscribe('latestPatchsetNumber', latestPatchNum$);
- this.subscribe('someProvidersAreLoading', someProvidersAreLoading$);
+ this.subscribe('someProvidersAreLoading', someProvidersAreLoadingSelected$);
}
static get styles() {
@@ -634,6 +737,9 @@
var(--spacing-xl);
border-bottom: 1px solid var(--border-color);
}
+ .header.notLatest {
+ background-color: var(--emphasis-color);
+ }
.headerTopRow,
.headerBottomRow {
max-width: 1600px;
@@ -665,10 +771,20 @@
.headerBottomRow {
margin-top: var(--spacing-s);
}
+ .headerTopRow .right,
.headerBottomRow .right {
display: flex;
align-items: center;
}
+ .headerTopRow .right .goToLatest {
+ display: none;
+ }
+ .notLatest .headerTopRow .right .goToLatest {
+ display: block;
+ }
+ .headerTopRow .right .goToLatest gr-button {
+ margin-right: var(--spacing-m);
+ }
.headerBottomRow iron-icon {
color: var(--link-color);
}
@@ -820,8 +936,22 @@
}
render() {
- return html`
- <div class="header">
+ // To pass CSS mixins for @apply to Polymer components, they need to appear
+ // in <style> inside the template.
+ const style = html`<style>
+ .headerTopRow .right .goToLatest gr-button {
+ --gr-button: {
+ padding: var(--spacing-s) var(--spacing-m);
+ text-transform: none;
+ }
+ }
+ </style>`;
+ const headerClasses = classMap({
+ header: true,
+ notLatest: !!this.checksPatchsetNumber,
+ });
+ return html`${style}
+ <div class="${headerClasses}">
<div class="headerTopRow">
<div class="left">
<h2 class="heading-2">Results</h2>
@@ -831,8 +961,13 @@
</div>
</div>
<div class="right">
+ <div class="goToLatest">
+ <gr-button @click="${this.goToLatestPatchset}" link
+ >Go to latest patchset</gr-button
+ >
+ </div>
<gr-dropdown-list
- value="${this.checksPatchsetNumber}"
+ value="${this.checksPatchsetNumber ?? this.latestPatchsetNumber}"
.items="${this.createPatchsetDropdownItems()}"
@value-change="${this.onPatchsetSelected}"
></gr-dropdown-list>
@@ -852,14 +987,20 @@
${this.renderSection(Category.WARNING)}
${this.renderSection(Category.INFO)}
${this.renderSection(Category.SUCCESS)}
- </div>
- `;
+ </div>`;
}
private renderLinks() {
const links = this.links ?? [];
if (links.length === 0) return;
- const primaryLinks = links.filter(a => a.primary).slice(0, 4);
+ const primaryLinks = links
+ .filter(a => a.primary)
+ // Showing the same icons twice without text is super confusing.
+ .filter(
+ (link: Link, index: number, array: Link[]) =>
+ array.findIndex(other => link.icon === other.icon) === index
+ )
+ .slice(0, 4);
const overflowLinks = links.filter(a => !primaryLinks.includes(a));
return html`
${primaryLinks.map(this.renderLink)}
@@ -957,6 +1098,10 @@
this.checksService.setPatchset(patchset as PatchSetNumber);
}
+ private goToLatestPatchset() {
+ this.checksService.setPatchset(undefined);
+ }
+
private createPatchsetDropdownItems() {
if (!this.latestPatchsetNumber) return [];
return Array.from(Array(this.latestPatchsetNumber), (_, i) => {
diff --git a/polygerrit-ui/app/elements/checks/gr-checks-runs.ts b/polygerrit-ui/app/elements/checks/gr-checks-runs.ts
index 21d0459..093eecb 100644
--- a/polygerrit-ui/app/elements/checks/gr-checks-runs.ts
+++ b/polygerrit-ui/app/elements/checks/gr-checks-runs.ts
@@ -40,9 +40,11 @@
worstCategory,
} from '../../services/checks/checks-util';
import {
+ allRunsSelectedPatchset$,
CheckRun,
- allRuns$,
+ ChecksPatchset,
fakeActions,
+ fakeLinks,
fakeRun0,
fakeRun1,
fakeRun2,
@@ -52,7 +54,6 @@
fakeRun4_3,
fakeRun4_4,
updateStateSetResults,
- fakeLinks,
} from '../../services/checks/checks-model';
import {assertIsDefined} from '../../utils/common-util';
import {whenVisible} from '../../utils/dom-util';
@@ -334,7 +335,7 @@
constructor() {
super();
- this.subscribe('runs', allRuns$);
+ this.subscribe('runs', allRunsSelectedPatchset$);
}
static get styles() {
@@ -443,8 +444,8 @@
?hidden="${!this.showFilter()}"
@input="${this.onInput}"
/>
- ${this.renderSection(RunStatus.COMPLETED)}
${this.renderSection(RunStatus.RUNNING)}
+ ${this.renderSection(RunStatus.COMPLETED)}
${this.renderSection(RunStatus.RUNNABLE)} ${this.renderFakeControls()}
`;
}
@@ -514,24 +515,31 @@
}
none() {
- updateStateSetResults('f0', [], []);
- updateStateSetResults('f1', []);
- updateStateSetResults('f2', []);
- updateStateSetResults('f3', []);
- updateStateSetResults('f4', []);
+ updateStateSetResults('f0', [], [], [], ChecksPatchset.LATEST);
+ updateStateSetResults('f1', [], [], [], ChecksPatchset.LATEST);
+ updateStateSetResults('f2', [], [], [], ChecksPatchset.LATEST);
+ updateStateSetResults('f3', [], [], [], ChecksPatchset.LATEST);
+ updateStateSetResults('f4', [], [], [], ChecksPatchset.LATEST);
}
all() {
- updateStateSetResults('f0', [fakeRun0], fakeActions, fakeLinks);
- updateStateSetResults('f1', [fakeRun1]);
- updateStateSetResults('f2', [fakeRun2]);
- updateStateSetResults('f3', [fakeRun3]);
- updateStateSetResults('f4', [
- fakeRun4_1,
- fakeRun4_2,
- fakeRun4_3,
- fakeRun4_4,
- ]);
+ updateStateSetResults(
+ 'f0',
+ [fakeRun0],
+ fakeActions,
+ fakeLinks,
+ ChecksPatchset.LATEST
+ );
+ updateStateSetResults('f1', [fakeRun1], [], [], ChecksPatchset.LATEST);
+ updateStateSetResults('f2', [fakeRun2], [], [], ChecksPatchset.LATEST);
+ updateStateSetResults('f3', [fakeRun3], [], [], ChecksPatchset.LATEST);
+ updateStateSetResults(
+ 'f4',
+ [fakeRun4_1, fakeRun4_2, fakeRun4_3, fakeRun4_4],
+ [],
+ [],
+ ChecksPatchset.LATEST
+ );
}
toggle(
@@ -541,7 +549,13 @@
links: Link[] = []
) {
const newRuns = this.runs.includes(runs[0]) ? [] : runs;
- updateStateSetResults(plugin, newRuns, actions, links);
+ updateStateSetResults(
+ plugin,
+ newRuns,
+ actions,
+ links,
+ ChecksPatchset.LATEST
+ );
}
renderSection(status: RunStatus) {
diff --git a/polygerrit-ui/app/elements/checks/gr-checks-tab.ts b/polygerrit-ui/app/elements/checks/gr-checks-tab.ts
index a04c190..963d28a 100644
--- a/polygerrit-ui/app/elements/checks/gr-checks-tab.ts
+++ b/polygerrit-ui/app/elements/checks/gr-checks-tab.ts
@@ -21,9 +21,9 @@
import {
CheckResult,
CheckRun,
- allResults$,
- allRuns$,
- checksPatchsetNumber$,
+ allResultsSelected$,
+ checksSelectedPatchsetNumber$,
+ allRunsSelectedPatchset$,
} from '../../services/checks/checks-model';
import './gr-checks-runs';
import './gr-checks-results';
@@ -72,9 +72,9 @@
constructor() {
super();
- this.subscribe('runs', allRuns$);
- this.subscribe('results', allResults$);
- this.subscribe('checksPatchsetNumber', checksPatchsetNumber$);
+ this.subscribe('runs', allRunsSelectedPatchset$);
+ this.subscribe('results', allResultsSelected$);
+ this.subscribe('checksPatchsetNumber', checksSelectedPatchsetNumber$);
this.subscribe('changeNum', changeNum$);
this.addEventListener('action-triggered', (e: ActionTriggeredEvent) =>
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder.ts b/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder.ts
index 424df2f..f70974f 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder.ts
+++ b/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder.ts
@@ -315,7 +315,9 @@
const firstGroupIsSkipped = !!contextGroups[0].skip;
const lastGroupIsSkipped = !!contextGroups[contextGroups.length - 1].skip;
- const showAbove = leftStart > 1 && !firstGroupIsSkipped;
+ const containsWholeFile = this._numLinesLeft === leftEnd - leftStart + 1;
+ const showAbove =
+ (leftStart > 1 && !firstGroupIsSkipped) || containsWholeFile;
const showBelow = leftEnd < this._numLinesLeft && !lastGroupIsSkipped;
if (showAbove) {
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-cursor/gr-diff-cursor.ts b/polygerrit-ui/app/elements/diff/gr-diff-cursor/gr-diff-cursor.ts
index 010786f..68bdaa6 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-cursor/gr-diff-cursor.ts
+++ b/polygerrit-ui/app/elements/diff/gr-diff-cursor/gr-diff-cursor.ts
@@ -55,8 +55,21 @@
private preventAutoScrollOnManualScroll = false;
set side(side: Side) {
+ if (this.sideInternal === side) {
+ return;
+ }
+ if (this.sideInternal && this.diffRow) {
+ this.fireCursorMoved(
+ 'line-cursor-moved-out',
+ this.diffRow,
+ this.sideInternal
+ );
+ }
this.sideInternal = side;
this.updateSideClass();
+ if (this.diffRow) {
+ this.fireCursorMoved('line-cursor-moved-in', this.diffRow, this.side);
+ }
}
get side(): Side {
@@ -478,19 +491,6 @@
toggleClass(this.diffRow, RIGHT_SIDE_CLASS, this.side === Side.RIGHT);
}
- _updateSide(_: Side, oldSide: Side) {
- if (!this.diffRow) {
- return;
- }
- if (oldSide) {
- this.fireCursorMoved('line-cursor-moved-out', this.diffRow, oldSide);
- }
- this.updateSideClass();
- if (this.diffRow) {
- this.fireCursorMoved('line-cursor-moved-in', this.diffRow, this.side);
- }
- }
-
_isActionType(type: GrDiffRowType) {
return (
type !== GrDiffLineType.BLANK && type !== GrDiffGroupType.CONTEXT_CONTROL
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view.ts b/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view.ts
index 2ce2abe..d626fe5 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view.ts
+++ b/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view.ts
@@ -1233,6 +1233,9 @@
}
return;
}
+ // shift + m navigates to next unreviewed file so request list of reviewed
+ // files even if manual review is not set
+ this._getReviewedFiles(this._changeNum, patchRange.patchNum);
if (paramsRecord.base?.view === GerritNav.View.DIFF) {
this._setReviewed(true);
diff --git a/polygerrit-ui/app/elements/settings/gr-account-info/gr-account-info.ts b/polygerrit-ui/app/elements/settings/gr-account-info/gr-account-info.ts
index 7ef5a73..d278d22 100644
--- a/polygerrit-ui/app/elements/settings/gr-account-info/gr-account-info.ts
+++ b/polygerrit-ui/app/elements/settings/gr-account-info/gr-account-info.ts
@@ -105,6 +105,8 @@
})
);
+ promises.push(this.restApiService.invalidateAccountsDetailCache());
+
promises.push(
this.restApiService.getAccount().then(account => {
if (!account) return;
diff --git a/polygerrit-ui/app/elements/shared/gr-icons/gr-icons.ts b/polygerrit-ui/app/elements/shared/gr-icons/gr-icons.ts
index e3a34c8..8968e18 100644
--- a/polygerrit-ui/app/elements/shared/gr-icons/gr-icons.ts
+++ b/polygerrit-ui/app/elements/shared/gr-icons/gr-icons.ts
@@ -148,6 +148,8 @@
<g id="playArrow"><path d="M0 0h24v24H0z" fill="none"/><path d="M8 5v14l11-7z"/></g>
<!-- This SVG is a copy from material.io https://fonts.google.com/icons?selected=Material%20Icons%3Apause-->
<g id="pause"><path d="M0 0h24v24H0z" fill="none"/><path d="M6 19h4V5H6v14zm8-14v14h4V5h-4z"/></g>
+ <!-- This SVG is a copy from material.io https://material.io/icons/#code-->
+ <g id="code"><path d="M0 0h24v24H0V0z" fill="none"/><path d="M9.4 16.6L4.8 12l4.6-4.6L8 6l-6 6 6 6 1.4-1.4zm5.2 0l4.6-4.6-4.6-4.6L16 6l6 6-6 6-1.4-1.4z"/></g>
</defs>
</svg>
</iron-iconset-svg>`;
diff --git a/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-api-interface.ts b/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-api-interface.ts
index 852f0b6..28bc229 100644
--- a/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-api-interface.ts
+++ b/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-api-interface.ts
@@ -1443,6 +1443,10 @@
this._restApiHelper.invalidateFetchPromisesPrefix('/accounts/');
}
+ invalidateAccountsDetailCache() {
+ this._restApiHelper.invalidateFetchPromisesPrefix('/accounts/self/detail');
+ }
+
getGroups(filter: string, groupsPerPage: number, offset?: number) {
const url = this._getGroupsUrl(filter, groupsPerPage, offset);
diff --git a/polygerrit-ui/app/services/change/change-model.ts b/polygerrit-ui/app/services/change/change-model.ts
index 9036871..312e78d 100644
--- a/polygerrit-ui/app/services/change/change-model.ts
+++ b/polygerrit-ui/app/services/change/change-model.ts
@@ -48,7 +48,7 @@
export function updateState(change?: ParsedChangeInfo) {
const current = privateState$.getValue();
// We want to make it easy for subscribers to react to change changes, so we
- // are explicitly emitting and additional `undefined` when the change number
+ // are explicitly emitting an additional `undefined` when the change number
// changes. So if you are subscribed to the latestPatchsetNumber for example,
// then you can rely on emissions even if the old and the new change have the
// same latestPatchsetNumber.
diff --git a/polygerrit-ui/app/services/change/change-service.ts b/polygerrit-ui/app/services/change/change-service.ts
index 1c6fc4c..0b9a1f2 100644
--- a/polygerrit-ui/app/services/change/change-service.ts
+++ b/polygerrit-ui/app/services/change/change-service.ts
@@ -15,7 +15,7 @@
* limitations under the License.
*/
import {routerChangeNum$} from '../router/router-model';
-import {updateState} from './change-model';
+import {change$, updateState} from './change-model';
import {ParsedChangeInfo} from '../../types/types';
import {appContext} from '../app-context';
import {ChangeInfo} from '../../types/common';
@@ -25,6 +25,8 @@
} from '../../utils/patch-set-util';
export class ChangeService {
+ private change?: ParsedChangeInfo;
+
private readonly restApiService = appContext.restApiService;
constructor() {
@@ -34,6 +36,9 @@
routerChangeNum$.subscribe(changeNum => {
if (!changeNum) updateState(undefined);
});
+ change$.subscribe(change => {
+ this.change = change;
+ });
}
/**
@@ -48,6 +53,15 @@
}
/**
+ * Typically you would just subscribe to change$ yourself to get updates. But
+ * sometimes it is nice to also be able to get the current ChangeInfo on
+ * demand. So here it is for your convenience.
+ */
+ getChange() {
+ return this.change;
+ }
+
+ /**
* Check whether there is no newer patch than the latest patch that was
* available when this change was loaded.
*
diff --git a/polygerrit-ui/app/services/checks/checks-model.ts b/polygerrit-ui/app/services/checks/checks-model.ts
index 60cb780..7d5f053 100644
--- a/polygerrit-ui/app/services/checks/checks-model.ts
+++ b/polygerrit-ui/app/services/checks/checks-model.ts
@@ -21,7 +21,6 @@
Category,
CheckResult as CheckResultApi,
CheckRun as CheckRunApi,
- ChecksApiConfig,
Link,
LinkIcon,
RunStatus,
@@ -32,6 +31,17 @@
import {AttemptDetail, createAttemptMap} from './checks-util';
import {assertIsDefined} from '../../utils/common-util';
+/**
+ * The checks model maintains the state of checks for two patchsets: the latest
+ * and (if different) also for the one selected in the checks tab. So we need
+ * the distinction in a lot of places for checks about whether the code affects
+ * the checks data of the LATEST or the SELECTED patchset.
+ */
+export enum ChecksPatchset {
+ LATEST = 'LATEST',
+ SELECTED = 'SELECTED',
+}
+
export interface CheckResult extends CheckResultApi {
/**
* Internally we want to uniquely identify a run with an id, for example when
@@ -75,21 +85,33 @@
errorMessage?: string;
/** Presence of loginCallback implicitly means that the provider is in NOT_LOGGED_IN state. */
loginCallback?: () => void;
- config?: ChecksApiConfig;
runs: CheckRun[];
actions: Action[];
links: Link[];
}
interface ChecksState {
- patchsetNumber?: PatchSetNumber;
- providerNameToState: {
+ /**
+ * This is the patchset number selected by the user. The *latest* patchset
+ * can be picked up from the change model.
+ */
+ patchsetNumberSelected?: PatchSetNumber;
+ /** Checks data for the latest patchset. */
+ pluginStateLatest: {
+ [name: string]: ChecksProviderState;
+ };
+ /**
+ * Checks data for the selected patchset. Note that `checksSelected$` below
+ * falls back to the data for the latest patchset, if no patchset is selected.
+ */
+ pluginStateSelected: {
[name: string]: ChecksProviderState;
};
}
const initialState: ChecksState = {
- providerNameToState: {},
+ pluginStateLatest: {},
+ pluginStateSelected: {},
};
const privateState$ = new BehaviorSubject(initialState);
@@ -97,29 +119,45 @@
// Re-exporting as Observable so that you can only subscribe, but not emit.
export const checksState$: Observable<ChecksState> = privateState$;
-export const checksPatchsetNumber$ = checksState$.pipe(
- map(state => state.patchsetNumber),
+export const checksSelectedPatchsetNumber$ = checksState$.pipe(
+ map(state => state.patchsetNumberSelected),
distinctUntilChanged()
);
-export const checksProviderState$ = checksState$.pipe(
- map(state => state.providerNameToState),
+export const checksLatest$ = checksState$.pipe(
+ map(state => state.pluginStateLatest),
distinctUntilChanged()
);
-export const aPluginHasRegistered$ = checksProviderState$.pipe(
+export const checksSelected$ = checksState$.pipe(
+ map(state =>
+ state.patchsetNumberSelected
+ ? state.pluginStateSelected
+ : state.pluginStateLatest
+ ),
+ distinctUntilChanged()
+);
+
+export const aPluginHasRegistered$ = checksLatest$.pipe(
map(state => Object.keys(state).length > 0),
distinctUntilChanged()
);
-export const someProvidersAreLoading$ = checksProviderState$.pipe(
+export const someProvidersAreLoadingLatest$ = checksLatest$.pipe(
map(state =>
Object.values(state).some(providerState => providerState.loading)
),
distinctUntilChanged()
);
-export const errorMessage$ = checksProviderState$.pipe(
+export const someProvidersAreLoadingSelected$ = checksSelected$.pipe(
+ map(state =>
+ Object.values(state).some(providerState => providerState.loading)
+ ),
+ distinctUntilChanged()
+);
+
+export const errorMessageLatest$ = checksLatest$.pipe(
map(
state =>
Object.values(state).find(
@@ -129,7 +167,7 @@
distinctUntilChanged()
);
-export const loginCallback$ = checksProviderState$.pipe(
+export const loginCallbackLatest$ = checksLatest$.pipe(
map(
state =>
Object.values(state).find(
@@ -139,7 +177,7 @@
distinctUntilChanged()
);
-export const allActions$ = checksProviderState$.pipe(
+export const topLevelActionsSelected$ = checksSelected$.pipe(
map(state =>
Object.values(state).reduce(
(allActions: Action[], providerState: ChecksProviderState) => [
@@ -151,7 +189,7 @@
)
);
-export const allLinks$ = checksProviderState$.pipe(
+export const topLevelLinksSelected$ = checksSelected$.pipe(
map(state =>
Object.values(state).reduce(
(allActions: Link[], providerState: ChecksProviderState) => [
@@ -163,7 +201,7 @@
)
);
-export const allRuns$ = checksProviderState$.pipe(
+export const allRunsLatestPatchset$ = checksLatest$.pipe(
map(state =>
Object.values(state).reduce(
(allRuns: CheckRun[], providerState: ChecksProviderState) => [
@@ -175,11 +213,23 @@
)
);
-export const allRunsLatest$ = allRuns$.pipe(
+export const allRunsSelectedPatchset$ = checksSelected$.pipe(
+ map(state =>
+ Object.values(state).reduce(
+ (allRuns: CheckRun[], providerState: ChecksProviderState) => [
+ ...allRuns,
+ ...providerState.runs,
+ ],
+ []
+ )
+ )
+);
+
+export const allRunsLatestPatchsetLatestAttempt$ = allRunsLatestPatchset$.pipe(
map(runs => runs.filter(run => run.isLatestAttempt))
);
-export const checkToPluginMap$ = checksProviderState$.pipe(
+export const checkToPluginMap$ = checksLatest$.pipe(
map(state => {
const map = new Map<string, string>();
for (const [pluginName, providerState] of Object.entries(state)) {
@@ -191,7 +241,7 @@
})
);
-export const allResults$ = checksProviderState$.pipe(
+export const allResultsSelected$ = checksSelected$.pipe(
map(state =>
Object.values(state)
.reduce(
@@ -211,16 +261,12 @@
// Must only be used by the checks service or whatever is in control of this
// model.
-export function updateStateSetProvider(
- pluginName: string,
- config?: ChecksApiConfig
-) {
+export function updateStateSetProvider(pluginName: string) {
const nextState = {...privateState$.getValue()};
- nextState.providerNameToState = {...nextState.providerNameToState};
- nextState.providerNameToState[pluginName] = {
+ nextState.pluginStateLatest = {...nextState.pluginStateLatest};
+ nextState.pluginStateLatest[pluginName] = {
pluginName,
loading: false,
- config,
runs: [],
actions: [],
links: [],
@@ -253,6 +299,7 @@
internalResultId: 'f0r1',
category: Category.ERROR,
summary: 'Running the mighty test has failed by crashing.',
+ message: `Btw, 1 is also not equal to 3. Did you know?`,
actions: [
{
name: 'Ignore',
@@ -276,8 +323,16 @@
],
tags: [{name: 'INTERRUPTED', color: TagColor.BROWN}, {name: 'WINDOWS'}],
links: [
- {primary: true, url: 'https://google.com', icon: LinkIcon.EXTERNAL},
+ {primary: false, url: 'https://google.com', icon: LinkIcon.EXTERNAL},
{primary: true, url: 'https://google.com', icon: LinkIcon.DOWNLOAD},
+ {
+ primary: true,
+ url: 'https://google.com',
+ icon: LinkIcon.DOWNLOAD_MOBILE,
+ },
+ {primary: true, url: 'https://google.com', icon: LinkIcon.IMAGE},
+ {primary: true, url: 'https://google.com', icon: LinkIcon.IMAGE},
+ {primary: false, url: 'https://google.com', icon: LinkIcon.IMAGE},
{primary: true, url: 'https://google.com', icon: LinkIcon.REPORT_BUG},
{primary: true, url: 'https://google.com', icon: LinkIcon.HELP_PAGE},
{primary: true, url: 'https://google.com', icon: LinkIcon.HISTORY},
@@ -290,6 +345,7 @@
export const fakeRun1: CheckRun = {
internalRunId: 'f1',
checkName: 'FAKE Super Check',
+ patchset: 1,
labelName: 'Verified',
isSingleAttempt: true,
isLatestAttempt: true,
@@ -302,6 +358,26 @@
message: `There is a lot to be said. A lot. I say, a lot.\n
So please keep reading.`,
tags: [{name: 'INTERRUPTED', color: TagColor.PURPLE}, {name: 'WINDOWS'}],
+ codePointers: [
+ {
+ path: '/COMMIT_MSG',
+ range: {
+ start_line: 10,
+ start_character: 0,
+ end_line: 10,
+ end_character: 0,
+ },
+ },
+ {
+ path: 'polygerrit-ui/app/api/checks.ts',
+ range: {
+ start_line: 5,
+ start_character: 0,
+ end_line: 7,
+ end_character: 0,
+ },
+ },
+ ],
links: [
{primary: true, url: 'https://google.com', icon: LinkIcon.EXTERNAL},
{primary: true, url: 'https://google.com', icon: LinkIcon.DOWNLOAD},
@@ -311,6 +387,18 @@
icon: LinkIcon.DOWNLOAD_MOBILE,
},
{primary: true, url: 'https://google.com', icon: LinkIcon.IMAGE},
+ {
+ primary: false,
+ url: 'https://google.com',
+ tooltip: 'look at this',
+ icon: LinkIcon.IMAGE,
+ },
+ {
+ primary: false,
+ url: 'https://google.com',
+ tooltip: 'not at this',
+ icon: LinkIcon.IMAGE,
+ },
],
},
],
@@ -472,32 +560,82 @@
{
url: 'https://www.google.com',
primary: true,
- tooltip: 'Tooltip for Bug Report Fake Link',
+ tooltip: 'Fake Bug Report 1',
icon: LinkIcon.REPORT_BUG,
},
{
url: 'https://www.google.com',
- primary: false,
- tooltip: 'Tooltip for External Fake Link',
+ primary: true,
+ tooltip: 'Fake Bug Report 2',
+ icon: LinkIcon.REPORT_BUG,
+ },
+ {
+ url: 'https://www.google.com',
+ primary: true,
+ tooltip: 'Fake Link 1',
icon: LinkIcon.EXTERNAL,
},
+ {
+ url: 'https://www.google.com',
+ primary: false,
+ tooltip: 'Fake Link 2',
+ icon: LinkIcon.EXTERNAL,
+ },
+ {
+ url: 'https://www.google.com',
+ primary: true,
+ tooltip: 'Fake Code Link',
+ icon: LinkIcon.CODE,
+ },
+ {
+ url: 'https://www.google.com',
+ primary: true,
+ tooltip: 'Fake Image Link',
+ icon: LinkIcon.IMAGE,
+ },
+ {
+ url: 'https://www.google.com',
+ primary: true,
+ tooltip: 'Fake Help Link',
+ icon: LinkIcon.HELP_PAGE,
+ },
];
-export function updateStateSetLoading(pluginName: string) {
+export function getPluginState(
+ state: ChecksState,
+ patchset: ChecksPatchset = ChecksPatchset.LATEST
+) {
+ if (patchset === ChecksPatchset.LATEST) {
+ state.pluginStateLatest = {...state.pluginStateLatest};
+ return state.pluginStateLatest;
+ } else {
+ state.pluginStateSelected = {...state.pluginStateSelected};
+ return state.pluginStateSelected;
+ }
+}
+
+export function updateStateSetLoading(
+ pluginName: string,
+ patchset: ChecksPatchset
+) {
const nextState = {...privateState$.getValue()};
- nextState.providerNameToState = {...nextState.providerNameToState};
- nextState.providerNameToState[pluginName] = {
- ...nextState.providerNameToState[pluginName],
+ const pluginState = getPluginState(nextState, patchset);
+ pluginState[pluginName] = {
+ ...pluginState[pluginName],
loading: true,
};
privateState$.next(nextState);
}
-export function updateStateSetError(pluginName: string, errorMessage: string) {
+export function updateStateSetError(
+ pluginName: string,
+ errorMessage: string,
+ patchset: ChecksPatchset
+) {
const nextState = {...privateState$.getValue()};
- nextState.providerNameToState = {...nextState.providerNameToState};
- nextState.providerNameToState[pluginName] = {
- ...nextState.providerNameToState[pluginName],
+ const pluginState = getPluginState(nextState, patchset);
+ pluginState[pluginName] = {
+ ...pluginState[pluginName],
loading: false,
errorMessage,
loginCallback: undefined,
@@ -509,12 +647,13 @@
export function updateStateSetNotLoggedIn(
pluginName: string,
- loginCallback: () => void
+ loginCallback: () => void,
+ patchset: ChecksPatchset
) {
const nextState = {...privateState$.getValue()};
- nextState.providerNameToState = {...nextState.providerNameToState};
- nextState.providerNameToState[pluginName] = {
- ...nextState.providerNameToState[pluginName],
+ const pluginState = getPluginState(nextState, patchset);
+ pluginState[pluginName] = {
+ ...pluginState[pluginName],
loading: false,
errorMessage: undefined,
loginCallback,
@@ -528,7 +667,8 @@
pluginName: string,
runs: CheckRunApi[],
actions: Action[] = [],
- links: Link[] = []
+ links: Link[] = [],
+ patchset: ChecksPatchset
) {
const attemptMap = createAttemptMap(runs);
for (const attemptInfo of attemptMap.values()) {
@@ -537,9 +677,9 @@
attemptInfo.attempts.sort((a, b) => (b.attempt ?? -1) - (a.attempt ?? -1));
}
const nextState = {...privateState$.getValue()};
- nextState.providerNameToState = {...nextState.providerNameToState};
- nextState.providerNameToState[pluginName] = {
- ...nextState.providerNameToState[pluginName],
+ const pluginState = getPluginState(nextState, patchset);
+ pluginState[pluginName] = {
+ ...pluginState[pluginName],
loading: false,
errorMessage: undefined,
loginCallback: undefined,
@@ -569,6 +709,6 @@
export function updateStateSetPatchset(patchsetNumber?: PatchSetNumber) {
const nextState = {...privateState$.getValue()};
- nextState.patchsetNumber = patchsetNumber;
+ nextState.patchsetNumberSelected = patchsetNumber;
privateState$.next(nextState);
}
diff --git a/polygerrit-ui/app/services/checks/checks-service.ts b/polygerrit-ui/app/services/checks/checks-service.ts
index 48d61e2..e1d435b 100644
--- a/polygerrit-ui/app/services/checks/checks-service.ts
+++ b/polygerrit-ui/app/services/checks/checks-service.ts
@@ -32,7 +32,8 @@
} from '../../api/checks';
import {change$, changeNum$, latestPatchNum$} from '../change/change-model';
import {
- checksPatchsetNumber$,
+ ChecksPatchset,
+ checksSelectedPatchsetNumber$,
checkToPluginMap$,
updateStateSetError,
updateStateSetLoading,
@@ -55,6 +56,7 @@
import {getShaByPatchNum} from '../../utils/patch-set-util';
import {assertIsDefined} from '../../utils/common-util';
import {ReportingService} from '../gr-reporting/gr-reporting';
+import {routerPatchNum$} from '../router/router-model';
export class ChecksService {
private readonly providers: {[name: string]: ChecksProvider} = {};
@@ -63,15 +65,26 @@
private checkToPluginMap = new Map<string, string>();
+ private latestPatchNum?: PatchSetNumber;
+
private readonly documentVisibilityChange$ = new BehaviorSubject(undefined);
constructor(readonly reporting: ReportingService) {
checkToPluginMap$.subscribe(map => {
this.checkToPluginMap = map;
});
- latestPatchNum$.subscribe(num => {
- updateStateSetPatchset(num);
- });
+ combineLatest([routerPatchNum$, latestPatchNum$]).subscribe(
+ ([routerPs, latestPs]) => {
+ this.latestPatchNum = latestPs;
+ if (latestPs === undefined) {
+ this.setPatchset(undefined);
+ } else if (typeof routerPs === 'number') {
+ this.setPatchset(routerPs);
+ } else {
+ this.setPatchset(latestPs);
+ }
+ }
+ );
document.addEventListener('visibilitychange', () => {
this.documentVisibilityChange$.next(undefined);
});
@@ -80,8 +93,8 @@
});
}
- setPatchset(num: PatchSetNumber) {
- updateStateSetPatchset(num);
+ setPatchset(num?: PatchSetNumber) {
+ updateStateSetPatchset(num === this.latestPatchNum ? undefined : num);
}
reload(pluginName: string) {
@@ -105,7 +118,16 @@
) {
this.providers[pluginName] = provider;
this.reloadSubjects[pluginName] = new BehaviorSubject<void>(undefined);
- updateStateSetProvider(pluginName, config);
+ updateStateSetProvider(pluginName);
+ this.initFetchingOfData(pluginName, config, ChecksPatchset.LATEST);
+ this.initFetchingOfData(pluginName, config, ChecksPatchset.SELECTED);
+ }
+
+ initFetchingOfData(
+ pluginName: string,
+ config: ChecksApiConfig,
+ patchset: ChecksPatchset
+ ) {
const pollIntervalMs = (config?.fetchPollingIntervalSeconds ?? 60) * 1000;
// Various events should trigger fetching checks from the provider:
// 1. Change number and patchset number changes.
@@ -114,7 +136,9 @@
// 4. A hidden Gerrit tab becoming visible.
combineLatest([
changeNum$,
- checksPatchsetNumber$,
+ patchset === ChecksPatchset.LATEST
+ ? latestPatchNum$
+ : checksSelectedPatchsetNumber$,
this.reloadSubjects[pluginName].pipe(throttleTime(1000)),
timer(0, pollIntervalMs),
this.documentVisibilityChange$,
@@ -125,27 +149,13 @@
withLatestFrom(change$),
switchMap(
([[changeNum, patchNum], change]): Observable<FetchResponse> => {
- if (
- !change ||
- !changeNum ||
- !patchNum ||
- typeof patchNum !== 'number'
- ) {
- return of({
- responseCode: ResponseCode.OK,
- runs: [],
- });
- }
+ if (!change || !changeNum || !patchNum) return of(this.empty());
+ if (typeof patchNum !== 'number') return of(this.empty());
assertIsDefined(change.revisions, 'change.revisions');
const patchsetSha = getShaByPatchNum(change.revisions, patchNum);
// Sometimes patchNum is updated earlier than change, so change
// revisions don't have patchNum yet
- if (!patchsetSha) {
- return of({
- responseCode: ResponseCode.OK,
- runs: [],
- });
- }
+ if (!patchsetSha) return of(this.empty());
const data: ChangeData = {
changeNumber: changeNum,
patchsetNumber: patchNum,
@@ -154,7 +164,7 @@
commmitMessage: getCurrentRevision(change)?.commit?.message,
changeInfo: change,
};
- return this.fetchResults(pluginName, data);
+ return this.fetchResults(pluginName, data, patchset);
}
),
catchError(e => {
@@ -169,24 +179,36 @@
switch (response.responseCode) {
case ResponseCode.ERROR:
assertIsDefined(response.errorMessage, 'errorMessage');
- updateStateSetError(pluginName, response.errorMessage);
+ updateStateSetError(pluginName, response.errorMessage, patchset);
break;
case ResponseCode.NOT_LOGGED_IN:
assertIsDefined(response.loginCallback, 'loginCallback');
- updateStateSetNotLoggedIn(pluginName, response.loginCallback);
+ updateStateSetNotLoggedIn(
+ pluginName,
+ response.loginCallback,
+ patchset
+ );
break;
case ResponseCode.OK:
updateStateSetResults(
pluginName,
response.runs ?? [],
response.actions ?? [],
- response.links ?? []
+ response.links ?? [],
+ patchset
);
break;
}
});
}
+ private empty(): FetchResponse {
+ return {
+ responseCode: ResponseCode.OK,
+ runs: [],
+ };
+ }
+
private createErrorResponse(
pluginName: string,
message: string
@@ -199,9 +221,10 @@
private fetchResults(
pluginName: string,
- data: ChangeData
+ data: ChangeData,
+ patchset: ChecksPatchset
): Observable<FetchResponse> {
- updateStateSetLoading(pluginName);
+ updateStateSetLoading(pluginName, patchset);
const timer = this.reporting.getTimer('ChecksPluginFetch');
const fetchPromise = this.providers[pluginName]
.fetch(data)
diff --git a/polygerrit-ui/app/services/checks/checks-util.ts b/polygerrit-ui/app/services/checks/checks-util.ts
index f08732f..9381f30 100644
--- a/polygerrit-ui/app/services/checks/checks-util.ts
+++ b/polygerrit-ui/app/services/checks/checks-util.ts
@@ -43,6 +43,8 @@
return 'help-outline';
case LinkIcon.REPORT_BUG:
return 'bug';
+ case LinkIcon.CODE:
+ return 'code';
default:
// We don't throw an assertion error here, because plugins don't have to
// be written in TypeScript, so we may encounter arbitrary strings for
@@ -312,14 +314,19 @@
};
}
-export function primaryLink(result?: CheckResultApi): Link | undefined {
- const links = result?.links ?? [];
- return links.find(link => link.primary);
+function allPrimaryLinks(result?: CheckResultApi): Link[] {
+ return (result?.links ?? []).filter(link => link.primary);
}
-export function otherLinks(result?: CheckResultApi): Link[] {
- const primary = primaryLink(result);
- const links = result?.links ?? [];
- // Just filter the one primary link, not all primary links.
- return links.filter(link => link !== primary);
+export function firstPrimaryLink(result?: CheckResultApi): Link | undefined {
+ return allPrimaryLinks(result).find(link => link.icon === LinkIcon.EXTERNAL);
+}
+
+export function otherPrimaryLinks(result?: CheckResultApi): Link[] {
+ const first = firstPrimaryLink(result);
+ return allPrimaryLinks(result).filter(link => link !== first);
+}
+
+export function secondaryLinks(result?: CheckResultApi): Link[] {
+ return (result?.links ?? []).filter(link => !link.primary);
}
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 3a890d9..cd3fab3 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
@@ -534,6 +534,7 @@
invalidateGroupsCache(): void;
invalidateReposCache(): void;
invalidateAccountsCache(): void;
+ invalidateAccountsDetailCache(): 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 3971fa5..a3df94c 100644
--- a/polygerrit-ui/app/test/mocks/gr-rest-api_mock.ts
+++ b/polygerrit-ui/app/test/mocks/gr-rest-api_mock.ts
@@ -411,6 +411,7 @@
invalidateAccountsCache(): void {},
invalidateGroupsCache(): void {},
invalidateReposCache(): void {},
+ invalidateAccountsDetailCache(): void {},
probePath(): Promise<boolean> {
return Promise.resolve(true);
},
diff --git a/resources/com/google/gerrit/server/tools/root/hooks/commit-msg b/resources/com/google/gerrit/server/tools/root/hooks/commit-msg
index 3a40d22..e1d6f22 100755
--- a/resources/com/google/gerrit/server/tools/root/hooks/commit-msg
+++ b/resources/com/google/gerrit/server/tools/root/hooks/commit-msg
@@ -16,6 +16,8 @@
# See the License for the specific language governing permissions and
# limitations under the License.
+set -u
+
# avoid [[ which is not POSIX sh.
if test "$#" != 1 ; then
echo "$0 requires an argument."
@@ -28,12 +30,17 @@
fi
# Do not create a change id if requested
-if test "false" = "`git config --bool --get gerrit.createChangeId`" ; then
+if test "false" = "$(git config --bool --get gerrit.createChangeId)" ; then
exit 0
fi
-# $RANDOM will be undefined if not using bash, so don't use set -u
-random=$( (whoami ; hostname ; date; cat $1 ; echo $RANDOM) | git hash-object --stdin)
+if git rev-parse --verify HEAD >/dev/null 2>&1; then
+ refhash="$(git rev-parse HEAD)"
+else
+ refhash="$(git hash-object -t tree /dev/null)"
+fi
+
+random=$({ git var GIT_COMMITTER_IDENT ; echo "$refhash" ; cat "$1"; } | git hash-object --stdin)
dest="$1.tmp.${random}"
trap 'rm -f "${dest}"' EXIT