Add 'uploader' and 'uploaderin' predicates

These predicates are exact copies of the 'owner' and 'ownerin'
predicates except that they work on the uploader of the latest
patch set. The intention is to use these primarily as submit
requirements, but they seem reasonably generic to also serve well
as index predicates.

Therefore, this commit adds a new ChangeIndex schema version
with an uploader field as well as predicates.

Change-Id: I4441917e9e3436ddf756b5268fae86acafad9c4b
diff --git a/Documentation/user-search.txt b/Documentation/user-search.txt
index 3977278..cc8d813 100644
--- a/Documentation/user-search.txt
+++ b/Documentation/user-search.txt
@@ -136,6 +136,19 @@
 +
 Changes originally submitted by a user in 'GROUP'.
 
+[[uploader]]
+uploader:'USER'::
++
+Changes where the latest patch set was uploaded by 'USER'.
+The special case of `uploader:self` will find changes uploaded
+by the caller.
+
+[[uploaderin]]
+uploaderin:'GROUP'::
++
+Changes where the latest patch set was uploaded by a user in
+'GROUP'.
+
 [[query]]
 query:'[name=]NAME[,user=USER]'::
 +
@@ -466,6 +479,12 @@
 True on any change where the current user is the change owner.
 Same as `owner:self`.
 
+is:uploader::
++
+True on any change where the current user is the uploader of
+the latest patch set.
+Same as `uploader:self`.
+
 is:reviewer::
 +
 True on any change where the current user is a reviewer.
diff --git a/java/com/google/gerrit/server/index/change/ChangeField.java b/java/com/google/gerrit/server/index/change/ChangeField.java
index bfe1ee1..b9569e4 100644
--- a/java/com/google/gerrit/server/index/change/ChangeField.java
+++ b/java/com/google/gerrit/server/index/change/ChangeField.java
@@ -294,6 +294,10 @@
   public static final FieldDef<ChangeData, Integer> OWNER =
       integer(ChangeQueryBuilder.FIELD_OWNER).build(changeGetter(c -> c.getOwner().get()));
 
+  /** Uploader of the latest patch set. */
+  public static final FieldDef<ChangeData, Integer> UPLOADER =
+      integer(ChangeQueryBuilder.FIELD_UPLOADER).build(cd -> cd.currentPatchSet().uploader().get());
+
   /** References the source change number that this change was cherry-picked from. */
   public static final FieldDef<ChangeData, Integer> CHERRY_PICK_OF_CHANGE =
       integer(ChangeQueryBuilder.FIELD_CHERRY_PICK_OF_CHANGE)
diff --git a/java/com/google/gerrit/server/index/change/ChangeSchemaDefinitions.java b/java/com/google/gerrit/server/index/change/ChangeSchemaDefinitions.java
index 30ab6e6a..9339d62 100644
--- a/java/com/google/gerrit/server/index/change/ChangeSchemaDefinitions.java
+++ b/java/com/google/gerrit/server/index/change/ChangeSchemaDefinitions.java
@@ -178,9 +178,14 @@
       new Schema.Builder<ChangeData>().add(V68).add(ChangeField.CHERRY_PICK).build();
 
   /** Added new field {@link ChangeField#ATTENTION_SET_USERS_COUNT}. */
+  @Deprecated
   static final Schema<ChangeData> V70 =
       new Schema.Builder<ChangeData>().add(V69).add(ChangeField.ATTENTION_SET_USERS_COUNT).build();
 
+  /** Added new field {@link ChangeField#UPLOADER}. */
+  static final Schema<ChangeData> V71 =
+      new Schema.Builder<ChangeData>().add(V70).add(ChangeField.UPLOADER).build();
+
   /**
    * Name of the change index to be used when contacting index backends or loading configurations.
    */
diff --git a/java/com/google/gerrit/server/query/change/ChangePredicates.java b/java/com/google/gerrit/server/query/change/ChangePredicates.java
index 4e638df..043b37d 100644
--- a/java/com/google/gerrit/server/query/change/ChangePredicates.java
+++ b/java/com/google/gerrit/server/query/change/ChangePredicates.java
@@ -125,6 +125,14 @@
   }
 
   /**
+   * Returns a predicate that matches changes where the latest patch set was uploaded by the
+   * provided {@link com.google.gerrit.entities.Account.Id}.
+   */
+  public static Predicate<ChangeData> uploader(Account.Id id) {
+    return new ChangeIndexPredicate(ChangeField.UPLOADER, id.toString());
+  }
+
+  /**
    * Returns a predicate that matches changes that are a cherry pick of the provided {@link
    * com.google.gerrit.entities.Change.Id}.
    */
diff --git a/java/com/google/gerrit/server/query/change/ChangeQueryBuilder.java b/java/com/google/gerrit/server/query/change/ChangeQueryBuilder.java
index f1fe520..da36633 100644
--- a/java/com/google/gerrit/server/query/change/ChangeQueryBuilder.java
+++ b/java/com/google/gerrit/server/query/change/ChangeQueryBuilder.java
@@ -195,6 +195,8 @@
   public static final String FIELD_SUBMISSIONID = "submissionid";
   public static final String FIELD_TR = "tr";
   public static final String FIELD_UNRESOLVED_COMMENT_COUNT = "unresolved";
+  public static final String FIELD_UPLOADER = "uploader";
+  public static final String FIELD_UPLOADERIN = "uploaderin";
   public static final String FIELD_VISIBLETO = "visibleto";
   public static final String FIELD_WATCHEDBY = "watchedby";
   public static final String FIELD_WIP = "wip";
@@ -661,6 +663,14 @@
       return ChangePredicates.owner(self());
     }
 
+    if ("uploader".equalsIgnoreCase(value)) {
+      if (!args.getSchema().hasField(ChangeField.UPLOADER)) {
+        throw new QueryParseException(
+            "'is:uploader' operator is not supported by change index version");
+      }
+      return ChangePredicates.uploader(self());
+    }
+
     if ("reviewer".equalsIgnoreCase(value)) {
       if (args.getSchema().hasField(ChangeField.WIP)) {
         return Predicate.and(
@@ -1163,6 +1173,23 @@
   }
 
   @Operator
+  public Predicate<ChangeData> uploader(String who)
+      throws QueryParseException, IOException, ConfigInvalidException {
+    if (!args.getSchema().hasField(ChangeField.UPLOADER)) {
+      throw new QueryParseException("'uploader' operator is not supported by change index version");
+    }
+    return uploader(parseAccount(who, (AccountState s) -> true));
+  }
+
+  private Predicate<ChangeData> uploader(Set<Account.Id> who) {
+    List<Predicate<ChangeData>> p = Lists.newArrayListWithCapacity(who.size());
+    for (Account.Id id : who) {
+      p.add(ChangePredicates.uploader(id));
+    }
+    return Predicate.or(p);
+  }
+
+  @Operator
   public Predicate<ChangeData> attention(String who)
       throws QueryParseException, IOException, ConfigInvalidException {
     if (!args.index.getSchema().hasField(ChangeField.ATTENTION_SET_USERS)) {
@@ -1212,6 +1239,31 @@
   }
 
   @Operator
+  public Predicate<ChangeData> uploaderin(String group) throws QueryParseException, IOException {
+    if (!args.getSchema().hasField(ChangeField.UPLOADER)) {
+      throw new QueryParseException("'uploader' operator is not supported by change index version");
+    }
+
+    GroupReference g = GroupBackends.findBestSuggestion(args.groupBackend, group);
+    if (g == null) {
+      throw error("Group " + group + " not found");
+    }
+
+    AccountGroup.UUID groupId = g.getUUID();
+    GroupDescription.Basic groupDescription = args.groupBackend.get(groupId);
+    if (!(groupDescription instanceof GroupDescription.Internal)) {
+      return new UploaderinPredicate(args.userFactory, groupId);
+    }
+
+    Set<Account.Id> accounts = getMembers(groupId);
+    List<Predicate<ChangeData>> p = Lists.newArrayListWithCapacity(accounts.size());
+    for (Account.Id id : accounts) {
+      p.add(ChangePredicates.uploader(id));
+    }
+    return Predicate.or(p);
+  }
+
+  @Operator
   public Predicate<ChangeData> r(String who)
       throws QueryParseException, IOException, ConfigInvalidException {
     return reviewer(who);
diff --git a/java/com/google/gerrit/server/query/change/UploaderinPredicate.java b/java/com/google/gerrit/server/query/change/UploaderinPredicate.java
new file mode 100644
index 0000000..f44c0b5
--- /dev/null
+++ b/java/com/google/gerrit/server/query/change/UploaderinPredicate.java
@@ -0,0 +1,50 @@
+// Copyright (C) 2021 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.query.change;
+
+import com.google.gerrit.entities.AccountGroup;
+import com.google.gerrit.entities.PatchSet;
+import com.google.gerrit.index.query.PostFilterPredicate;
+import com.google.gerrit.server.IdentifiedUser;
+
+/**
+ * Predicate that matches changes where the latest patch set was uploaded by a user in the provided
+ * group.
+ */
+public class UploaderinPredicate extends PostFilterPredicate<ChangeData> {
+  protected final IdentifiedUser.GenericFactory userFactory;
+  protected final AccountGroup.UUID uuid;
+
+  public UploaderinPredicate(IdentifiedUser.GenericFactory userFactory, AccountGroup.UUID uuid) {
+    super(ChangeQueryBuilder.FIELD_UPLOADERIN, uuid.get());
+    this.userFactory = userFactory;
+    this.uuid = uuid;
+  }
+
+  @Override
+  public boolean match(ChangeData cd) {
+    PatchSet latestPatchSet = cd.currentPatchSet();
+    if (latestPatchSet == null) {
+      return false;
+    }
+    IdentifiedUser uploader = userFactory.create(latestPatchSet.uploader());
+    return uploader.getEffectiveGroups().contains(uuid);
+  }
+
+  @Override
+  public int getCost() {
+    return 2;
+  }
+}
diff --git a/javatests/com/google/gerrit/server/query/change/AbstractQueryChangesTest.java b/javatests/com/google/gerrit/server/query/change/AbstractQueryChangesTest.java
index 2c6dd66..332548a 100644
--- a/javatests/com/google/gerrit/server/query/change/AbstractQueryChangesTest.java
+++ b/javatests/com/google/gerrit/server/query/change/AbstractQueryChangesTest.java
@@ -601,6 +601,29 @@
   }
 
   @Test
+  public void byUploader() throws Exception {
+    assume().that(getSchema().hasField(ChangeField.UPLOADER)).isTrue();
+    Account.Id user2 =
+        accountManager.authenticate(authRequestFactory.createForUser("anotheruser")).getAccountId();
+    CurrentUser user2CurrentUser = userFactory.create(user2);
+
+    TestRepository<Repo> repo = createProject("repo");
+    Change change1 = insert(repo, newChange(repo), userId);
+    assertQuery("is:uploader", change1);
+    assertQuery("uploader:" + userId.get(), change1);
+    change1 = newPatchSet(repo, change1, user2CurrentUser);
+    // Uploader has changed
+    assertQuery("uploader:" + userId.get());
+    assertQuery("uploader:" + user2.get(), change1);
+
+    requestContext.setContext(newRequestContext(user2));
+    assertQuery("is:uploader", change1); // self (user2)
+
+    String nameEmail = user2CurrentUser.asIdentifiedUser().getNameEmail();
+    assertQuery("uploader: \"" + nameEmail + "\"", change1);
+  }
+
+  @Test
   public void byAuthorExact() throws Exception {
     assume().that(getSchema().hasField(ChangeField.EXACT_AUTHOR)).isTrue();
     byAuthorOrCommitterExact("author:");
@@ -716,6 +739,20 @@
   }
 
   @Test
+  public void byUploaderIn() throws Exception {
+    assume().that(getSchema().hasField(ChangeField.UPLOADER)).isTrue();
+    TestRepository<Repo> repo = createProject("repo");
+    Change change1 = insert(repo, newChange(repo), userId);
+    assertQuery("uploaderin:Administrators", change1);
+
+    Account.Id user2 =
+        accountManager.authenticate(authRequestFactory.createForUser("anotheruser")).getAccountId();
+    CurrentUser user2CurrentUser = userFactory.create(user2);
+    newPatchSet(repo, change1, user2CurrentUser);
+    assertQuery("uploaderin:Administrators");
+  }
+
+  @Test
   public void byProject() throws Exception {
     TestRepository<Repo> repo1 = createProject("repo1");
     TestRepository<Repo> repo2 = createProject("repo2");
@@ -2585,7 +2622,7 @@
     gApi.changes().id(change2.getId().get()).current().review(new ReviewInput().message("comment"));
 
     PatchSet.Id ps3_1 = change3.currentPatchSetId();
-    change3 = newPatchSet(repo, change3);
+    change3 = newPatchSet(repo, change3, user);
     assertThat(change3.currentPatchSetId()).isNotEqualTo(ps3_1);
     // Response to previous patch set still counts as reviewing.
     gApi.changes()
@@ -3891,7 +3928,8 @@
     }
   }
 
-  protected Change newPatchSet(TestRepository<Repo> repo, Change c) throws Exception {
+  protected Change newPatchSet(TestRepository<Repo> repo, Change c, CurrentUser user)
+      throws Exception {
     // Add a new file so the patch set is not a trivial rebase, to avoid default
     // Code-Review label copying.
     int n = c.currentPatchSetId().get() + 1;