Merge branch 'stable-3.0'

* stable-3.0:
  Promote aggregation key to case class
  Add ability to ignore specific files suffixes

Change-Id: Id83be727f627232863b6bc897a597b04adce0b17
diff --git a/src/main/resources/Documentation/config.md b/src/main/resources/Documentation/config.md
index 6c08a06..e9d9718 100644
--- a/src/main/resources/Documentation/config.md
+++ b/src/main/resources/Documentation/config.md
@@ -34,4 +34,24 @@
   ```ini
   [contributors]
     extract-issues = true
+  ```
+
+- `contributors.ignore-file-suffix`
+
+  List of file suffixes to be ignored from the analytics.
+  Files matching any of the specified suffixes will not be accounted for in
+  `num_files`, `num_distinct_files`, `added_lines` and `deleted_lines` fields
+  nor will they be listed in the `commits.files` array field.
+  This can be used to explicitly ignore binary files for which, file-based
+  statistics makes little or no sense.
+
+  Default: empty
+
+  Example:
+  ```ini
+  [contributors]
+    ignore-file-suffix = .dmg
+    ignore-file-suffix = .ko
+    ignore-file-suffix = .png
+    ignore-file-suffix = .exe
   ```
\ No newline at end of file
diff --git a/src/main/scala/com/googlesource/gerrit/plugins/analytics/AnalyticsConfig.scala b/src/main/scala/com/googlesource/gerrit/plugins/analytics/AnalyticsConfig.scala
index fd31626..10eb8ed 100644
--- a/src/main/scala/com/googlesource/gerrit/plugins/analytics/AnalyticsConfig.scala
+++ b/src/main/scala/com/googlesource/gerrit/plugins/analytics/AnalyticsConfig.scala
@@ -23,15 +23,18 @@
 trait AnalyticsConfig {
   def botlikeFilenameRegexps: List[String]
   def isExtractIssues: Boolean
+  def ignoreFileSuffixes: List[String]
 }
 
 class AnalyticsConfigImpl @Inject() (val pluginConfigFactory: PluginConfigFactory, @PluginName val pluginName: String) extends AnalyticsConfig{
   lazy val botlikeFilenameRegexps: List[String] = pluginConfigBotLikeFilenameRegexp
   lazy val isExtractIssues: Boolean = pluginConfig.getBoolean(Contributors, null, ExtractIssues, false)
+  lazy val ignoreFileSuffixes: List[String] = pluginConfig.getStringList(Contributors, null, IgnoreFileSuffix).toList
 
   private lazy val pluginConfig: Config = pluginConfigFactory.getGlobalPluginConfig(pluginName)
   private val Contributors = "contributors"
   private val BotlikeFilenameRegexp = "botlike-filename-regexp"
   private val ExtractIssues = "extract-issues"
+  private val IgnoreFileSuffix = "ignore-file-suffix"
   private lazy val pluginConfigBotLikeFilenameRegexp = pluginConfig.getStringList(Contributors, null, BotlikeFilenameRegexp).toList
 }
diff --git a/src/main/scala/com/googlesource/gerrit/plugins/analytics/Contributors.scala b/src/main/scala/com/googlesource/gerrit/plugins/analytics/Contributors.scala
index f634725..98e4e06 100644
--- a/src/main/scala/com/googlesource/gerrit/plugins/analytics/Contributors.scala
+++ b/src/main/scala/com/googlesource/gerrit/plugins/analytics/Contributors.scala
@@ -24,8 +24,6 @@
 import com.googlesource.gerrit.plugins.analytics.common._
 import org.kohsuke.args4j.{Option => ArgOption}
 
-import scala.util.Try
-
 @CommandMetaData(name = "contributors", description = "Extracts the list of contributors to a project")
 class ContributorsCommand @Inject()(val executor: ContributorsService,
                                     val projects: ProjectsCollection,
@@ -130,10 +128,11 @@
 }
 
 class ContributorsService @Inject()(repoManager: GitRepositoryManager,
-                                    projectCache:ProjectCache,
+                                    projectCache: ProjectCache,
                                     histogram: UserActivityHistogram,
                                     gsonFmt: GsonFormatter,
                                     commitsStatisticsCache: CommitsStatisticsCache) {
+
   import RichBoolean._
 
   def get(projectRes: ProjectResource, startDate: Option[Long], stopDate: Option[Long],
@@ -141,7 +140,7 @@
   : TraversableOnce[UserActivitySummary] = {
 
     ManagedResource.use(repoManager.openRepository(projectRes.getNameKey)) { repo =>
-      val stats  = new Statistics(projectRes.getNameKey, commitsStatisticsCache)
+      val stats = new Statistics(projectRes.getNameKey, commitsStatisticsCache)
       val branchesExtractor = extractBranches.option(new BranchesExtractor(repo))
 
       histogram.get(repo, new AggregatedHistogramFilterByDates(startDate, stopDate, branchesExtractor, aggregationStrategy))
@@ -155,10 +154,10 @@
 
 case class IssueInfo(code: String, link: String)
 
-case class UserActivitySummary(year: Integer,
-                               month: Integer,
-                               day: Integer,
-                               hour: Integer,
+case class UserActivitySummary(year: Option[Int],
+                               month: Option[Int],
+                               day: Option[Int],
+                               hour: Option[Int],
                                name: String,
                                email: String,
                                numCommits: Integer,
@@ -179,24 +178,30 @@
   def apply(statisticsHandler: Statistics)(uca: AggregatedUserCommitActivity)
   : Iterable[UserActivitySummary] = {
 
-    def stringToIntOrNull(x: String): Integer = Try(new Integer(x)).getOrElse(null)
+    statisticsHandler.forCommits(uca.getIds: _*).map { stat =>
+      val maybeBranches =
+        uca.key.branch.fold(Array.empty[String])(Array(_))
 
-    uca.key.split("/", AggregationStrategy.MAX_MAPPING_TOKENS) match {
-      case Array(email, year, month, day, hour, branch) =>
-        statisticsHandler.forCommits(uca.getIds: _*).map { stat =>
-          val maybeBranches =
-            Option(branch).filter(_.nonEmpty).map(b => Array(b)).getOrElse(Array.empty)
-
-          UserActivitySummary(
-            stringToIntOrNull(year), stringToIntOrNull(month), stringToIntOrNull(day), stringToIntOrNull(hour),
-            uca.getName, email, stat.commits.size,
-            stat.numFiles, stat.numDistinctFiles, stat.addedLines, stat.deletedLines,
-            stat.commits.toArray, maybeBranches, stat.issues.map(_.code)
-              .toArray, stat.issues.map(_.link).toArray, uca.getLatest, stat
-              .isForMergeCommits,stat.isForBotLike
-          )
-        }
-      case _ => throw new Exception(s"invalid key format found ${uca.key}")
+      UserActivitySummary(
+        uca.key.year,
+        uca.key.month,
+        uca.key.day,
+        uca.key.hour,
+        uca.getName,
+        uca.key.email,
+        stat.commits.size,
+        stat.numFiles,
+        stat.numDistinctFiles,
+        stat.addedLines,
+        stat.deletedLines,
+        stat.commits.toArray,
+        maybeBranches,
+        stat.issues.map(_.code).toArray,
+        stat.issues.map(_.link).toArray,
+        uca.getLatest,
+        stat.isForMergeCommits,
+        stat.isForBotLike
+      )
     }
   }
 }
diff --git a/src/main/scala/com/googlesource/gerrit/plugins/analytics/common/AggregatedCommitHistogram.scala b/src/main/scala/com/googlesource/gerrit/plugins/analytics/common/AggregatedCommitHistogram.scala
index 74abf08..f39fc73 100644
--- a/src/main/scala/com/googlesource/gerrit/plugins/analytics/common/AggregatedCommitHistogram.scala
+++ b/src/main/scala/com/googlesource/gerrit/plugins/analytics/common/AggregatedCommitHistogram.scala
@@ -16,19 +16,19 @@
 
 import java.util.Date
 
-import com.googlesource.gerrit.plugins.analytics.common.AggregationStrategy.BY_BRANCH
+import com.googlesource.gerrit.plugins.analytics.common.AggregationStrategy.{AggregationKey, BY_BRANCH}
 import org.eclipse.jgit.lib.PersonIdent
 import org.eclipse.jgit.revwalk.RevCommit
 import org.gitective.core.stat.{CommitHistogram, CommitHistogramFilter, UserCommitActivity}
 
-class AggregatedUserCommitActivity(val key: String, val name: String, val email: String)
+class AggregatedUserCommitActivity(val key: AggregationKey, val name: String, val email: String)
   extends UserCommitActivity(name, email)
 
 class AggregatedCommitHistogram(var aggregationStrategy: AggregationStrategy)
   extends CommitHistogram {
 
   def includeWithBranches(commit: RevCommit, user: PersonIdent, branches: Set[String]): Unit = {
-    for ( branch <- branches ) {
+    for (branch <- branches) {
       val originalStrategy = aggregationStrategy
       this.aggregationStrategy = BY_BRANCH(branch, aggregationStrategy)
       this.include(commit, user)
@@ -38,11 +38,13 @@
 
   override def include(commit: RevCommit, user: PersonIdent): AggregatedCommitHistogram = {
     val key = aggregationStrategy.mapping(user, commit.getAuthorIdent.getWhen)
-    val activity = Option(users.get(key)) match {
+    val keyString = key.toString
+
+    val activity = Option(users.get(keyString)) match {
       case None =>
         val newActivity = new AggregatedUserCommitActivity(key,
           user.getName, user.getEmailAddress)
-        users.put(key, newActivity)
+        users.put(keyString, newActivity)
         newActivity
       case Some(foundActivity) => foundActivity
     }
@@ -56,7 +58,7 @@
 }
 
 object AggregatedCommitHistogram {
-  type AggregationStrategyMapping = (PersonIdent, Date) => String
+  type AggregationStrategyMapping = (PersonIdent, Date) => AggregationKey
 }
 
 abstract class AbstractCommitHistogramFilter(aggregationStrategy: AggregationStrategy)
diff --git a/src/main/scala/com/googlesource/gerrit/plugins/analytics/common/AggregationStrategy.scala b/src/main/scala/com/googlesource/gerrit/plugins/analytics/common/AggregationStrategy.scala
index 079c307..3292fc9 100644
--- a/src/main/scala/com/googlesource/gerrit/plugins/analytics/common/AggregationStrategy.scala
+++ b/src/main/scala/com/googlesource/gerrit/plugins/analytics/common/AggregationStrategy.scala
@@ -41,33 +41,57 @@
     def utc: LocalDateTime = d.toInstant.atZone(ZoneOffset.UTC).toLocalDateTime
   }
 
+  case class AggregationKey(email: String,
+                            year: Option[Int] = None,
+                            month: Option[Int] = None,
+                            day: Option[Int] = None,
+                            hour: Option[Int] = None,
+                            branch: Option[String] = None)
+
   object EMAIL extends AggregationStrategy {
     val name: String = "EMAIL"
-    val mapping: (PersonIdent, Date) => String = (p, _) => s"${p.getEmailAddress}/////"
+    val mapping: (PersonIdent, Date) => AggregationKey = (p, _) =>
+      AggregationKey(email = p.getEmailAddress)
   }
 
   object EMAIL_YEAR extends AggregationStrategy {
     val name: String = "EMAIL_YEAR"
-    val mapping: (PersonIdent, Date) => String = (p, d) => s"${p.getEmailAddress}/${d.utc.getYear}////"
+    val mapping: (PersonIdent, Date) => AggregationKey = (p, d) =>
+      AggregationKey(email = p.getEmailAddress, year = Some(d.utc.getYear))
   }
 
   object EMAIL_MONTH extends AggregationStrategy {
     val name: String = "EMAIL_MONTH"
-    val mapping: (PersonIdent, Date) => String = (p, d) => s"${p.getEmailAddress}/${d.utc.getYear}/${d.utc.getMonthValue}///"
+    val mapping: (PersonIdent, Date) => AggregationKey = (p, d) =>
+      AggregationKey(email = p.getEmailAddress,
+                     year = Some(d.utc.getYear),
+                     month = Some(d.utc.getMonthValue))
   }
 
   object EMAIL_DAY extends AggregationStrategy {
     val name: String = "EMAIL_DAY"
-    val mapping: (PersonIdent, Date) => String = (p, d) => s"${p.getEmailAddress}/${d.utc.getYear}/${d.utc.getMonthValue}/${d.utc.getDayOfMonth}//"
+    val mapping: (PersonIdent, Date) => AggregationKey = (p, d) =>
+      AggregationKey(email = p.getEmailAddress,
+                     year = Some(d.utc.getYear),
+                     month = Some(d.utc.getMonthValue),
+                     day = Some(d.utc.getDayOfMonth))
   }
 
   object EMAIL_HOUR extends AggregationStrategy {
     val name: String = "EMAIL_HOUR"
-    val mapping: (PersonIdent, Date) => String = (p, d) => s"${p.getEmailAddress}/${d.utc.getYear}/${d.utc.getMonthValue}/${d.utc.getDayOfMonth}/${d.utc.getHour}/"
+    val mapping: (PersonIdent, Date) => AggregationKey = (p, d) =>
+      AggregationKey(email = p.getEmailAddress,
+                     year = Some(d.utc.getYear),
+                     month = Some(d.utc.getMonthValue),
+                     day = Some(d.utc.getDayOfMonth),
+                     hour = Some(d.utc.getHour))
   }
 
-  case class BY_BRANCH(branch: String, baseAggregationStrategy: AggregationStrategy) extends AggregationStrategy {
+  case class BY_BRANCH(branch: String,
+                       baseAggregationStrategy: AggregationStrategy)
+      extends AggregationStrategy {
     val name: String = s"BY_BRANCH($branch)"
-    val mapping: (PersonIdent, Date) => String = (p, d) => s"${baseAggregationStrategy.mapping(p, d)}$branch"
+    val mapping: (PersonIdent, Date) => AggregationKey = (p, d) =>
+      baseAggregationStrategy.mapping(p, d).copy(branch = Some(branch))
   }
 }
diff --git a/src/main/scala/com/googlesource/gerrit/plugins/analytics/common/CommitsStatisticsCache.scala b/src/main/scala/com/googlesource/gerrit/plugins/analytics/common/CommitsStatisticsCache.scala
index 8d573f0..c684c57 100644
--- a/src/main/scala/com/googlesource/gerrit/plugins/analytics/common/CommitsStatisticsCache.scala
+++ b/src/main/scala/com/googlesource/gerrit/plugins/analytics/common/CommitsStatisticsCache.scala
@@ -20,7 +20,6 @@
 import com.googlesource.gerrit.plugins.analytics.common.CommitsStatisticsCache.COMMITS_STATISTICS_CACHE
 import org.eclipse.jgit.lib.ObjectId
 
-@ImplementedBy(classOf[CommitsStatisticsCacheImpl])
 trait CommitsStatisticsCache {
   def get(project: String, objectId: ObjectId): CommitsStatistics
 }
diff --git a/src/main/scala/com/googlesource/gerrit/plugins/analytics/common/CommitsStatisticsCacheModule.scala b/src/main/scala/com/googlesource/gerrit/plugins/analytics/common/CommitsStatisticsCacheModule.scala
index 9b2e358..5e4fe2f 100644
--- a/src/main/scala/com/googlesource/gerrit/plugins/analytics/common/CommitsStatisticsCacheModule.scala
+++ b/src/main/scala/com/googlesource/gerrit/plugins/analytics/common/CommitsStatisticsCacheModule.scala
@@ -21,6 +21,7 @@
 class CommitsStatisticsCacheModule extends CacheModule() {
 
   override protected def configure(): Unit = {
+    bind(classOf[CommitsStatisticsCache]).to(classOf[CommitsStatisticsCacheImpl])
     persist(CommitsStatisticsCache.COMMITS_STATISTICS_CACHE, classOf[CommitsStatisticsCacheKey], classOf[CommitsStatistics])
       .version(1)
       .diskLimit(-1)
diff --git a/src/main/scala/com/googlesource/gerrit/plugins/analytics/common/CommitsStatisticsLoader.scala b/src/main/scala/com/googlesource/gerrit/plugins/analytics/common/CommitsStatisticsLoader.scala
index 75ac467..98d591d 100644
--- a/src/main/scala/com/googlesource/gerrit/plugins/analytics/common/CommitsStatisticsLoader.scala
+++ b/src/main/scala/com/googlesource/gerrit/plugins/analytics/common/CommitsStatisticsLoader.scala
@@ -34,7 +34,8 @@
   gitRepositoryManager: GitRepositoryManager,
   projectCache: ProjectCache,
   botLikeExtractor: BotLikeExtractor,
-  config: AnalyticsConfig
+  config: AnalyticsConfig,
+  ignoreFileSuffixFilter: IgnoreFileSuffixFilter
 ) extends CacheLoader[CommitsStatisticsCacheKey, CommitsStatistics] {
 
   override def load(cacheKey: CommitsStatisticsCacheKey): CommitsStatistics = {
@@ -70,6 +71,7 @@
 
           val df = new DiffFormatter(DisabledOutputStream.INSTANCE)
           df.setRepository(repo)
+          df.setPathFilter(ignoreFileSuffixFilter)
           df.setDiffComparator(RawTextComparator.DEFAULT)
           df.setDetectRenames(true)
           val diffs = df.scan(oldTree, newTree).asScala
diff --git a/src/main/scala/com/googlesource/gerrit/plugins/analytics/common/GsonFormatter.scala b/src/main/scala/com/googlesource/gerrit/plugins/analytics/common/GsonFormatter.scala
index d36cfef..b054554 100644
--- a/src/main/scala/com/googlesource/gerrit/plugins/analytics/common/GsonFormatter.scala
+++ b/src/main/scala/com/googlesource/gerrit/plugins/analytics/common/GsonFormatter.scala
@@ -15,19 +15,18 @@
 package com.googlesource.gerrit.plugins.analytics.common
 
 import java.io.PrintWriter
-
-import com.google.gerrit.json.OutputFormat
-import com.google.gson.{Gson, GsonBuilder, JsonSerializer}
-import com.google.inject.Singleton
 import java.lang.reflect.Type
 
-import com.google.gson._
+import com.google.gerrit.json.OutputFormat
+import com.google.gson.{Gson, GsonBuilder, JsonSerializer, _}
+import com.google.inject.Singleton
 
 @Singleton
 class GsonFormatter {
   val gsonBuilder: GsonBuilder =
     OutputFormat.JSON_COMPACT.newGsonBuilder
       .registerTypeHierarchyAdapter(classOf[Iterable[Any]], new IterableSerializer)
+      .registerTypeHierarchyAdapter(classOf[Option[Any]], new OptionSerializer())
 
   def format[T](values: TraversableOnce[T], out: PrintWriter) {
     val gson: Gson = gsonBuilder.create
@@ -45,4 +44,12 @@
     }
   }
 
+  class OptionSerializer extends JsonSerializer[Option[Any]] {
+    def serialize(src: Option[Any], typeOfSrc: Type, context: JsonSerializationContext): JsonElement = {
+      src match {
+        case None => JsonNull.INSTANCE
+        case Some(v) => context.serialize(v)
+      }
+    }
+  }
 }
diff --git a/src/main/scala/com/googlesource/gerrit/plugins/analytics/common/IgnoreFileSuffixFilter.scala b/src/main/scala/com/googlesource/gerrit/plugins/analytics/common/IgnoreFileSuffixFilter.scala
new file mode 100644
index 0000000..d875eb0
--- /dev/null
+++ b/src/main/scala/com/googlesource/gerrit/plugins/analytics/common/IgnoreFileSuffixFilter.scala
@@ -0,0 +1,35 @@
+// Copyright (C) 2019 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.googlesource.gerrit.plugins.analytics.common
+
+import com.google.inject.{Inject, Singleton}
+import com.googlesource.gerrit.plugins.analytics.AnalyticsConfig
+import org.eclipse.jgit.treewalk.TreeWalk
+import org.eclipse.jgit.treewalk.filter.TreeFilter
+import org.gitective.core.PathFilterUtils
+
+@Singleton
+case class IgnoreFileSuffixFilter @Inject() (config: AnalyticsConfig) extends TreeFilter {
+
+  private lazy val suffixFilter =
+    if (config.ignoreFileSuffixes.nonEmpty)
+      PathFilterUtils.orSuffix(config.ignoreFileSuffixes:_*).negate()
+    else
+      TreeFilter.ALL
+
+  override def include(treeWalk: TreeWalk): Boolean = treeWalk.isSubtree || suffixFilter.include(treeWalk)
+  override def shouldBeRecursive(): Boolean = suffixFilter.shouldBeRecursive()
+  override def clone(): TreeFilter = this
+}
diff --git a/src/test/scala/com/googlesource/gerrit/plugins/analytics/common/BotLikeExtractorImplSpec.scala b/src/test/scala/com/googlesource/gerrit/plugins/analytics/common/BotLikeExtractorImplSpec.scala
index 5bb73c5..42b0d1c 100644
--- a/src/test/scala/com/googlesource/gerrit/plugins/analytics/common/BotLikeExtractorImplSpec.scala
+++ b/src/test/scala/com/googlesource/gerrit/plugins/analytics/common/BotLikeExtractorImplSpec.scala
@@ -49,5 +49,6 @@
   private def newBotLikeExtractorImpl(botLikeRegexps: List[String]) = new BotLikeExtractorImpl(new AnalyticsConfig {
     override lazy val botlikeFilenameRegexps = botLikeRegexps
     override lazy val isExtractIssues: Boolean = false
+    override def ignoreFileSuffixes: List[String] = List.empty
   })
 }
diff --git a/src/test/scala/com/googlesource/gerrit/plugins/analytics/common/IgnoreFileSuffixFilterSpec.scala b/src/test/scala/com/googlesource/gerrit/plugins/analytics/common/IgnoreFileSuffixFilterSpec.scala
new file mode 100644
index 0000000..4ed373c
--- /dev/null
+++ b/src/test/scala/com/googlesource/gerrit/plugins/analytics/common/IgnoreFileSuffixFilterSpec.scala
@@ -0,0 +1,54 @@
+// Copyright (C) 2019 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.googlesource.gerrit.plugins.analytics.common
+
+import com.google.gerrit.acceptance.UseLocalDisk
+import com.googlesource.gerrit.plugins.analytics.AnalyticsConfig
+import com.googlesource.gerrit.plugins.analytics.test.GerritTestDaemon
+import org.eclipse.jgit.treewalk.TreeWalk
+import org.scalatest.{FlatSpec, Matchers}
+
+@UseLocalDisk
+class IgnoreFileSuffixFilterSpec extends FlatSpec with Matchers with GerritTestDaemon {
+
+  behavior of "IgnoreFileSuffixFilter"
+
+  it should "include a file with suffix not listed in configuration" in {
+    val ignoreSuffix = ".dmg"
+    val fileSuffix = ".txt"
+    val aFile = s"aFile$fileSuffix"
+    val commit = testFileRepository.commitFile(aFile, "some content")
+
+    val walk = TreeWalk.forPath(testFileRepository.getRepository, aFile, commit.getTree)
+
+    newIgnoreFileSuffix(ignoreSuffix).include(walk) shouldBe true
+  }
+
+  it should "not include a file with suffix listed in configuration" in {
+    val ignoreSuffix = ".dmg"
+    val aFile = s"aFile$ignoreSuffix"
+    val commit = testFileRepository.commitFile(aFile, "some content")
+
+    val walk = TreeWalk.forPath(testFileRepository.getRepository, aFile, commit.getTree)
+
+    newIgnoreFileSuffix(ignoreSuffix).include(walk) shouldBe false
+  }
+
+  private def newIgnoreFileSuffix(suffixes: String*) = IgnoreFileSuffixFilter(new AnalyticsConfig {
+    override lazy val botlikeFilenameRegexps = List.empty
+    override lazy val isExtractIssues: Boolean = false
+    override def ignoreFileSuffixes: List[String] = suffixes.toList
+  })
+}
diff --git a/src/test/scala/com/googlesource/gerrit/plugins/analytics/test/CommitStatisticsSpec.scala b/src/test/scala/com/googlesource/gerrit/plugins/analytics/test/CommitStatisticsSpec.scala
index b2ae9e7..8c5eb60 100644
--- a/src/test/scala/com/googlesource/gerrit/plugins/analytics/test/CommitStatisticsSpec.scala
+++ b/src/test/scala/com/googlesource/gerrit/plugins/analytics/test/CommitStatisticsSpec.scala
@@ -16,7 +16,7 @@
 
 import com.google.gerrit.acceptance.UseLocalDisk
 import com.googlesource.gerrit.plugins.analytics.CommitInfo
-import com.googlesource.gerrit.plugins.analytics.common.{CommitsStatistics, CommitsStatisticsLoader, Statistics}
+import com.googlesource.gerrit.plugins.analytics.common.{CommitsStatistics, Statistics}
 import org.scalatest.{FlatSpec, Inside, Matchers}
 
 @UseLocalDisk
@@ -131,5 +131,4 @@
       case wrongContent => fail(s"Expected two results instead got $wrongContent")
     }
   }
-
 }
diff --git a/src/test/scala/com/googlesource/gerrit/plugins/analytics/test/ContributorsServiceSpec.scala b/src/test/scala/com/googlesource/gerrit/plugins/analytics/test/ContributorsServiceSpec.scala
new file mode 100644
index 0000000..acefed1
--- /dev/null
+++ b/src/test/scala/com/googlesource/gerrit/plugins/analytics/test/ContributorsServiceSpec.scala
@@ -0,0 +1,83 @@
+// Copyright (C) 2019 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.googlesource.gerrit.plugins.analytics.test
+
+import java.lang.reflect.Type
+
+import com.google.gerrit.acceptance.UseLocalDisk
+import com.google.gson._
+import com.googlesource.gerrit.plugins.analytics.UserActivitySummary
+import com.googlesource.gerrit.plugins.analytics.common.AggregationStrategy.EMAIL_HOUR
+import com.googlesource.gerrit.plugins.analytics.common.GsonFormatter
+import com.googlesource.gerrit.plugins.analytics.test.TestAnalyticsConfig.IGNORED_FILE_SUFFIX
+import org.scalatest.{FlatSpec, Inside, Matchers}
+
+import scala.collection.JavaConverters._
+
+@UseLocalDisk
+class ContributorsServiceSpec extends FlatSpec with Matchers with GerritTestDaemon with Inside {
+
+  "ContributorsService" should "get commit statistics" in {
+    val aContributorName = "Contributor Name"
+    val aContributorEmail = "contributor@test.com"
+    val aFileName = "file.txt"
+    val anIgnoredFileName = s"file$IGNORED_FILE_SUFFIX"
+
+    val commit = testFileRepository.commitFiles(
+      List(anIgnoredFileName -> "1\n2\n", aFileName -> "1\n2\n"),
+      newPersonIdent(aContributorName, aContributorEmail)
+    )
+
+    val statsJson = daemonTest.restSession.get(s"/projects/${fileRepositoryName.get()}/analytics~contributors?aggregate=${EMAIL_HOUR.name}")
+
+    statsJson.assertOK()
+
+    val stats = TestGson().fromJson(statsJson.getEntityContent, classOf[UserActivitySummary])
+
+    inside(stats) {
+      case UserActivitySummary(_, _, _, _, theAuthorName, theAuthorEmail, numCommits, numFiles, numDistinctFiles, addedLines, deletedLines, commits, _, _, _, _, _, _) =>
+        theAuthorName shouldBe aContributorName
+        theAuthorEmail shouldBe aContributorEmail
+        numCommits shouldBe 1
+        numFiles shouldBe 1
+        numDistinctFiles shouldBe 1
+        addedLines shouldBe 2
+        deletedLines shouldBe 0
+        commits.head.files should contain only aFileName
+        commits.head.sha1 shouldBe commit.name
+    }
+  }
+}
+
+object TestGson {
+
+  class SetStringDeserializer extends JsonDeserializer[Set[String]] {
+    override def deserialize(json: JsonElement, typeOfT: Type, context: JsonDeserializationContext): Set[String] =
+      json.getAsJsonArray.asScala.map(_.getAsString).toSet
+  }
+
+  class OptionDeserializer extends JsonDeserializer[Option[Any]] {
+    override def deserialize(jsonElement: JsonElement, `type`: Type, jsonDeserializationContext: JsonDeserializationContext): Option[Any] = {
+      Some(jsonElement)
+    }
+  }
+
+  def apply(): Gson =
+    new GsonFormatter()
+      .gsonBuilder
+      .registerTypeHierarchyAdapter(classOf[Iterable[String]], new SetStringDeserializer)
+      .registerTypeHierarchyAdapter(classOf[Option[Any]], new OptionDeserializer())
+      .create()
+}
\ No newline at end of file
diff --git a/src/test/scala/com/googlesource/gerrit/plugins/analytics/test/GerritTestDaemon.scala b/src/test/scala/com/googlesource/gerrit/plugins/analytics/test/GerritTestDaemon.scala
index 0024e1e..1b6b674 100644
--- a/src/test/scala/com/googlesource/gerrit/plugins/analytics/test/GerritTestDaemon.scala
+++ b/src/test/scala/com/googlesource/gerrit/plugins/analytics/test/GerritTestDaemon.scala
@@ -20,9 +20,13 @@
 import com.google.gerrit.acceptance.{AbstractDaemonTest, GitUtil}
 import com.google.gerrit.extensions.annotations.PluginName
 import com.google.gerrit.extensions.client.SubmitType
+import com.google.gerrit.acceptance._
+import com.google.gerrit.extensions.restapi.RestApiModule
 import com.google.gerrit.reviewdb.client.Project
-import com.google.inject.{AbstractModule, Module}
-import com.googlesource.gerrit.plugins.analytics.AnalyticsConfig
+import com.google.gerrit.server.project.ProjectResource.PROJECT_KIND
+import com.google.inject.AbstractModule
+import com.googlesource.gerrit.plugins.analytics.{AnalyticsConfig, ContributorsResource}
+import com.googlesource.gerrit.plugins.analytics.common.CommitsStatisticsCache
 import org.eclipse.jgit.api.MergeCommand.FastForwardMode
 import org.eclipse.jgit.api.{Git, MergeResult}
 import org.eclipse.jgit.internal.storage.file.FileRepository
@@ -69,6 +73,7 @@
     new PersonIdent(new PersonIdent(name, email), ts)
 
   override def beforeEach {
+    daemonTest.setUpTestPlugin()
     fileRepositoryName = daemonTest.newProject(testSpecificRepositoryName)
     fileRepository = daemonTest.getRepository(fileRepositoryName)
     testFileRepository = GitUtil.newTestRepository(fileRepository)
@@ -136,9 +141,14 @@
   }
 }
 
-object GerritTestDaemon extends AbstractDaemonTest {
+@TestPlugin(
+  name = "analytics",
+  sysModule = "com.googlesource.gerrit.plugins.analytics.test.GerritTestDaemon$TestModule"
+)
+object GerritTestDaemon extends LightweightPluginDaemonTest {
   baseConfig = new Config()
   AbstractDaemonTest.temporaryFolder.create()
+  tempDataDir.create()
 
   def newProject(nameSuffix: String) = {
     resourcePrefix = ""
@@ -151,15 +161,21 @@
   def adminAuthor = admin.newIdent
 
   def getInstance[T](clazz: Class[T]): T =
-    server.getTestInjector.getInstance(clazz)
+    plugin.getSysInjector.getInstance(clazz)
 
-  override def createModule(): Module = new AbstractModule {
+  def getCanonicalWebUrl: String = canonicalWebUrl.get()
+
+  def restSession: RestSession = adminRestSession
+
+  class TestModule extends AbstractModule {
     override def configure(): Unit = {
-      bind(classOf[AnalyticsConfig]).toInstance(new AnalyticsConfig {
-        override def botlikeFilenameRegexps: List[String] = List.empty
-        override def isExtractIssues: Boolean = true
+      bind(classOf[CommitsStatisticsCache]).to(classOf[CommitsStatisticsNoCache])
+      bind(classOf[AnalyticsConfig]).toInstance(TestAnalyticsConfig)
+      install(new RestApiModule() {
+        override protected def configure() = {
+          get(PROJECT_KIND, "contributors").to(classOf[ContributorsResource])
+        }
       })
-      bind(classOf[String]).annotatedWith(classOf[PluginName]).toInstance("analytics")
     }
   }
 }
\ No newline at end of file
diff --git a/src/test/scala/com/googlesource/gerrit/plugins/analytics/test/TestAnalyticsConfig.scala b/src/test/scala/com/googlesource/gerrit/plugins/analytics/test/TestAnalyticsConfig.scala
new file mode 100644
index 0000000..598df31
--- /dev/null
+++ b/src/test/scala/com/googlesource/gerrit/plugins/analytics/test/TestAnalyticsConfig.scala
@@ -0,0 +1,24 @@
+// Copyright (C) 2019 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.googlesource.gerrit.plugins.analytics.test
+
+import com.googlesource.gerrit.plugins.analytics.AnalyticsConfig
+
+object TestAnalyticsConfig extends AnalyticsConfig {
+  val IGNORED_FILE_SUFFIX = ".bin"
+  val botlikeFilenameRegexps: List[String] = List.empty
+  val isExtractIssues: Boolean = true
+  val ignoreFileSuffixes: List[String] = List(IGNORED_FILE_SUFFIX)
+}
diff --git a/src/test/scala/com/googlesource/gerrit/plugins/analytics/test/TestCommitStatisticsNoCache.scala b/src/test/scala/com/googlesource/gerrit/plugins/analytics/test/TestCommitStatisticsNoCache.scala
index 8834418..b0b962f 100644
--- a/src/test/scala/com/googlesource/gerrit/plugins/analytics/test/TestCommitStatisticsNoCache.scala
+++ b/src/test/scala/com/googlesource/gerrit/plugins/analytics/test/TestCommitStatisticsNoCache.scala
@@ -14,6 +14,7 @@
 
 package com.googlesource.gerrit.plugins.analytics.test
 
+import com.google.inject.Inject
 import com.googlesource.gerrit.plugins.analytics.common._
 import org.eclipse.jgit.lib.ObjectId
 
@@ -23,7 +24,7 @@
   lazy val commitsStatisticsNoCache  = CommitsStatisticsNoCache(daemonTest.getInstance(classOf[CommitsStatisticsLoader]))
 }
 
-case class CommitsStatisticsNoCache(commitsStatisticsLoader: CommitsStatisticsLoader) extends CommitsStatisticsCache {
+case class CommitsStatisticsNoCache @Inject() (commitsStatisticsLoader: CommitsStatisticsLoader) extends CommitsStatisticsCache {
   override def get(project: String, objectId: ObjectId): CommitsStatistics =
     commitsStatisticsLoader.load(CommitsStatisticsCacheKey(project, objectId))
 }