Merge "Add Typescript test migration instruction to README.md"
diff --git a/Documentation/rest-api-changes.txt b/Documentation/rest-api-changes.txt
index 6b73482..c74b2c3 100644
--- a/Documentation/rest-api-changes.txt
+++ b/Documentation/rest-api-changes.txt
@@ -5935,6 +5935,8 @@
If a user is added while already in the attention set, the
request is silently ignored.
+The user must be a reviewer, cc, uploader, or owner on the change.
+
.Request
----
POST /changes/myProject~master~I8473b95934b5732ac55d26311a706c9c2bde9940/attention HTTP/1.0
@@ -7662,7 +7664,8 @@
`ready` and `work_in_progress` to be true.
|`add_to_attention_set` |optional|
list of link:#attention-set-input[AttentionSetInput] entities to add
-to the link:#attention-set[attention set].
+to the link:#attention-set[attention set]. Users that are not reviewers,
+ccs, owner, or uploader are silently ignored.
|`remove_from_attention_set` |optional|
list of link:#attention-set-input[AttentionSetInput] entities to remove
from the link:#attention-set[attention set].
diff --git a/Documentation/user-named-destinations.txt b/Documentation/user-named-destinations.txt
index 1b6f143..a1ab258 100644
--- a/Documentation/user-named-destinations.txt
+++ b/Documentation/user-named-destinations.txt
@@ -13,6 +13,7 @@
row in a destination file represents a single destination in the
named set. The left column represents the ref of the destination,
and the right column represents the project of the destination.
+The named destinations can be publicly accessible by other users.
Example destination file named `destinations/myreviews`:
diff --git a/Documentation/user-named-queries.txt b/Documentation/user-named-queries.txt
index e79b3da..c01f790 100644
--- a/Documentation/user-named-queries.txt
+++ b/Documentation/user-named-queries.txt
@@ -7,7 +7,8 @@
link:intro-user.html#user-refs[user's ref] in the `All-Users` project. The
user's queries file is a 2 column tab delimited file. The left
column represents the name of the query, and the right column
-represents the query expression represented by the name.
+represents the query expression represented by the name. The named queries
+can be publicly accessible by other users.
Example queries file:
diff --git a/Documentation/user-search.txt b/Documentation/user-search.txt
index ffe889f..5f18e9b 100644
--- a/Documentation/user-search.txt
+++ b/Documentation/user-search.txt
@@ -106,9 +106,11 @@
that was scraped out of the commit message.
[[destination]]
-destination:'NAME'::
+destination:'[name=]NAME[,user=USER]'::
+
-Changes which match the current user's destination named 'NAME'.
+Changes which match the specified USER's destination named 'NAME'. If 'USER'
+is unspecified, the current user is used. The named destinations can be
+publicly accessible by other users.
(see link:user-named-destinations.html[Named Destinations]).
[[owner]]
@@ -123,9 +125,11 @@
Changes originally submitted by a user in 'GROUP'.
[[query]]
-query:'NAME'::
+query:'[name=]NAME[,user=USER]'::
+
-Changes which match the current user's query named 'NAME'
+Changes which match the specified USER's query named 'NAME'. If 'USER'
+is unspecified, the current user is used. The named queries can be
+publicly accessible by other users.
(see link:user-named-queries.html[Named Queries]).
[[reviewer]]
@@ -304,6 +308,11 @@
`file:"^name[1-3].xml"`.
+
Slash ('/') is used path separator.
++
+More examples:
+* `-file:^path/.*` - changes that do not modify files from `path/`,
+* `file:{^~(path/.*)}` - changes that modify files not from `path/` (but may
+contain files from `path/`).
[[file]]
file:'NAME', f:'NAME'::
@@ -524,7 +533,7 @@
For example, added:>50 will be true for any change which adds at least 50
lines.
+
-Valid relations are >=, >, <=, <, or no relation, which will match if the
+Valid relations are >=, >, \<=, <, or no relation, which will match if the
number of lines is exactly equal.
[[commentby]]
@@ -576,7 +585,7 @@
For example, unresolved:>0 will be true for any change which has at least one unresolved
comment while unresolved:0 will be true for any change which has all comments resolved.
+
-Valid relations are >=, >, <=, <, or no relation, which will match if the number of unresolved
+Valid relations are >=, >, \<=, <, or no relation, which will match if the number of unresolved
comments is exactly equal.
== Argument Quoting
@@ -693,7 +702,7 @@
Matches changes with a +1 code review where the reviewer is in the
ldap/linux.workflow group.
-`label:Code-Review<=-1`::
+`label:Code-Review\<=-1`::
+
Matches changes with either a -1, -2, or any lower score.
diff --git a/java/com/google/gerrit/server/change/ChangeJson.java b/java/com/google/gerrit/server/change/ChangeJson.java
index 8323cfd..0cfc6cb 100644
--- a/java/com/google/gerrit/server/change/ChangeJson.java
+++ b/java/com/google/gerrit/server/change/ChangeJson.java
@@ -244,7 +244,7 @@
Provider<ConsistencyChecker> checkerProvider,
ActionJson actionJson,
ChangeNotes.Factory notesFactory,
- LabelsJson.Factory labelsJsonFactory,
+ LabelsJson labelsJson,
RemoveReviewerControl removeReviewerControl,
TrackingFooters trackingFooters,
Metrics metrics,
@@ -261,7 +261,7 @@
this.checkerProvider = checkerProvider;
this.actionJson = actionJson;
this.notesFactory = notesFactory;
- this.labelsJson = labelsJsonFactory.create(options);
+ this.labelsJson = labelsJson;
this.removeReviewerControl = removeReviewerControl;
this.trackingFooters = trackingFooters;
this.metrics = metrics;
diff --git a/java/com/google/gerrit/server/change/LabelsJson.java b/java/com/google/gerrit/server/change/LabelsJson.java
index b1d154c..76992e8 100644
--- a/java/com/google/gerrit/server/change/LabelsJson.java
+++ b/java/com/google/gerrit/server/change/LabelsJson.java
@@ -42,17 +42,15 @@
import com.google.gerrit.extensions.common.ApprovalInfo;
import com.google.gerrit.extensions.common.LabelInfo;
import com.google.gerrit.extensions.common.VotingRangeInfo;
-import com.google.gerrit.server.ApprovalsUtil;
import com.google.gerrit.server.ChangeUtil;
import com.google.gerrit.server.account.AccountLoader;
-import com.google.gerrit.server.notedb.ChangeNotes;
import com.google.gerrit.server.notedb.ReviewerStateInternal;
import com.google.gerrit.server.permissions.LabelPermission;
import com.google.gerrit.server.permissions.PermissionBackend;
import com.google.gerrit.server.permissions.PermissionBackendException;
import com.google.gerrit.server.query.change.ChangeData;
import com.google.inject.Inject;
-import com.google.inject.assistedinject.Assisted;
+import com.google.inject.Singleton;
import java.sql.Timestamp;
import java.util.ArrayList;
import java.util.Collection;
@@ -68,28 +66,15 @@
/**
* Produces label-related entities, like {@link LabelInfo}s, which is serialized to JSON afterwards.
*/
+@Singleton
public class LabelsJson {
private static final FluentLogger logger = FluentLogger.forEnclosingClass();
- public interface Factory {
- LabelsJson create(Iterable<ListChangesOption> options);
- }
-
- private final ApprovalsUtil approvalsUtil;
- private final ChangeNotes.Factory notesFactory;
private final PermissionBackend permissionBackend;
- private final boolean lazyLoad;
@Inject
- LabelsJson(
- ApprovalsUtil approvalsUtil,
- ChangeNotes.Factory notesFactory,
- PermissionBackend permissionBackend,
- @Assisted Iterable<ListChangesOption> options) {
- this.approvalsUtil = approvalsUtil;
- this.notesFactory = notesFactory;
+ LabelsJson(PermissionBackend permissionBackend) {
this.permissionBackend = permissionBackend;
- this.lazyLoad = containsAnyOf(Sets.immutableEnumSet(options), ChangeJson.REQUIRE_LAZY_LOAD);
}
/**
@@ -253,14 +238,10 @@
private Map<String, Short> currentLabels(Account.Id accountId, ChangeData cd) {
Map<String, Short> result = new HashMap<>();
- for (PatchSetApproval psa :
- approvalsUtil.byPatchSetUser(
- lazyLoad ? cd.notes() : notesFactory.createFromIndexedChange(cd.change()),
- cd.change().currentPatchSetId(),
- accountId,
- null,
- null)) {
- result.put(psa.label(), psa.value());
+ for (PatchSetApproval psa : cd.currentApprovals()) {
+ if (psa.accountId().equals(accountId)) {
+ result.put(psa.label(), psa.value());
+ }
}
return result;
}
diff --git a/java/com/google/gerrit/server/config/GerritGlobalModule.java b/java/com/google/gerrit/server/config/GerritGlobalModule.java
index 2edf00c..6a25afd 100644
--- a/java/com/google/gerrit/server/config/GerritGlobalModule.java
+++ b/java/com/google/gerrit/server/config/GerritGlobalModule.java
@@ -111,7 +111,6 @@
import com.google.gerrit.server.change.ChangeJson;
import com.google.gerrit.server.change.ChangeKindCacheImpl;
import com.google.gerrit.server.change.ChangePluginDefinedInfoFactory;
-import com.google.gerrit.server.change.LabelsJson;
import com.google.gerrit.server.change.MergeabilityCacheImpl;
import com.google.gerrit.server.change.ReviewerSuggestion;
import com.google.gerrit.server.change.RevisionJson;
@@ -273,7 +272,6 @@
factory(ChangeData.AssistedFactory.class);
factory(ChangeJson.AssistedFactory.class);
factory(ChangeIsVisibleToPredicate.Factory.class);
- factory(LabelsJson.Factory.class);
factory(MergeUtil.Factory.class);
factory(PatchScriptFactory.Factory.class);
factory(PatchScriptFactoryForAutoFix.Factory.class);
diff --git a/java/com/google/gerrit/server/notedb/ChangeNotes.java b/java/com/google/gerrit/server/notedb/ChangeNotes.java
index 890a1b1..41263a4 100644
--- a/java/com/google/gerrit/server/notedb/ChangeNotes.java
+++ b/java/com/google/gerrit/server/notedb/ChangeNotes.java
@@ -141,17 +141,6 @@
return new ChangeNotes(args, newChange(project, changeId), true, null).load();
}
- /**
- * Create change notes for a change that was loaded from index. This method should only be used
- * when database access is harmful and potentially stale data from the index is acceptable.
- *
- * @param change change loaded from secondary index
- * @return change notes
- */
- public ChangeNotes createFromIndexedChange(Change change) {
- return new ChangeNotes(args, change, true, null);
- }
-
public ChangeNotes createForBatchUpdate(Change change, boolean shouldExist) {
return new ChangeNotes(args, change, shouldExist, null).load();
}
diff --git a/java/com/google/gerrit/server/notedb/ChangeUpdate.java b/java/com/google/gerrit/server/notedb/ChangeUpdate.java
index c3b0e79..6d75bd2 100644
--- a/java/com/google/gerrit/server/notedb/ChangeUpdate.java
+++ b/java/com/google/gerrit/server/notedb/ChangeUpdate.java
@@ -65,6 +65,7 @@
import com.google.gerrit.entities.SubmissionId;
import com.google.gerrit.entities.SubmitRecord;
import com.google.gerrit.exceptions.StorageException;
+import com.google.gerrit.extensions.client.ReviewerState;
import com.google.gerrit.server.CurrentUser;
import com.google.gerrit.server.GerritPersonIdent;
import com.google.gerrit.server.account.ServiceUserClassifier;
@@ -87,6 +88,7 @@
import java.util.Optional;
import java.util.Set;
import java.util.stream.Collectors;
+import java.util.stream.Stream;
import org.eclipse.jgit.errors.ConfigInvalidException;
import org.eclipse.jgit.lib.CommitBuilder;
import org.eclipse.jgit.lib.ObjectId;
@@ -821,6 +823,22 @@
AttentionSetUtil.additionsOnly(getNotes().getAttentionSet()).stream()
.map(AttentionSetUpdate::account)
.collect(Collectors.toSet());
+
+ // Current reviewers/ccs are the reviewers/ccs before the update + the new reviewers/ccs - the
+ // deleted reviewers/ccs.
+ Set<Account.Id> currentReviewers =
+ Stream.concat(
+ getNotes().getReviewers().all().stream(),
+ reviewers.entrySet().stream()
+ .filter(r -> r.getValue().asReviewerState() != ReviewerState.REMOVED)
+ .map(r -> r.getKey()))
+ .collect(Collectors.toSet());
+ currentReviewers.removeAll(
+ reviewers.entrySet().stream()
+ .filter(r -> r.getValue().asReviewerState() == ReviewerState.REMOVED)
+ .map(r -> r.getKey())
+ .collect(ImmutableSet.toImmutableSet()));
+
for (AttentionSetUpdate attentionSetUpdate : plannedAttentionSetUpdates.values()) {
if (attentionSetUpdate.operation() == AttentionSetUpdate.Operation.ADD
&& currentUsersInAttentionSet.contains(attentionSetUpdate.account())) {
@@ -848,11 +866,27 @@
continue;
}
+ // Don't add accounts that are not active in the change to the attention set.
+ if (attentionSetUpdate.operation() == AttentionSetUpdate.Operation.ADD
+ && !isActiveOnChange(currentReviewers, attentionSetUpdate.account())) {
+ continue;
+ }
+
addFooter(msg, FOOTER_ATTENTION, noteUtil.attentionSetUpdateToJson(attentionSetUpdate));
}
}
/**
+ * Returns whether {@code accountId} is active on a change based on the {@code currentReviewers}.
+ * Activity is defined as being a part of the reviewers, an uploader, or an owner of a change.
+ */
+ private boolean isActiveOnChange(Set<Account.Id> currentReviewers, Account.Id accountId) {
+ return currentReviewers.contains(accountId)
+ || getChange().getOwner().equals(accountId)
+ || getNotes().getCurrentPatchSet().uploader().equals(accountId);
+ }
+
+ /**
* When set, default attention set rules are ignored (E.g, adding reviewers -> adds to attention
* set, etc).
*/
diff --git a/java/com/google/gerrit/server/query/change/ChangeQueryBuilder.java b/java/com/google/gerrit/server/query/change/ChangeQueryBuilder.java
index 6f4ccb7..ff90c3f 100644
--- a/java/com/google/gerrit/server/query/change/ChangeQueryBuilder.java
+++ b/java/com/google/gerrit/server/query/change/ChangeQueryBuilder.java
@@ -199,6 +199,7 @@
public static final String FIELD_CHERRY_PICK_OF_CHANGE = "cherrypickofchange";
public static final String FIELD_CHERRY_PICK_OF_PATCHSET = "cherrypickofpatchset";
+ public static final String ARG_ID_NAME = "name";
public static final String ARG_ID_USER = "user";
public static final String ARG_ID_GROUP = "group";
public static final String ARG_ID_OWNER = "owner";
@@ -1224,9 +1225,36 @@
}
@Operator
- public Predicate<ChangeData> query(String name) throws QueryParseException {
+ public Predicate<ChangeData> query(String value) throws QueryParseException {
+ // [name=]<name>[,user=<user>] || [user=<user>,][name=]<name>
+ PredicateArgs inputArgs = new PredicateArgs(value);
+ String name = null;
+ Account.Id account = null;
+
try (Repository git = args.repoManager.openRepository(args.allUsersName)) {
- VersionedAccountQueries q = VersionedAccountQueries.forUser(self());
+ // [name=]<name>
+ if (inputArgs.keyValue.containsKey(ARG_ID_NAME)) {
+ name = inputArgs.keyValue.get(ARG_ID_NAME);
+ } else if (inputArgs.positional.size() == 1) {
+ name = Iterables.getOnlyElement(inputArgs.positional);
+ } else if (inputArgs.positional.size() > 1) {
+ throw new QueryParseException("Error parsing named query: " + value);
+ }
+
+ // [,user=<user>]
+ if (inputArgs.keyValue.containsKey(ARG_ID_USER)) {
+ Set<Account.Id> accounts = parseAccount(inputArgs.keyValue.get(ARG_ID_USER));
+ if (accounts != null && accounts.size() > 1) {
+ throw error(
+ String.format(
+ "\"%s\" resolves to multiple accounts", inputArgs.keyValue.get(ARG_ID_USER)));
+ }
+ account = (accounts == null ? self() : Iterables.getOnlyElement(accounts));
+ } else {
+ account = self();
+ }
+
+ VersionedAccountQueries q = VersionedAccountQueries.forUser(account);
q.load(args.allUsersName, git);
String query = q.getQueryList().getQuery(name);
if (query != null) {
@@ -1236,7 +1264,7 @@
throw new QueryParseException(
"Unknown named query (no " + args.allUsersName + " repo): " + name, e);
} catch (IOException | ConfigInvalidException e) {
- throw new QueryParseException("Error parsing named query: " + name, e);
+ throw new QueryParseException("Error parsing named query: " + value, e);
}
throw new QueryParseException("Unknown named query: " + name);
}
@@ -1248,19 +1276,46 @@
}
@Operator
- public Predicate<ChangeData> destination(String name) throws QueryParseException {
+ public Predicate<ChangeData> destination(String value) throws QueryParseException {
+ // [name=]<name>[,user=<user>] || [user=<user>,][name=]<name>
+ PredicateArgs inputArgs = new PredicateArgs(value);
+ String name = null;
+ Account.Id account = null;
+
try (Repository git = args.repoManager.openRepository(args.allUsersName)) {
- VersionedAccountDestinations d = VersionedAccountDestinations.forUser(self());
+ // [name=]<name>
+ if (inputArgs.keyValue.containsKey(ARG_ID_NAME)) {
+ name = inputArgs.keyValue.get(ARG_ID_NAME);
+ } else if (inputArgs.positional.size() == 1) {
+ name = Iterables.getOnlyElement(inputArgs.positional);
+ } else if (inputArgs.positional.size() > 1) {
+ throw new QueryParseException("Error parsing named destination: " + value);
+ }
+
+ // [,user=<user>]
+ if (inputArgs.keyValue.containsKey(ARG_ID_USER)) {
+ Set<Account.Id> accounts = parseAccount(inputArgs.keyValue.get(ARG_ID_USER));
+ if (accounts != null && accounts.size() > 1) {
+ throw error(
+ String.format(
+ "\"%s\" resolves to multiple accounts", inputArgs.keyValue.get(ARG_ID_USER)));
+ }
+ account = (accounts == null ? self() : Iterables.getOnlyElement(accounts));
+ } else {
+ account = self();
+ }
+
+ VersionedAccountDestinations d = VersionedAccountDestinations.forUser(account);
d.load(args.allUsersName, git);
Set<BranchNameKey> destinations = d.getDestinationList().getDestinations(name);
if (destinations != null && !destinations.isEmpty()) {
- return new DestinationPredicate(destinations, name);
+ return new DestinationPredicate(destinations, value);
}
} catch (RepositoryNotFoundException e) {
throw new QueryParseException(
"Unknown named destination (no " + args.allUsersName + " repo): " + name, e);
} catch (IOException | ConfigInvalidException e) {
- throw new QueryParseException("Error parsing named destination: " + name, e);
+ throw new QueryParseException("Error parsing named destination: " + value, e);
}
throw new QueryParseException("Unknown named destination: " + name);
}
diff --git a/java/com/google/gerrit/server/restapi/change/AddToAttentionSet.java b/java/com/google/gerrit/server/restapi/change/AddToAttentionSet.java
index a5be14f..cb1256c 100644
--- a/java/com/google/gerrit/server/restapi/change/AddToAttentionSet.java
+++ b/java/com/google/gerrit/server/restapi/change/AddToAttentionSet.java
@@ -42,6 +42,7 @@
public class AddToAttentionSet
implements RestCollectionModifyView<
ChangeResource, AttentionSetEntryResource, AttentionSetInput> {
+
private final BatchUpdate.Factory updateFactory;
private final AccountResolver accountResolver;
private final AddToAttentionSetOp.Factory opFactory;
@@ -72,8 +73,9 @@
public Response<AccountInfo> apply(ChangeResource changeResource, AttentionSetInput input)
throws Exception {
AttentionSetUtil.validateInput(input);
+ Account.Id attentionUserId =
+ AttentionSetUtil.resolveAccount(accountResolver, changeResource.getNotes(), input.user);
- Account.Id attentionUserId = accountResolver.resolve(input.user).asUnique().account().id();
if (serviceUserClassifier.isServiceUser(attentionUserId)) {
throw new BadRequestException(
String.format(
diff --git a/java/com/google/gerrit/server/restapi/change/AttentionSet.java b/java/com/google/gerrit/server/restapi/change/AttentionSet.java
index 45d78dc..f72fe64ec 100644
--- a/java/com/google/gerrit/server/restapi/change/AttentionSet.java
+++ b/java/com/google/gerrit/server/restapi/change/AttentionSet.java
@@ -17,14 +17,15 @@
import com.google.gerrit.entities.Account;
import com.google.gerrit.extensions.registration.DynamicMap;
import com.google.gerrit.extensions.restapi.AuthException;
+import com.google.gerrit.extensions.restapi.BadRequestException;
import com.google.gerrit.extensions.restapi.ChildCollection;
import com.google.gerrit.extensions.restapi.IdString;
import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
import com.google.gerrit.extensions.restapi.RestView;
import com.google.gerrit.server.account.AccountResolver;
-import com.google.gerrit.server.account.AccountResolver.UnresolvableAccountException;
import com.google.gerrit.server.change.AttentionSetEntryResource;
import com.google.gerrit.server.change.ChangeResource;
+import com.google.gerrit.server.util.AttentionSetUtil;
import com.google.inject.Inject;
import com.google.inject.Singleton;
import java.io.IOException;
@@ -58,12 +59,10 @@
@Override
public AttentionSetEntryResource parse(ChangeResource changeResource, IdString idString)
- throws ResourceNotFoundException, AuthException, IOException, ConfigInvalidException {
- try {
- Account.Id accountId = accountResolver.resolve(idString.get()).asUnique().account().id();
- return new AttentionSetEntryResource(changeResource, accountId);
- } catch (UnresolvableAccountException e) {
- throw new ResourceNotFoundException(idString, e);
- }
+ throws ResourceNotFoundException, AuthException, IOException, ConfigInvalidException,
+ BadRequestException {
+ Account.Id accountId =
+ AttentionSetUtil.resolveAccount(accountResolver, changeResource.getNotes(), idString.get());
+ return new AttentionSetEntryResource(changeResource, accountId);
}
}
diff --git a/java/com/google/gerrit/server/restapi/change/RemoveFromAttentionSet.java b/java/com/google/gerrit/server/restapi/change/RemoveFromAttentionSet.java
index c4dd04e..7fe463e 100644
--- a/java/com/google/gerrit/server/restapi/change/RemoveFromAttentionSet.java
+++ b/java/com/google/gerrit/server/restapi/change/RemoveFromAttentionSet.java
@@ -30,6 +30,7 @@
import com.google.gerrit.server.permissions.PermissionBackendException;
import com.google.gerrit.server.update.BatchUpdate;
import com.google.gerrit.server.update.UpdateException;
+import com.google.gerrit.server.util.AttentionSetUtil;
import com.google.gerrit.server.util.time.TimeUtil;
import com.google.inject.Inject;
import java.io.IOException;
@@ -69,13 +70,9 @@
}
input.user = Strings.nullToEmpty(input.user).trim();
if (!input.user.isEmpty()) {
- Account.Id attentionUserId = null;
- try {
- attentionUserId = accountResolver.resolve(input.user).asUnique().account().id();
- } catch (AccountResolver.UnresolvableAccountException ex) {
- throw new BadRequestException(
- "The user specified in the input body couldn't be found.", ex);
- }
+ Account.Id attentionUserId =
+ AttentionSetUtil.resolveAccount(
+ accountResolver, attentionResource.getChangeResource().getNotes(), input.user);
if (attentionUserId.get() != attentionResource.getAccountId().get()) {
throw new BadRequestException(
"The field \"user\" must be empty, or must match the user specified in the URL.");
diff --git a/java/com/google/gerrit/server/restapi/change/ReplyAttentionSetUpdates.java b/java/com/google/gerrit/server/restapi/change/ReplyAttentionSetUpdates.java
index 65c0cda..a1bd678 100644
--- a/java/com/google/gerrit/server/restapi/change/ReplyAttentionSetUpdates.java
+++ b/java/com/google/gerrit/server/restapi/change/ReplyAttentionSetUpdates.java
@@ -312,10 +312,14 @@
throws BadRequestException, IOException, PermissionBackendException,
UnprocessableEntityException, ConfigInvalidException {
AttentionSetUtil.validateInput(add);
- Account.Id attentionUserId =
- getAccountIdAndValidateUser(changeNotes, add.user, accountsChangedInCommit);
-
- addToAttentionSet(bu, changeNotes, attentionUserId, add.reason, false);
+ try {
+ Account.Id attentionUserId =
+ getAccountIdAndValidateUser(changeNotes, add.user, accountsChangedInCommit);
+ addToAttentionSet(bu, changeNotes, attentionUserId, add.reason, false);
+ } catch (AccountResolver.UnresolvableAccountException ex) {
+ // This happens only when the account doesn't exist. Silently ignore it. If we threw an error
+ // message here, then it would be possible to probe whether an account exists.
+ }
}
private void removeFromAttentionSet(
@@ -326,10 +330,14 @@
throws BadRequestException, IOException, PermissionBackendException,
UnprocessableEntityException, ConfigInvalidException {
AttentionSetUtil.validateInput(remove);
- Account.Id attentionUserId =
- getAccountIdAndValidateUser(changeNotes, remove.user, accountsChangedInCommit);
-
- removeFromAttentionSet(bu, changeNotes, attentionUserId, remove.reason, false);
+ try {
+ Account.Id attentionUserId =
+ getAccountIdAndValidateUser(changeNotes, remove.user, accountsChangedInCommit);
+ removeFromAttentionSet(bu, changeNotes, attentionUserId, remove.reason, false);
+ } catch (AccountResolver.UnresolvableAccountException ex) {
+ // This happens only when the account doesn't exist. Silently ignore it. If we threw an error
+ // message here, then it would be possible to probe whether an account exists.
+ }
}
private Account.Id getAccountId(ChangeNotes changeNotes, String user)
@@ -356,15 +364,21 @@
ChangeNotes changeNotes, String user, Set<Account.Id> accountsChangedInCommit)
throws ConfigInvalidException, IOException, PermissionBackendException,
UnprocessableEntityException, BadRequestException {
- Account.Id attentionUserId = getAccountId(changeNotes, user);
- if (accountsChangedInCommit.contains(attentionUserId)) {
- throw new BadRequestException(
- String.format(
- "%s can not be added/removed twice, and can not be added and "
- + "removed at the same time",
- user));
+ try {
+ Account.Id attentionUserId = getAccountId(changeNotes, user);
+ if (accountsChangedInCommit.contains(attentionUserId)) {
+ throw new BadRequestException(
+ String.format(
+ "%s can not be added/removed twice, and can not be added and "
+ + "removed at the same time",
+ user));
+ }
+ accountsChangedInCommit.add(attentionUserId);
+ return attentionUserId;
+ } catch (AccountResolver.UnresolvableAccountException ex) {
+ // This can only happen if this user can't see the account or the account doesn't exist.
+ // Silently modify the account's attention set anyway, if the account exists.
+ return accountResolver.resolveIgnoreVisibility(user).asUnique().account().id();
}
- accountsChangedInCommit.add(attentionUserId);
- return attentionUserId;
}
}
diff --git a/java/com/google/gerrit/server/restapi/change/Submit.java b/java/com/google/gerrit/server/restapi/change/Submit.java
index e77bfe7..790b2db 100644
--- a/java/com/google/gerrit/server/restapi/change/Submit.java
+++ b/java/com/google/gerrit/server/restapi/change/Submit.java
@@ -30,8 +30,10 @@
import com.google.gerrit.entities.Change;
import com.google.gerrit.entities.PatchSet;
import com.google.gerrit.entities.Project;
+import com.google.gerrit.entities.SubmitTypeRecord;
import com.google.gerrit.exceptions.StorageException;
import com.google.gerrit.extensions.api.changes.SubmitInput;
+import com.google.gerrit.extensions.client.SubmitType;
import com.google.gerrit.extensions.common.ChangeInfo;
import com.google.gerrit.extensions.restapi.AuthException;
import com.google.gerrit.extensions.restapi.ResourceConflictException;
@@ -66,12 +68,14 @@
import com.google.inject.Provider;
import com.google.inject.Singleton;
import java.io.IOException;
+import java.util.Arrays;
import java.util.Collection;
import java.util.EnumSet;
import java.util.HashMap;
import java.util.HashSet;
import java.util.Map;
import java.util.Set;
+import java.util.stream.Collectors;
import org.eclipse.jgit.errors.ConfigInvalidException;
import org.eclipse.jgit.errors.RepositoryNotFoundException;
import org.eclipse.jgit.lib.Config;
@@ -373,8 +377,15 @@
public Collection<ChangeData> unmergeableChanges(ChangeSet cs) throws IOException {
Set<ChangeData> mergeabilityMap = new HashSet<>();
+ Set<ObjectId> outDatedPatchsets = new HashSet<>();
for (ChangeData change : cs.changes()) {
mergeabilityMap.add(change);
+ // Add all the patchsets commit ids except the current patchset.
+ outDatedPatchsets.addAll(
+ change.notes().getPatchSets().values().stream()
+ .map(p -> p.commitId())
+ .collect(Collectors.toSet()));
+ outDatedPatchsets.remove(change.currentPatchSet().commitId());
}
ListMultimap<BranchNameKey, ChangeData> cbb = cs.changesByBranch();
@@ -388,12 +399,17 @@
allParents.add(parent.getId());
}
}
-
for (ChangeData change : targetBranch) {
+
RevCommit commit = commits.get(change.getId());
boolean isMergeCommit = commit.getParentCount() > 1;
boolean isLastInChain = !allParents.contains(commit.getId());
-
+ if (Arrays.stream(commit.getParents()).anyMatch(c -> outDatedPatchsets.contains(c.getId()))
+ && !isCherryPickSubmit(change)) {
+ // Found a parent that depends on an outdated patchset and the submit strategy is not
+ // cherry-pick.
+ continue;
+ }
// Recheck mergeability rather than using value stored in the index,
// which may be stale.
// TODO(dborowitz): This is ugly; consider providing a way to not read
@@ -419,6 +435,11 @@
return mergeabilityMap;
}
+ private boolean isCherryPickSubmit(ChangeData changeData) {
+ SubmitTypeRecord submitTypeRecord = changeData.submitTypeRecord();
+ return submitTypeRecord.isOk() && submitTypeRecord.type == SubmitType.CHERRY_PICK;
+ }
+
private HashMap<Change.Id, RevCommit> findCommits(
Collection<ChangeData> changes, Project.NameKey project) throws IOException {
HashMap<Change.Id, RevCommit> commits = new HashMap<>();
diff --git a/java/com/google/gerrit/server/submit/MergeOp.java b/java/com/google/gerrit/server/submit/MergeOp.java
index 45e544d2..01c7b75 100644
--- a/java/com/google/gerrit/server/submit/MergeOp.java
+++ b/java/com/google/gerrit/server/submit/MergeOp.java
@@ -353,7 +353,7 @@
}
if (record.requirements != null) {
record.requirements.stream()
- .map(SubmitRequirement::fallbackText)
+ .map(MergeOp::describeSubmitRequirement)
.forEach(blockingConditions::add);
}
return Joiner.on("; ").join(blockingConditions);
@@ -389,6 +389,10 @@
return Joiner.on("; ").join(labelResults);
}
+ private static String describeSubmitRequirement(SubmitRequirement submitRequirement) {
+ return String.format("Submit requirement not fulfilled: %s", submitRequirement.fallbackText());
+ }
+
private void checkSubmitRulesAndState(ChangeSet cs, boolean allowMerged)
throws ResourceConflictException {
checkArgument(
diff --git a/java/com/google/gerrit/server/submit/MergeSuperSet.java b/java/com/google/gerrit/server/submit/MergeSuperSet.java
index 93c78a8..67f2907 100644
--- a/java/com/google/gerrit/server/submit/MergeSuperSet.java
+++ b/java/com/google/gerrit/server/submit/MergeSuperSet.java
@@ -18,19 +18,16 @@
import static java.util.Objects.requireNonNull;
import com.google.common.base.Strings;
-import com.google.common.flogger.FluentLogger;
import com.google.gerrit.entities.Change;
import com.google.gerrit.extensions.registration.DynamicItem;
import com.google.gerrit.extensions.restapi.AuthException;
import com.google.gerrit.server.CurrentUser;
import com.google.gerrit.server.config.GerritServerConfig;
import com.google.gerrit.server.logging.TraceContext;
-import com.google.gerrit.server.notedb.ChangeNotes;
import com.google.gerrit.server.permissions.ChangePermission;
import com.google.gerrit.server.permissions.PermissionBackend;
import com.google.gerrit.server.permissions.PermissionBackendException;
import com.google.gerrit.server.plugincontext.PluginContext;
-import com.google.gerrit.server.project.NoSuchChangeException;
import com.google.gerrit.server.project.ProjectCache;
import com.google.gerrit.server.project.ProjectState;
import com.google.gerrit.server.query.change.ChangeData;
@@ -55,8 +52,6 @@
* included.
*/
public class MergeSuperSet {
- private static final FluentLogger logger = FluentLogger.forEnclosingClass();
-
private final ChangeData.Factory changeDataFactory;
private final Provider<InternalChangeQuery> queryProvider;
private final Provider<MergeOpRepoManager> repoManagerProvider;
@@ -64,7 +59,6 @@
private final PermissionBackend permissionBackend;
private final Config cfg;
private final ProjectCache projectCache;
- private final ChangeNotes.Factory notesFactory;
private MergeOpRepoManager orm;
private boolean closeOrm;
@@ -77,8 +71,7 @@
Provider<MergeOpRepoManager> repoManagerProvider,
DynamicItem<MergeSuperSetComputation> mergeSuperSetComputation,
PermissionBackend permissionBackend,
- ProjectCache projectCache,
- ChangeNotes.Factory notesFactory) {
+ ProjectCache projectCache) {
this.cfg = cfg;
this.changeDataFactory = changeDataFactory;
this.queryProvider = queryProvider;
@@ -86,7 +79,6 @@
this.mergeSuperSetComputation = mergeSuperSetComputation;
this.permissionBackend = permissionBackend;
this.projectCache = projectCache;
- this.notesFactory = notesFactory;
}
public static boolean wholeTopicEnabled(Config config) {
@@ -212,24 +204,8 @@
if (!projectCache.get(cd.project()).map(ProjectState::statePermitsRead).orElse(false)) {
return false;
}
-
- ChangeNotes notes;
try {
- notes = cd.notes();
- } catch (NoSuchChangeException e) {
- // The change was found in the index but is missing in NoteDb.
- // This can happen in systems with multiple primary nodes when the replication of the index
- // documents is faster than the replication of the Git data.
- // Instead of failing, create the change notes from the index data so that the read permission
- // check can be performed successfully.
- logger.atWarning().log(
- "Got change %d of project %s from index, but couldn't find it in NoteDb",
- cd.getId().get(), cd.project().get());
- notes = notesFactory.createFromIndexedChange(cd.change());
- }
-
- try {
- permissionBackend.user(user).change(notes).check(ChangePermission.READ);
+ permissionBackend.user(user).change(cd).check(ChangePermission.READ);
return true;
} catch (AuthException e) {
return false;
diff --git a/java/com/google/gerrit/server/util/AttentionSetUtil.java b/java/com/google/gerrit/server/util/AttentionSetUtil.java
index 62cad3f..26c862d 100644
--- a/java/com/google/gerrit/server/util/AttentionSetUtil.java
+++ b/java/com/google/gerrit/server/util/AttentionSetUtil.java
@@ -16,11 +16,16 @@
import com.google.common.base.Strings;
import com.google.common.collect.ImmutableSet;
+import com.google.gerrit.entities.Account;
import com.google.gerrit.entities.AttentionSetUpdate;
import com.google.gerrit.entities.AttentionSetUpdate.Operation;
import com.google.gerrit.extensions.api.changes.AttentionSetInput;
import com.google.gerrit.extensions.restapi.BadRequestException;
+import com.google.gerrit.server.account.AccountResolver;
+import com.google.gerrit.server.notedb.ChangeNotes;
+import java.io.IOException;
import java.util.Collection;
+import org.eclipse.jgit.errors.ConfigInvalidException;
/** Common helpers for dealing with attention set data structures. */
public class AttentionSetUtil {
@@ -49,5 +54,45 @@
}
}
+ /**
+ * Returns the {@code Account.Id} of {@code user} if the user is active on the change, and exists.
+ * If the user doesn't exist or is not active on the change, the same exception is thrown to
+ * disallow probing for account existence based on exception type.
+ */
+ public static Account.Id resolveAccount(
+ AccountResolver accountResolver, ChangeNotes changeNotes, String user)
+ throws ConfigInvalidException, IOException, BadRequestException {
+ // We will throw this exception if the account doesn't exist, or if the account is not active.
+ // This is purposely the same exception so that users can't probe for account existence based on
+ // the thrown exception.
+ BadRequestException possibleExceptionForNotFoundOrInactiveAccount =
+ new BadRequestException(
+ String.format(
+ "%s doesn't exist or is not active on the change as an owner, uploader, "
+ + "reviewer, or cc so they can't be added to the attention set",
+ user));
+ Account.Id attentionUserId;
+ try {
+ attentionUserId = accountResolver.resolveIgnoreVisibility(user).asUnique().account().id();
+ } catch (AccountResolver.UnresolvableAccountException ex) {
+ possibleExceptionForNotFoundOrInactiveAccount.initCause(ex);
+ throw possibleExceptionForNotFoundOrInactiveAccount;
+ }
+ if (!isActiveOnTheChange(changeNotes, attentionUserId)) {
+ throw possibleExceptionForNotFoundOrInactiveAccount;
+ }
+ return attentionUserId;
+ }
+
+ /**
+ * Returns whether {@code attentionUserId} is active on a change. Activity is defined as being a
+ * part of the reviewers, an uploader, or an owner of a change.
+ */
+ private static boolean isActiveOnTheChange(ChangeNotes changeNotes, Account.Id attentionUserId) {
+ return changeNotes.getChange().getOwner().equals(attentionUserId)
+ || changeNotes.getCurrentPatchSet().uploader().equals(attentionUserId)
+ || changeNotes.getReviewers().all().stream().anyMatch(id -> id.equals(attentionUserId));
+ }
+
private AttentionSetUtil() {}
}
diff --git a/java/com/google/gerrit/server/util/PluginLogFile.java b/java/com/google/gerrit/server/util/PluginLogFile.java
index de8b3aa..8235623 100644
--- a/java/com/google/gerrit/server/util/PluginLogFile.java
+++ b/java/com/google/gerrit/server/util/PluginLogFile.java
@@ -40,8 +40,13 @@
public void start() {
AsyncAppender asyncAppender = systemLog.createAsyncAppender(logName, layout, true, true);
Logger logger = LogManager.getLogger(logName);
- logger.removeAppender(logName);
- logger.addAppender(asyncAppender);
+ if (logger.getAppender(logName) == null) {
+ synchronized (this) {
+ if (logger.getAppender(logName) == null) {
+ logger.addAppender(asyncAppender);
+ }
+ }
+ }
logger.setAdditivity(false);
}
diff --git a/javatests/com/google/gerrit/acceptance/rest/change/AbstractSubmit.java b/javatests/com/google/gerrit/acceptance/rest/change/AbstractSubmit.java
index faef5aa..085d23d 100644
--- a/javatests/com/google/gerrit/acceptance/rest/change/AbstractSubmit.java
+++ b/javatests/com/google/gerrit/acceptance/rest/change/AbstractSubmit.java
@@ -1439,6 +1439,12 @@
assertWithMessage("enabled bit on submit action").that(desc.isEnabled()).isTrue();
}
+ protected void assertSubmitDisabled(String changeId) throws Throwable {
+ RevisionResource rsrc = parseCurrentRevisionResource(changeId);
+ UiAction.Description desc = submitHandler.getDescription(rsrc);
+ assertWithMessage("enabled bit on submit action").that(desc.isEnabled()).isFalse();
+ }
+
protected void assertChangeMergedEvents(String... expected) throws Throwable {
eventRecorder.assertChangeMergedEvents(project.get(), "refs/heads/master", expected);
}
diff --git a/javatests/com/google/gerrit/acceptance/rest/change/AbstractSubmitByMerge.java b/javatests/com/google/gerrit/acceptance/rest/change/AbstractSubmitByMerge.java
index f77552d..9c496fa 100644
--- a/javatests/com/google/gerrit/acceptance/rest/change/AbstractSubmitByMerge.java
+++ b/javatests/com/google/gerrit/acceptance/rest/change/AbstractSubmitByMerge.java
@@ -20,6 +20,7 @@
import com.google.gerrit.acceptance.PushOneCommit;
import com.google.gerrit.acceptance.TestProjectInput;
import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
+import com.google.gerrit.entities.PatchSet;
import com.google.gerrit.extensions.client.InheritableBoolean;
import com.google.inject.Inject;
import org.eclipse.jgit.revwalk.RevCommit;
@@ -117,4 +118,49 @@
assertThat(head.getParent(0)).isEqualTo(change1.getCommit());
assertThat(head.getParent(1)).isEqualTo(change2.getCommit());
}
+
+ @Test
+ public void dependencyOnOutdatedPatchSetPreventsMerge() throws Throwable {
+ // Create a change
+ PushOneCommit change = pushFactory.create(user.newIdent(), testRepo, "fix", "a.txt", "foo");
+ PushOneCommit.Result changeResult = change.to("refs/for/master");
+ PatchSet.Id patchSetId = changeResult.getPatchSetId();
+
+ // Create a successor change.
+ PushOneCommit change2 =
+ pushFactory.create(user.newIdent(), testRepo, "feature", "b.txt", "bar");
+ PushOneCommit.Result change2Result = change2.to("refs/for/master");
+
+ // Create new patch set for first change.
+ testRepo.reset(changeResult.getCommit().name());
+ amendChange(changeResult.getChangeId());
+
+ // Approve both changes
+ approve(changeResult.getChangeId());
+ approve(change2Result.getChangeId());
+
+ // submit button is disabled.
+ assertSubmitDisabled(change2Result.getChangeId());
+
+ submitWithConflict(
+ change2Result.getChangeId(),
+ "Failed to submit 2 changes due to the following problems:\n"
+ + "Change "
+ + change2Result.getChange().getId()
+ + ": Depends on change that was not submitted."
+ + " Commit "
+ + change2Result.getCommit().name()
+ + " depends on commit "
+ + changeResult.getCommit().name()
+ + ", which is outdated patch set "
+ + patchSetId.get()
+ + " of change "
+ + changeResult.getChange().getId()
+ + ". The latest patch set is "
+ + changeResult.getPatchSetId().get()
+ + ".");
+
+ assertRefUpdatedEvents();
+ assertChangeMergedEvents();
+ }
}
diff --git a/javatests/com/google/gerrit/acceptance/rest/change/AbstractSubmitByRebase.java b/javatests/com/google/gerrit/acceptance/rest/change/AbstractSubmitByRebase.java
index 955dd7a..5eb19df 100644
--- a/javatests/com/google/gerrit/acceptance/rest/change/AbstractSubmitByRebase.java
+++ b/javatests/com/google/gerrit/acceptance/rest/change/AbstractSubmitByRebase.java
@@ -29,6 +29,7 @@
import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
import com.google.gerrit.entities.BranchNameKey;
+import com.google.gerrit.entities.PatchSet;
import com.google.gerrit.entities.Permission;
import com.google.gerrit.entities.Project;
import com.google.gerrit.extensions.client.ChangeStatus;
@@ -384,4 +385,49 @@
gApi.changes().id(change2.getChangeId()).current().rebase();
submit(change2.getChangeId());
}
+
+ @Test
+ public void dependencyOnOutdatedPatchSetPreventsRebase() throws Throwable {
+ // Create a change
+ PushOneCommit change = pushFactory.create(user.newIdent(), testRepo, "fix", "a.txt", "foo");
+ PushOneCommit.Result changeResult = change.to("refs/for/master");
+ PatchSet.Id patchSetId = changeResult.getPatchSetId();
+
+ // Create a successor change.
+ PushOneCommit change2 =
+ pushFactory.create(user.newIdent(), testRepo, "feature", "b.txt", "bar");
+ PushOneCommit.Result change2Result = change2.to("refs/for/master");
+
+ // Create new patch set for first change.
+ testRepo.reset(changeResult.getCommit().name());
+ amendChange(changeResult.getChangeId());
+
+ // Approve both changes
+ approve(changeResult.getChangeId());
+ approve(change2Result.getChangeId());
+
+ // submit button is disabled.
+ assertSubmitDisabled(change2Result.getChangeId());
+
+ submitWithConflict(
+ change2Result.getChangeId(),
+ "Failed to submit 2 changes due to the following problems:\n"
+ + "Change "
+ + change2Result.getChange().getId()
+ + ": Depends on change that was not submitted."
+ + " Commit "
+ + change2Result.getCommit().name()
+ + " depends on commit "
+ + changeResult.getCommit().name()
+ + ", which is outdated patch set "
+ + patchSetId.get()
+ + " of change "
+ + changeResult.getChange().getId()
+ + ". The latest patch set is "
+ + changeResult.getPatchSetId().get()
+ + ".");
+
+ assertRefUpdatedEvents();
+ assertChangeMergedEvents();
+ }
}
diff --git a/javatests/com/google/gerrit/acceptance/rest/change/AttentionSetIT.java b/javatests/com/google/gerrit/acceptance/rest/change/AttentionSetIT.java
index d0641e4..9e944a2 100644
--- a/javatests/com/google/gerrit/acceptance/rest/change/AttentionSetIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/change/AttentionSetIT.java
@@ -35,6 +35,7 @@
import com.google.gerrit.common.Nullable;
import com.google.gerrit.entities.Account;
import com.google.gerrit.entities.AttentionSetUpdate;
+import com.google.gerrit.entities.AttentionSetUpdate.Operation;
import com.google.gerrit.entities.Change;
import com.google.gerrit.entities.Patch;
import com.google.gerrit.extensions.api.changes.AddReviewerInput;
@@ -115,48 +116,48 @@
PushOneCommit.Result r = createChange();
requestScopeOperations.setApiUser(user.id());
int accountId =
- change(r).addToAttentionSet(new AttentionSetInput(user.email(), "first"))._accountId;
- assertThat(accountId).isEqualTo(user.id().get());
+ change(r).addToAttentionSet(new AttentionSetInput(admin.email(), "first"))._accountId;
+ assertThat(accountId).isEqualTo(admin.id().get());
AttentionSetUpdate expectedAttentionSetUpdate =
AttentionSetUpdate.createFromRead(
- fakeClock.now(), user.id(), AttentionSetUpdate.Operation.ADD, "first");
+ fakeClock.now(), admin.id(), AttentionSetUpdate.Operation.ADD, "first");
assertThat(r.getChange().attentionSet()).containsExactly(expectedAttentionSetUpdate);
// Second add is ignored.
accountId =
- change(r).addToAttentionSet(new AttentionSetInput(user.email(), "second"))._accountId;
- assertThat(accountId).isEqualTo(user.id().get());
+ change(r).addToAttentionSet(new AttentionSetInput(admin.email(), "second"))._accountId;
+ assertThat(accountId).isEqualTo(admin.id().get());
assertThat(r.getChange().attentionSet()).containsExactly(expectedAttentionSetUpdate);
// Only one email since the second add was ignored.
String emailBody = Iterables.getOnlyElement(email.getMessages()).body();
assertThat(emailBody)
.contains(
- user.fullName()
- + " added themselves to the attention set of this change.\n The reason is: first.");
+ String.format(
+ "%s requires the attention of %s to this change.\n The reason is: first.",
+ user.fullName(), admin.fullName()));
}
@Test
public void addMultipleUsers() throws Exception {
PushOneCommit.Result r = createChange();
Instant timestamp1 = fakeClock.now();
- int accountId1 =
- change(r).addToAttentionSet(new AttentionSetInput(user.email(), "user"))._accountId;
- assertThat(accountId1).isEqualTo(user.id().get());
+ // implictly adds the user to the attention set when adding as reviewer
+ change(r).addReviewer(user.email());
fakeClock.advance(Duration.ofSeconds(42));
Instant timestamp2 = fakeClock.now();
int accountId2 =
change(r)
- .addToAttentionSet(new AttentionSetInput(admin.id().toString(), "admin"))
+ .addToAttentionSet(new AttentionSetInput(admin.id().toString(), "manual update"))
._accountId;
assertThat(accountId2).isEqualTo(admin.id().get());
AttentionSetUpdate expectedAttentionSetUpdate1 =
AttentionSetUpdate.createFromRead(
- timestamp1, user.id(), AttentionSetUpdate.Operation.ADD, "user");
+ timestamp1, user.id(), AttentionSetUpdate.Operation.ADD, "Reviewer was added");
AttentionSetUpdate expectedAttentionSetUpdate2 =
AttentionSetUpdate.createFromRead(
- timestamp2, admin.id(), AttentionSetUpdate.Operation.ADD, "admin");
+ timestamp2, admin.id(), AttentionSetUpdate.Operation.ADD, "manual update");
assertThat(r.getChange().attentionSet())
.containsExactly(expectedAttentionSetUpdate1, expectedAttentionSetUpdate2);
}
@@ -164,7 +165,9 @@
@Test
public void removeUser() throws Exception {
PushOneCommit.Result r = createChange();
- change(r).addToAttentionSet(new AttentionSetInput(user.email(), "added"));
+ // implictly adds the user to the attention set when adding as reviewer
+ change(r).addReviewer(user.email());
+ sender.clear();
requestScopeOperations.setApiUser(user.id());
fakeClock.advance(Duration.ofSeconds(42));
@@ -191,6 +194,9 @@
@Test
public void removeUserWithInvalidUserInput() throws Exception {
PushOneCommit.Result r = createChange();
+ // implictly adds the user to the attention set when adding as reviewer
+ change(r).addReviewer(user.email());
+
BadRequestException exception =
assertThrows(
BadRequestException.class,
@@ -199,7 +205,9 @@
.attention(user.id().toString())
.remove(new AttentionSetInput("invalid user", "reason")));
assertThat(exception.getMessage())
- .isEqualTo("The user specified in the input body couldn't be found.");
+ .isEqualTo(
+ "invalid user doesn't exist or is not active on the change as an owner, "
+ + "uploader, reviewer, or cc so they can't be added to the attention set");
exception =
assertThrows(
@@ -214,16 +222,10 @@
}
@Test
- public void removeUnrelatedUser() throws Exception {
- PushOneCommit.Result r = createChange();
- change(r).attention(user.id().toString()).remove(new AttentionSetInput("foo"));
- assertThat(r.getChange().attentionSet()).isEmpty();
- }
-
- @Test
public void abandonRemovesUsers() throws Exception {
PushOneCommit.Result r = createChange();
- change(r).addToAttentionSet(new AttentionSetInput(user.email(), "user"));
+ // implictly adds the user to the attention set when adding as reviewer
+ change(r).addReviewer(user.email());
change(r).addToAttentionSet(new AttentionSetInput(admin.email(), "admin"));
change(r).abandon();
@@ -244,7 +246,8 @@
@Test
public void workInProgressRemovesUsers() throws Exception {
PushOneCommit.Result r = createChange();
- change(r).addToAttentionSet(new AttentionSetInput(user.email(), "reason"));
+ // implictly adds the user to the attention set when adding as reviewer
+ change(r).addReviewer(user.email());
change(r).setWorkInProgress();
@@ -258,13 +261,10 @@
public void submitRemovesUsersForAllSubmittedChanges() throws Exception {
PushOneCommit.Result r1 = createChange("refs/heads/master", "file1", "content");
- change(r1)
- .current()
- .review(ReviewInput.approve().addUserToAttentionSet(user.email(), "reason"));
+ // implictly adds the user to the attention set when adding as reviewer
+ change(r1).current().review(ReviewInput.approve().reviewer(user.email()));
PushOneCommit.Result r2 = createChange("refs/heads/master", "file2", "content");
- change(r2)
- .current()
- .review(ReviewInput.approve().addUserToAttentionSet(user.email(), "reason"));
+ change(r2).current().review(ReviewInput.approve().reviewer(user.email()));
change(r2).current().submit();
@@ -286,11 +286,10 @@
@Test
public void robotSubmitsRemovesUsers() throws Exception {
- PushOneCommit.Result r1 = createChange("refs/heads/master", "file1", "content");
+ PushOneCommit.Result r = createChange("refs/heads/master", "file1", "content");
- change(r1)
- .current()
- .review(ReviewInput.approve().addUserToAttentionSet(user.email(), "reason"));
+ // implictly adds the user to the attention set when adding as reviewer
+ change(r).addReviewer(user.email());
TestAccount robot =
accountCreator.create(
@@ -301,11 +300,12 @@
ServiceUserClassifier.SERVICE_USERS,
"Administrators");
requestScopeOperations.setApiUser(robot.id());
- change(r1).current().submit();
+ change(r).current().review(ReviewInput.approve());
+ change(r).current().submit();
// Attention set updates that relate to the admin (the person who replied) are filtered out.
AttentionSetUpdate attentionSet =
- Iterables.getOnlyElement(getAttentionSetUpdatesForUser(r1, user));
+ Iterables.getOnlyElement(getAttentionSetUpdatesForUser(r, user));
assertThat(attentionSet).hasAccountIdThat().isEqualTo(user.id());
assertThat(attentionSet).hasOperationThat().isEqualTo(AttentionSetUpdate.Operation.REMOVE);
@@ -533,7 +533,9 @@
@Test
public void reviewersAreNotAddedForNoReasonBecauseOfAnUpdate() throws Exception {
PushOneCommit.Result r = createChange();
- change(r).addToAttentionSet(new AttentionSetInput(user.email(), "user"));
+ // implictly adds the user to the attention set when adding as reviewer
+ change(r).addReviewer(user.email());
+
change(r).attention(user.id().toString()).remove(new AttentionSetInput("removed"));
HashtagsInput hashtagsInput = new HashtagsInput();
@@ -567,7 +569,8 @@
@Test
public void reviewRemovesManuallyRemovedUserFromAttentionSet() throws Exception {
PushOneCommit.Result r = createChange();
- change(r).addToAttentionSet(new AttentionSetInput(user.email(), "reason"));
+ // implictly adds the user to the attention set when adding as reviewer
+ change(r).addReviewer(user.email());
requestScopeOperations.setApiUser(user.id());
ReviewInput reviewInput =
@@ -587,7 +590,8 @@
@Test
public void reviewWithManualAdditionToAttentionSetFailsWithoutReason() throws Exception {
PushOneCommit.Result r = createChange();
- change(r).addToAttentionSet(new AttentionSetInput(user.email(), "reason"));
+ // implictly adds the user to the attention set when adding as reviewer
+ change(r).addReviewer(user.email());
ReviewInput reviewInput = ReviewInput.create().addUserToAttentionSet(user.email(), "");
@@ -611,7 +615,8 @@
@Test
public void reviewAddReviewerWhileRemovingFromAttentionSetJustRemovesUser() throws Exception {
PushOneCommit.Result r = createChange();
- change(r).addToAttentionSet(new AttentionSetInput(user.email(), "addition"));
+ // implictly adds the user to the attention set when adding as reviewer
+ change(r).addReviewer(user.email());
ReviewInput reviewInput =
ReviewInput.create()
@@ -772,7 +777,15 @@
requestScopeOperations.setApiUser(user.id());
- change(r).addToAttentionSet(new AttentionSetInput(user.email(), "reason"));
+ // add the user to the attention set.
+ change(r)
+ .current()
+ .review(
+ ReviewInput.create()
+ .reviewer(user.email(), ReviewerState.CC, true)
+ .addUserToAttentionSet(user.email(), "reason"));
+
+ // add the user as reviewer but still be removed on reply.
ReviewInput reviewInput = ReviewInput.create().reviewer(user.email());
change(r).current().review(reviewInput);
@@ -1144,6 +1157,9 @@
@Test
public void repliesWhileAddingAsReviewerStillRemovesUser() throws Exception {
PushOneCommit.Result r = createChange();
+ // implictly adds the user to the attention set when adding as reviewer
+ change(r).addReviewer(user.email());
+
change(r).addToAttentionSet(new AttentionSetInput(user.email(), "remove"));
requestScopeOperations.setApiUser(user.id());
@@ -1237,6 +1253,8 @@
accountCreator.create(
"robot1", "robot1@example.com", "Ro Bot", "Ro", ServiceUserClassifier.SERVICE_USERS);
PushOneCommit.Result r = createChange();
+ // Make the robot active on the change.
+ change(r).addReviewer(robot.email());
// Throw an error when adding a robot explicitly.
BadRequestException exception =
@@ -1335,13 +1353,15 @@
public void addUsersToAttentionSetInPrivateChanges() throws Exception {
PushOneCommit.Result r = createChange();
change(r).setPrivate(true);
- change(r).current().review(new ReviewInput().addUserToAttentionSet(user.email(), "reason"));
+
+ // implictly adds the user to the attention set when adding as reviewer
+ change(r).addReviewer(user.email());
AttentionSetUpdate attentionSet =
Iterables.getOnlyElement(getAttentionSetUpdatesForUser(r, user));
assertThat(attentionSet).hasAccountIdThat().isEqualTo(user.id());
assertThat(attentionSet).hasOperationThat().isEqualTo(AttentionSetUpdate.Operation.ADD);
- assertThat(attentionSet).hasReasonThat().isEqualTo("reason");
+ assertThat(attentionSet).hasReasonThat().isEqualTo("Reviewer was added");
}
@Test
@@ -1532,6 +1552,156 @@
assertThat(sender.getMessages()).isNotEmpty();
}
+ @Test
+ public void cannotAddIrrelevantUserToAttentionSet() throws Exception {
+ PushOneCommit.Result r = createChange();
+
+ BadRequestException exception =
+ assertThrows(
+ BadRequestException.class,
+ () -> change(r).addToAttentionSet(new AttentionSetInput(user.email(), "reason")));
+ assertThat(exception.getMessage())
+ .isEqualTo(
+ String.format(
+ "%s doesn't exist or is not active on the change as an owner, uploader, reviewer, "
+ + "or cc so they can't be added to the attention set",
+ user.email()));
+ }
+
+ @Test
+ public void cannotAddNonExistingUserToAttentionSet() throws Exception {
+ PushOneCommit.Result r = createChange();
+
+ BadRequestException exception =
+ assertThrows(
+ BadRequestException.class,
+ () -> change(r).addToAttentionSet(new AttentionSetInput("INVALID USER", "reason")));
+ assertThat(exception.getMessage())
+ .isEqualTo(
+ "INVALID USER doesn't exist or is not active on the change as an owner,"
+ + " uploader, reviewer, or cc so they can't be added to the attention set");
+ }
+
+ @Test
+ public void cannotRemoveIrrelevantUserToAttentionSet() throws Exception {
+ PushOneCommit.Result r = createChange();
+
+ BadRequestException exception =
+ assertThrows(
+ BadRequestException.class,
+ () -> change(r).attention(user.email()).remove(new AttentionSetInput("reason")));
+ assertThat(exception.getMessage())
+ .isEqualTo(
+ String.format(
+ "%s doesn't exist or is not active on the change as an owner, uploader, reviewer, "
+ + "or cc so they can't be added to the attention set",
+ user.email()));
+ }
+
+ @Test
+ public void cannotRemoveIrrelevantUserToAttentionSetWithUserInInput() throws Exception {
+ PushOneCommit.Result r = createChange();
+
+ BadRequestException exception =
+ assertThrows(
+ BadRequestException.class,
+ () ->
+ change(r)
+ .attention(user.email())
+ .remove(new AttentionSetInput(user.email(), "reason")));
+ assertThat(exception.getMessage())
+ .isEqualTo(
+ String.format(
+ "%s doesn't exist or is not active on the change as an owner, uploader, reviewer, "
+ + "or cc so they can't be added to the attention set",
+ user.email()));
+ }
+
+ @Test
+ public void cannotRemoveNonExistingUser() throws Exception {
+ PushOneCommit.Result r = createChange();
+
+ BadRequestException exception =
+ assertThrows(
+ BadRequestException.class,
+ () -> change(r).attention("INVALID USER").remove(new AttentionSetInput("reason")));
+ assertThat(exception.getMessage())
+ .isEqualTo(
+ "INVALID USER doesn't exist or is not active on the change as an owner,"
+ + " uploader, reviewer, or cc so they can't be added to the attention set");
+ }
+
+ @Test
+ public void irrelevantUsersAddedToAttentionSetAreIgnoredOnReply() throws Exception {
+ PushOneCommit.Result r = createChange();
+
+ change(r).current().review(ReviewInput.create().addUserToAttentionSet(user.email(), "reason"));
+ assertThat(getAttentionSetUpdatesForUser(r, user)).isEmpty();
+ }
+
+ @Test
+ public void newReviewerCanBeAddedToTheAttentionSetManually() throws Exception {
+ PushOneCommit.Result r = createChange();
+ change(r)
+ .current()
+ .review(
+ ReviewInput.create()
+ .reviewer(user.email())
+ .addUserToAttentionSet(user.email(), "reason")
+ .blockAutomaticAttentionSetRules());
+ assertThat(Iterables.getOnlyElement(getAttentionSetUpdatesForUser(r, user)).operation())
+ .isEqualTo(Operation.ADD);
+ }
+
+ @Test
+ public void newReviewerCanBeAddedToTheAttentionSetAutomatically() throws Exception {
+ PushOneCommit.Result r = createChange();
+ change(r).current().review(ReviewInput.create().reviewer(user.email()));
+ assertThat(Iterables.getOnlyElement(getAttentionSetUpdatesForUser(r, user)).operation())
+ .isEqualTo(Operation.ADD);
+ }
+
+ @GerritConfig(name = "accounts.visibility", value = "NONE")
+ public void onReplyCanAddInvisibleUsersToAttentionSetOnVisibleChanges() throws Exception {
+ PushOneCommit.Result r = createChange();
+ requestScopeOperations.setApiUser(user.id());
+
+ // admin is invisible to the user, but they can still add them to the attention set since they
+ // see the change.
+ change(r).current().review(ReviewInput.create().addUserToAttentionSet(admin.email(), "reason"));
+ assertThat(Iterables.getOnlyElement(getAttentionSetUpdatesForUser(r, admin)).operation())
+ .isEqualTo(Operation.ADD);
+ }
+
+ @Test
+ public void onReplyNonExistingUsersAreSilentlyIgnored() throws Exception {
+ PushOneCommit.Result r = createChange();
+
+ change(r)
+ .current()
+ .review(ReviewInput.create().addUserToAttentionSet("INVALID USER", "reason"));
+ assertThat(getAttentionSetUpdates(r.getChange().getId())).isEmpty();
+ }
+
+ @Test
+ @GerritConfig(name = "accounts.visibility", value = "NONE")
+ public void canModifyAttentionSetForInvisibleUsersOnVisibleChanges() throws Exception {
+ PushOneCommit.Result r = createChange();
+ requestScopeOperations.setApiUser(user.id());
+
+ // admin is invisible to the user, but they can still add them to the attention set since they
+ // see the change.
+ change(r).addToAttentionSet(new AttentionSetInput(admin.email(), "reason"));
+ assertThat(Iterables.getOnlyElement(getAttentionSetUpdatesForUser(r, admin)).operation())
+ .isEqualTo(Operation.ADD);
+
+ // admin is invisible to the user, but they can still remove them to the attention set since
+ // they see the change.
+ change(r).attention(admin.id().toString()).remove(new AttentionSetInput("removed"));
+ assertThat(Iterables.getOnlyElement(getAttentionSetUpdatesForUser(r, admin)).operation())
+ .isEqualTo(Operation.REMOVE);
+ }
+
private List<AttentionSetUpdate> getAttentionSetUpdatesForUser(
PushOneCommit.Result r, TestAccount account) {
return getAttentionSetUpdates(r.getChange().getId()).stream()
diff --git a/javatests/com/google/gerrit/acceptance/rest/change/SubmitByCherryPickIT.java b/javatests/com/google/gerrit/acceptance/rest/change/SubmitByCherryPickIT.java
index 6f519f1..5bcf995 100644
--- a/javatests/com/google/gerrit/acceptance/rest/change/SubmitByCherryPickIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/change/SubmitByCherryPickIT.java
@@ -25,6 +25,7 @@
import com.google.gerrit.acceptance.TestProjectInput;
import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
import com.google.gerrit.common.FooterConstants;
+import com.google.gerrit.entities.PatchSet;
import com.google.gerrit.extensions.api.changes.SubmitInput;
import com.google.gerrit.extensions.client.ChangeStatus;
import com.google.gerrit.extensions.client.InheritableBoolean;
@@ -373,4 +374,27 @@
change2.getChangeId(),
headAfterFirstSubmit.name());
}
+
+ @Test
+ public void dependencyOnOutdatedPatchSetNotPreventingCherryPick() throws Throwable {
+ // Create a change
+ PushOneCommit change = pushFactory.create(user.newIdent(), testRepo, "fix", "a.txt", "foo");
+ PushOneCommit.Result changeResult = change.to("refs/for/master");
+ PatchSet.Id patchSetId = changeResult.getPatchSetId();
+
+ // Create a successor change.
+ PushOneCommit change2 =
+ pushFactory.create(user.newIdent(), testRepo, "feature", "b.txt", "bar");
+ PushOneCommit.Result change2Result = change2.to("refs/for/master");
+
+ // Create new patch set for first change.
+ testRepo.reset(changeResult.getCommit().name());
+ amendChange(changeResult.getChangeId());
+
+ // Approve both changes
+ approve(changeResult.getChangeId());
+ approve(change2Result.getChangeId());
+
+ assertSubmittable(change2Result.getChangeId());
+ }
}
diff --git a/javatests/com/google/gerrit/acceptance/rest/change/SubmitByFastForwardIT.java b/javatests/com/google/gerrit/acceptance/rest/change/SubmitByFastForwardIT.java
index 1912697..66eb48c 100644
--- a/javatests/com/google/gerrit/acceptance/rest/change/SubmitByFastForwardIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/change/SubmitByFastForwardIT.java
@@ -22,6 +22,7 @@
import com.google.gerrit.acceptance.PushOneCommit;
import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
import com.google.gerrit.entities.Change;
+import com.google.gerrit.entities.PatchSet;
import com.google.gerrit.entities.Permission;
import com.google.gerrit.extensions.client.SubmitType;
import com.google.gerrit.extensions.common.ActionInfo;
@@ -170,4 +171,49 @@
assertRefUpdatedEvents(initialHead, headAfterSubmit);
assertChangeMergedEvents(id1, headAfterSubmit.name());
}
+
+ @Test
+ public void dependencyOnOutdatedPatchSetPreventsFastForward() throws Throwable {
+ // Create a change
+ PushOneCommit change = pushFactory.create(user.newIdent(), testRepo, "fix", "a.txt", "foo");
+ PushOneCommit.Result changeResult = change.to("refs/for/master");
+ PatchSet.Id patchSetId = changeResult.getPatchSetId();
+
+ // Create a successor change.
+ PushOneCommit change2 =
+ pushFactory.create(user.newIdent(), testRepo, "feature", "b.txt", "bar");
+ PushOneCommit.Result change2Result = change2.to("refs/for/master");
+
+ // Create new patch set for first change.
+ testRepo.reset(changeResult.getCommit().name());
+ amendChange(changeResult.getChangeId());
+
+ // Approve both changes
+ approve(changeResult.getChangeId());
+ approve(change2Result.getChangeId());
+
+ // submit button is disabled.
+ assertSubmitDisabled(change2Result.getChangeId());
+
+ submitWithConflict(
+ change2Result.getChangeId(),
+ "Failed to submit 2 changes due to the following problems:\n"
+ + "Change "
+ + change2Result.getChange().getId()
+ + ": Depends on change that was not submitted."
+ + " Commit "
+ + change2Result.getCommit().name()
+ + " depends on commit "
+ + changeResult.getCommit().name()
+ + ", which is outdated patch set "
+ + patchSetId.get()
+ + " of change "
+ + changeResult.getChange().getId()
+ + ". The latest patch set is "
+ + changeResult.getPatchSetId().get()
+ + ".");
+
+ assertRefUpdatedEvents();
+ assertChangeMergedEvents();
+ }
}
diff --git a/javatests/com/google/gerrit/acceptance/rest/change/SubmitByMergeIfNecessaryIT.java b/javatests/com/google/gerrit/acceptance/rest/change/SubmitByMergeIfNecessaryIT.java
index 5fe741d..995de0d 100644
--- a/javatests/com/google/gerrit/acceptance/rest/change/SubmitByMergeIfNecessaryIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/change/SubmitByMergeIfNecessaryIT.java
@@ -29,7 +29,6 @@
import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
import com.google.gerrit.entities.BranchNameKey;
-import com.google.gerrit.entities.PatchSet;
import com.google.gerrit.entities.Permission;
import com.google.gerrit.entities.Project;
import com.google.gerrit.extensions.api.changes.ChangeApi;
@@ -534,48 +533,6 @@
}
@Test
- public void dependencyOnOutdatedPatchSetPreventsMerge() throws Throwable {
- // Create a change
- PushOneCommit change = pushFactory.create(user.newIdent(), testRepo, "fix", "a.txt", "foo");
- PushOneCommit.Result changeResult = change.to("refs/for/master");
- PatchSet.Id patchSetId = changeResult.getPatchSetId();
-
- // Create a successor change.
- PushOneCommit change2 =
- pushFactory.create(user.newIdent(), testRepo, "feature", "b.txt", "bar");
- PushOneCommit.Result change2Result = change2.to("refs/for/master");
-
- // Create new patch set for first change.
- testRepo.reset(changeResult.getCommit().name());
- amendChange(changeResult.getChangeId());
-
- // Approve both changes
- approve(changeResult.getChangeId());
- approve(change2Result.getChangeId());
-
- submitWithConflict(
- change2Result.getChangeId(),
- "Failed to submit 2 changes due to the following problems:\n"
- + "Change "
- + change2Result.getChange().getId()
- + ": Depends on change that was not submitted."
- + " Commit "
- + change2Result.getCommit().name()
- + " depends on commit "
- + changeResult.getCommit().name()
- + ", which is outdated patch set "
- + patchSetId.get()
- + " of change "
- + changeResult.getChange().getId()
- + ". The latest patch set is "
- + changeResult.getPatchSetId().get()
- + ".");
-
- assertRefUpdatedEvents();
- assertChangeMergedEvents();
- }
-
- @Test
public void dependencyOnDeletedChangePreventsMerge() throws Throwable {
// Create a change
PushOneCommit change = pushFactory.create(user.newIdent(), testRepo, "fix", "a.txt", "foo");
diff --git a/javatests/com/google/gerrit/server/notedb/ChangeNotesTest.java b/javatests/com/google/gerrit/server/notedb/ChangeNotesTest.java
index 44dd831..cc0b109 100644
--- a/javatests/com/google/gerrit/server/notedb/ChangeNotesTest.java
+++ b/javatests/com/google/gerrit/server/notedb/ChangeNotesTest.java
@@ -809,6 +809,8 @@
public void addAttentionStatusForMultipleUsers() throws Exception {
Change c = newChange();
ChangeUpdate update = newUpdate(c, changeOwner);
+ // put the user as cc to ensure that the user took part in this change.
+ update.putReviewer(otherUser.getAccount().id(), CC);
AttentionSetUpdate attentionSetUpdate0 =
AttentionSetUpdate.createForWrite(changeOwner.getAccountId(), Operation.ADD, "test");
AttentionSetUpdate attentionSetUpdate1 =
diff --git a/javatests/com/google/gerrit/server/query/change/AbstractQueryChangesTest.java b/javatests/com/google/gerrit/server/query/change/AbstractQueryChangesTest.java
index 02f514a..1de548f 100644
--- a/javatests/com/google/gerrit/server/query/change/AbstractQueryChangesTest.java
+++ b/javatests/com/google/gerrit/server/query/change/AbstractQueryChangesTest.java
@@ -3055,6 +3055,14 @@
gApi.changes().id(change.getChangeId()).addToAttentionSet(input);
Account.Id user2Id =
accountManager.authenticate(AuthRequest.forUser("anotheruser")).getAccountId();
+
+ // Add the second user as cc to ensure that user took part of the change and can be added to the
+ // attention set.
+ AddReviewerInput addReviewerInput = new AddReviewerInput();
+ addReviewerInput.reviewer = user2Id.toString();
+ addReviewerInput.state = ReviewerState.CC;
+ gApi.changes().id(change.getChangeId()).addReviewer(addReviewerInput);
+
input = new AttentionSetInput(user2Id.toString(), "reason 2");
gApi.changes().id(change.getChangeId()).addToAttentionSet(input);
@@ -3085,6 +3093,7 @@
assertQuery("-assignee:" + user.getUserName().get(), change2);
}
+ @GerritConfig(name = "accounts.visibility", value = "NONE")
@Test
public void userDestination() throws Exception {
TestRepository<Repo> repo1 = createProject("repo1");
@@ -3096,6 +3105,8 @@
.hasMessageThat()
.isEqualTo("Unknown named destination: foo");
+ Account.Id anotherUserId =
+ accountManager.authenticate(AuthRequest.forUser("anotheruser")).getAccountId();
String destination1 = "refs/heads/master\trepo1";
String destination2 = "refs/heads/master\trepo2";
String destination3 = "refs/heads/master\trepo1\nrefs/heads/master\trepo2";
@@ -3111,8 +3122,32 @@
allUsers.branch(refsUsers).commit().add("destinations/destination4", destination4).create();
allUsers.branch(refsUsers).commit().add("destinations/destination5", destination5).create();
+ String anotherRefsUsers = RefNames.refsUsers(anotherUserId);
+ allUsers
+ .branch(anotherRefsUsers)
+ .commit()
+ .add("destinations/destination6", destination1)
+ .create();
+ allUsers
+ .branch(anotherRefsUsers)
+ .commit()
+ .add("destinations/destination7", destination2)
+ .create();
+ allUsers
+ .branch(anotherRefsUsers)
+ .commit()
+ .add("destinations/destination8", destination3)
+ .create();
+ allUsers
+ .branch(anotherRefsUsers)
+ .commit()
+ .add("destinations/destination9", destination4)
+ .create();
+
Ref userRef = allUsers.getRepository().exactRef(refsUsers);
+ Ref anotherUserRef = allUsers.getRepository().exactRef(anotherRefsUsers);
assertThat(userRef).isNotNull();
+ assertThat(anotherUserRef).isNotNull();
}
assertQuery("destination:destination1", change1);
@@ -3120,38 +3155,87 @@
assertQuery("destination:destination3", change2, change1);
assertQuery("destination:destination4");
assertQuery("destination:destination5");
+ assertQuery("destination:destination6,user=" + anotherUserId, change1);
+ assertQuery("destination:name=destination6,user=" + anotherUserId, change1);
+ assertQuery("destination:user=" + anotherUserId + ",destination7", change2);
+ assertQuery("destination:user=" + anotherUserId + ",name=destination8", change2, change1);
+ assertQuery("destination:destination9,user=" + anotherUserId);
+
+ assertThatQueryException("destination:destination3,user=" + anotherUserId)
+ .hasMessageThat()
+ .isEqualTo("Unknown named destination: destination3");
+ assertThatQueryException("destination:destination3,user=test")
+ .hasMessageThat()
+ .isEqualTo("Account 'test' not found");
+
+ requestContext.setContext(newRequestContext(anotherUserId));
+ // account 1000000 is not visible to 'anotheruser' as they are not an admin
+ assertThatQueryException("destination:destination3,user=" + userId)
+ .hasMessageThat()
+ .isEqualTo("Account '1000000' not found");
}
+ @GerritConfig(name = "accounts.visibility", value = "NONE")
@Test
public void userQuery() throws Exception {
TestRepository<Repo> repo = createProject("repo");
Change change1 = insert(repo, newChange(repo));
Change change2 = insert(repo, newChangeForBranch(repo, "stable"));
+ Account.Id anotherUserId =
+ accountManager.authenticate(AuthRequest.forUser("anotheruser")).getAccountId();
String queryListText =
"query1\tproject:repo\n"
+ "query2\tproject:repo status:open\n"
+ "query3\tproject:repo branch:stable\n"
+ "query4\tproject:repo branch:other";
+ String anotherQueryListText =
+ "query5\tproject:repo\n"
+ + "query6\tproject:repo status:merged\n"
+ + "query7\tproject:repo branch:stable\n"
+ + "query8\tproject:repo branch:other";
try (TestRepository<Repo> allUsers =
new TestRepository<>(repoManager.openRepository(allUsersName));
- MetaDataUpdate md = metaDataUpdateFactory.create(allUsersName)) {
+ MetaDataUpdate md = metaDataUpdateFactory.create(allUsersName);
+ MetaDataUpdate anotherMd = metaDataUpdateFactory.create(allUsersName)) {
VersionedAccountQueries queries = VersionedAccountQueries.forUser(userId);
queries.load(md);
queries.setQueryList(queryListText);
queries.commit(md);
+ VersionedAccountQueries anotherQueries = VersionedAccountQueries.forUser(anotherUserId);
+ anotherQueries.load(anotherMd);
+ anotherQueries.setQueryList(anotherQueryListText);
+ anotherQueries.commit(anotherMd);
}
assertThatQueryException("query:foo").hasMessageThat().isEqualTo("Unknown named query: foo");
+ assertThatQueryException("query:query1,user=" + anotherUserId)
+ .hasMessageThat()
+ .isEqualTo("Unknown named query: query1");
+ assertThatQueryException("query:query1,user=test")
+ .hasMessageThat()
+ .isEqualTo("Account 'test' not found");
+
+ requestContext.setContext(newRequestContext(anotherUserId));
+ // account 1000000 is not visible to 'anotheruser' as they are not an admin
+ assertThatQueryException("query:query1,user=" + userId)
+ .hasMessageThat()
+ .isEqualTo("Account '1000000' not found");
+ requestContext.setContext(newRequestContext(userId));
assertQuery("query:query1", change2, change1);
assertQuery("query:query2", change2, change1);
+ assertQuery("query:name=query5,user=" + anotherUserId, change2, change1);
+ assertQuery("query:user=" + anotherUserId + ",name=query6");
gApi.changes().id(change1.getChangeId()).current().review(ReviewInput.approve());
gApi.changes().id(change1.getChangeId()).current().submit();
assertQuery("query:query2", change2);
assertQuery("query:query3", change2);
assertQuery("query:query4");
+ assertQuery("query:query6,user=" + anotherUserId, change1);
+ assertQuery("query:user=" + anotherUserId + ",query7", change2);
+ assertQuery("query:query8,user=" + anotherUserId);
}
@Test
diff --git a/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata.ts b/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata.ts
index 69e9900..f8a5940 100644
--- a/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata.ts
+++ b/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata.ts
@@ -594,8 +594,7 @@
if (
role === ChangeRole.AUTHOR &&
- rev.commit &&
- rev.commit.author &&
+ rev.commit?.author &&
change.owner.email !== rev.commit.author.email
) {
return rev.commit.author;
@@ -603,9 +602,11 @@
if (
role === ChangeRole.COMMITTER &&
- rev.commit &&
- rev.commit.committer &&
- change.owner.email !== rev.commit.committer.email
+ rev.commit?.committer &&
+ change.owner.email !== rev.commit.committer.email &&
+ !(
+ rev.uploader?.email && rev.uploader.email === rev.commit.committer.email
+ )
) {
return rev.commit.committer;
}
diff --git a/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata_test.js b/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata_test.js
index 5f53641..5bdf105 100644
--- a/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata_test.js
+++ b/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata_test.js
@@ -210,13 +210,13 @@
test('_getNonOwnerRole that it does not return uploader', () => {
// Set the uploader email to be the same as the owner.
change.revisions.rev1.uploader._account_id = 1019328;
- assert.isNull(element._getNonOwnerRole(change,
+ assert.isNotOk(element._getNonOwnerRole(change,
element._CHANGE_ROLE.UPLOADER));
});
test('_getNonOwnerRole null for uploader with no current rev', () => {
delete change.current_revision;
- assert.isNull(element._getNonOwnerRole(change,
+ assert.isNotOk(element._getNonOwnerRole(change,
element._CHANGE_ROLE.UPLOADER));
});
@@ -235,33 +235,39 @@
suite('role=committer', () => {
test('_getNonOwnerRole for committer', () => {
+ change.revisions.rev1.uploader.email = 'ghh@def';
assert.deepEqual(
element._getNonOwnerRole(change, element._CHANGE_ROLE.COMMITTER),
{email: 'ghi@def'});
});
+ test('_getNonOwnerRole is null if committer is same as uploader', () => {
+ assert.isNotOk(element._getNonOwnerRole(change,
+ element._CHANGE_ROLE.COMMITTER));
+ });
+
test('_getNonOwnerRole that it does not return committer', () => {
// Set the committer email to be the same as the owner.
change.revisions.rev1.commit.committer.email = 'abc@def';
- assert.isNull(element._getNonOwnerRole(change,
+ assert.isNotOk(element._getNonOwnerRole(change,
element._CHANGE_ROLE.COMMITTER));
});
test('_getNonOwnerRole null for committer with no current rev', () => {
delete change.current_revision;
- assert.isNull(element._getNonOwnerRole(change,
+ assert.isNotOk(element._getNonOwnerRole(change,
element._CHANGE_ROLE.COMMITTER));
});
test('_getNonOwnerRole null for committer with no commit', () => {
delete change.revisions.rev1.commit;
- assert.isNull(element._getNonOwnerRole(change,
+ assert.isNotOk(element._getNonOwnerRole(change,
element._CHANGE_ROLE.COMMITTER));
});
test('_getNonOwnerRole null for committer with no committer', () => {
delete change.revisions.rev1.commit.committer;
- assert.isNull(element._getNonOwnerRole(change,
+ assert.isNotOk(element._getNonOwnerRole(change,
element._CHANGE_ROLE.COMMITTER));
});
});
@@ -276,25 +282,25 @@
test('_getNonOwnerRole that it does not return author', () => {
// Set the author email to be the same as the owner.
change.revisions.rev1.commit.author.email = 'abc@def';
- assert.isNull(element._getNonOwnerRole(change,
+ assert.isNotOk(element._getNonOwnerRole(change,
element._CHANGE_ROLE.AUTHOR));
});
test('_getNonOwnerRole null for author with no current rev', () => {
delete change.current_revision;
- assert.isNull(element._getNonOwnerRole(change,
+ assert.isNotOk(element._getNonOwnerRole(change,
element._CHANGE_ROLE.AUTHOR));
});
test('_getNonOwnerRole null for author with no commit', () => {
delete change.revisions.rev1.commit;
- assert.isNull(element._getNonOwnerRole(change,
+ assert.isNotOk(element._getNonOwnerRole(change,
element._CHANGE_ROLE.AUTHOR));
});
test('_getNonOwnerRole null for author with no author', () => {
delete change.revisions.rev1.commit.author;
- assert.isNull(element._getNonOwnerRole(change,
+ assert.isNotOk(element._getNonOwnerRole(change,
element._CHANGE_ROLE.AUTHOR));
});
});
@@ -425,6 +431,7 @@
element.change = {
current_revision: '456',
revisions: {456: revision('111')},
+ owner: {},
};
element.revision = revision('222');
assert.equal(element._currentParents[0].commit, '222');
diff --git a/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view.ts b/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view.ts
index 8504df4..1119a20 100644
--- a/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view.ts
+++ b/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view.ts
@@ -151,6 +151,7 @@
import {GrButton} from '../../shared/gr-button/gr-button';
import {GrMessagesList} from '../gr-messages-list/gr-messages-list';
import {GrThreadList} from '../gr-thread-list/gr-thread-list';
+import {PORTING_COMMENTS_CHANGE_LATENCY_LABEL} from '../../../services/gr-reporting/gr-reporting';
const CHANGE_ID_ERROR = {
MISMATCH: 'mismatch',
@@ -2127,9 +2128,20 @@
this._robotCommentThreads = undefined;
if (!this._changeNum)
throw new Error('missing required changeNum property');
- return this.$.commentAPI
+
+ const portedCommentsPromise = this.$.commentAPI.getPortedComments(
+ this._changeNum
+ );
+ const commentsPromise = this.$.commentAPI
.loadAll(this._changeNum)
- .then(comments => this._recomputeComments(comments));
+ .then(comments => {
+ this.reporting.time(PORTING_COMMENTS_CHANGE_LATENCY_LABEL);
+ this._recomputeComments(comments);
+ });
+ Promise.all([portedCommentsPromise, commentsPromise]).then(() => {
+ this.reporting.timeEnd(PORTING_COMMENTS_CHANGE_LATENCY_LABEL);
+ });
+ return commentsPromise;
}
/**
diff --git a/polygerrit-ui/app/elements/change/gr-download-dialog/gr-download-dialog.ts b/polygerrit-ui/app/elements/change/gr-download-dialog/gr-download-dialog.ts
index eb8d4cb..27d9756 100644
--- a/polygerrit-ui/app/elements/change/gr-download-dialog/gr-download-dialog.ts
+++ b/polygerrit-ui/app/elements/change/gr-download-dialog/gr-download-dialog.ts
@@ -188,7 +188,7 @@
if (patchNumEquals(rev._number, patchNum)) {
const parentLength =
rev.commit && rev.commit.parents ? rev.commit.parents.length : 0;
- return parentLength === 0;
+ return parentLength === 0 || parentLength > 1;
}
}
return false;
diff --git a/polygerrit-ui/app/elements/change/gr-download-dialog/gr-download-dialog_test.js b/polygerrit-ui/app/elements/change/gr-download-dialog/gr-download-dialog_test.js
index 213c202..7401026 100644
--- a/polygerrit-ui/app/elements/change/gr-download-dialog/gr-download-dialog_test.js
+++ b/polygerrit-ui/app/elements/change/gr-download-dialog/gr-download-dialog_test.js
@@ -174,21 +174,33 @@
test('_computeHidePatchFile', () => {
const patchNum = '1';
- const change1 = {
+ const changeWithNoParent = {
revisions: {
r1: {_number: 1, commit: {parents: []}},
},
};
- assert.isTrue(element._computeHidePatchFile(change1, patchNum));
+ assert.isTrue(element._computeHidePatchFile(changeWithNoParent, patchNum));
- const change2 = {
+ const changeWithOneParent = {
revisions: {
r1: {_number: 1, commit: {parents: [
{commit: 'p1'},
]}},
},
};
- assert.isFalse(element._computeHidePatchFile(change2, patchNum));
+ assert.isFalse(
+ element._computeHidePatchFile(changeWithOneParent, patchNum));
+
+ const changeWithMultipleParents = {
+ revisions: {
+ r1: {_number: 1, commit: {parents: [
+ {commit: 'p1'},
+ {commit: 'p2'},
+ ]}},
+ },
+ };
+ assert.isTrue(
+ element._computeHidePatchFile(changeWithMultipleParents, patchNum));
});
});
diff --git a/polygerrit-ui/app/elements/change/gr-label-scores/gr-label-scores_html.ts b/polygerrit-ui/app/elements/change/gr-label-scores/gr-label-scores_html.ts
index 974e55d..7b1fb7f 100644
--- a/polygerrit-ui/app/elements/change/gr-label-scores/gr-label-scores_html.ts
+++ b/polygerrit-ui/app/elements/change/gr-label-scores/gr-label-scores_html.ts
@@ -34,7 +34,7 @@
display: table-row;
}
gr-label-score-row.no-access {
- display: var(--label-no-access-display, table-row);
+ display: none;
}
</style>
<div class="scoresTable">
diff --git a/polygerrit-ui/app/elements/core/gr-search-bar/gr-search-bar.ts b/polygerrit-ui/app/elements/core/gr-search-bar/gr-search-bar.ts
index c26afe3..d785a2f 100644
--- a/polygerrit-ui/app/elements/core/gr-search-bar/gr-search-bar.ts
+++ b/polygerrit-ui/app/elements/core/gr-search-bar/gr-search-bar.ts
@@ -39,7 +39,7 @@
import {MergeabilityComputationBehavior} from '../../../constants/constants';
// Possible static search options for auto complete, without negations.
-const SEARCH_OPERATORS = [
+const SEARCH_OPERATORS: ReadonlyArray<string> = [
'added:',
'age:',
'age:1week', // Give an example age
@@ -117,7 +117,7 @@
];
// All of the ops, with corresponding negations.
-const SEARCH_OPERATORS_WITH_NEGATIONS_SET = new Set(
+const SEARCH_OPERATORS_WITH_NEGATIONS_SET: ReadonlySet<string> = new Set(
SEARCH_OPERATORS.concat(SEARCH_OPERATORS.map(op => `-${op}`))
);
@@ -149,6 +149,8 @@
return htmlTemplate;
}
+ private searchOperators = new Set(SEARCH_OPERATORS_WITH_NEGATIONS_SET);
+
/**
* Fired when a search is committed
*
@@ -203,7 +205,7 @@
mergeability ===
MergeabilityComputationBehavior.REF_UPDATED_AND_CHANGE_REINDEX
) {
- // add 'is:mergeable' to SEARCH_OPERATORS_WITH_NEGATIONS_SET
+ // add 'is:mergeable' to searchOperators
this._addOperator('is:mergeable');
}
if (serverConfig) {
@@ -225,9 +227,9 @@
}
_addOperator(name: string, include_neg = true) {
- SEARCH_OPERATORS_WITH_NEGATIONS_SET.add(name);
+ this.searchOperators.add(name);
if (include_neg) {
- SEARCH_OPERATORS_WITH_NEGATIONS_SET.add(`-${name}`);
+ this.searchOperators.add(`-${name}`);
}
}
@@ -264,9 +266,9 @@
if (!this._inputVal) return;
const trimmedInput = this._inputVal.trim();
if (trimmedInput) {
- const predefinedOpOnlyQuery = [
- ...SEARCH_OPERATORS_WITH_NEGATIONS_SET,
- ].some(op => op.endsWith(':') && op === trimmedInput);
+ const predefinedOpOnlyQuery = [...this.searchOperators].some(
+ op => op.endsWith(':') && op === trimmedInput
+ );
if (predefinedOpOnlyQuery) {
return;
}
@@ -319,7 +321,7 @@
default:
return Promise.resolve(
- [...SEARCH_OPERATORS_WITH_NEGATIONS_SET]
+ [...this.searchOperators]
.filter(operator => operator.includes(input))
.map(operator => {
return {text: operator};
diff --git a/polygerrit-ui/app/elements/diff/gr-comment-api/gr-comment-api.ts b/polygerrit-ui/app/elements/diff/gr-comment-api/gr-comment-api.ts
index 0514553..8dce63f 100644
--- a/polygerrit-ui/app/elements/diff/gr-comment-api/gr-comment-api.ts
+++ b/polygerrit-ui/app/elements/diff/gr-comment-api/gr-comment-api.ts
@@ -35,6 +35,7 @@
RobotCommentInfo,
UrlEncodedCommentId,
NumericChangeId,
+ RevisionId,
} from '../../../types/common';
import {hasOwnProperty} from '../../../utils/common-util';
import {CommentSide} from '../../../constants/constants';
@@ -611,6 +612,19 @@
);
}
+ getPortedComments(changeNum: NumericChangeId, revision?: RevisionId) {
+ if (!revision) revision = 'current';
+ return Promise.all([
+ this.$.restAPI.getPortedComments(changeNum, revision),
+ this.$.restAPI.getPortedDrafts(changeNum, revision),
+ ]).then(result => {
+ return {
+ portedComments: result[0],
+ portedDrafts: result[1],
+ };
+ });
+ }
+
/**
* Load all comments (with drafts and robot comments) for the given change
* number. The returned promise resolves when the comments have loaded, but
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 d1f52ae..91fd137 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
@@ -18,6 +18,7 @@
import {GrDiffLine, GrDiffLineType, LineNumber} from '../gr-diff/gr-diff-line';
import {
GrDiffGroup,
+ GrDiffGroupRange,
GrDiffGroupType,
hideInContextControl,
rangeBySide,
@@ -64,6 +65,10 @@
};
}
+export interface ContentLoadNeededEventDetail {
+ lineRange: GrDiffGroupRange;
+}
+
export abstract class GrDiffBuilder {
private readonly _diff: DiffInfo;
@@ -93,7 +98,7 @@
this._numLinesLeft = this._diff.content
? this._diff.content.reduce((sum, chunk) => {
const left = chunk.a || chunk.ab;
- return sum + (left ? left.length : 0);
+ return sum + (left?.length || chunk.skip || 0);
}, 0)
: 0;
this._prefs = prefs;
@@ -311,8 +316,13 @@
const td = this._createElement('td');
const showPartialLinks = numLines > PARTIAL_CONTEXT_AMOUNT;
-
- if (showPartialLinks && leftStart > 1) {
+ const firstGroupIsSkipped = !!contextGroups[0].skip;
+ const lastGroupIsSkipped = !!contextGroups[contextGroups.length - 1].skip;
+ const showAboveButton =
+ showPartialLinks && leftStart > 1 && !firstGroupIsSkipped;
+ const showBelowButton =
+ showPartialLinks && leftEnd < this._numLinesLeft && !lastGroupIsSkipped;
+ if (showAboveButton) {
td.appendChild(
this._createContextButton(
ContextButtonType.ABOVE,
@@ -322,7 +332,6 @@
)
);
}
-
td.appendChild(
this._createContextButton(
ContextButtonType.ALL,
@@ -331,8 +340,7 @@
numLines
)
);
-
- if (showPartialLinks && leftEnd < this._numLinesLeft) {
+ if (showBelowButton) {
td.appendChild(
this._createContextButton(
ContextButtonType.BELOW,
@@ -342,7 +350,6 @@
)
);
}
-
return td;
}
@@ -359,7 +366,9 @@
let text = '';
let groups: GrDiffGroup[] = []; // The groups that replace this one if tapped.
+ let requiresLoad = false;
if (type === GrDiffBuilder.ContextButtonType.ALL) {
+ requiresLoad = contextGroups.find(c => !!c.skip) !== undefined;
const icon = this._createElement('iron-icon', 'showContext');
icon.setAttribute('icon', 'gr-icons:unfold-more');
button.appendChild(icon);
@@ -368,6 +377,10 @@
if (numLines > 1) {
text += 's';
}
+ if (requiresLoad) {
+ // Expanding content would require load of more data
+ text += ' (too large)';
+ }
groups.push(...contextGroups);
} else if (type === GrDiffBuilder.ContextButtonType.ABOVE) {
text = `+${context} above`;
@@ -380,15 +393,36 @@
textSpan.textContent = text;
button.appendChild(textSpan);
- button.addEventListener('tap', e => {
- const event = e as ContextEvent;
- event.detail = {
- groups,
- section,
- numLines,
- };
- // Let it bubble up the DOM tree.
- });
+ if (requiresLoad) {
+ button.addEventListener('tap', e => {
+ e.stopPropagation();
+ const firstRange = groups[0].lineRange;
+ const lastRange = groups[groups.length - 1].lineRange;
+ const lineRange = {
+ left: {start: firstRange.left.start, end: lastRange.left.end},
+ right: {start: firstRange.right.start, end: lastRange.right.end},
+ };
+ button.dispatchEvent(
+ new CustomEvent<ContentLoadNeededEventDetail>('content-load-needed', {
+ detail: {
+ lineRange,
+ },
+ bubbles: true,
+ composed: true,
+ })
+ );
+ });
+ } else {
+ button.addEventListener('tap', e => {
+ const event = e as ContextEvent;
+ event.detail = {
+ groups,
+ section,
+ numLines,
+ };
+ // Let it bubble up the DOM tree.
+ });
+ }
return button;
}
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-processor/gr-diff-processor.ts b/polygerrit-ui/app/elements/diff/gr-diff-processor/gr-diff-processor.ts
index 39e12ca..08ea1a6 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-processor/gr-diff-processor.ts
+++ b/polygerrit-ui/app/elements/diff/gr-diff-processor/gr-diff-processor.ts
@@ -274,7 +274,7 @@
}
_isCollapsibleChunk(chunk: DiffContent) {
- return (chunk.ab || chunk.common) && !chunk.keyLocation;
+ return (chunk.ab || chunk.common || chunk.skip) && !chunk.keyLocation;
}
/**
@@ -326,7 +326,11 @@
}
_commonChunkLength(chunk: DiffContent) {
+ if (chunk.skip) {
+ return chunk.skip;
+ }
console.assert(!!chunk.ab || !!chunk.common);
+
console.assert(
!chunk.a || (!!chunk.b && chunk.a.length === chunk.b.length),
'common chunk needs same number of a and b lines: ',
@@ -354,13 +358,21 @@
offsetLeft: number,
offsetRight: number
): GrDiffGroup {
- const type = chunk.ab ? GrDiffGroupType.BOTH : GrDiffGroupType.DELTA;
+ const type =
+ chunk.ab || chunk.skip ? GrDiffGroupType.BOTH : GrDiffGroupType.DELTA;
const lines = this._linesFromChunk(chunk, offsetLeft, offsetRight);
const group = new GrDiffGroup(type, lines);
group.keyLocation = !!chunk.keyLocation;
group.dueToRebase = !!chunk.due_to_rebase;
group.dueToMove = !!chunk.due_to_move;
+ group.skip = chunk.skip;
group.ignoredWhitespaceOnly = !!chunk.common;
+ if (chunk.skip) {
+ group.lineRange = {
+ left: {start: offsetLeft, end: offsetLeft + chunk.skip - 1},
+ right: {start: offsetRight, end: offsetRight + chunk.skip - 1},
+ };
+ }
return group;
}
@@ -497,7 +509,7 @@
for (const chunk of chunks) {
// If it isn't a common chunk, append it as-is and update line numbers.
- if (!chunk.ab && !chunk.common) {
+ if (!chunk.ab && !chunk.skip && !chunk.common) {
if (chunk.a) {
leftLineNum += chunk.a.length;
}
@@ -522,7 +534,13 @@
leftLineNum += numLines;
rightLineNum += numLines;
- if (chunk.ab) {
+ if (chunk.skip) {
+ result.push({
+ ...chunk,
+ skip: chunk.skip,
+ keyLocation: false,
+ });
+ } else if (chunk.ab) {
result.push(
...this._splitAtChunkEnds(chunk.ab, chunkEnds).map(
({lines, keyLocation}) => {
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-processor/gr-diff-processor_test.js b/polygerrit-ui/app/elements/diff/gr-diff-processor/gr-diff-processor_test.js
index 3a074bb..eed5900 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-processor/gr-diff-processor_test.js
+++ b/polygerrit-ui/app/elements/diff/gr-diff-processor/gr-diff-processor_test.js
@@ -171,6 +171,56 @@
});
});
+ test('at the beginning with skip chunks', async () => {
+ element.context = 10;
+ const content = [
+ {ab: new Array(20)
+ .fill('all work and no play make jack a dull boy')},
+ {skip: 43900},
+ {ab: new Array(30)
+ .fill('some other content')},
+ {a: ['some other content']},
+ ];
+
+ await element.process(content);
+
+ const groups = element.groups;
+
+ // group[0] is the file group
+
+ const commonGroup = groups[1];
+
+ // Hidden context before
+ assert.equal(commonGroup.type, GrDiffGroupType.CONTEXT_CONTROL);
+ assert.instanceOf(commonGroup.contextGroups[0], GrDiffGroup);
+ assert.equal(commonGroup.contextGroups[0].lines.length, 20);
+ for (const l of commonGroup.contextGroups[0].lines) {
+ assert.equal(l.text, 'all work and no play make jack a dull boy');
+ }
+
+ // Skipped group
+ const skipGroup = commonGroup.contextGroups[1];
+ assert.equal(skipGroup.skip, 43900);
+ const expectedRange = {
+ left: {start: 21, end: 43920},
+ right: {start: 21, end: 43920},
+ };
+ assert.deepEqual(skipGroup.lineRange, expectedRange);
+
+ // Hidden context after
+ assert.equal(commonGroup.contextGroups[2].lines.length, 20);
+ for (const l of commonGroup.contextGroups[2].lines) {
+ assert.equal(l.text, 'some other content');
+ }
+
+ // Displayed lines
+ assert.equal(groups[2].type, GrDiffGroupType.BOTH);
+ assert.equal(groups[2].lines.length, 10);
+ for (const l of groups[2].lines) {
+ assert.equal(l.text, 'some other content');
+ }
+ });
+
test('at the beginning, smaller than context', () => {
element.context = 10;
const content = [
@@ -414,6 +464,55 @@
});
});
+ test('in the middle with skip chunks', async () => {
+ element.context = 10;
+ const content = [
+ {a: ['all work and no play make andybons a dull boy']},
+ {ab: new Array(20)
+ .fill('all work and no play make jill a dull girl')},
+ {skip: 60},
+ {ab: new Array(20)
+ .fill('all work and no play make jill a dull girl')},
+ {a: ['all work and no play make andybons a dull boy']},
+ ];
+
+ await element.process(content);
+
+ const groups = element.groups;
+
+ // group[0] is the file group
+ // group[1] is the chunk with a
+ // group[2] is the displayed part of ab before
+
+ const commonGroup = groups[3];
+
+ // Hidden context before
+ assert.equal(commonGroup.type, GrDiffGroupType.CONTEXT_CONTROL);
+ assert.instanceOf(commonGroup.contextGroups[0], GrDiffGroup);
+ assert.equal(commonGroup.contextGroups[0].lines.length, 10);
+ for (const l of commonGroup.contextGroups[0].lines) {
+ assert.equal(
+ l.text, 'all work and no play make jill a dull girl');
+ }
+
+ // Skipped group
+ const skipGroup = commonGroup.contextGroups[1];
+ assert.equal(skipGroup.skip, 60);
+ const expectedRange = {
+ left: {start: 22, end: 81},
+ right: {start: 21, end: 80},
+ };
+ assert.deepEqual(skipGroup.lineRange, expectedRange);
+
+ // Hidden context after
+ assert.equal(commonGroup.contextGroups[2].lines.length, 10);
+ for (const l of commonGroup.contextGroups[2].lines) {
+ assert.equal(
+ l.text, 'all work and no play make jill a dull girl');
+ }
+ // group[4] is the displayed part of the second ab
+ });
+
test('break up common diff chunks', () => {
element.keyLocations = {
left: {1: true},
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 4bde136..f749f0e 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
@@ -101,6 +101,7 @@
import {CommentMap} from '../../../utils/comment-util';
import {AppElementParams} from '../../gr-app-types';
import {CustomKeyboardEvent, OpenFixPreviewEvent} from '../../../types/events';
+import {PORTING_COMMENTS_DIFF_LATENCY_LABEL} from '../../../services/gr-reporting/gr-reporting';
const ERR_REVIEW_STATUS = 'Couldn’t change file review status.';
const MSG_LOADING_BLAME = 'Loading blame...';
@@ -1060,6 +1061,11 @@
return;
}
+ const portedCommentsPromise = this.$.commentAPI.getPortedComments(
+ value.changeNum,
+ value.patchNum || 'current'
+ );
+
const promises: Promise<unknown>[] = [];
promises.push(this._getDiffPreferences());
@@ -1080,10 +1086,14 @@
this._loading = true;
return Promise.all(promises)
.then(r => {
+ this.reporting.time(PORTING_COMMENTS_DIFF_LATENCY_LABEL);
this._loading = false;
this._initPatchRange();
this._initCommitRange();
this.$.diffHost.comments = this._commentsForDiff;
+ portedCommentsPromise.then(() => {
+ this.reporting.timeEnd(PORTING_COMMENTS_DIFF_LATENCY_LABEL);
+ });
const edit = r[4] as EditInfo | undefined;
if (edit) {
this.set(`_change.revisions.${edit.commit.commit}`, {
diff --git a/polygerrit-ui/app/elements/diff/gr-diff/gr-diff-group.ts b/polygerrit-ui/app/elements/diff/gr-diff/gr-diff-group.ts
index 9f5cdf3..588b9d1 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff/gr-diff-group.ts
+++ b/polygerrit-ui/app/elements/diff/gr-diff/gr-diff-group.ts
@@ -38,7 +38,7 @@
end: number | null;
}
-interface GrDiffGroupRange {
+export interface GrDiffGroupRange {
left: Range;
right: Range;
}
@@ -89,7 +89,13 @@
[before, hidden] = _splitCommonGroups(hidden, hiddenStart);
}
if (hiddenEnd) {
- [hidden, after] = _splitCommonGroups(hidden, hiddenEnd - hiddenStart);
+ let beforeLength = 0;
+ if (before.length > 0) {
+ const beforeStart = before[0].lineRange.left.start || 0;
+ const beforeEnd = before[before.length - 1].lineRange.left.end || 0;
+ beforeLength = beforeEnd - beforeStart + 1;
+ }
+ [hidden, after] = _splitCommonGroups(hidden, hiddenEnd - beforeLength);
}
} else {
[hidden, after] = [[], hidden];
@@ -106,6 +112,67 @@
}
/**
+ * Splits a group in two, defined by leftSplit and rightSplit. Primarily to be
+ * used in function _splitCommonGroups
+ * Groups with some lines before and some lines after the split will be split
+ * into two groups, which will be put into the first and second list.
+ *
+ * @param group The group to be split in two
+ * @param leftSplit The line number relative to the split on the left side
+ * @param rightSplit The line number relative to the split on the right side
+ * @return two new groups, one before the split and another after it
+ */
+function _splitGroupInTwo(
+ group: GrDiffGroup,
+ leftSplit: number,
+ rightSplit: number
+) {
+ let beforeSplit: GrDiffGroup | undefined;
+ let afterSplit: GrDiffGroup | undefined;
+ // split line is in the middle of a group, we need to break the group
+ // in lines before and after the split.
+ if (group.skip) {
+ // Currently we assume skip chunks "refuse" to be split. Expanding this
+ // group will in the future mean load more data - and therefore we want to
+ // fire an event when user wants to do it.
+ const closerToStartThanEnd =
+ leftSplit - (group.lineRange.left.start || 0) <
+ (group.lineRange.right.end || 0) - leftSplit;
+ if (closerToStartThanEnd) {
+ afterSplit = group;
+ } else {
+ beforeSplit = group;
+ }
+ } else {
+ const before = [];
+ const after = [];
+ for (const line of group.lines) {
+ if (
+ (line.beforeNumber && line.beforeNumber < leftSplit) ||
+ (line.afterNumber && line.afterNumber < rightSplit)
+ ) {
+ before.push(line);
+ } else {
+ after.push(line);
+ }
+ }
+ if (before.length) {
+ beforeSplit =
+ before.length === group.lines.length
+ ? group
+ : group.cloneWithLines(before);
+ }
+ if (after.length) {
+ afterSplit =
+ after.length === group.lines.length
+ ? group
+ : group.cloneWithLines(after);
+ }
+ }
+ return {beforeSplit, afterSplit};
+}
+
+/**
* Splits a list of common groups into two lists of groups.
*
* Groups where all lines are before or all lines are after the split will be
@@ -129,47 +196,28 @@
const beforeGroups = [];
const afterGroups = [];
for (const group of groups) {
- if (
+ const isCompletelyBefore =
(group.lineRange.left.end || 0) < leftSplit ||
- (group.lineRange.right.end || 0) < rightSplit
- ) {
- beforeGroups.push(group);
- continue;
- }
- if (
+ (group.lineRange.right.end || 0) < rightSplit;
+ const isCompletelyAfter =
leftSplit <= (group.lineRange.left.start || 0) ||
- rightSplit <= (group.lineRange.right.start || 0)
- ) {
+ rightSplit <= (group.lineRange.right.start || 0);
+ if (isCompletelyBefore) {
+ beforeGroups.push(group);
+ } else if (isCompletelyAfter) {
afterGroups.push(group);
- continue;
- }
-
- const before = [];
- const after = [];
- for (const line of group.lines) {
- if (
- (line.beforeNumber && line.beforeNumber < leftSplit) ||
- (line.afterNumber && line.afterNumber < rightSplit)
- ) {
- before.push(line);
- } else {
- after.push(line);
+ } else {
+ const {beforeSplit, afterSplit} = _splitGroupInTwo(
+ group,
+ leftSplit,
+ rightSplit
+ );
+ if (beforeSplit) {
+ beforeGroups.push(beforeSplit);
}
- }
-
- if (before.length) {
- beforeGroups.push(
- before.length === group.lines.length
- ? group
- : group.cloneWithLines(before)
- );
- }
- if (after.length) {
- afterGroups.push(
- after.length === group.lines.length
- ? group
- : group.cloneWithLines(after)
- );
+ if (afterSplit) {
+ afterGroups.push(afterSplit);
+ }
}
}
return [beforeGroups, afterGroups];
@@ -213,6 +261,8 @@
contextGroups: GrDiffGroup[] = [];
+ skip?: number;
+
/** Both start and end line are inclusive. */
lineRange: GrDiffGroupRange = {
left: {start: null, end: null},
diff --git a/polygerrit-ui/app/elements/diff/gr-diff/gr-diff-group_test.js b/polygerrit-ui/app/elements/diff/gr-diff/gr-diff-group_test.js
index 13fc4d1..3423834 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff/gr-diff-group_test.js
+++ b/polygerrit-ui/app/elements/diff/gr-diff/gr-diff-group_test.js
@@ -177,6 +177,35 @@
assert.deepEqual(collapsedGroups[3].lines, groups[2].lines.slice(1));
});
+ suite('with skip chunks', () => {
+ setup(() => {
+ const skipGroup = new GrDiffGroup(GrDiffGroupType.BOTH);
+ skipGroup.skip = 60;
+ skipGroup.lineRange = {
+ left: {start: 8, end: 67},
+ right: {start: 10, end: 69},
+ };
+ groups = [
+ new GrDiffGroup(GrDiffGroupType.BOTH, [
+ new GrDiffLine(GrDiffLineType.BOTH, 5, 7),
+ new GrDiffLine(GrDiffLineType.BOTH, 6, 8),
+ new GrDiffLine(GrDiffLineType.BOTH, 7, 9),
+ ]),
+ skipGroup,
+ new GrDiffGroup(GrDiffGroupType.BOTH, [
+ new GrDiffLine(GrDiffLineType.BOTH, 68, 70),
+ new GrDiffLine(GrDiffLineType.BOTH, 69, 71),
+ new GrDiffLine(GrDiffLineType.BOTH, 70, 72),
+ ]),
+ ];
+ });
+
+ test('refuses to split skip group when closer to before', () => {
+ const collapsedGroups = hideInContextControl(groups, 4, 10);
+ assert.deepEqual(groups, collapsedGroups);
+ });
+ });
+
test('groups unchanged if the hidden range is empty', () => {
assert.deepEqual(
hideInContextControl(groups, 0, 0), groups);
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 7d08560..1a89008 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
@@ -2795,6 +2795,43 @@
return this._changeBaseURL(changeNum, patchNum).then(url => url + endpoint);
}
+ getPortedComments(
+ changeNum: NumericChangeId,
+ revision: RevisionId
+ ): Promise<PathToCommentsInfoMap | undefined> {
+ // maintaining a custom error function so that errors do not surface in UI
+ const errFn: ErrorCallback = (response?: Response | null) => {
+ if (response)
+ console.info(`Fetching ported comments failed, ${response.status}`);
+ };
+ return this._getChangeURLAndFetch({
+ changeNum,
+ endpoint: '/ported_comments/',
+ revision,
+ errFn,
+ });
+ }
+
+ getPortedDrafts(
+ changeNum: NumericChangeId,
+ revision: RevisionId
+ ): Promise<PathToCommentsInfoMap | undefined> {
+ // maintaining a custom error function so that errors do not surface in UI
+ const errFn: ErrorCallback = (response?: Response | null) => {
+ if (response)
+ console.info(`Fetching ported drafts failed, ${response.status}`);
+ };
+ return this.getLoggedIn().then(loggedIn => {
+ if (!loggedIn) return {};
+ return this._getChangeURLAndFetch({
+ changeNum,
+ endpoint: '/ported_drafts/',
+ revision,
+ errFn,
+ });
+ });
+ }
+
saveDiffDraft(
changeNum: NumericChangeId,
patchNum: PatchSetNum,
diff --git a/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-api-interface_test.js b/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-api-interface_test.js
index 505fd58..d75c186 100644
--- a/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-api-interface_test.js
+++ b/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-api-interface_test.js
@@ -21,6 +21,7 @@
import {GrReviewerUpdatesParser} from './gr-reviewer-updates-parser.js';
import {ListChangesOption} from '../../../utils/change-util.js';
import {appContext} from '../../../services/app-context.js';
+import {createChange} from '../../../test/test-data-generators.js';
const basicFixture = fixtureFromElement('gr-rest-api-interface');
@@ -1343,6 +1344,28 @@
assert.isTrue(handler.calledOnce);
});
+ test('ported comment errors do not trigger error dialog', () => {
+ const change = createChange();
+ const dispatchStub = sinon.stub(element._restApiHelper, 'dispatchEvent');
+ sinon.stub(element._restApiHelper, 'fetchJSON').returns(Promise.resolve({
+ ok: false}));
+
+ element.getPortedComments(change._number, 'current');
+
+ assert.isFalse(dispatchStub.called);
+ });
+
+ test('ported drafts are not requested user is not logged in', () => {
+ const change = createChange();
+ sinon.stub(element, 'getLoggedIn').returns(Promise.resolve(false));
+ const getChangeURLAndFetchStub = sinon.stub(element,
+ '_getChangeURLAndFetch');
+
+ element.getPortedDrafts(change._number, 'current');
+
+ assert.isFalse(getChangeURLAndFetchStub.called);
+ });
+
test('saveChangeStarred', async () => {
sinon.stub(element, 'getFromProjectLookup')
.returns(Promise.resolve('test'));
diff --git a/polygerrit-ui/app/services/gr-reporting/gr-reporting.ts b/polygerrit-ui/app/services/gr-reporting/gr-reporting.ts
index 743e0f4..e6139e5 100644
--- a/polygerrit-ui/app/services/gr-reporting/gr-reporting.ts
+++ b/polygerrit-ui/app/services/gr-reporting/gr-reporting.ts
@@ -20,6 +20,10 @@
// TODO(dmfilippov): TS-fix-any use more specific type instead if possible
export type EventDetails = any;
+export const PORTING_COMMENTS_DIFF_LATENCY_LABEL = 'PortingCommentsDiffLatency';
+export const PORTING_COMMENTS_CHANGE_LATENCY_LABEL =
+ 'PortingCommentsChangeLatency';
+
export interface Timer {
reset(): this;
end(): this;
diff --git a/polygerrit-ui/app/services/services/gr-rest-api/gr-rest-api.ts b/polygerrit-ui/app/services/services/gr-rest-api/gr-rest-api.ts
index 950619b..08dbb16 100644
--- a/polygerrit-ui/app/services/services/gr-rest-api/gr-rest-api.ts
+++ b/polygerrit-ui/app/services/services/gr-rest-api/gr-rest-api.ts
@@ -457,6 +457,16 @@
cancelCondition?: CancelConditionCallback
): Promise<ChangeInfo | undefined | null>;
+ getPortedComments(
+ changeNum: NumericChangeId,
+ revision: RevisionId
+ ): Promise<PathToCommentsInfoMap | undefined>;
+
+ getPortedDrafts(
+ changeNum: NumericChangeId,
+ revision: RevisionId
+ ): Promise<PathToCommentsInfoMap | undefined>;
+
getDiffComments(
changeNum: NumericChangeId
): Promise<PathToCommentsInfoMap | undefined>;
diff --git a/polygerrit-ui/app/types/common.ts b/polygerrit-ui/app/types/common.ts
index 1d1d32e..96cb02a 100644
--- a/polygerrit-ui/app/types/common.ts
+++ b/polygerrit-ui/app/types/common.ts
@@ -1184,6 +1184,11 @@
export type PathToCommentsInfoMap = {[path: string]: CommentInfo[]};
+export type PortedCommentsAndDrafts = {
+ portedComments?: PathToCommentsInfoMap;
+ portedDrafts?: PathToCommentsInfoMap;
+};
+
/**
* The CommentRange entity describes the range of an inline comment.
* https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#comment-range
@@ -1249,7 +1254,7 @@
edit_b?: number[][];
due_to_rebase?: boolean;
due_to_move?: boolean;
- skip?: string;
+ skip?: number;
common?: string;
keyLocation?: boolean;
}