feat: Allow listing tags in specific order

Tags are sorted alphabetically by default. To allow more control
for the users, this commit adds support for more sorting options,
like the tag creation time.

Release-Notes: Allow listing tags in specific order
Change-Id: I4020a8d929c1c9760ee04f85a295962fb0887371
diff --git a/Documentation/rest-api-projects.txt b/Documentation/rest-api-projects.txt
index fa30c87..b0e1b49 100644
--- a/Documentation/rest-api-projects.txt
+++ b/Documentation/rest-api-projects.txt
@@ -2514,6 +2514,53 @@
   ]
 ----
 
+SortBy(sortby)::
+Sort the returned tags by one of the supported sort options: ref (default), creation_time.
++
+.Request
+----
+  GET /projects/work%2Fmy-project/tags?sortby=creation_time HTTP/1.0
+----
++
+.Response
+----
+  HTTP/1.1 200 OK
+  Content-Disposition: attachment
+  Content-Type: application/json; charset=UTF-8
+
+  )]}'
+  [
+    {
+      "ref": "refs/tags/v1.0",
+      "revision": "49ce77fdcfd3398dc0dedbe016d1a425fd52d666",
+      "object": "1624f5af8ae89148d1a3730df8c290413e3dcf30",
+      "message": "Annotated tag",
+      "tagger": {
+        "name": "David Pursehouse",
+        "email": "david.pursehouse@sonymobile.com",
+        "date": "2014-10-06 07:35:03.000000000",
+        "tz": 540
+      }
+    },
+    {
+      "ref": "refs/tags/v3.0",
+      "revision": "c628685b3c5a3614571ecb5c8fceb85db9112313",
+      "object": "1624f5af8ae89148d1a3730df8c290413e3dcf30",
+      "message": "Signed tag\n-----BEGIN PGP SIGNATURE-----\nVersion: GnuPG v1.4.11 (GNU/Linux)\n\niQEcBAABAgAGBQJUMlqYAAoJEPI2qVPgglptp7MH/j+KFcittFbxfSnZjUl8n5IZ\nveZo7wE+syjD9sUbMH4EGv0WYeVjphNTyViBof+stGTNkB0VQzLWI8+uWmOeiJ4a\nzj0LsbDOxodOEMI5tifo02L7r4Lzj++EbqtKv8IUq2kzYoQ2xjhKfFiGjeYOn008\n9PGnhNblEHZgCHguGR6GsfN8bfA2XNl9B5Ysl5ybX1kAVs/TuLZR4oDMZ/pW2S75\nhuyNnSgcgq7vl2gLGefuPs9lxkg5Fj3GZr7XPZk4pt/x1oiH7yXxV4UzrUwg2CC1\nfHrBlNbQ4uJNY8TFcwof52Z0cagM5Qb/ZSLglHbqEDGA68HPqbwf5z2hQyU2/U4\u003d\n\u003dZtUX\n-----END PGP SIGNATURE-----",
+      "tagger": {
+        "name": "David Pursehouse",
+        "email": "david.pursehouse@sonymobile.com",
+        "date": "2014-10-06 09:02:16.000000000",
+        "tz": 540
+      }
+    },
+    {
+      "ref": "refs/tags/v2.0",
+      "revision": "1624f5af8ae89148d1a3730df8c290413e3dcf30"
+    }
+  ]
+----
+
 Substring(m)::
 Limit the results to those tags that match the specified substring.
 +
diff --git a/java/com/google/gerrit/extensions/api/projects/ProjectApi.java b/java/com/google/gerrit/extensions/api/projects/ProjectApi.java
index fa94e0d..0b1b6b0 100644
--- a/java/com/google/gerrit/extensions/api/projects/ProjectApi.java
+++ b/java/com/google/gerrit/extensions/api/projects/ProjectApi.java
@@ -22,6 +22,7 @@
 import com.google.gerrit.extensions.common.BatchLabelInput;
 import com.google.gerrit.extensions.common.ChangeInfo;
 import com.google.gerrit.extensions.common.LabelDefinitionInfo;
+import com.google.gerrit.extensions.common.ListTagSortOption;
 import com.google.gerrit.extensions.common.ProjectInfo;
 import com.google.gerrit.extensions.common.SubmitRequirementInfo;
 import com.google.gerrit.extensions.restapi.NotImplementedException;
@@ -76,6 +77,7 @@
     protected String substring;
     protected String regex;
     protected String nextPageToken;
+    protected ListTagSortOption sortBy = ListTagSortOption.REF;
 
     public abstract List<T> get() throws RestApiException;
 
@@ -94,6 +96,11 @@
       return this;
     }
 
+    public ListRefsRequest<T> withSortBy(ListTagSortOption sortBy) {
+      this.sortBy = sortBy;
+      return this;
+    }
+
     public ListRefsRequest<T> withNextPageToken(String token) {
       this.nextPageToken = token;
       return this;
@@ -121,6 +128,10 @@
       return descendingOrder;
     }
 
+    public ListTagSortOption getSortBy() {
+      return sortBy;
+    }
+
     public String getNextPageToken() {
       return nextPageToken;
     }
diff --git a/java/com/google/gerrit/extensions/common/ListTagSortOption.java b/java/com/google/gerrit/extensions/common/ListTagSortOption.java
new file mode 100644
index 0000000..2140924
--- /dev/null
+++ b/java/com/google/gerrit/extensions/common/ListTagSortOption.java
@@ -0,0 +1,20 @@
+// Copyright (C) 2024 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.extensions.common;
+
+public enum ListTagSortOption {
+  REF,
+  CREATION_TIME,
+}
diff --git a/java/com/google/gerrit/server/CmdLineParserModule.java b/java/com/google/gerrit/server/CmdLineParserModule.java
index be6b4cd8..c523a29 100644
--- a/java/com/google/gerrit/server/CmdLineParserModule.java
+++ b/java/com/google/gerrit/server/CmdLineParserModule.java
@@ -18,12 +18,14 @@
 import com.google.gerrit.entities.AccountGroup;
 import com.google.gerrit.entities.Change;
 import com.google.gerrit.entities.PatchSet;
+import com.google.gerrit.extensions.common.ListTagSortOption;
 import com.google.gerrit.extensions.config.FactoryModule;
 import com.google.gerrit.server.args4j.AccountGroupIdHandler;
 import com.google.gerrit.server.args4j.AccountGroupUUIDHandler;
 import com.google.gerrit.server.args4j.AccountIdHandler;
 import com.google.gerrit.server.args4j.ChangeIdHandler;
 import com.google.gerrit.server.args4j.InstantHandler;
+import com.google.gerrit.server.args4j.ListTagSortOptionHandler;
 import com.google.gerrit.server.args4j.ObjectIdHandler;
 import com.google.gerrit.server.args4j.PatchSetIdHandler;
 import com.google.gerrit.server.args4j.ProjectHandler;
@@ -54,6 +56,7 @@
     registerOptionHandler(PatchSet.Id.class, PatchSetIdHandler.class);
     registerOptionHandler(ProjectState.class, ProjectHandler.class);
     registerOptionHandler(SocketAddress.class, SocketAddressHandler.class);
+    registerOptionHandler(ListTagSortOption.class, ListTagSortOptionHandler.class);
   }
 
   private <T> void registerOptionHandler(Class<T> type, Class<? extends OptionHandler<T>> impl) {
diff --git a/java/com/google/gerrit/server/args4j/ListTagSortOptionHandler.java b/java/com/google/gerrit/server/args4j/ListTagSortOptionHandler.java
new file mode 100644
index 0000000..9359ca1
--- /dev/null
+++ b/java/com/google/gerrit/server/args4j/ListTagSortOptionHandler.java
@@ -0,0 +1,54 @@
+// Copyright (C) 2024 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.args4j;
+
+import static com.google.gerrit.util.cli.Localizable.localizable;
+
+import com.google.gerrit.extensions.common.ListTagSortOption;
+import com.google.inject.Inject;
+import com.google.inject.assistedinject.Assisted;
+import org.kohsuke.args4j.CmdLineException;
+import org.kohsuke.args4j.CmdLineParser;
+import org.kohsuke.args4j.OptionDef;
+import org.kohsuke.args4j.spi.OptionHandler;
+import org.kohsuke.args4j.spi.Parameters;
+import org.kohsuke.args4j.spi.Setter;
+
+public class ListTagSortOptionHandler extends OptionHandler<ListTagSortOption> {
+  @Inject
+  public ListTagSortOptionHandler(
+      @Assisted CmdLineParser parser,
+      @Assisted OptionDef option,
+      @Assisted Setter<ListTagSortOption> setter) {
+    super(parser, option, setter);
+  }
+
+  @Override
+  public int parseArguments(Parameters params) throws CmdLineException {
+    String param = params.getParameter(0);
+    try {
+      setter.addValue(ListTagSortOption.valueOf(param.toUpperCase()));
+      return 1;
+    } catch (IllegalArgumentException e) {
+      throw new CmdLineException(
+          owner, localizable("\"%s\" is not a valid sort option: %s"), param, e.getMessage());
+    }
+  }
+
+  @Override
+  public String getDefaultMetaVariable() {
+    return "SORT";
+  }
+}
diff --git a/java/com/google/gerrit/server/restapi/project/ListTags.java b/java/com/google/gerrit/server/restapi/project/ListTags.java
index 5eca1fc..24bbb24 100644
--- a/java/com/google/gerrit/server/restapi/project/ListTags.java
+++ b/java/com/google/gerrit/server/restapi/project/ListTags.java
@@ -15,12 +15,12 @@
 package com.google.gerrit.server.restapi.project;
 
 import static com.google.gerrit.entities.RefNames.isConfigRef;
-import static java.util.Comparator.comparing;
 
 import com.google.common.collect.ImmutableList;
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.extensions.api.projects.ProjectApi.ListRefsRequest;
 import com.google.gerrit.extensions.api.projects.TagInfo;
+import com.google.gerrit.extensions.common.ListTagSortOption;
 import com.google.gerrit.extensions.common.WebLinkInfo;
 import com.google.gerrit.extensions.restapi.IdString;
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
@@ -59,6 +59,7 @@
   private final GitRepositoryManager repoManager;
   private final PermissionBackend permissionBackend;
   private final WebLinks links;
+  private final TagSorter tagSorter;
 
   @Option(
       name = "--limit",
@@ -81,7 +82,7 @@
   @Option(
       name = "--descending",
       aliases = {"-d"},
-      usage = "sort the returned tags in descending order")
+      usage = "return the tags in descending order")
   public void setDescendingOrder(boolean descendingOrder) {
     this.descendingOrder = descendingOrder;
   }
@@ -104,18 +105,31 @@
     this.matchRegex = matchRegex;
   }
 
+  @Option(
+      name = "--sort-by",
+      aliases = {"-sortby"},
+      usage = "sort the tags")
+  private void setSortBy(ListTagSortOption sortBy) {
+    this.sortBy = sortBy;
+  }
+
   private int limit;
   private int start;
   private boolean descendingOrder;
   private String matchSubstring;
   private String matchRegex;
+  private ListTagSortOption sortBy = ListTagSortOption.REF;
 
   @Inject
   public ListTags(
-      GitRepositoryManager repoManager, PermissionBackend permissionBackend, WebLinks webLinks) {
+      GitRepositoryManager repoManager,
+      PermissionBackend permissionBackend,
+      WebLinks webLinks,
+      TagSorter tagSorter) {
     this.repoManager = repoManager;
     this.permissionBackend = permissionBackend;
     this.links = webLinks;
+    this.tagSorter = tagSorter;
   }
 
   public ListTags request(ListRefsRequest<TagInfo> request) {
@@ -124,6 +138,7 @@
     this.setDescendingOrder(request.getDescendingOrder());
     this.setMatchSubstring(request.getSubstring());
     this.setMatchRegex(request.getRegex());
+    this.setSortBy(request.getSortBy());
     return this;
   }
 
@@ -147,7 +162,7 @@
       }
     }
 
-    tags.sort(comparing(t -> t.ref));
+    tagSorter.sort(sortBy, tags, descendingOrder);
     if (descendingOrder) {
       Collections.reverse(tags);
     }
diff --git a/java/com/google/gerrit/server/restapi/project/TagSorter.java b/java/com/google/gerrit/server/restapi/project/TagSorter.java
new file mode 100644
index 0000000..4776ce1
--- /dev/null
+++ b/java/com/google/gerrit/server/restapi/project/TagSorter.java
@@ -0,0 +1,46 @@
+// Copyright (C) 2024 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.restapi.project;
+
+import static java.util.Comparator.comparing;
+
+import com.google.gerrit.extensions.api.projects.TagInfo;
+import com.google.gerrit.extensions.common.ListTagSortOption;
+import com.google.inject.Inject;
+import java.sql.Timestamp;
+import java.util.Comparator;
+import java.util.List;
+
+public class TagSorter {
+  @Inject
+  public TagSorter() {}
+
+  /** Sort the tags by the given sort option, in place */
+  public void sort(ListTagSortOption sortBy, List<TagInfo> tags, boolean descendingOrder) {
+    switch (sortBy) {
+      case CREATION_TIME:
+        Comparator<Timestamp> nullsComparator =
+            descendingOrder
+                ? Comparator.nullsFirst(Comparator.naturalOrder())
+                : Comparator.nullsLast(Comparator.naturalOrder());
+        tags.sort(comparing(t -> t.created, nullsComparator));
+        break;
+      case REF:
+      default:
+        tags.sort(comparing(t -> t.ref));
+        break;
+    }
+  }
+}
diff --git a/javatests/com/google/gerrit/acceptance/rest/project/TagsIT.java b/javatests/com/google/gerrit/acceptance/rest/project/TagsIT.java
index b69ca1c..e2809b0 100644
--- a/javatests/com/google/gerrit/acceptance/rest/project/TagsIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/project/TagsIT.java
@@ -27,6 +27,7 @@
 import com.google.gerrit.acceptance.AbstractDaemonTest;
 import com.google.gerrit.acceptance.NoHttpd;
 import com.google.gerrit.acceptance.PushOneCommit;
+import com.google.gerrit.acceptance.UseClockStep;
 import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
 import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
 import com.google.gerrit.entities.AccessSection;
@@ -35,6 +36,7 @@
 import com.google.gerrit.extensions.api.projects.TagApi;
 import com.google.gerrit.extensions.api.projects.TagInfo;
 import com.google.gerrit.extensions.api.projects.TagInput;
+import com.google.gerrit.extensions.common.ListTagSortOption;
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.BadRequestException;
 import com.google.gerrit.extensions.restapi.MethodNotAllowedException;
@@ -120,6 +122,7 @@
   }
 
   @Test
+  @UseClockStep
   public void listTags() throws Exception {
     createTags();
 
@@ -160,6 +163,19 @@
     // with descending order
     result = getTags().withDescendingOrder(true).get();
     assertTagList(FluentIterable.from(Lists.reverse(testTags)), result);
+
+    // with sortBy creation time
+    result = getTags().withSortBy(ListTagSortOption.CREATION_TIME).get();
+    assertTagList(FluentIterable.from(Lists.reverse(testTags)), result);
+
+    // with sortBy, descending order and limit
+    result =
+        getTags()
+            .withDescendingOrder(true)
+            .withLimit(2)
+            .withSortBy(ListTagSortOption.CREATION_TIME)
+            .get();
+    assertTagList(FluentIterable.from(ImmutableList.of("tag-A", "tag-B")), result);
   }
 
   @Test
@@ -481,9 +497,10 @@
     TagInput input = new TagInput();
     input.revision = revision;
 
-    for (String tagname : testTags) {
+    // Creating the tags in reverse order to allow testing the sortBy option
+    for (String tagname : Lists.reverse(testTags)) {
+      input.message = tagname; // This updates the 'created' time of the tag
       TagInfo result = tag(tagname).create(input).get();
-      assertThat(result.revision).isEqualTo(input.revision);
       assertThat(result.ref).isEqualTo(R_TAGS + tagname);
     }
   }
diff --git a/javatests/com/google/gerrit/server/restapi/project/TagSorterTest.java b/javatests/com/google/gerrit/server/restapi/project/TagSorterTest.java
new file mode 100644
index 0000000..9a926a2
--- /dev/null
+++ b/javatests/com/google/gerrit/server/restapi/project/TagSorterTest.java
@@ -0,0 +1,83 @@
+// Copyright (C) 2024 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.restapi.project;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import com.google.gerrit.extensions.api.projects.TagInfo;
+import com.google.gerrit.extensions.common.ListTagSortOption;
+import com.google.gerrit.extensions.common.WebLinkInfo;
+import java.time.Instant;
+import java.util.ArrayList;
+import java.util.List;
+import org.junit.Before;
+import org.junit.Test;
+
+public class TagSorterTest {
+  private static final String revision = "dfdd715e31db256dfba48239f83f9b8da4bc243f";
+  private static final boolean canDelete = true;
+  private static final List<WebLinkInfo> webLinks = new ArrayList<>();
+  private static final TagSorter tagSorter = new TagSorter();
+  private List<TagInfo> tags;
+
+  @Before
+  public void initializeTags() {
+    tags = createTags();
+  }
+
+  @Test
+  public void testSortTagsByRef() {
+    tagSorter.sort(ListTagSortOption.REF, tags, false);
+
+    assertThat(tags.get(0).ref).isEqualTo("refs/tags/v1.0");
+    assertThat(tags.get(1).ref).isEqualTo("refs/tags/v2.0");
+    assertThat(tags.get(2).ref).isEqualTo("refs/tags/v3.0");
+    assertThat(tags.get(3).ref).isEqualTo("refs/tags/v4.0");
+  }
+
+  @Test
+  public void testSortTagsByCreationTime() {
+    tagSorter.sort(ListTagSortOption.CREATION_TIME, tags, false);
+
+    assertThat(tags.get(0).ref).isEqualTo("refs/tags/v2.0");
+    assertThat(tags.get(1).ref).isEqualTo("refs/tags/v3.0");
+    assertThat(tags.get(2).ref).isEqualTo("refs/tags/v1.0");
+    assertThat(tags.get(3).ref).isEqualTo("refs/tags/v4.0");
+  }
+
+  @Test
+  public void testSortTagsByCreationTimeDescendingOrder() {
+    tagSorter.sort(ListTagSortOption.CREATION_TIME, tags, true);
+
+    assertThat(tags.get(0).ref).isEqualTo("refs/tags/v4.0");
+    assertThat(tags.get(1).ref).isEqualTo("refs/tags/v2.0");
+    assertThat(tags.get(2).ref).isEqualTo("refs/tags/v3.0");
+    assertThat(tags.get(3).ref).isEqualTo("refs/tags/v1.0");
+  }
+
+  private List<TagInfo> createTags() {
+    Instant t1 = Instant.now();
+    Instant t2 = t1.minusSeconds(10);
+    Instant t3 = t1.minusSeconds(1);
+
+    List<TagInfo> tags = new ArrayList<>();
+    tags.add(new TagInfo("refs/tags/v1.0", revision, canDelete, webLinks, t1));
+    tags.add(new TagInfo("refs/tags/v2.0", revision, canDelete, webLinks, t2));
+    tags.add(new TagInfo("refs/tags/v3.0", revision, canDelete, webLinks, t3));
+    tags.add(new TagInfo("refs/tags/v4.0", revision, canDelete, webLinks, (Instant) null));
+
+    return tags;
+  }
+}