Add REST APIs to list tags and get a specified tag of a project

GET on /projects/project-name/tags/ lists the tags of that project.

GET on /projects/project-name/tags/tag-name gets information about
that tag.

Change-Id: Icfff2647045b937b6ffc199fae1a3ee5565aa76a
diff --git a/Documentation/rest-api-projects.txt b/Documentation/rest-api-projects.txt
index 20648dc..296184b 100644
--- a/Documentation/rest-api-projects.txt
+++ b/Documentation/rest-api-projects.txt
@@ -1356,6 +1356,102 @@
   }
 ----
 
+[[tag-endpoints]]
+== Tag Endpoints
+
+[[list-tags]]
+=== List Tags
+--
+'GET /projects/link:#project-name[\{project-name\}]/tags/'
+--
+
+List the tags of a project.
+
+As result a list of link:#tag-info[TagInfo] entries is returned.
+
+Only includes tags under the `refs/tags/` namespace.
+
+.Request
+----
+  GET /projects/work%2Fmy-project/tags/ 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/v2.0",
+      "revision": "1624f5af8ae89148d1a3730df8c290413e3dcf30"
+    },
+    {
+      "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
+      }
+    }
+  ]
+----
+
+[[get-tag]]
+=== Get Tag
+--
+'GET /projects/link:#project-name[\{project-name\}]/tags/link:#tag-id[\{tag-id\}]'
+--
+
+Retrieves a tag of a project.
+
+.Request
+----
+  GET /projects/work%2Fmy-project/tags/v1.0 HTTP/1.0
+----
+
+As response a link:#tag-info[TagInfo] entity is returned that describes the tag.
+
+.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
+    }
+  }
+----
+
+
 [[commit-endpoints]]
 == Commit Endpoints
 
@@ -1664,6 +1760,10 @@
 === \{commit-id\}
 Commit ID.
 
+[[tag-id]]
+=== \{tag-id\}
+The name of a tag. The prefix `refs/tags/` can be omitted.
+
 [[dashboard-id]]
 === \{dashboard-id\}
 The ID of a dashboard in the format '<ref>:<path>'.
@@ -2168,6 +2268,24 @@
 |`size_of_packed_objects`  |Size of packed objects in bytes.
 |======================================
 
+[[tag-info]]
+=== TagInfo
+The `TagInfo` entity contains information about a tag.
+
+[options="header",width="50%",cols="1,^2,4"]
+|=========================
+|Field Name  ||Description
+|`ref`       ||The ref of the tag.
+|`revision`  ||For lightweight tags, the revision of the commit to which the tag
+points. For annotated tags, the revision of the tag object.
+|`object`|Only set for annotated tags.|The revision of the object to which the
+tag points.
+|`message`|Only set for annotated tags.|The tag message. For signed tags, includes
+the signature.
+|`tagger`|Only set for annotated tags.|The tagger as a
+link:rest-api-changes.html#git-person-info[GitPersonInfo] entity.
+|=========================
+
 [[theme-info]]
 === ThemeInfo
 The `ThemeInfo` entity describes a theme.
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/project/TagsIT.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/project/TagsIT.java
new file mode 100644
index 0000000..58dc065
--- /dev/null
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/project/TagsIT.java
@@ -0,0 +1,142 @@
+// Copyright (C) 2014 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.acceptance.rest.project;
+
+import static org.junit.Assert.assertEquals;
+
+import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.acceptance.PushOneCommit;
+import com.google.gerrit.acceptance.RestResponse;
+import com.google.gerrit.common.data.Permission;
+import com.google.gerrit.extensions.common.TagInfo;
+import com.google.gson.reflect.TypeToken;
+
+import org.apache.http.HttpStatus;
+import org.junit.Test;
+
+import java.util.List;
+
+public class TagsIT extends AbstractDaemonTest {
+  @Test
+  public void listTagsOfNonExistingProject_NotFound() throws Exception {
+    assertEquals(HttpStatus.SC_NOT_FOUND,
+        adminSession.get("/projects/non-existing/tags").getStatusCode());
+  }
+
+  @Test
+  public void listTagsOfNonVisibleProject_NotFound() throws Exception {
+    blockRead(project, "refs/*");
+    assertEquals(HttpStatus.SC_NOT_FOUND,
+        userSession.get("/projects/" + project.get() + "/tags").getStatusCode());
+  }
+
+  @Test
+  public void listTags() throws Exception {
+    grant(Permission.SUBMIT, project, "refs/for/refs/heads/master");
+    grant(Permission.CREATE, project, "refs/tags/*");
+    grant(Permission.PUSH, project, "refs/tags/*");
+
+    PushOneCommit.Tag tag1 = new PushOneCommit.Tag("v1.0");
+    PushOneCommit push1 = pushFactory.create(db, admin.getIdent());
+    push1.setTag(tag1);
+    PushOneCommit.Result r1 = push1.to(git, "refs/for/master%submit");
+    r1.assertOkStatus();
+
+    PushOneCommit.AnnotatedTag tag2 =
+        new PushOneCommit.AnnotatedTag("v2.0", "annotation", admin.getIdent());
+    PushOneCommit push2 = pushFactory.create(db, admin.getIdent());
+    push2.setTag(tag2);
+    PushOneCommit.Result r2 = push2.to(git, "refs/for/master%submit");
+    r2.assertOkStatus();
+
+    List<TagInfo> result =
+        toTagInfoList(adminSession.get("/projects/" + project.get() + "/tags"));
+    assertEquals(2, result.size());
+
+    TagInfo t = result.get(0);
+    assertEquals("refs/tags/" + tag1.name, t.ref);
+    assertEquals(r1.getCommitId().getName(), t.revision);
+
+    t = result.get(1);
+    assertEquals("refs/tags/" + tag2.name, t.ref);
+    assertEquals(r2.getCommitId().getName(), t.object);
+    assertEquals(tag2.message, t.message);
+    assertEquals(tag2.tagger.getName(), t.tagger.name);
+    assertEquals(tag2.tagger.getEmailAddress(), t.tagger.email);
+  }
+
+  @Test
+  public void listTagsOfNonVisibleBranch() throws Exception {
+    grant(Permission.SUBMIT, project, "refs/for/refs/heads/master");
+    grant(Permission.SUBMIT, project, "refs/for/refs/heads/hidden");
+    grant(Permission.CREATE, project, "refs/tags/*");
+    grant(Permission.PUSH, project, "refs/tags/*");
+
+    PushOneCommit.Tag tag1 = new PushOneCommit.Tag("v1.0");
+    PushOneCommit push1 = pushFactory.create(db, admin.getIdent());
+    push1.setTag(tag1);
+    PushOneCommit.Result r1 = push1.to(git, "refs/for/master%submit");
+    r1.assertOkStatus();
+
+    pushTo("refs/heads/hidden");
+    PushOneCommit.Tag tag2 = new PushOneCommit.Tag("v2.0");
+    PushOneCommit push2 = pushFactory.create(db, admin.getIdent());
+    push2.setTag(tag2);
+    PushOneCommit.Result r2 = push2.to(git, "refs/for/hidden%submit");
+    r2.assertOkStatus();
+
+    List<TagInfo> result =
+        toTagInfoList(adminSession.get("/projects/" + project.get() + "/tags"));
+    assertEquals(2, result.size());
+    assertEquals("refs/tags/" + tag1.name, result.get(0).ref);
+    assertEquals(r1.getCommitId().getName(), result.get(0).revision);
+    assertEquals("refs/tags/" + tag2.name, result.get(1).ref);
+    assertEquals(r2.getCommitId().getName(), result.get(1).revision);
+
+    blockRead(project, "refs/heads/hidden");
+    result =
+        toTagInfoList(adminSession.get("/projects/" + project.get() + "/tags"));
+    assertEquals(1, result.size());
+    assertEquals("refs/tags/" + tag1.name, result.get(0).ref);
+    assertEquals(r1.getCommitId().getName(), result.get(0).revision);
+  }
+
+  @Test
+  public void getTag() throws Exception {
+    grant(Permission.SUBMIT, project, "refs/for/refs/heads/master");
+    grant(Permission.CREATE, project, "refs/tags/*");
+    grant(Permission.PUSH, project, "refs/tags/*");
+
+    PushOneCommit.Tag tag1 = new PushOneCommit.Tag("v1.0");
+    PushOneCommit push1 = pushFactory.create(db, admin.getIdent());
+    push1.setTag(tag1);
+    PushOneCommit.Result r1 = push1.to(git, "refs/for/master%submit");
+    r1.assertOkStatus();
+
+    RestResponse response =
+        adminSession.get("/projects/" + project.get() + "/tags/" + tag1.name);
+    TagInfo tagInfo =
+        newGson().fromJson(response.getReader(), TagInfo.class);
+    assertEquals("refs/tags/" + tag1.name, tagInfo.ref);
+    assertEquals(r1.getCommitId().getName(), tagInfo.revision);
+  }
+
+  private static List<TagInfo> toTagInfoList(RestResponse r) throws Exception {
+    List<TagInfo> result =
+        newGson().fromJson(r.getReader(),
+            new TypeToken<List<TagInfo>>() {}.getType());
+    return result;
+  }
+}
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/common/TagInfo.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/common/TagInfo.java
new file mode 100644
index 0000000..3e3d8db
--- /dev/null
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/common/TagInfo.java
@@ -0,0 +1,36 @@
+// Copyright (C) 2014 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 class TagInfo {
+  public String ref;
+  public String revision;
+  public String object;
+  public String message;
+  public GitPerson tagger;
+
+  public TagInfo(String ref, String revision) {
+    this.ref = ref;
+    this.revision = revision;
+  }
+
+  public TagInfo(String ref, String revision, String object,
+      String message, GitPerson tagger) {
+    this(ref, revision);
+    this.object = object;
+    this.message = message;
+    this.tagger = tagger;
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/project/GetTag.java b/gerrit-server/src/main/java/com/google/gerrit/server/project/GetTag.java
new file mode 100644
index 0000000..5b78e08
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/project/GetTag.java
@@ -0,0 +1,28 @@
+// Copyright (C) 2014 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.project;
+
+import com.google.gerrit.extensions.common.TagInfo;
+import com.google.gerrit.extensions.restapi.RestReadView;
+import com.google.inject.Singleton;
+
+@Singleton
+public class GetTag implements RestReadView<TagResource> {
+
+  @Override
+  public TagInfo apply(TagResource resource) {
+    return resource.getTagInfo();
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/project/ListTags.java b/gerrit-server/src/main/java/com/google/gerrit/server/project/ListTags.java
new file mode 100644
index 0000000..e12b38a
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/project/ListTags.java
@@ -0,0 +1,159 @@
+// Copyright (C) 2014 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.project;
+
+import com.google.common.collect.ImmutableMap;
+import com.google.common.collect.Lists;
+import com.google.gerrit.extensions.common.TagInfo;
+import com.google.gerrit.extensions.restapi.IdString;
+import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
+import com.google.gerrit.extensions.restapi.RestReadView;
+import com.google.gerrit.reviewdb.client.Project;
+import com.google.gerrit.reviewdb.server.ReviewDb;
+import com.google.gerrit.server.CommonConverters;
+import com.google.gerrit.server.git.ChangeCache;
+import com.google.gerrit.server.git.GitRepositoryManager;
+import com.google.gerrit.server.git.TagCache;
+import com.google.gerrit.server.git.VisibleRefFilter;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import com.google.inject.Singleton;
+
+import org.eclipse.jgit.errors.MissingObjectException;
+import org.eclipse.jgit.errors.RepositoryNotFoundException;
+import org.eclipse.jgit.lib.Constants;
+import org.eclipse.jgit.lib.Ref;
+import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.revwalk.RevObject;
+import org.eclipse.jgit.revwalk.RevTag;
+import org.eclipse.jgit.revwalk.RevWalk;
+
+import java.io.IOException;
+import java.util.Collections;
+import java.util.Comparator;
+import java.util.List;
+import java.util.Map;
+
+@Singleton
+public class ListTags implements RestReadView<ProjectResource> {
+  private final GitRepositoryManager repoManager;
+  private final Provider<ReviewDb> dbProvider;
+  private final TagCache tagCache;
+  private final ChangeCache changeCache;
+
+  @Inject
+  public ListTags(GitRepositoryManager repoManager,
+      Provider<ReviewDb> dbProvider,
+      TagCache tagCache,
+      ChangeCache changeCache) {
+    this.repoManager = repoManager;
+    this.dbProvider = dbProvider;
+    this.tagCache = tagCache;
+    this.changeCache = changeCache;
+  }
+
+  @Override
+  public List<TagInfo> apply(ProjectResource resource) throws IOException,
+      ResourceNotFoundException {
+    List<TagInfo> tags = Lists.newArrayList();
+
+    Repository repo = getRepository(resource.getNameKey());
+
+    try {
+      RevWalk rw = new RevWalk(repo);
+      try {
+        Map<String, Ref> all = visibleTags(resource.getControl(), repo,
+            repo.getRefDatabase().getRefs(Constants.R_TAGS));
+        for (Ref ref : all.values()) {
+          tags.add(createTagInfo(ref, rw));
+        }
+      } finally {
+        rw.dispose();
+      }
+    } finally {
+      repo.close();
+    }
+
+    Collections.sort(tags, new Comparator<TagInfo>() {
+      @Override
+      public int compare(TagInfo a, TagInfo b) {
+        return a.ref.compareTo(b.ref);
+      }
+    });
+
+    return tags;
+  }
+
+  public TagInfo get(ProjectResource resource, IdString id)
+      throws ResourceNotFoundException, IOException {
+    Repository repo = getRepository(resource.getNameKey());
+
+    String tagName = id.get();
+    if (!tagName.startsWith(Constants.R_TAGS)) {
+      tagName = Constants.R_TAGS + tagName;
+    }
+
+    try {
+      RevWalk rw = new RevWalk(repo);
+      try {
+        Ref ref = repo.getRefDatabase().getRef(tagName);
+        if (ref != null && !visibleTags(resource.getControl(), repo,
+            ImmutableMap.of(ref.getName(), ref)).isEmpty()) {
+          return createTagInfo(ref, rw);
+        }
+      } finally {
+        rw.dispose();
+      }
+    } finally {
+      repo.close();
+    }
+    throw new ResourceNotFoundException(id);
+  }
+
+  private Repository getRepository(Project.NameKey project)
+      throws ResourceNotFoundException, IOException {
+    try {
+      return repoManager.openRepository(project);
+    } catch (RepositoryNotFoundException noGitRepository) {
+      throw new ResourceNotFoundException();
+    }
+  }
+
+  private Map<String, Ref> visibleTags(ProjectControl control, Repository repo,
+      Map<String, Ref> tags) {
+    return new VisibleRefFilter(tagCache, changeCache, repo,
+        control, dbProvider.get(), false).filter(tags, true);
+  }
+
+  private static TagInfo createTagInfo(Ref ref, RevWalk rw)
+      throws MissingObjectException, IOException {
+    RevObject object = rw.parseAny(ref.getObjectId());
+    if (object instanceof RevTag) {
+      RevTag tag = (RevTag)object;
+      // Annotated or signed tag
+      return new TagInfo(
+          Constants.R_TAGS + tag.getTagName(),
+          tag.getName(),
+          tag.getObject().getName(),
+          tag.getFullMessage().trim(),
+          CommonConverters.toGitPerson(tag.getTaggerIdent()));
+    } else {
+      // Lightweight tag
+      return new TagInfo(
+          ref.getName(),
+          ref.getObjectId().getName());
+    }
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/project/Module.java b/gerrit-server/src/main/java/com/google/gerrit/server/project/Module.java
index 7b50b0f..ace221d 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/project/Module.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/project/Module.java
@@ -20,6 +20,7 @@
 import static com.google.gerrit.server.project.DashboardResource.DASHBOARD_KIND;
 import static com.google.gerrit.server.project.FileResource.FILE_KIND;
 import static com.google.gerrit.server.project.ProjectResource.PROJECT_KIND;
+import static com.google.gerrit.server.project.TagResource.TAG_KIND;
 
 import com.google.gerrit.extensions.registration.DynamicMap;
 import com.google.gerrit.extensions.restapi.RestApiModule;
@@ -37,6 +38,7 @@
     DynamicMap.mapOf(binder(), DASHBOARD_KIND);
     DynamicMap.mapOf(binder(), FILE_KIND);
     DynamicMap.mapOf(binder(), COMMIT_KIND);
+    DynamicMap.mapOf(binder(), TAG_KIND);
 
     put(PROJECT_KIND).to(PutProject.class);
     get(PROJECT_KIND).to(GetProject.class);
@@ -71,6 +73,9 @@
     get(COMMIT_KIND).to(GetCommit.class);
     child(COMMIT_KIND, "files").to(FilesInCommitCollection.class);
 
+    child(PROJECT_KIND, "tags").to(TagsCollection.class);
+    get(TAG_KIND).to(GetTag.class);
+
     child(PROJECT_KIND, "dashboards").to(DashboardsCollection.class);
     get(DASHBOARD_KIND).to(GetDashboard.class);
     put(DASHBOARD_KIND).to(SetDashboard.class);
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/project/TagResource.java b/gerrit-server/src/main/java/com/google/gerrit/server/project/TagResource.java
new file mode 100644
index 0000000..12be5d3
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/project/TagResource.java
@@ -0,0 +1,35 @@
+// Copyright (C) 2014 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.project;
+
+import com.google.gerrit.extensions.common.TagInfo;
+import com.google.gerrit.extensions.restapi.RestView;
+import com.google.inject.TypeLiteral;
+
+public class TagResource extends ProjectResource {
+  public static final TypeLiteral<RestView<TagResource>> TAG_KIND =
+      new TypeLiteral<RestView<TagResource>>() {};
+
+  private final TagInfo tag;
+
+  public TagResource(ProjectControl control, TagInfo tag) {
+    super(control);
+    this.tag = tag;
+  }
+
+  public TagInfo getTagInfo() {
+    return tag;
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/project/TagsCollection.java b/gerrit-server/src/main/java/com/google/gerrit/server/project/TagsCollection.java
new file mode 100644
index 0000000..0c70285
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/project/TagsCollection.java
@@ -0,0 +1,55 @@
+// Copyright (C) 2014 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.project;
+
+import com.google.gerrit.extensions.registration.DynamicMap;
+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.inject.Inject;
+import com.google.inject.Singleton;
+
+import java.io.IOException;
+
+@Singleton
+public class TagsCollection implements
+    ChildCollection<ProjectResource, TagResource> {
+  private final DynamicMap<RestView<TagResource>> views;
+  private final ListTags list;
+
+  @Inject
+  public TagsCollection(DynamicMap<RestView<TagResource>> views,
+     ListTags list) {
+    this.views = views;
+    this.list = list;
+  }
+
+  @Override
+  public RestView<ProjectResource> list() throws ResourceNotFoundException {
+    return list;
+  }
+
+  @Override
+  public TagResource parse(ProjectResource resource, IdString id)
+      throws ResourceNotFoundException, IOException {
+    return new TagResource(resource.getControl(), list.get(resource, id));
+  }
+
+  @Override
+  public DynamicMap<RestView<TagResource>> views() {
+    return views;
+  }
+}