Aggregate contributors stats by day/month/year

Adapt CommitHistogram to take care of various levels
of aggregations, including HOUR, DAY, MONTH, YEAR.

Output records include the key field holding
<useremail>[/<year>[/<month>[/<day>[/<hour>]]]]
so that consumers can understand which aggregation level has been used.

Aggregation is enabled with --aggregate --g aliases
Omitting --aggregate will default to aggregation by email which is
almost identical to previous version (except for the added field key).

Change-Id: I047a4d11e695afff56dd28a0b9c8d73e001b7782
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 184f550..5b267f0 100644
--- a/src/main/scala/com/googlesource/gerrit/plugins/analytics/Contributors.scala
+++ b/src/main/scala/com/googlesource/gerrit/plugins/analytics/Contributors.scala
@@ -1,4 +1,4 @@
-// Copyright (C) 2016 The Android Open Source Project
+// Copyright (C) 2017 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.
@@ -22,9 +22,7 @@
 import com.googlesource.gerrit.plugins.analytics.common.DateConversions._
 import com.googlesource.gerrit.plugins.analytics.common._
 import org.eclipse.jgit.lib.ObjectId
-import org.gitective.core.stat.UserCommitActivity
 import org.kohsuke.args4j.{Option => ArgOption}
-import org.slf4j.LoggerFactory
 
 
 @CommandMetaData(name = "contributors", description = "Extracts the list of contributors to a project")
@@ -35,7 +33,8 @@
 
   private var beginDate: Option[Long] = None
 
-  @ArgOption(name = "--since", aliases = Array("--after", "-b"), usage = "(included) begin timestamp. Must be in the format 2006-01-02[ 15:04:05[.890][ -0700]]")
+  @ArgOption(name = "--since", aliases = Array("--after", "-b"),
+    usage = "(included) begin timestamp. Must be in the format 2006-01-02[ 15:04:05[.890][ -0700]]")
   def setBeginDate(date: String) {
     try {
       beginDate = Some(date)
@@ -46,7 +45,8 @@
 
   private var endDate: Option[Long] = None
 
-  @ArgOption(name = "--until", aliases = Array("--before", "-e"), usage = "(excluded) end timestamp. Must be in the format 2006-01-02[ 15:04:05[.890][ -0700]]")
+  @ArgOption(name = "--until", aliases = Array("--before", "-e"),
+    usage = "(excluded) end timestamp. Must be in the format 2006-01-02[ 15:04:05[.890][ -0700]]")
   def setEndDate(date: String) {
     try {
       endDate = Some(date)
@@ -55,8 +55,22 @@
     }
   }
 
+  private var granularity: Option[AggregationStrategy] = None
+
+  @ArgOption(name = "--aggregate", aliases = Array("-g"),
+    usage = "Type of aggregation requested. ")
+  def setGranularity(value: String) {
+    try {
+      granularity = Some(AggregationStrategy.apply(value))
+    } catch {
+      case e: Exception => throw die(s"Invalid granularity ${e.getMessage}")
+    }
+  }
+
+
   override protected def run =
-    gsonFmt.format(executor.get(projectRes, beginDate, endDate), stdout)
+    gsonFmt.format(executor.get(projectRes, beginDate, endDate,
+      granularity.getOrElse(AggregationStrategy.EMAIL)), stdout)
 
 }
 
@@ -66,7 +80,8 @@
 
   private var beginDate: Option[Long] = None
 
-  @ArgOption(name = "--since", aliases = Array("--after", "-b"), metaVar = "QUERY", usage = "(included) begin timestamp. Must be in the format 2006-01-02[ 15:04:05[.890][ -0700]]")
+  @ArgOption(name = "--since", aliases = Array("--after", "-b"), metaVar = "QUERY",
+    usage = "(included) begin timestamp. Must be in the format 2006-01-02[ 15:04:05[.890][ -0700]]")
   def setBeginDate(date: String) {
     try {
       beginDate = Some(date)
@@ -77,7 +92,8 @@
 
   private var endDate: Option[Long] = None
 
-  @ArgOption(name = "--until", aliases = Array("--before", "-e"), metaVar = "QUERY", usage = "(excluded) end timestamp. Must be in the format 2006-01-02[ 15:04:05[.890][ -0700]]")
+  @ArgOption(name = "--until", aliases = Array("--before", "-e"), metaVar = "QUERY",
+    usage = "(excluded) end timestamp. Must be in the format 2006-01-02[ 15:04:05[.890][ -0700]]")
   def setEndDate(date: String) {
     try {
       endDate = Some(date)
@@ -86,18 +102,34 @@
     }
   }
 
+  private var granularity: Option[AggregationStrategy] = None
+
+  @ArgOption(name = "--granularity", aliases = Array("--aggregate", "-g"), metaVar = "QUERY",
+    usage = "(excluded) end timestamp. Must be in the format 2006-01-02[ 15:04:05[.890][ -0700]]")
+  def setGranularity(value: String) {
+    try {
+      granularity = Some(AggregationStrategy.apply(value))
+    } catch {
+      case e: Exception => throw new BadRequestException(s"Invalid granularity ${e.getMessage}")
+    }
+  }
+
   override def apply(projectRes: ProjectResource) =
     Response.ok(
-      new GsonStreamedResult[UserActivitySummary](gson, executor.get(projectRes, beginDate, endDate)))
+      new GsonStreamedResult[UserActivitySummary](gson,
+        executor.get(projectRes, beginDate, endDate,
+          granularity.getOrElse(AggregationStrategy.EMAIL))))
 }
 
 class ContributorsService @Inject()(repoManager: GitRepositoryManager,
                                     histogram: UserActivityHistogram,
                                     gsonFmt: GsonFormatter) {
 
-  def get(projectRes: ProjectResource, startDate: Option[Long], stopDate: Option[Long]): TraversableOnce[UserActivitySummary] = {
+  def get(projectRes: ProjectResource, startDate: Option[Long], stopDate: Option[Long],
+          aggregationStrategy: AggregationStrategy): TraversableOnce[UserActivitySummary] = {
     ManagedResource.use(repoManager.openRepository(projectRes.getNameKey)) {
-      histogram.get(_, new AuthorHistogramFilterByDates(startDate, stopDate))
+      histogram.get(_, new AggregatedHistogramFilterByDates(startDate, stopDate,
+        aggregationStrategy))
         .par
         .map(UserActivitySummary.apply).toStream
     }
@@ -106,13 +138,29 @@
 
 case class CommitInfo(sha1: String, date: Long, merge: Boolean)
 
-case class UserActivitySummary(name: String, email: String, numCommits: Int,
-                               commits: Array[CommitInfo], lastCommitDate: Long)
+case class UserActivitySummary(year: Integer,
+                               month: Integer,
+                               day: Integer,
+                               hour: Integer,
+                               name: String,
+                               email: String,
+                               numCommits: Integer,
+                               commits: Array[CommitInfo],
+                               lastCommitDate: Long)
 
 object UserActivitySummary {
-  def apply(uca: UserCommitActivity): UserActivitySummary =
-    UserActivitySummary(uca.getName, uca.getEmail, uca.getCount,
-      getCommits(uca.getIds, uca.getTimes, uca.getMerges), uca.getLatest)
+  def apply(uca: AggregatedUserCommitActivity): UserActivitySummary = {
+    val INCLUDESEMPTY = -1
+
+    implicit def stringToIntOrNull(x: String): Integer = if (x.isEmpty) null else new Integer(x)
+
+    uca.key.split("/", INCLUDESEMPTY) match {
+      case a@Array(email, year, month, day, hour) =>
+        UserActivitySummary(year, month, day, hour, uca.getName, uca.getEmail, uca.getCount,
+          getCommits(uca.getIds, uca.getTimes, uca.getMerges), uca.getLatest)
+      case _ => throw new Exception(s"invalid key format found ${uca.key}")
+    }
+  }
 
   private def getCommits(ids: Array[ObjectId], times: Array[Long], merges: Array[Boolean]):
   Array[CommitInfo] = {
diff --git a/src/main/scala/com/googlesource/gerrit/plugins/analytics/Module.scala b/src/main/scala/com/googlesource/gerrit/plugins/analytics/Module.scala
index df65ae4..935a198 100644
--- a/src/main/scala/com/googlesource/gerrit/plugins/analytics/Module.scala
+++ b/src/main/scala/com/googlesource/gerrit/plugins/analytics/Module.scala
@@ -1,4 +1,4 @@
-// Copyright (C) 2016 The Android Open Source Project
+// Copyright (C) 2017 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.
diff --git a/src/main/scala/com/googlesource/gerrit/plugins/analytics/SshModule.scala b/src/main/scala/com/googlesource/gerrit/plugins/analytics/SshModule.scala
index 6a2b076..48a48b4 100644
--- a/src/main/scala/com/googlesource/gerrit/plugins/analytics/SshModule.scala
+++ b/src/main/scala/com/googlesource/gerrit/plugins/analytics/SshModule.scala
@@ -1,4 +1,4 @@
-// Copyright (C) 2016 The Android Open Source Project
+// Copyright (C) 2017 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.
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
new file mode 100644
index 0000000..5420a28
--- /dev/null
+++ b/src/main/scala/com/googlesource/gerrit/plugins/analytics/common/AggregatedCommitHistogram.scala
@@ -0,0 +1,61 @@
+// Copyright (C) 2017 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 java.util.Date
+
+import com.googlesource.gerrit.plugins.analytics.common.AggregatedCommitHistogram.AggregationStrategyMapping
+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)
+  extends UserCommitActivity(name, email)
+
+class AggregatedCommitHistogram(val aggregationStrategyForUser: AggregationStrategyMapping)
+  extends CommitHistogram {
+
+  override def include(commit: RevCommit, user: PersonIdent): AggregatedCommitHistogram = {
+    val key = aggregationStrategyForUser(user, commit.getAuthorIdent.getWhen)
+    val activity = Option(users.get(key)) match {
+      case None =>
+        val newActivity = new AggregatedUserCommitActivity(key,
+          user.getName, user.getEmailAddress)
+        users.put(key, newActivity)
+        newActivity
+      case Some(foundActivity) => foundActivity
+    }
+    activity.include(commit, user)
+    this
+  }
+
+  def getAggregatedUserActivity: Array[AggregatedUserCommitActivity] = {
+    users.values.toArray(new Array[AggregatedUserCommitActivity](users.size))
+  }
+}
+
+object AggregatedCommitHistogram {
+  type AggregationStrategyMapping = (PersonIdent, Date) => String
+
+  def apply(aggregationStrategy: AggregationStrategyMapping) =
+    new AggregatedCommitHistogram(aggregationStrategy)
+}
+
+abstract class AbstractCommitHistogramFilter(aggregationStrategyMapping: AggregationStrategyMapping)
+  extends CommitHistogramFilter {
+  val AbstractHistogram = new AggregatedCommitHistogram(aggregationStrategyMapping)
+
+  override def getHistogram = AbstractHistogram
+}
diff --git a/src/main/scala/com/googlesource/gerrit/plugins/analytics/common/AuthorHistogramFilterByDates.scala b/src/main/scala/com/googlesource/gerrit/plugins/analytics/common/AggregatedHistogramFilterByDates.scala
similarity index 64%
rename from src/main/scala/com/googlesource/gerrit/plugins/analytics/common/AuthorHistogramFilterByDates.scala
rename to src/main/scala/com/googlesource/gerrit/plugins/analytics/common/AggregatedHistogramFilterByDates.scala
index b9cf786..cf90d4a 100644
--- a/src/main/scala/com/googlesource/gerrit/plugins/analytics/common/AuthorHistogramFilterByDates.scala
+++ b/src/main/scala/com/googlesource/gerrit/plugins/analytics/common/AggregatedHistogramFilterByDates.scala
@@ -1,4 +1,4 @@
-// Copyright (C) 2016 The Android Open Source Project
+// Copyright (C) 2017 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.
@@ -14,28 +14,27 @@
 
 package com.googlesource.gerrit.plugins.analytics.common
 
-import java.util.Date
-
+import com.googlesource.gerrit.plugins.analytics.common.AggregatedCommitHistogram.AggregationStrategyMapping
 import org.eclipse.jgit.revwalk.{RevCommit, RevWalk}
-import org.gitective.core.stat.CommitHistogramFilter
 
 /**
   * Commit filter that includes commits only on the specified interval
   * starting from and to excluded
   */
-class AuthorHistogramFilterByDates(val from: Option[Long] = None, val to: Option[Long] = None)
-  extends CommitHistogramFilter {
+class AggregatedHistogramFilterByDates(val from: Option[Long] = None, val to: Option[Long] = None,
+                                       val aggregationStrategy: AggregationStrategy = AggregationStrategy.EMAIL)
+  extends AbstractCommitHistogramFilter(aggregationStrategy.mapping) {
 
   override def include(walker: RevWalk, commit: RevCommit) = {
     val commitDate = commit.getCommitterIdent.getWhen.getTime
     val author = commit.getAuthorIdent
     if (from.fold(true)(commitDate >=) && to.fold(true)(commitDate <)) {
-      histogram.include(commit, author)
+      getHistogram.include(commit, author)
       true
     } else {
       false
     }
   }
 
-  override def clone = new AuthorHistogramFilterByDates(from, to)
+  override def clone = new AggregatedHistogramFilterByDates(from, to, aggregationStrategy)
 }
\ No newline at end of file
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
new file mode 100644
index 0000000..a7e559f
--- /dev/null
+++ b/src/main/scala/com/googlesource/gerrit/plugins/analytics/common/AggregationStrategy.scala
@@ -0,0 +1,53 @@
+// Copyright (C) 2017 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 java.security.InvalidParameterException
+import java.time.{LocalDateTime, ZoneOffset}
+import java.util.Date
+
+import com.googlesource.gerrit.plugins.analytics.common.AggregatedCommitHistogram.AggregationStrategyMapping
+
+sealed case class AggregationStrategy(name: String, mapping: AggregationStrategyMapping)
+
+object AggregationStrategy {
+  val values = List(EMAIL, EMAIL_HOUR, EMAIL_DAY, EMAIL_MONTH, EMAIL_YEAR)
+
+  def apply(name: String): AggregationStrategy =
+    values.find(_.name == name.toUpperCase) match {
+      case Some(g) => g
+      case None => throw new InvalidParameterException(
+        s"Must be one of: ${values.map(_.name).mkString(",")}")
+    }
+
+  implicit class PimpedDate(val d: Date) extends AnyVal {
+    def utc: LocalDateTime = d.toInstant.atZone(ZoneOffset.UTC).toLocalDateTime
+  }
+
+  object EMAIL extends AggregationStrategy("EMAIL",
+    (p, _) => s"${p.getEmailAddress}////")
+
+  object EMAIL_YEAR extends AggregationStrategy("EMAIL_YEAR",
+    (p, d) => s"${p.getEmailAddress}/${d.utc.getYear}///")
+
+  object EMAIL_MONTH extends AggregationStrategy("EMAIL_MONTH",
+    (p, d) => s"${p.getEmailAddress}/${d.utc.getYear}/${d.utc.getMonthValue}//")
+
+  object EMAIL_DAY extends AggregationStrategy("EMAIL_DAY",
+    (p, d) => s"${p.getEmailAddress}/${d.utc.getYear}/${d.utc.getMonthValue}/${d.utc.getDayOfMonth}/")
+
+  object EMAIL_HOUR extends AggregationStrategy("EMAIL_HOUR",
+    (p, d) => s"${p.getEmailAddress}/${d.utc.getYear}/${d.utc.getMonthValue}/${d.utc.getDayOfMonth}/${d.utc.getHour}")
+}
diff --git a/src/main/scala/com/googlesource/gerrit/plugins/analytics/common/DateConversions.scala b/src/main/scala/com/googlesource/gerrit/plugins/analytics/common/DateConversions.scala
index 4aa31a7..3da8249 100644
--- a/src/main/scala/com/googlesource/gerrit/plugins/analytics/common/DateConversions.scala
+++ b/src/main/scala/com/googlesource/gerrit/plugins/analytics/common/DateConversions.scala
@@ -1,4 +1,4 @@
-// Copyright (C) 2016 The Android Open Source Project
+// Copyright (C) 2017 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.
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 f9ab7a9..ba80bf2 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
@@ -1,4 +1,4 @@
-// Copyright (C) 2016 The Android Open Source Project
+// Copyright (C) 2017 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.
diff --git a/src/main/scala/com/googlesource/gerrit/plugins/analytics/common/JsonStreamedResultBuilder.scala b/src/main/scala/com/googlesource/gerrit/plugins/analytics/common/JsonStreamedResultBuilder.scala
index 991be59..004259e 100644
--- a/src/main/scala/com/googlesource/gerrit/plugins/analytics/common/JsonStreamedResultBuilder.scala
+++ b/src/main/scala/com/googlesource/gerrit/plugins/analytics/common/JsonStreamedResultBuilder.scala
@@ -1,4 +1,4 @@
-// Copyright (C) 2016 The Android Open Source Project
+// Copyright (C) 2017 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.
diff --git a/src/main/scala/com/googlesource/gerrit/plugins/analytics/common/ManagedResources.scala b/src/main/scala/com/googlesource/gerrit/plugins/analytics/common/ManagedResources.scala
index 83da961..5cf2b74 100644
--- a/src/main/scala/com/googlesource/gerrit/plugins/analytics/common/ManagedResources.scala
+++ b/src/main/scala/com/googlesource/gerrit/plugins/analytics/common/ManagedResources.scala
@@ -1,4 +1,4 @@
-// Copyright (C) 2016 The Android Open Source Project
+// Copyright (C) 2017 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.
diff --git a/src/main/scala/com/googlesource/gerrit/plugins/analytics/common/ProjectResourceParser.scala b/src/main/scala/com/googlesource/gerrit/plugins/analytics/common/ProjectResourceParser.scala
index a375ff4..711d5ac 100644
--- a/src/main/scala/com/googlesource/gerrit/plugins/analytics/common/ProjectResourceParser.scala
+++ b/src/main/scala/com/googlesource/gerrit/plugins/analytics/common/ProjectResourceParser.scala
@@ -1,3 +1,17 @@
+// Copyright (C) 2017 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 java.io.IOException
diff --git a/src/main/scala/com/googlesource/gerrit/plugins/analytics/common/UserActivityHistogram.scala b/src/main/scala/com/googlesource/gerrit/plugins/analytics/common/UserActivityHistogram.scala
index cebc84e..5b5a70b 100644
--- a/src/main/scala/com/googlesource/gerrit/plugins/analytics/common/UserActivityHistogram.scala
+++ b/src/main/scala/com/googlesource/gerrit/plugins/analytics/common/UserActivityHistogram.scala
@@ -1,4 +1,4 @@
-// Copyright (C) 2016 The Android Open Source Project
+// Copyright (C) 2017 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.
@@ -21,10 +21,10 @@
 
 @Singleton
 class UserActivityHistogram {
-  def get(repo: Repository, filter: CommitHistogramFilter) = {
+  def get(repo: Repository, filter: AbstractCommitHistogramFilter) = {
     val finder = new CommitFinder(repo)
     finder.setFilter(filter).find
     val histogram = filter.getHistogram
-    histogram.getUserActivity
+    histogram.getAggregatedUserActivity
   }
 }
\ No newline at end of file
diff --git a/src/test/scala/com/googlesource/gerrit/plugins/analytics/test/AuthorHistogramFilterByDatesSpec.scala b/src/test/scala/com/googlesource/gerrit/plugins/analytics/test/AggregatedHistogramFilterByDatesSpec.scala
similarity index 81%
rename from src/test/scala/com/googlesource/gerrit/plugins/analytics/test/AuthorHistogramFilterByDatesSpec.scala
rename to src/test/scala/com/googlesource/gerrit/plugins/analytics/test/AggregatedHistogramFilterByDatesSpec.scala
index 6f4a8bc..b525c7a 100644
--- a/src/test/scala/com/googlesource/gerrit/plugins/analytics/test/AuthorHistogramFilterByDatesSpec.scala
+++ b/src/test/scala/com/googlesource/gerrit/plugins/analytics/test/AggregatedHistogramFilterByDatesSpec.scala
@@ -16,22 +16,23 @@
 
 import java.util.Date
 
-import com.googlesource.gerrit.plugins.analytics.common.AuthorHistogramFilterByDates
+import com.googlesource.gerrit.plugins.analytics.common.{AggregationStrategy, AggregatedHistogramFilterByDates}
 import org.eclipse.jgit.lib.PersonIdent
 import org.gitective.core.CommitFinder
 import org.scalatest.{BeforeAndAfterEach, FlatSpec, Matchers}
 
-class AuthorHistogramFilterByDatesSpec extends FlatSpec with GitTestCase with BeforeAndAfterEach with Matchers {
+class AggregatedHistogramFilterByDatesSpec extends FlatSpec with GitTestCase with BeforeAndAfterEach with Matchers {
+
 
   "Author history filter" should
     "select one commit without intervals restriction" in {
 
     add("file.txt", "some content")
-    val filter = new AuthorHistogramFilterByDates
+    val filter = new AggregatedHistogramFilterByDates
     new CommitFinder(testRepo).setFilter(filter).find
 
     val userActivity = filter.getHistogram.getUserActivity
-    filter.getHistogram.getUserActivity should have size (1)
+    filter.getHistogram.getUserActivity should have size 1
     val activity = userActivity.head
     activity.getCount should be(1)
     activity.getName should be(author.getName)
@@ -47,14 +48,14 @@
 
     secondCommitTs should be > firstCommitTs
 
-    val filter = new AuthorHistogramFilterByDates(from = Some(secondCommitTs))
+    val filter = new AggregatedHistogramFilterByDates(from = Some(secondCommitTs))
     new CommitFinder(testRepo).setFilter(filter).find
 
     val userActivity = filter.getHistogram.getUserActivity
-    userActivity should have size (1)
+    userActivity should have size 1
     val activity = userActivity.head
 
-    activity.getTimes should have size (1)
+    activity.getTimes should have size 1
     activity.getName should be(person.getName)
     activity.getEmail should be(person.getEmailAddress)
   }
@@ -68,14 +69,14 @@
 
     secondCommitTs should be > firstCommitTs
 
-    val filter = new AuthorHistogramFilterByDates(to = Some(secondCommitTs))
+    val filter = new AggregatedHistogramFilterByDates(to = Some(secondCommitTs))
     new CommitFinder(testRepo).setFilter(filter).find
 
     val userActivity = filter.getHistogram.getUserActivity
-    userActivity should have size (1)
+    userActivity should have size 1
     val activity = userActivity.head
 
-    activity.getTimes should have size (1)
+    activity.getTimes should have size 1
     activity.getName should be(person.getName)
     activity.getEmail should be(person.getEmailAddress)
   }
@@ -92,14 +93,14 @@
     middleCommitTs should be > firstCommitTs
     lastCommitTs should be > middleCommitTs
 
-    val filter = new AuthorHistogramFilterByDates(from = Some(middleCommitTs), to = Some(lastCommitTs))
+    val filter = new AggregatedHistogramFilterByDates(from = Some(middleCommitTs), to = Some(lastCommitTs))
     new CommitFinder(testRepo).setFilter(filter).find
 
     val userActivity = filter.getHistogram.getUserActivity
-    userActivity should have size (1)
+    userActivity should have size 1
     val activity = userActivity.head
 
-    activity.getTimes should have size (1)
+    activity.getTimes should have size 1
     activity.getName should be(person.getName)
     activity.getEmail should be(person.getEmailAddress)
   }
diff --git a/src/test/scala/com/googlesource/gerrit/plugins/analytics/test/AggregationSpec.scala b/src/test/scala/com/googlesource/gerrit/plugins/analytics/test/AggregationSpec.scala
new file mode 100644
index 0000000..bbb691d
--- /dev/null
+++ b/src/test/scala/com/googlesource/gerrit/plugins/analytics/test/AggregationSpec.scala
@@ -0,0 +1,199 @@
+// Copyright (C) 2017 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.util.Date
+
+import com.googlesource.gerrit.plugins.analytics.common.{AggregatedHistogramFilterByDates, AggregatedUserCommitActivity, AggregationStrategy}
+import org.eclipse.jgit.revwalk.RevCommit
+import org.gitective.core.CommitFinder
+import org.scalatest.{FlatSpec, Inspectors, Matchers}
+import AggregationStrategy._
+
+class AggregationSpec extends FlatSpec with Matchers with GitTestCase with Inspectors {
+
+  import com.googlesource.gerrit.plugins.analytics.common.DateConversions._
+
+  def commit(committer: String, when: String, content: String): RevCommit = {
+    val date = new Date(isoStringToLongDate(when))
+    val person = newPersonIdent(committer, committer, date)
+    add("afile.txt", content, author = person, committer = author)
+  }
+
+  def aggregateBy(strategy: AggregationStrategy) = {
+    val filter = new AggregatedHistogramFilterByDates(aggregationStrategy = strategy)
+    new CommitFinder(testRepo).setFilter(filter).find
+    filter.getHistogram.getAggregatedUserActivity
+  }
+
+  "AggregatedHistogramFilter by email and year" should "aggregate two commits from the same author the same year" in {
+    commit("john", "2017-08-01", "first commit")
+    commit("john", "2017-10-05", "second commit")
+
+    val userActivity = aggregateBy(EMAIL_YEAR)
+
+    userActivity should have size 1
+    userActivity.head.getCount should be(2)
+    userActivity.head.email should be("john")
+  }
+
+  it should "keep as separate rows activity from the same author on two different year" in {
+    commit("john", "2017-08-01", "first commit")
+    commit("john", "2018-09-01", "second commit")
+
+    val userActivity = aggregateBy(EMAIL_YEAR)
+
+    userActivity should have size 2
+    forAll(userActivity) {
+      activity => {
+        activity.email should be("john")
+        activity.getCount should be(1)
+        1
+      }
+    }
+  }
+
+  it should "keep as separate rows activity from two different authors on the same year" in {
+    commit("john", "2017-08-01", "first commit")
+    commit("bob", "2017-12-05", "second commit")
+
+    val userActivity = aggregateBy(EMAIL_YEAR)
+
+    userActivity should have size 2
+    userActivity.map(_.email) should contain allOf("john", "bob")
+    forAll(userActivity) {
+      _.getCount should be(1)
+    }
+  }
+
+  "AggregatedHistogramFilter by email and month" should "aggregate two commits from the same author the same month" in {
+    commit("john", "2017-08-01", "first commit")
+    commit("john", "2017-08-05", "second commit")
+
+    val userActivity = aggregateBy(EMAIL_MONTH)
+
+    userActivity should have size 1
+    userActivity.head.getCount should be(2)
+    userActivity.head.email should be("john")
+  }
+
+  it should "keep as separate rows activity from the same author on two different months" in {
+    commit("john", "2017-08-01", "first commit")
+    commit("john", "2017-09-01", "second commit")
+
+    val userActivity = aggregateBy(EMAIL_MONTH)
+
+    userActivity should have size 2
+    forAll(userActivity) {
+      activity => {
+        activity.email should be("john")
+        activity.getCount should be(1)
+        1
+      }
+    }
+  }
+
+  it should "keep as separate rows activity from two different authors on the same month" in {
+    commit("john", "2017-08-01", "first commit")
+    commit("bob", "2017-08-05", "second commit")
+
+    val userActivity = aggregateBy(EMAIL_MONTH)
+
+    userActivity should have size 2
+    userActivity.map(_.email) should contain allOf("john", "bob")
+    forAll(userActivity) {
+      _.getCount should be(1)
+    }
+  }
+
+  "AggregatedHistogramFilter by email and day" should "aggregate two commits of the same author the same day" in {
+    commit("john", "2017-08-01", "first commit")
+    commit("john", "2017-08-01", "second commit")
+
+    val userActivity = aggregateBy(EMAIL_DAY)
+
+    userActivity should have size 1
+    userActivity.head.getCount should be(2)
+    userActivity.head.email should be("john")
+  }
+
+  it should "keep as separate rows activity from the same author on two different days" in {
+    commit("john", "2017-08-01", "first commit")
+    commit("john", "2017-08-02", "second commit")
+
+    val userActivity = aggregateBy(EMAIL_DAY)
+
+    userActivity should have size 2
+    forAll(userActivity) {
+      activity => {
+        activity.email should be("john")
+        activity.getCount should be
+        1
+      }
+    }
+  }
+
+  it should "keep as separate rows activity from two different authors on the same day" in {
+    commit("john", "2017-08-01", "first commit")
+    commit("bob", "2017-08-01", "second commit")
+
+    val userActivity = aggregateBy(EMAIL_DAY)
+
+    userActivity should have size 2
+    userActivity.map(_.email) should contain allOf("john", "bob")
+    forAll(userActivity) {
+      _.getCount should be(1)
+    }
+  }
+
+  "AggregatedHistogramFilter by email and hour" should "aggregate two commits of the same author on the same hour" in {
+    commit("john", "2017-08-01 10:15:03", "first commit")
+    commit("john", "2017-08-01 10:45:01", "second commit")
+
+    val userActivity = aggregateBy(EMAIL_HOUR)
+
+    userActivity should have size 1
+    userActivity.head.email should be("john")
+    userActivity.head.getCount should be(2)
+  }
+
+  it should "keep separate commits from the same author on different hours" in {
+    commit("john", "2017-08-01 10:15:03", "first commit")
+    commit("john", "2017-08-01 11:30:01", "second commit")
+
+    val userActivity = aggregateBy(EMAIL_HOUR)
+
+    userActivity should have size 2
+    forAll(userActivity) {
+      activity => {
+        activity.email should be("john")
+        activity.getCount should be(1)
+      }
+    }
+  }
+
+  it should "keep separate commits from different authors on the same hour" in {
+    commit("john", "2017-08-01 10:15:03", "first commit")
+    commit("bob", "2017-08-01 10:20:00", "second commit")
+
+    val userActivity = aggregateBy(EMAIL_HOUR)
+
+    userActivity should have size 2
+    forAll(userActivity) {
+      _.getCount should be(1)
+    }
+    userActivity.map(_.email) should contain allOf("john", "bob")
+  }
+}