Merge branch 'stable-2.14' into stable-2.15

* stable-2.14:
  Align Gerrit API version to 2.14.12
  Upgrade sbt plugins

Change-Id: Ib0f2d009a244727c650057bdb108e564b8f59c38
diff --git a/README.md b/README.md
index f360e7b..29bc565 100644
--- a/README.md
+++ b/README.md
@@ -48,31 +48,45 @@
 ### Contributors
 
 Extract a unordered list of project contributors statistics, including the
-commits data relevant for statistics purposes, such as timestamp and merge flag.
+commits data relevant for statistics purposes, such as number of involved files, and optionally also the list of belonging branches,
+number of added/deleted lines, timestamp and merge flag.
+
+Optionally, extract information on issues using the [commentLink](https://gerrit-review.googlesource.com/Documentation/config-gerrit.html#commentlink)
+Gerrit configuration and enrich the statistics with the issue-ids and links obtained from
+the commit message.
+
 
 *REST*
 
-/projects/{project-name}/analytics~contributors
+/projects/{project-name}/analytics~contributors[?since=2006-01-02[15:04:05[.890][-0700]]][&until=2018-01-02[18:01:03[.333][-0700]]][&aggregate=email_year]
 
 *SSH*
 
-analytics contributors {project-name}
+analytics contributors {project-name} [--since 2006-01-02[15:04:05[.890][-0700]]] [--until 2018-01-02[18:01:03[.333][-0700]]]
+
+### Parameters
+
+- --since -b Starting timestamp to consider
+- --until -e Ending timestamp (excluded) to consider
+- --aggregate -granularity -g one of email, email_year, email_month, email_day, email_hour defaulting to aggregation by email
+- --extract-branches -r enables branches extraction for each commit
+- --extract-issues -i enables the extraction of issues from commentLink
+
+NOTE: Timestamp format is consistent with Gerrit's query syntax, see /Documentation/user-search.html for details.
 
 REST Example:
 
 ```
-   $ curl http://gerrit.mycompany.com/project/myproyject/analytics~contributors
-
-   {"name":"John Doe","email":"john.doe@mycompany.com","num_commits":1,"commits":[{"sha1":"6a1f73738071e299f600017d99f7252d41b96b4b","date":"Apr 28, 2011 5:13:14 AM","merge":false}]}
-   {"name":"Matt Smith","email":"matt.smith@mycompany.com","num_commits":1,"commits":[{"sha1":"54527e7e3086758a23e3b069f183db6415aca304","date":"Sep 8, 2015 3:11:23 AM","merge":true}]}
+   $ curl http://gerrit.mycompany.com/projects/myproject/analytics~contributors
+   {"name":"John Doe","email":"john.doe@mycompany.com","num_commits":1, "num_files":4,"added_lines":9,"deleted_lines":1, "commits":[{"sha1":"6a1f73738071e299f600017d99f7252d41b96b4b","date":"Apr 28, 2011 5:13:14 AM","merge":false}]}
+   {"name":"Matt Smith","email":"matt.smith@mycompany.com","num_commits":1, "num_files":1,"added_lines":90,"deleted_lines":10,"commits":[{"sha1":"54527e7e3086758a23e3b069f183db6415aca304","date":"Sep 8, 2015 3:11:23 AM","merge":true}],"branches":["master","branch1"]}
 ```
 
 SSH Example:
 
 ```
-   $ ssh -p 29418 admin@gerrit.mycompany.com analytics contributors
-
-   {"name":"John Doe","email":"john.doe@mycompany.com","num_commits":1,"commits":[{"sha1":"6a1f73738071e299f600017d99f7252d41b96b4b","date":"Apr 28, 2011 5:13:14 AM","merge":false}]}
-   {"name":"Matt Smith","email":"matt.smith@mycompany.com","num_commits":1,"commits":[{"sha1":"54527e7e3086758a23e3b069f183db6415aca304","date":"Sep 8, 2015 3:11:23 AM","merge":true}]}
+   $ ssh -p 29418 admin@gerrit.mycompany.com analytics contributors myproject --since 2017-08-01 --until 2017-12-31 --extract-issues
+   {"name":"John Doe","email":"john.doe@mycompany.com","num_commits":1, "num_files":4,"added_lines":9,"deleted_lines":1, "commits":[{"sha1":"6a1f73738071e299f600017d99f7252d41b96b4b","date":"Apr 28, 2011 5:13:14 AM","merge":false}], "issues_codes":["PRJ-001"],"issues_links":["https://jira.company.org/PRJ-001"]}
+   {"name":"Matt Smith","email":"matt.smith@mycompany.com","num_commits":1, "num_files":1,"added_lines":90,"deleted_lines":10,"commits":[{"sha1":"54527e7e3086758a23e3b069f183db6415aca304","date":"Sep 8, 2015 3:11:23 AM","merge":true}],"branches":["master","branch1"],"issues_codes":["PRJ-002","PRJ-003"],"issues_links":["https://jira.company.org/PRJ-002","https://jira.company.org/PRJ-003"]}
 ```
 
diff --git a/build.sbt b/build.sbt
index 4b96130..3f95333 100644
--- a/build.sbt
+++ b/build.sbt
@@ -1,9 +1,11 @@
-import sbt.Keys._
+enablePlugins(GitVersioning)
 
 val gerritApiVersion = "2.14.12"
 
 val pluginName = "analytics"
 
+git.useGitDescribe := true
+
 lazy val root = (project in file("."))
   .settings(
     name := pluginName,
@@ -13,7 +15,7 @@
     scalaVersion := "2.11.8",
 
     libraryDependencies ++= Seq(
-      "io.fabric8" % "gitective-core" % "0.9.19"
+      "io.fabric8" % "gitective-core" % "0.9.37"
         exclude ("org.eclipse.jgit", "org.eclipse.jgit"),
 
       "com.google.inject" % "guice" % "4.2.0" % Provided,
@@ -36,7 +38,3 @@
     )
   )
 
-
-
-
-
diff --git a/project/build.properties b/project/build.properties
new file mode 100644
index 0000000..64317fd
--- /dev/null
+++ b/project/build.properties
@@ -0,0 +1 @@
+sbt.version=0.13.15
diff --git a/project/plugins.sbt b/project/plugins.sbt
index b401671..30fcaf9 100644
--- a/project/plugins.sbt
+++ b/project/plugins.sbt
@@ -1,4 +1,5 @@
 logLevel := Level.Warn
 addSbtPlugin("com.eed3si9n" % "sbt-assembly" % "0.14.7")
 addSbtPlugin("com.typesafe.sbteclipse" % "sbteclipse-plugin" % "5.2.4")
+addSbtPlugin("com.typesafe.sbt" % "sbt-git" % "0.9.3")
 
diff --git a/src/main/java/com/googlesource/gerrit/plugins/analytics/CommitInfo.java b/src/main/java/com/googlesource/gerrit/plugins/analytics/CommitInfo.java
deleted file mode 100644
index d7fbd9a..0000000
--- a/src/main/java/com/googlesource/gerrit/plugins/analytics/CommitInfo.java
+++ /dev/null
@@ -1,28 +0,0 @@
-// Copyright (C) 2016 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;
-
-public class CommitInfo {
-  public final String sha1;
-  public final long date;
-  public final boolean merge;
-
-  public CommitInfo(String sha1, long date, boolean merge) {
-    super();
-    this.sha1 = sha1;
-    this.date = date;
-    this.merge = merge;
-  }
-}
diff --git a/src/main/java/com/googlesource/gerrit/plugins/analytics/ContributorsCommand.java b/src/main/java/com/googlesource/gerrit/plugins/analytics/ContributorsCommand.java
deleted file mode 100644
index 0105ea5..0000000
--- a/src/main/java/com/googlesource/gerrit/plugins/analytics/ContributorsCommand.java
+++ /dev/null
@@ -1,60 +0,0 @@
-// Copyright (C) 2016 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;
-
-import com.google.gerrit.extensions.restapi.UnprocessableEntityException;
-import com.google.gerrit.server.project.ProjectResource;
-import com.google.gerrit.server.project.ProjectsCollection;
-import com.google.gerrit.sshd.CommandMetaData;
-import com.google.gerrit.sshd.SshCommand;
-import com.google.inject.Inject;
-
-import org.kohsuke.args4j.Argument;
-
-import java.io.IOException;
-
-@CommandMetaData(name = "contributors", description = "Extracts the list of contributors to a project")
-public class ContributorsCommand extends SshCommand {
-  private final ProjectsCollection projects;
-  private final ContributorsResource contributors;
-  private final GsonFormatter gsonFmt;
-
-  @Inject
-  public ContributorsCommand(ProjectsCollection projects,
-      ContributorsResource contributors, GsonFormatter gsonFmt) {
-    this.projects = projects;
-    this.contributors = contributors;
-    this.gsonFmt = gsonFmt;
-  }
-
-  @Argument(usage = "project name", metaVar = "PROJECT", required = true)
-  void setProject(String project) throws IllegalArgumentException {
-    try {
-      this.projectRes = projects.parse(project);
-    } catch (UnprocessableEntityException e) {
-      throw new IllegalArgumentException(e.getLocalizedMessage(), e);
-    } catch (IOException e) {
-      throw new IllegalArgumentException(
-          "I/O Error while trying to access project " + project, e);
-    }
-  }
-
-  private ProjectResource projectRes;
-
-  @Override
-  protected void run() throws UnloggedFailure, Failure, Exception {
-    gsonFmt.format(contributors.getStream(projectRes), stdout);
-  }
-}
diff --git a/src/main/java/com/googlesource/gerrit/plugins/analytics/ContributorsResource.java b/src/main/java/com/googlesource/gerrit/plugins/analytics/ContributorsResource.java
deleted file mode 100644
index d9963de..0000000
--- a/src/main/java/com/googlesource/gerrit/plugins/analytics/ContributorsResource.java
+++ /dev/null
@@ -1,72 +0,0 @@
-// Copyright (C) 2013 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;
-
-import com.google.gerrit.extensions.restapi.BinaryResult;
-import com.google.gerrit.extensions.restapi.Response;
-import com.google.gerrit.extensions.restapi.RestReadView;
-import com.google.gerrit.server.git.GitRepositoryManager;
-import com.google.gerrit.server.project.ProjectResource;
-import com.google.inject.Inject;
-
-import org.eclipse.jgit.errors.RepositoryNotFoundException;
-import org.eclipse.jgit.lib.Repository;
-
-import java.io.IOException;
-import java.io.OutputStream;
-import java.io.PrintWriter;
-import java.util.stream.Stream;
-
-class ContributorsResource implements RestReadView<ProjectResource> {
-  private final GitRepositoryManager repoManager;
-  private final UserSummaryExport userSummary;
-  private final GsonFormatter gsonFmt;
-
-  class JsonStreamedResult<T> extends BinaryResult {
-    private final Stream<T> committers;
-
-    public JsonStreamedResult(Stream<T> committers) {
-      this.committers = committers;
-    }
-
-    @Override
-    public void writeTo(OutputStream os) throws IOException {
-      try (PrintWriter sout = new PrintWriter(os)) {
-        gsonFmt.format(committers, sout);
-      }
-    }
-  }
-
-  @Inject
-  public ContributorsResource(GitRepositoryManager repoManager,
-      UserSummaryExport userSummary, GsonFormatter gsonFmt) {
-    this.repoManager = repoManager;
-    this.userSummary = userSummary;
-    this.gsonFmt = gsonFmt;
-  }
-
-  @Override
-  public Response<BinaryResult> apply(ProjectResource projectRes)
-      throws RepositoryNotFoundException, IOException {
-    return Response.ok(new JsonStreamedResult<>(getStream(projectRes)));
-  }
-
-  public Stream<UserActivitySummary> getStream(ProjectResource projectRes)
-      throws RepositoryNotFoundException, IOException {
-    try (Repository repo = repoManager.openRepository(projectRes.getNameKey())) {
-      return userSummary.getCommittersStream(repo);
-    }
-  }
-}
diff --git a/src/main/java/com/googlesource/gerrit/plugins/analytics/GsonFormatter.java b/src/main/java/com/googlesource/gerrit/plugins/analytics/GsonFormatter.java
deleted file mode 100644
index eecd33b..0000000
--- a/src/main/java/com/googlesource/gerrit/plugins/analytics/GsonFormatter.java
+++ /dev/null
@@ -1,43 +0,0 @@
-// Copyright (C) 2016 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;
-
-import com.google.gerrit.server.OutputFormat;
-import com.google.gson.Gson;
-import com.google.gson.GsonBuilder;
-import com.google.inject.Inject;
-import com.google.inject.Singleton;
-
-import java.io.PrintWriter;
-import java.util.stream.Stream;
-
-@Singleton
-public class GsonFormatter {
-  private GsonBuilder gsonBuilder;
-
-  @Inject
-  public GsonFormatter() {
-    gsonBuilder = OutputFormat.JSON_COMPACT.newGsonBuilder();
-  }
-
-  public <T> void format(Stream<T> values, PrintWriter out) {
-    final Gson gson = gsonBuilder.create();
-
-    values.sequential().forEach((T value) -> {
-      gson.toJson(value, out);
-      out.println();
-    });
-  }
-}
diff --git a/src/main/java/com/googlesource/gerrit/plugins/analytics/Module.java b/src/main/java/com/googlesource/gerrit/plugins/analytics/Module.java
deleted file mode 100644
index 3d2f6eb..0000000
--- a/src/main/java/com/googlesource/gerrit/plugins/analytics/Module.java
+++ /dev/null
@@ -1,34 +0,0 @@
-// Copyright (C) 2013 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;
-
-import static com.google.gerrit.server.project.ProjectResource.PROJECT_KIND;
-
-import com.google.gerrit.extensions.restapi.RestApiModule;
-import com.google.inject.AbstractModule;
-
-public class Module extends AbstractModule {
-
-  @Override
-  protected void configure() {
-
-    install(new RestApiModule() {
-      @Override
-      protected void configure() {
-        get(PROJECT_KIND, "contributors").to(ContributorsResource.class);
-      }
-    });
-  }
-}
diff --git a/src/main/java/com/googlesource/gerrit/plugins/analytics/UserActivitySummary.java b/src/main/java/com/googlesource/gerrit/plugins/analytics/UserActivitySummary.java
deleted file mode 100644
index f94d7be..0000000
--- a/src/main/java/com/googlesource/gerrit/plugins/analytics/UserActivitySummary.java
+++ /dev/null
@@ -1,58 +0,0 @@
-// Copyright (C) 2016 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;
-
-
-import org.eclipse.jgit.lib.ObjectId;
-// import org.gitective.core.stat.UserCommitActivity;
-import org.gitective.core.stat.UserCommitActivity;
-
-import java.util.ArrayList;
-import java.util.List;
-
-public class UserActivitySummary {
-  public final String name;
-  public final String email;
-  public final int numCommits;
-  public final List<CommitInfo> commits;
-  public final long lastCommitDate;
-
-
-  public UserActivitySummary(String name, String email, int numCommits,
-      List<CommitInfo> commits, long lastCommitDate) {
-    this.name = name;
-    this.email = email;
-    this.numCommits = numCommits;
-    this.commits = commits;
-    this.lastCommitDate = lastCommitDate;
-  }
-
-  public static UserActivitySummary fromUserActivity(UserCommitActivity uca) {
-    return new UserActivitySummary(uca.getName(), uca.getEmail(),
-        uca.getCount(), getCommits(uca.getIds(), uca.getTimes(),
-            uca.getMerges()), uca.getLatest());
-  }
-
-  private static List<CommitInfo> getCommits(ObjectId[] ids, long[] times,
-      boolean[] merges) {
-    List<CommitInfo> commits = new ArrayList<>(ids.length);
-
-    for (int i = 0; i < ids.length; i++) {
-      commits.add(new CommitInfo(ids[i].name(), times[i], merges[i]));
-    }
-
-    return commits;
-  }
-}
diff --git a/src/main/java/com/googlesource/gerrit/plugins/analytics/UserSummaryExport.java b/src/main/java/com/googlesource/gerrit/plugins/analytics/UserSummaryExport.java
deleted file mode 100644
index 953dc3a..0000000
--- a/src/main/java/com/googlesource/gerrit/plugins/analytics/UserSummaryExport.java
+++ /dev/null
@@ -1,42 +0,0 @@
-// Copyright (C) 2016 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;
-
-import com.google.inject.Singleton;
-
-import org.eclipse.jgit.lib.Repository;
-import org.gitective.core.CommitFinder;
-import org.gitective.core.stat.AuthorHistogramFilter;
-import org.gitective.core.stat.CommitHistogram;
-import org.gitective.core.stat.CommitHistogramFilter;
-import org.gitective.core.stat.UserCommitActivity;
-
-import java.util.stream.Stream;
-
-@Singleton
-public class UserSummaryExport {
-
-  public Stream<UserActivitySummary> getCommittersStream(Repository repo) {
-    CommitFinder finder = new CommitFinder(repo);
-    CommitHistogramFilter filter = new AuthorHistogramFilter();
-    finder.setFilter(filter).find();
-    CommitHistogram histogram = filter.getHistogram();
-    UserCommitActivity[] authorActivity = histogram.getUserActivity();
-
-    return Stream.of(authorActivity)
-        .parallel()
-        .map(UserActivitySummary::fromUserActivity);
-  }
-}
diff --git a/src/main/scala/com/googlesource/gerrit/plugins/analytics/Contributors.scala b/src/main/scala/com/googlesource/gerrit/plugins/analytics/Contributors.scala
new file mode 100644
index 0000000..3c1253a
--- /dev/null
+++ b/src/main/scala/com/googlesource/gerrit/plugins/analytics/Contributors.scala
@@ -0,0 +1,214 @@
+// 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
+
+import com.google.gerrit.extensions.api.projects.CommentLinkInfo
+import com.google.gerrit.extensions.restapi.{BadRequestException, Response, RestReadView}
+import com.google.gerrit.server.git.GitRepositoryManager
+import com.google.gerrit.server.project.{ProjectCache, ProjectResource, ProjectsCollection}
+import com.google.gerrit.sshd.{CommandMetaData, SshCommand}
+import com.google.inject.Inject
+import com.googlesource.gerrit.plugins.analytics.common.DateConversions._
+import com.googlesource.gerrit.plugins.analytics.common._
+import org.kohsuke.args4j.{Option => ArgOption}
+
+
+@CommandMetaData(name = "contributors", description = "Extracts the list of contributors to a project")
+class ContributorsCommand @Inject()(val executor: ContributorsService,
+                                    val projects: ProjectsCollection,
+                                    val gsonFmt: GsonFormatter)
+  extends SshCommand with ProjectResourceParser {
+
+  private var beginDate: Option[Long] = None
+  private var endDate: Option[Long] = None
+  private var granularity: Option[AggregationStrategy] = None
+
+  @ArgOption(name = "--extract-branches", aliases = Array("-r"),
+    usage = "Do extra parsing to extract a list of all branches for each line")
+  private var extractBranches: Boolean = false
+
+  @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)
+    } catch {
+      case e: Exception => throw die(s"Invalid begin date ${e.getMessage}")
+    }
+  }
+
+  @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)
+    } catch {
+      case e: Exception => throw die(s"Invalid end date ${e.getMessage}")
+    }
+  }
+
+  @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}")
+    }
+  }
+
+  @ArgOption(name = "--extract-issues", aliases = Array("-i"),
+    usage = "Extract a list of issues and links using the Gerrit's commentLink configuration")
+  private var extractIssues: Boolean = false
+
+  override protected def run =
+    gsonFmt.format(executor.get(projectRes, beginDate, endDate,
+      granularity.getOrElse(AggregationStrategy.EMAIL), extractBranches, extractIssues), stdout)
+
+}
+
+class ContributorsResource @Inject()(val executor: ContributorsService,
+                                     val gson: GsonFormatter)
+  extends RestReadView[ProjectResource] {
+
+  private var beginDate: Option[Long] = None
+  private var endDate: Option[Long] = None
+  private var granularity: Option[AggregationStrategy] = 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]]")
+  def setBeginDate(date: String) {
+    try {
+      beginDate = Some(date)
+    } catch {
+      case e: Exception => throw new BadRequestException(s"Invalid begin date ${e.getMessage}")
+    }
+  }
+
+  @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)
+    } catch {
+      case e: Exception => throw new BadRequestException(s"Invalid end date ${e.getMessage}")
+    }
+  }
+
+  @ArgOption(name = "--granularity", aliases = Array("--aggregate", "-g"), metaVar = "QUERY",
+    usage = "can be one of EMAIL, EMAIL_HOUR, EMAIL_DAY, EMAIL_MONTH, EMAIL_YEAR, defaulting to EMAIL")
+  def setGranularity(value: String) {
+    try {
+      granularity = Some(AggregationStrategy.apply(value))
+    } catch {
+      case e: Exception => throw new BadRequestException(s"Invalid granularity ${e.getMessage}")
+    }
+  }
+
+  @ArgOption(name = "--extract-branches", aliases = Array("-r"),
+    usage = "Do extra parsing to extract a list of all branches for each line")
+  private var extractBranches: Boolean = false
+
+  @ArgOption(name = "--extract-issues", aliases = Array("-i"),
+    usage = "Extract a list of issues and links using the Gerrit's commentLink configuration")
+  private var extractIssues: Boolean = false
+
+  override def apply(projectRes: ProjectResource) =
+    Response.ok(
+      new GsonStreamedResult[UserActivitySummary](gson,
+        executor.get(projectRes, beginDate, endDate,
+          granularity.getOrElse(AggregationStrategy.EMAIL), extractBranches, extractIssues)))
+}
+
+class ContributorsService @Inject()(repoManager: GitRepositoryManager,
+                                    projectCache:ProjectCache,
+                                    histogram: UserActivityHistogram,
+                                    gsonFmt: GsonFormatter) {
+  import RichBoolean._
+  import scala.collection.JavaConverters._
+
+  def get(projectRes: ProjectResource, startDate: Option[Long], stopDate: Option[Long],
+          aggregationStrategy: AggregationStrategy, extractBranches: Boolean, extractIssues: Boolean)
+  : TraversableOnce[UserActivitySummary] = {
+    val nameKey = projectRes.getNameKey
+    val commentLinks: List[CommentLinkInfo] = extractIssues.option {
+      projectCache.get(nameKey).getCommentLinks.asScala
+    }.toList.flatten
+
+    ManagedResource.use(repoManager.openRepository(projectRes.getNameKey)) { repo =>
+      val stats = new Statistics(repo, commentLinks.asJava)
+      val commitsBranchesOptionalEnricher = extractBranches.option(
+        new CommitsBranches(repo, startDate, stopDate)
+      )
+      histogram.get(repo, new AggregatedHistogramFilterByDates(startDate, stopDate,
+        aggregationStrategy))
+        .par
+        .flatMap(UserActivitySummary.apply(stats, commitsBranchesOptionalEnricher))
+        .toStream
+    }
+  }
+}
+
+case class CommitInfo(sha1: String, date: Long, merge: Boolean, files: java.util.Set[String])
+
+case class IssueInfo(code: String, link: String)
+
+case class UserActivitySummary(year: Integer,
+                               month: Integer,
+                               day: Integer,
+                               hour: Integer,
+                               name: String,
+                               email: String,
+                               numCommits: Integer,
+                               numFiles: Integer,
+                               numDistinctFiles: Integer,
+                               addedLines: Integer,
+                               deletedLines: Integer,
+                               commits: Array[CommitInfo],
+                               branches: Array[String],
+                               issuesCodes: Array[String],
+                               issuesLinks: Array[String],
+                               lastCommitDate: Long,
+                               isMerge: Boolean
+                              )
+
+object UserActivitySummary {
+  def apply(statisticsHandler: Statistics,
+            branchesLabeler: Option[CommitsBranches])
+           (uca: AggregatedUserCommitActivity)
+  : Iterable[UserActivitySummary] = {
+    val INCLUDESEMPTY = -1
+
+    implicit def stringToIntOrNull(x: String): Integer = if (x.isEmpty) null else new Integer(x)
+
+    uca.key.split("/", INCLUDESEMPTY) match {
+      case Array(email, year, month, day, hour) =>
+        val branches = branchesLabeler.fold(Set.empty[String]) {
+          labeler => labeler.forCommits(uca.getIds)
+        }
+        statisticsHandler.forCommits(uca.getIds: _*).map { stat =>
+          UserActivitySummary(
+            year, month, day, hour, uca.getName, uca.getEmail, uca.getCount,
+            stat.numFiles, stat.numDistinctFiles, stat.addedLines, stat.deletedLines,
+            stat.commits.toArray, branches.toArray, stat.issues.map(_.code)
+              .toArray, stat.issues.map(_.link).toArray, uca.getLatest, stat
+              .isForMergeCommits
+          )
+        }
+      case _ => throw new Exception(s"invalid key format found ${uca.key}")
+    }
+  }
+}
+
diff --git a/src/main/scala/com/googlesource/gerrit/plugins/analytics/Module.scala b/src/main/scala/com/googlesource/gerrit/plugins/analytics/Module.scala
new file mode 100644
index 0000000..935a198
--- /dev/null
+++ b/src/main/scala/com/googlesource/gerrit/plugins/analytics/Module.scala
@@ -0,0 +1,30 @@
+// 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
+
+import com.google.gerrit.extensions.restapi.RestApiModule
+import com.google.gerrit.server.project.ProjectResource.PROJECT_KIND
+import com.google.inject.AbstractModule
+
+class Module extends AbstractModule {
+
+  override protected def configure() {
+    install(new RestApiModule() {
+      override protected def configure() = {
+        get(PROJECT_KIND, "contributors").to(classOf[ContributorsResource])
+      }
+    })
+  }
+}
diff --git a/src/main/java/com/googlesource/gerrit/plugins/analytics/SshModule.java b/src/main/scala/com/googlesource/gerrit/plugins/analytics/SshModule.scala
similarity index 64%
rename from src/main/java/com/googlesource/gerrit/plugins/analytics/SshModule.java
rename to src/main/scala/com/googlesource/gerrit/plugins/analytics/SshModule.scala
index 7f015f9..48a48b4 100644
--- a/src/main/java/com/googlesource/gerrit/plugins/analytics/SshModule.java
+++ 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.
@@ -12,14 +12,13 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.googlesource.gerrit.plugins.analytics;
+package com.googlesource.gerrit.plugins.analytics
 
-import com.google.gerrit.sshd.PluginCommandModule;
+import com.google.gerrit.sshd.PluginCommandModule
 
-public class SshModule extends PluginCommandModule {
+class SshModule extends PluginCommandModule {
 
-  @Override
-  protected void configureCommands() {
-    command(ContributorsCommand.class);
+  override protected def configureCommands {
+    command(classOf[ContributorsCommand])
   }
-}
+}
\ No newline at end of file
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/AggregatedHistogramFilterByDates.scala b/src/main/scala/com/googlesource/gerrit/plugins/analytics/common/AggregatedHistogramFilterByDates.scala
new file mode 100644
index 0000000..9f92e3c
--- /dev/null
+++ b/src/main/scala/com/googlesource/gerrit/plugins/analytics/common/AggregatedHistogramFilterByDates.scala
@@ -0,0 +1,39 @@
+// 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 org.eclipse.jgit.revwalk.{RevCommit, RevWalk}
+
+/**
+  * Commit filter that includes commits only on the specified interval
+  * starting from and to excluded
+  */
+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 <)) {
+      getHistogram.include(commit, author)
+      true
+    } else {
+      false
+    }
+  }
+
+  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/CommitsBranches.scala b/src/main/scala/com/googlesource/gerrit/plugins/analytics/common/CommitsBranches.scala
new file mode 100644
index 0000000..46cca17
--- /dev/null
+++ b/src/main/scala/com/googlesource/gerrit/plugins/analytics/common/CommitsBranches.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 com.googlesource.gerrit.plugins.analytics.common.ManagedResource.use
+import org.eclipse.jgit.api.Git
+import org.eclipse.jgit.lib.{Constants, ObjectId, Repository}
+import org.eclipse.jgit.revwalk.RevWalk
+import org.eclipse.jgit.revwalk.filter.CommitTimeRevFilter
+
+import scala.collection.JavaConversions._
+import scala.collection.mutable
+
+class CommitsBranches(repo: Repository, from: Option[Long] = None,
+                      to: Option[Long] = None) {
+
+  def forCommits(objectIds: TraversableOnce[ObjectId]): Set[String] = {
+    val commitToBranchesMap = new mutable.HashMap[String, mutable.Set[String]]
+      with mutable.MultiMap[String, String]
+    use(new Git(repo)) { git =>
+      val refs = git.branchList.call
+      for (ref <- refs) {
+        val branchName = ref.getName.drop(Constants.R_HEADS.length)
+        use(new RevWalk(repo)) { rw: RevWalk =>
+          from.foreach(d1 => rw.setRevFilter(CommitTimeRevFilter.after(d1)))
+          to.foreach(d2 => rw.setRevFilter(CommitTimeRevFilter.before(d2)))
+          rw.markStart(rw.parseCommit(ref.getObjectId))
+          rw.foreach { rev =>
+            val sha1 = rev.getName
+            commitToBranchesMap.addBinding(sha1, branchName)
+          }
+        }
+      }
+      objectIds.foldLeft(Set.empty[String]) {
+        (branches, objectId) => {
+          branches ++ commitToBranchesMap(objectId.getName)
+        }
+      }.filter(_.nonEmpty)
+    }
+  }
+}
diff --git a/src/main/scala/com/googlesource/gerrit/plugins/analytics/common/CommitsStatistics.scala b/src/main/scala/com/googlesource/gerrit/plugins/analytics/common/CommitsStatistics.scala
new file mode 100644
index 0000000..2dc7515
--- /dev/null
+++ b/src/main/scala/com/googlesource/gerrit/plugins/analytics/common/CommitsStatistics.scala
@@ -0,0 +1,158 @@
+// 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 com.google.gerrit.extensions.api.projects.CommentLinkInfo
+import com.googlesource.gerrit.plugins.analytics.{CommitInfo, IssueInfo}
+import com.googlesource.gerrit.plugins.analytics.common.ManagedResource.use
+import org.eclipse.jgit.diff.{DiffFormatter, RawTextComparator}
+import org.eclipse.jgit.lib.{ObjectId, Repository}
+import org.eclipse.jgit.revwalk.RevWalk
+import org.eclipse.jgit.treewalk.{CanonicalTreeParser, EmptyTreeIterator}
+import org.eclipse.jgit.util.io.DisabledOutputStream
+import org.slf4j.LoggerFactory
+
+import scala.collection.JavaConversions._
+import scala.util.matching.Regex
+
+/**
+  * Collects overall stats on a series of commits and provides some basic info on the included commits
+  *
+  * @param addedLines           sum of the number of line additions in the included commits
+  * @param deletedLines         sum of the number of line deletions in the included commits
+  * @param isForMergeCommits    true if the current instance is including stats for merge commits and false if
+  *                             calculated for NON merge commits. The current code is not generating stats objects for
+  *                             a mixture of merge and non-merge commits
+  * @param commits              list of commits the stats are calculated for
+  */
+case class CommitsStatistics(
+                              addedLines: Int,
+                              deletedLines: Int,
+                              isForMergeCommits: Boolean,
+                              commits: List[CommitInfo],
+                              issues: List[IssueInfo] = Nil
+                            ) {
+  require(commits.forall(_.merge == isForMergeCommits), s"Creating a stats object with isMergeCommit = $isForMergeCommits but containing commits of different type")
+
+  /**
+    * sum of the number of files in each of the included commits
+    */
+  val numFiles: Int = commits.map(_.files.size).sum
+
+  /**
+    * number of distinct files the included commits have been touching
+    */
+  val numDistinctFiles: Int = changedFiles.size
+
+  def isEmpty: Boolean = commits.isEmpty
+
+  def changedFiles: Set[String] = commits.map(_.files.toSet).fold(Set.empty)(_ union _)
+
+  // Is not a proper monoid since we cannot sum a MergeCommit with a non merge one but it would overkill to define two classes
+  def + (that: CommitsStatistics) = {
+    require(this.isForMergeCommits == that.isForMergeCommits, "Cannot sum a merge commit stats with a non merge commit stats")
+    this.copy(
+      addedLines = this.addedLines + that.addedLines,
+      deletedLines = this.deletedLines + that.deletedLines,
+      commits = this.commits ++ that.commits,
+      issues = this.issues ++ that.issues
+    )
+  }
+}
+
+object CommitsStatistics {
+  val Empty = CommitsStatistics(0, 0, false, List[CommitInfo](), List[IssueInfo]())
+  val EmptyMerge = Empty.copy(isForMergeCommits = true)
+}
+
+class Statistics(repo: Repository, commentInfoList: java.util.List[CommentLinkInfo] = Nil) {
+
+  val log = LoggerFactory.getLogger(classOf[Statistics])
+  val replacers = commentInfoList.map(info =>
+    Replacer(
+      info.`match`.r,
+      Option(info.link).getOrElse(info.html)))
+
+  /**
+    * Returns up to two different CommitsStatistics object grouping the stats into merge and non-merge commits
+    *
+    * @param commits
+    * @return
+    */
+  def forCommits(commits: ObjectId*): Iterable[CommitsStatistics] = {
+
+    val stats = commits.map(forSingleCommit)
+
+    val nonMergeStats = stats.filterNot(_.isForMergeCommits).foldLeft(CommitsStatistics.Empty)(_ + _)
+    val mergeStats = stats.filter(_.isForMergeCommits).foldLeft(CommitsStatistics.EmptyMerge)(_ + _)
+
+    List(nonMergeStats, mergeStats).filterNot(_.isEmpty)
+  }
+
+  protected def forSingleCommit(objectId: ObjectId): CommitsStatistics = {
+    import RevisionBrowsingSupport._
+
+    // I can imagine this kind of statistics is already being available in Gerrit but couldn't understand how to access it
+    // which Injection can be useful for this task?
+    use(new RevWalk(repo)) { rw =>
+      val reader = repo.newObjectReader()
+      val commit = rw.parseCommit(objectId)
+      val commitMessage = commit.getFullMessage
+
+      val oldTree = {
+        // protects against initial commit
+        if (commit.getParentCount == 0)
+          new EmptyTreeIterator
+        else
+          new CanonicalTreeParser(null, reader, rw.parseCommit(commit.getParent(0).getId).getTree)
+      }
+
+      val newTree = new CanonicalTreeParser(null, reader, commit.getTree)
+
+      val df = new DiffFormatter(DisabledOutputStream.INSTANCE)
+      df.setRepository(repo)
+      df.setDiffComparator(RawTextComparator.DEFAULT)
+      df.setDetectRenames(true)
+      val diffs = df.scan(oldTree, newTree)
+      case class Lines(deleted: Int, added: Int) {
+        def +(other: Lines) = Lines(deleted + other.deleted, added + other.added)
+      }
+      val lines = (for {
+        diff <- diffs
+        edit <- df.toFileHeader(diff).toEditList
+      } yield Lines(edit.getEndA - edit.getBeginA, edit.getEndB - edit.getBeginB)).fold(Lines(0, 0))(_ + _)
+
+      val files: Set[String] = diffs.map(df.toFileHeader(_).getNewPath).toSet
+
+      val commitInfo = CommitInfo(objectId.getName, commit.getAuthorIdent.getWhen.getTime, commit.isMerge, files)
+
+      CommitsStatistics(lines.added, lines.deleted, commitInfo.merge, List(commitInfo), extractIssues(commitMessage))
+    }
+  }
+
+  def extractIssues(commitMessage: String): List[IssueInfo] = {
+    replacers.flatMap {
+      case Replacer(pattern, replaced) =>
+        pattern.findAllIn(commitMessage)
+          .map(code => {
+            val transformed = pattern.replaceAllIn(code, replaced)
+            IssueInfo(code, transformed)
+          })
+    }.toList
+  }
+
+  case class Replacer(pattern: Regex, replaced: String)
+
+}
diff --git a/src/main/java/com/googlesource/gerrit/plugins/analytics/SshModule.java b/src/main/scala/com/googlesource/gerrit/plugins/analytics/common/DateConversions.scala
similarity index 64%
copy from src/main/java/com/googlesource/gerrit/plugins/analytics/SshModule.java
copy to src/main/scala/com/googlesource/gerrit/plugins/analytics/common/DateConversions.scala
index 7f015f9..3da8249 100644
--- a/src/main/java/com/googlesource/gerrit/plugins/analytics/SshModule.java
+++ 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.
@@ -12,14 +12,11 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.googlesource.gerrit.plugins.analytics;
+package com.googlesource.gerrit.plugins.analytics.common
 
-import com.google.gerrit.sshd.PluginCommandModule;
+import com.google.gwtjsonrpc.common.JavaSqlTimestampHelper
 
-public class SshModule extends PluginCommandModule {
+object DateConversions {
 
-  @Override
-  protected void configureCommands() {
-    command(ContributorsCommand.class);
-  }
+  implicit def isoStringToLongDate(s: String): Long = JavaSqlTimestampHelper.parseTimestamp(s).getTime
 }
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
new file mode 100644
index 0000000..58b5dc5
--- /dev/null
+++ b/src/main/scala/com/googlesource/gerrit/plugins/analytics/common/GsonFormatter.scala
@@ -0,0 +1,36 @@
+// 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.PrintWriter
+
+import com.google.gerrit.server.OutputFormat
+import com.google.gson.{Gson, GsonBuilder}
+import com.google.inject.Singleton
+import com.googlesource.gerrit.plugins.analytics.CommitInfo
+
+@Singleton
+class GsonFormatter {
+  val gsonBuilder: GsonBuilder =
+    OutputFormat.JSON_COMPACT.newGsonBuilder
+
+  def format[T](values: TraversableOnce[T], out: PrintWriter) {
+    val gson: Gson = gsonBuilder.create
+    for (value <- values) {
+      gson.toJson(value, out)
+      out.println()
+    }
+  }
+}
\ No newline at end of file
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
new file mode 100644
index 0000000..004259e
--- /dev/null
+++ b/src/main/scala/com/googlesource/gerrit/plugins/analytics/common/JsonStreamedResultBuilder.scala
@@ -0,0 +1,25 @@
+// 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.{OutputStream, PrintWriter}
+
+import com.google.gerrit.extensions.restapi.BinaryResult
+
+class GsonStreamedResult[T](val jsonFmt: GsonFormatter,
+                            val committers: TraversableOnce[T]) extends BinaryResult {
+  override def writeTo(os: OutputStream) =
+    ManagedResource.use(new PrintWriter(os))(jsonFmt.format(committers, _))
+}
diff --git a/src/main/java/com/googlesource/gerrit/plugins/analytics/SshModule.java b/src/main/scala/com/googlesource/gerrit/plugins/analytics/common/ManagedResources.scala
similarity index 63%
copy from src/main/java/com/googlesource/gerrit/plugins/analytics/SshModule.java
copy to src/main/scala/com/googlesource/gerrit/plugins/analytics/common/ManagedResources.scala
index 7f015f9..5cf2b74 100644
--- a/src/main/java/com/googlesource/gerrit/plugins/analytics/SshModule.java
+++ 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.
@@ -12,14 +12,12 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.googlesource.gerrit.plugins.analytics;
+package com.googlesource.gerrit.plugins.analytics.common
 
-import com.google.gerrit.sshd.PluginCommandModule;
-
-public class SshModule extends PluginCommandModule {
-
-  @Override
-  protected void configureCommands() {
-    command(ContributorsCommand.class);
-  }
+object ManagedResource {
+  def use[A <: { def close(): Unit }, B](resource: A)(code: A ⇒ B): B =
+    try
+      code(resource)
+    finally
+      resource.close()
 }
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
new file mode 100644
index 0000000..711d5ac
--- /dev/null
+++ b/src/main/scala/com/googlesource/gerrit/plugins/analytics/common/ProjectResourceParser.scala
@@ -0,0 +1,37 @@
+// 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
+
+import com.google.gerrit.extensions.restapi.UnprocessableEntityException
+import com.google.gerrit.server.project.{ProjectResource, ProjectsCollection}
+import org.kohsuke.args4j.Argument
+
+trait ProjectResourceParser {
+  def projects: ProjectsCollection
+
+  var projectRes: ProjectResource = null
+
+  @Argument(usage = "project name", metaVar = "PROJECT", required = true)
+  def setProject(project: String): Unit = {
+    try {
+      this.projectRes = projects.parse(project)
+    } catch {
+      case e: Exception =>
+        throw new IllegalArgumentException("Error while trying to access project " + project, e)
+    }
+  }
+}
diff --git a/src/main/scala/com/googlesource/gerrit/plugins/analytics/common/RevisionBrowsingSupport.scala b/src/main/scala/com/googlesource/gerrit/plugins/analytics/common/RevisionBrowsingSupport.scala
new file mode 100644
index 0000000..d07143a
--- /dev/null
+++ b/src/main/scala/com/googlesource/gerrit/plugins/analytics/common/RevisionBrowsingSupport.scala
@@ -0,0 +1,11 @@
+package com.googlesource.gerrit.plugins.analytics.common
+
+import org.eclipse.jgit.revwalk.RevCommit
+
+object RevisionBrowsingSupport {
+
+  implicit class PimpedRevCommit(val self: RevCommit) extends AnyVal {
+    def isMerge : Boolean = self.getParentCount > 1
+  }
+
+}
diff --git a/src/main/java/com/googlesource/gerrit/plugins/analytics/SshModule.java b/src/main/scala/com/googlesource/gerrit/plugins/analytics/common/RichBoolean.scala
similarity index 63%
copy from src/main/java/com/googlesource/gerrit/plugins/analytics/SshModule.java
copy to src/main/scala/com/googlesource/gerrit/plugins/analytics/common/RichBoolean.scala
index 7f015f9..ff4cdcf 100644
--- a/src/main/java/com/googlesource/gerrit/plugins/analytics/SshModule.java
+++ b/src/main/scala/com/googlesource/gerrit/plugins/analytics/common/RichBoolean.scala
@@ -1,25 +1,22 @@
-// Copyright (C) 2016 The Android Open Source Project
+// Copyright (C) 2018 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;
+package com.googlesource.gerrit.plugins.analytics.common
 
-import com.google.gerrit.sshd.PluginCommandModule;
-
-public class SshModule extends PluginCommandModule {
-
-  @Override
-  protected void configureCommands() {
-    command(ContributorsCommand.class);
+object RichBoolean {
+  implicit class RichBoolean(val b: Boolean) extends AnyVal {
+    final def option[A](a: => A): Option[A] = if (b) Some(a) else None
   }
+
 }
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
new file mode 100644
index 0000000..ba73fcb
--- /dev/null
+++ b/src/main/scala/com/googlesource/gerrit/plugins/analytics/common/UserActivityHistogram.scala
@@ -0,0 +1,38 @@
+// 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 com.google.gerrit.extensions.restapi.PreconditionFailedException
+import com.google.inject.Singleton
+import org.eclipse.jgit.lib.Repository
+import org.gitective.core.CommitFinder
+
+@Singleton
+class UserActivityHistogram {
+  def get(repo: Repository, filter: AbstractCommitHistogramFilter) = {
+    val finder = new CommitFinder(repo)
+
+    try {
+      finder.setFilter(filter).find
+      val histogram = filter.getHistogram
+      histogram.getAggregatedUserActivity
+    } catch {
+      // 'find' throws an IllegalArgumentException when the conditions to walk through the commits tree are not met,
+      // i.e: an empty repository doesn't have the starting commit.
+      case _: IllegalArgumentException => Array.empty[AggregatedUserCommitActivity]
+      case e: Exception => throw new PreconditionFailedException(s"Cannot find commits: ${e.getMessage}").initCause(e)
+    }
+  }
+}
\ No newline at end of file
diff --git a/src/test/scala/com/googlesource/gerrit/plugins/analytics/common/CommitsBranchesTest.scala b/src/test/scala/com/googlesource/gerrit/plugins/analytics/common/CommitsBranchesTest.scala
new file mode 100644
index 0000000..ad9f712
--- /dev/null
+++ b/src/test/scala/com/googlesource/gerrit/plugins/analytics/common/CommitsBranchesTest.scala
@@ -0,0 +1,27 @@
+package com.googlesource.gerrit.plugins.analytics.common
+
+import com.googlesource.gerrit.plugins.analytics.test.GitTestCase
+import org.eclipse.jgit.internal.storage.file.FileRepository
+import org.scalatest.{FlatSpec, Matchers}
+
+class CommitsBranchesTest extends FlatSpec with Matchers with GitTestCase {
+  def commitsBranches = new CommitsBranches(new FileRepository(testRepo))
+
+  "getAllCommitsLabeledWithBranches" should "label correctly a set of " +
+    "commits" in {
+    val c1 = add("file", "content")
+    val c2 = add("file2", "content")
+    val c3 = add("file3", "content")
+    val c4 = add("file4", "content")
+    branch("feature/branch")
+    val c5 = add("fileOnBranch", "content2")
+    val c6 = add("fileOnBranch2", "content2")
+    val c7 = add("fileOnBranch3", "content2")
+    val c8 = add("fileOnBranch4", "content2")
+
+    commitsBranches.forCommits(Seq(c1, c2, c3, c4)) should be(
+      Set("master", "feature/branch"))
+
+    commitsBranches.forCommits(Seq(c7, c8)) should be(Set("feature/branch"))
+  }
+}
diff --git a/src/test/scala/com/googlesource/gerrit/plugins/analytics/common/UserActivityHistogramTest.scala b/src/test/scala/com/googlesource/gerrit/plugins/analytics/common/UserActivityHistogramTest.scala
new file mode 100644
index 0000000..282d29f
--- /dev/null
+++ b/src/test/scala/com/googlesource/gerrit/plugins/analytics/common/UserActivityHistogramTest.scala
@@ -0,0 +1,37 @@
+// 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 com.googlesource.gerrit.plugins.analytics.common.AggregationStrategy.EMAIL_YEAR
+import com.googlesource.gerrit.plugins.analytics.test.GitTestCase
+import org.eclipse.jgit.internal.storage.file.FileRepository
+import org.scalatest.{FlatSpec, Matchers}
+
+class UserActivityHistogramTest extends FlatSpec with Matchers with GitTestCase {
+
+  "UserActivityHistogram" should "return no activities" in {
+    val repo = new FileRepository(testRepo)
+    val filter = new AggregatedHistogramFilterByDates(aggregationStrategy = EMAIL_YEAR)
+    new UserActivityHistogram().get(repo, filter) should have size 0
+  }
+
+  it should "aggregate to one activity" in {
+    val repo = new FileRepository(testRepo)
+    add("test.txt", "content")
+    val filter = new AggregatedHistogramFilterByDates(aggregationStrategy = EMAIL_YEAR)
+    new UserActivityHistogram().get(repo, filter) should have size 1
+  }
+
+}
diff --git a/src/test/scala/com/googlesource/gerrit/plugins/analytics/test/AggregatedHistogramFilterByDatesSpec.scala b/src/test/scala/com/googlesource/gerrit/plugins/analytics/test/AggregatedHistogramFilterByDatesSpec.scala
new file mode 100644
index 0000000..b525c7a
--- /dev/null
+++ b/src/test/scala/com/googlesource/gerrit/plugins/analytics/test/AggregatedHistogramFilterByDatesSpec.scala
@@ -0,0 +1,107 @@
+// 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.{AggregationStrategy, AggregatedHistogramFilterByDates}
+import org.eclipse.jgit.lib.PersonIdent
+import org.gitective.core.CommitFinder
+import org.scalatest.{BeforeAndAfterEach, FlatSpec, 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 AggregatedHistogramFilterByDates
+    new CommitFinder(testRepo).setFilter(filter).find
+
+    val userActivity = filter.getHistogram.getUserActivity
+    filter.getHistogram.getUserActivity should have size 1
+    val activity = userActivity.head
+    activity.getCount should be(1)
+    activity.getName should be(author.getName)
+    activity.getEmail should be(author.getEmailAddress)
+  }
+
+  it should "select only the second of two commits based on the start timestamp" in {
+    val firstCommitTs = add("file.txt", "some content")
+      .getCommitterIdent.getWhen.getTime
+    val person = newPersonIdent("Second person", "second@company.com", new Date(firstCommitTs + 1000L))
+    val secondCommitTs = add("file.txt", "other content", author = person, committer = person)
+      .getCommitterIdent.getWhen.getTime
+
+    secondCommitTs should be > firstCommitTs
+
+    val filter = new AggregatedHistogramFilterByDates(from = Some(secondCommitTs))
+    new CommitFinder(testRepo).setFilter(filter).find
+
+    val userActivity = filter.getHistogram.getUserActivity
+    userActivity should have size 1
+    val activity = userActivity.head
+
+    activity.getTimes should have size 1
+    activity.getName should be(person.getName)
+    activity.getEmail should be(person.getEmailAddress)
+  }
+
+  it should "select only the first of two commits based on the end timestamp" in {
+    val person = newPersonIdent("First person", "first@company.com")
+    val firstCommitTs = add("file.txt", "some content", author = person, committer = person)
+      .getCommitterIdent.getWhen.getTime
+    val secondCommitTs = add("file.txt", "other content", committer = new PersonIdent(committer, new Date(firstCommitTs + 1000L)))
+      .getCommitterIdent.getWhen.getTime
+
+    secondCommitTs should be > firstCommitTs
+
+    val filter = new AggregatedHistogramFilterByDates(to = Some(secondCommitTs))
+    new CommitFinder(testRepo).setFilter(filter).find
+
+    val userActivity = filter.getHistogram.getUserActivity
+    userActivity should have size 1
+    val activity = userActivity.head
+
+    activity.getTimes should have size 1
+    activity.getName should be(person.getName)
+    activity.getEmail should be(person.getEmailAddress)
+  }
+
+  it should "select only one middle commit out of three based on interval from/to timestamp" in {
+    val firstCommitTs = add("file.txt", "some content")
+      .getCommitterIdent.getWhen.getTime
+    val person = newPersonIdent("Middle person", "middle@company.com", new Date(firstCommitTs + 1000L))
+    val middleCommitTs = add("file.txt", "other content", author = person, committer = person)
+      .getCommitterIdent.getWhen.getTime
+    val lastCommitTs = add("file.text", "yet other content", committer = new PersonIdent(committer, new Date(middleCommitTs + 1000L)))
+      .getCommitterIdent.getWhen.getTime
+
+    middleCommitTs should be > firstCommitTs
+    lastCommitTs should be > middleCommitTs
+
+    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
+    val activity = userActivity.head
+
+    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")
+  }
+}
diff --git a/src/test/scala/com/googlesource/gerrit/plugins/analytics/test/CommitInfoSpec.scala b/src/test/scala/com/googlesource/gerrit/plugins/analytics/test/CommitInfoSpec.scala
new file mode 100644
index 0000000..7de2c03
--- /dev/null
+++ b/src/test/scala/com/googlesource/gerrit/plugins/analytics/test/CommitInfoSpec.scala
@@ -0,0 +1,22 @@
+package com.googlesource.gerrit.plugins.analytics.test
+
+import com.google.common.collect.Sets.newHashSet
+import com.google.gerrit.server.OutputFormat
+import com.googlesource.gerrit.plugins.analytics.CommitInfo
+import org.scalatest.{FlatSpec, Matchers}
+
+class CommitInfoSpec extends FlatSpec with Matchers {
+
+  "CommitInfo" should "be serialised as JSON correctly" in {
+    val commitInfo = CommitInfo(sha1 = "sha", date = 1000l, merge = false, files = newHashSet("file1", "file2"))
+
+    val gsonBuilder = OutputFormat.JSON_COMPACT.newGsonBuilder
+
+    val actual = gsonBuilder.create().toJson(commitInfo)
+    List(actual) should contain oneOf(
+      "{\"sha1\":\"sha\",\"date\":1000,\"merge\":false,\"files\":[\"file1\",\"file2\"]}",
+      "{\"sha1\":\"sha\",\"date\":1000,\"merge\":false,\"files\":[\"file2\",\"file1\"]}"
+    )
+  }
+
+}
diff --git a/src/test/scala/com/googlesource/gerrit/plugins/analytics/test/CommitStatisticsCommentLinkSpec.scala b/src/test/scala/com/googlesource/gerrit/plugins/analytics/test/CommitStatisticsCommentLinkSpec.scala
new file mode 100644
index 0000000..b5d7482
--- /dev/null
+++ b/src/test/scala/com/googlesource/gerrit/plugins/analytics/test/CommitStatisticsCommentLinkSpec.scala
@@ -0,0 +1,98 @@
+// Copyright (C) 2018 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.{Arrays, Date}
+
+import com.google.gerrit.extensions.api.projects.CommentLinkInfo
+import com.googlesource.gerrit.plugins.analytics.IssueInfo
+import com.googlesource.gerrit.plugins.analytics.common.{CommitsStatistics, Statistics}
+import org.eclipse.jgit.internal.storage.file.FileRepository
+import org.eclipse.jgit.revwalk.RevCommit
+import org.scalatest.{FlatSpec, Inside, Matchers}
+import scala.collection.JavaConverters._
+
+class CommitStatisticsCommentLinkSpec extends FlatSpec with GitTestCase with Matchers with Inside {
+
+  def createCommentLinkInfo(pattern: String, link: Option[String] = None, html: Option[String] = None) = {
+    val info = new CommentLinkInfo
+    info.`match` = pattern
+    info.link = link.getOrElse(null)
+    info.html = html.getOrElse(null)
+    info
+  }
+
+  def commit(committer: String, fileName: String, content: String, message: Option[String] = None): RevCommit = {
+    val date = new Date()
+    val person = newPersonIdent(committer, committer, date)
+    add(testRepo, fileName, content, author = person, committer = author, message = message.getOrElse("** no message **"))
+  }
+
+  class TestEnvironment(val repo: FileRepository = new FileRepository(testRepo),
+                        val commentLinks: java.util.List[CommentLinkInfo] = Seq(
+                          createCommentLinkInfo(pattern = "(bug\\s+#?)(\\d+)",
+                            link = Some("http://bugs.example.com/show_bug.cgi?id=$2")),
+                          createCommentLinkInfo(pattern = "([Bb]ug:\\s+)(\\d+)",
+                            html = Some("$1<a href=\"http://trak.example.com/$2\">$2</a>"))).asJava) {
+
+    lazy val stats = new Statistics(repo, commentLinks)
+  }
+
+  it should "collect no commentslink if no matching" in new TestEnvironment {
+    val nocomments = commit("user", "file1.txt", "content1")
+
+    inside(stats.forCommits(nocomments)) {
+      case List(s: CommitsStatistics) =>
+        s.issues should have size 0
+    }
+
+  }
+  it should "collect simple bugzilla comments" in new TestEnvironment {
+    val simpleComment = commit("user", "file1.txt", "content2", message =
+      Some("this solves bug #23"))
+
+    inside(stats.forCommits(simpleComment)) {
+      case List(s: CommitsStatistics) =>
+        s.issues should have size 1
+        s.issues should contain(IssueInfo("bug #23", "http://bugs.example.com/show_bug.cgi?id=23"))
+    }
+
+  }
+  it should "collect simple track link" in new TestEnvironment {
+    val simpleTrackComment = commit("user", "file1.txt", "content3", message
+      = Some("this solves Bug: 1234"))
+
+    inside(stats.forCommits(simpleTrackComment)) {
+      case List(s: CommitsStatistics) =>
+        s.issues should have size 1
+        s.issues should contain(IssueInfo("Bug: 1234", "Bug: <a href=\"http://trak.example.com/1234\">1234</a>"))
+    }
+
+  }
+  it should "collect multiple links" in new TestEnvironment {
+    val multipleComments = commit("user", "file1.txt", "content4", message =
+      Some("this solves bug 12 and Bug: 23"))
+
+    inside(stats.forCommits(multipleComments)) {
+      case List(s: CommitsStatistics) =>
+        s.issues should contain allOf(
+          IssueInfo("bug 12", "http://bugs.example.com/show_bug.cgi?id=12"),
+          IssueInfo("Bug: 23", "Bug: <a href=\"http://trak.example.com/23\">23</a>")
+        )
+    }
+
+  }
+
+}
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
new file mode 100644
index 0000000..df4260a
--- /dev/null
+++ b/src/test/scala/com/googlesource/gerrit/plugins/analytics/test/CommitStatisticsSpec.scala
@@ -0,0 +1,159 @@
+// 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.google.common.collect.Sets
+import com.google.common.collect.Sets.newHashSet
+import com.googlesource.gerrit.plugins.analytics.CommitInfo
+import com.googlesource.gerrit.plugins.analytics.common.{CommitsStatistics, Statistics}
+import org.eclipse.jgit.api.{Git, MergeResult}
+import org.eclipse.jgit.internal.storage.file.FileRepository
+import org.eclipse.jgit.revwalk.RevCommit
+import org.scalatest.{FlatSpec, Inside, Matchers}
+
+
+class CommitStatisticsSpec extends FlatSpec with GitTestCase with Matchers with Inside {
+
+
+  class TestEnvironment {
+    val repo = new FileRepository(testRepo)
+    val stats = new Statistics(repo)
+  }
+
+  def commit(committer: String, fileName: String, content: String): RevCommit = {
+    val date = new Date()
+    val person = newPersonIdent(committer, committer, date)
+    add(testRepo, fileName, content, author = person, committer = author)
+  }
+
+  def mergeCommit(committer: String, fname: String, content: String): MergeResult = {
+    val currentBranch = Git.open(testRepo).getRepository.getBranch
+    val tmpBranch = branch(testRepo, "tmp")
+    try {
+      val commitToMerge = commit(committer, fname, content)
+      checkout(currentBranch)
+      mergeBranch("tmp", true)
+    } finally {
+       deleteBranch(testRepo, tmpBranch.getName)
+    }
+  }
+
+  "CommitStatistics" should "stats a single file added" in new TestEnvironment {
+    val change = commit("user", "file1.txt", "line1\nline2")
+
+    inside(stats.forCommits(change)) { case List(s: CommitsStatistics) =>
+      s.numFiles should be(1)
+      s.addedLines should be(2)
+      s.deletedLines should be(0)
+    }
+  }
+
+  it should "sum to another compatible CommitStatistics generating an aggregated stat" in {
+    val commit1 = CommitInfo("sha_1", 1000l, false, newHashSet("file1"))
+    val commit2 = CommitInfo("sha_2", 2000l, false, newHashSet("file1"))
+    val commit3 = CommitInfo("sha_3", 3000l, false, newHashSet("file2"))
+    val commit4 = CommitInfo("sha_4", 1000l, false, newHashSet("file1"))
+
+    val stat1 = CommitsStatistics(3, 4, false, List(commit1, commit2))
+    val stat2 = CommitsStatistics(5, 7, false, List(commit3, commit4))
+
+    (stat1 + stat2) shouldBe CommitsStatistics(8, 11, false, List(commit1, commit2, commit3, commit4))
+  }
+
+  it should "fail if trying to be added to a CommitStatistics object for a different isMerge value" in {
+    an [IllegalArgumentException] should be thrownBy  (CommitsStatistics.EmptyMerge + CommitsStatistics.Empty)
+  }
+
+  it should "stats multiple files added" in new TestEnvironment {
+    val initial = commit("user", "file1.txt", "line1\nline2\n")
+    val second = add(testRepo,
+      List(
+        "file1.txt" -> "line1\n",
+        "file2.txt" -> "line1\nline2\n"
+      ), "second commit")
+
+    inside(stats.forCommits(second)) { case List(s: CommitsStatistics) =>
+      s.numFiles should be(2)
+      s.addedLines should be(2)
+      s.deletedLines should be(1)
+    }
+  }
+
+  it should "stats lines eliminated" in new TestEnvironment {
+    val initial = commit("user", "file1.txt", "line1\nline2\nline3")
+    val second = commit("user", "file1.txt", "line1\n")
+    inside(stats.forCommits(second)) { case List(s: CommitsStatistics) =>
+      s.numFiles should be(1)
+      s.addedLines should be(0)
+      s.deletedLines should be(2)
+    }
+  }
+
+  it should "stats a Seq[RevCommit]" in new TestEnvironment {
+    val initial = add(testRepo,
+      List(
+        "file1.txt" -> "line1\n",
+        "file3.txt" -> "line1\nline2\n"),
+      "first commit")
+
+    val second = add(testRepo,
+      List(
+        "file1.txt" -> "line1a\n",
+        "file2.txt" -> "line1\nline2\n"),
+      "second commit")
+
+    inside(stats.forCommits(initial, second)) { case List(nonMergeStats: CommitsStatistics) =>
+      nonMergeStats.numFiles should be(4)
+      nonMergeStats.numDistinctFiles should be(3)
+      nonMergeStats.addedLines should be(6)
+      nonMergeStats.deletedLines should be(1)
+    }
+  }
+
+  it should "return zero value stats if the commit does not include any file" in new TestEnvironment {
+    val emptyCommit = add(testRepo, List.empty, "Empty commit")
+    inside(stats.forCommits(emptyCommit)) { case List(stats) =>
+      stats.numFiles should be(0)
+      stats.addedLines should be(0)
+      stats.deletedLines should be(0)
+    }
+  }
+
+  it should "split merge commits and non-merge commits" in new TestEnvironment {
+    val firstNonMerge = commit("user", "file1.txt", "line1\nline2\n")
+    val merge = mergeCommit("user", "file1.txt", "line1\nline2\nline3")
+    val nonMerge = add(testRepo,
+      List(
+        "file1.txt" -> "line1\n",
+        "file2.txt" -> "line1\nline2\n"),
+      "second commit")
+
+    inside(stats.forCommits(firstNonMerge, merge.getNewHead, nonMerge)) {
+      case List(nonMergeStats, mergeStats) =>
+        mergeStats.numFiles should be(1)
+        mergeStats.addedLines should be(1)
+        mergeStats.deletedLines should be(0)
+
+        nonMergeStats.numFiles should be(3)
+        nonMergeStats.addedLines should be(4)
+        nonMergeStats.deletedLines should be(2)
+
+      case wrongContent => fail(s"Expected two results instead got $wrongContent")
+    }
+  }
+
+}
diff --git a/src/test/scala/com/googlesource/gerrit/plugins/analytics/test/GitTestCase.scala b/src/test/scala/com/googlesource/gerrit/plugins/analytics/test/GitTestCase.scala
new file mode 100644
index 0000000..8a1dcf7
--- /dev/null
+++ b/src/test/scala/com/googlesource/gerrit/plugins/analytics/test/GitTestCase.scala
@@ -0,0 +1,394 @@
+//
+// Copyright (c) 2011 Kevin Sawicki <kevinsawicki@gmail.com>
+//
+// Permission is hereby granted, free of charge, to any person obtaining a copy
+// of this software and associated documentation files (the "Software"), to
+// deal in the Software without restriction, including without limitation the
+// rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
+// sell copies of the Software, and to permit persons to whom the Software is
+// furnished to do so, subject to the following conditions:
+//
+// The above copyright notice and this permission notice shall be included in
+// all copies or substantial portions of the Software.
+//
+// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
+// IN THE SOFTWARE.
+//
+// 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.io.{File, PrintWriter}
+import java.nio.file.Files
+import java.text.MessageFormat
+import java.util.Date
+
+import com.googlesource.gerrit.plugins.analytics.common.ManagedResource.use
+import org.eclipse.jgit.api.MergeCommand.FastForwardMode
+import org.eclipse.jgit.api.{Git, MergeResult}
+import org.eclipse.jgit.api.errors.GitAPIException
+import org.eclipse.jgit.lib.{Constants, PersonIdent, Ref}
+import org.eclipse.jgit.merge.MergeStrategy
+import org.eclipse.jgit.notes.Note
+import org.eclipse.jgit.revwalk.RevCommit
+import org.gitective.core.CommitUtils
+import org.scalatest.{BeforeAndAfterEach, Suite}
+
+
+/**
+  * Base test case with utilities for common Git operations performed during
+  * testing
+  */
+trait GitTestCase extends BeforeAndAfterEach {
+  self: Suite =>
+  /**
+    * Test repository .git directory
+    */
+  protected var testRepo: File = null
+
+  /**
+    * Author used for commits
+    */
+  protected val author = new PersonIdent("Test Author", "author@test.com")
+
+  def newPersonIdent(name: String = "Test Person", email: String = "person@test.com", ts: Date = new Date()) =
+    new PersonIdent(new PersonIdent(name, email), ts)
+
+  /**
+    * Committer used for commits
+    */
+  protected val committer = new PersonIdent("Test Committer", "committer@test.com")
+
+  /**
+    * Set up method that initializes git repository
+    *
+    *
+    */
+
+  override def beforeEach = {
+    testRepo = initRepo
+  }
+
+  /**
+    * Initialize a new repo in a new directory
+    *
+    * @return created .git folder
+    * @throws GitAPIException
+    */
+  protected def initRepo: File = {
+    val dir = Files.createTempDirectory(threadSpecificDirectoryPrefix).toFile
+    Git.init.setDirectory(dir).setBare(false).call
+    val repo = new File(dir, Constants.DOT_GIT)
+    assert(repo.exists)
+    repo.deleteOnExit()
+    repo
+  }
+
+  private def threadSpecificDirectoryPrefix =
+    "git-test-case-" + Thread.currentThread().getName.replaceAll("~[0-9a-zA-Z]", "_") + System.nanoTime
+
+  /**
+    * Create branch with name and checkout
+    *
+    * @param name
+    * @return branch ref
+    *
+    */
+  protected def branch(name: String): Ref = branch(testRepo, name)
+
+  /**
+    * Delete branch with name
+    *
+    * @param name
+    * @return branch ref
+    *
+    */
+  protected def deleteBranch(name: String): String = deleteBranch(testRepo, name)
+
+  /**
+    * Create branch with name and checkout
+    *
+    * @param repo
+    * @param name
+    * @return branch ref
+    *
+    */
+  protected def branch(repo: File, name: String): Ref = {
+    use(Git.open(repo)) { git =>
+      git.branchCreate.setName(name).call
+      checkout(repo, name)
+    }
+  }
+
+  /**
+    * Delete branch with name
+    *
+    * @param repo
+    * @param name
+    * @return branch ref
+    *
+    */
+  protected def deleteBranch(repo: File, name: String): String = {
+    use(Git.open(repo)) { git =>
+      git.branchDelete().setBranchNames(name).call.get(0)
+    }
+  }
+
+  /**
+    * Checkout branch
+    *
+    * @param name
+    * @return branch ref
+    *
+    */
+  protected def checkout(name: String): Ref = checkout(testRepo, name)
+
+  /**
+    * Checkout branch
+    *
+    * @param repo
+    * @param name
+    * @return branch ref
+    *
+    */
+  @throws[Exception]
+  protected def checkout(repo: File, name: String): Ref = {
+    use(Git.open(repo)) { git =>
+      git.checkout.setName(name).call
+    }
+  } ensuring(_ != null, "Unable to checkout result")
+
+  /**
+    * Create tag with name
+    *
+    * @param name
+    * @return tag ref
+    *
+    */
+  protected def tag(name: String): Ref = tag(testRepo, name)
+
+  /**
+    * Create tag with name
+    *
+    * @param repo
+    * @param name
+    * @return tag ref
+    *
+    */
+  protected def tag(repo: File, name: String): Ref = {
+    use(Git.open(repo)) { git =>
+      git.tag.setName(name).setMessage(name).call
+      git.getRepository.getTags.get(name)
+    }
+  } ensuring(_ != null, s"Unable to tag file $name")
+
+  /**
+    * Add file to test repository
+    *
+    * @param path
+    * @param content
+    * @return commit
+    *
+    */
+  protected def add(path: String, content: String, author: PersonIdent = author, committer: PersonIdent = committer): RevCommit = add(testRepo, path, content, author, committer)
+
+  /**
+    * Add file to test repository
+    *
+    * @param repo
+    * @param path
+    * @param content
+    * @return commit
+    *
+    */
+  protected def add(repo: File, path: String, content: String, author: PersonIdent, committer: PersonIdent): RevCommit = {
+    val message = MessageFormat.format("Committing {0} at {1}", path, new Date)
+    add(repo, path, content, message, author, committer)
+  }
+
+
+  /**
+    * Add file to test repository
+    *
+    * @param repo
+    * @param path
+    * @param content
+    * @param message
+    * @return commit
+    *
+    */
+  protected def add(repo: File, path: String, content: String, message: String, author: PersonIdent, committer: PersonIdent): RevCommit = {
+    val file = new File(repo.getParentFile, path)
+    if (!file.getParentFile.exists) assert(file.getParentFile.mkdirs)
+    if (!file.exists) assert(file.createNewFile)
+
+    val writer = new PrintWriter(file)
+    try
+      writer.print(Option(content).fold("")(identity))
+    finally writer.close()
+
+    use(Git.open(repo)) { git =>
+      git.add.addFilepattern(path).call
+      git.commit.setOnly(path).setMessage(message).setAuthor(author).setCommitter(committer).call
+    }
+  }  ensuring (_ != null, s"Unable to commit addition of path $path")
+
+  /**
+    * Move file in test repository
+    *
+    * @param from
+    * @param to
+    * @return commit
+    *
+    */
+  protected def mv(from: String, to: String): RevCommit = mv(testRepo, from, to, MessageFormat.format("Moving {0} to {1} at {2}", from, to, new Date))
+
+  /**
+    * Move file in test repository
+    *
+    * @param from
+    * @param to
+    * @param message
+    * @return commit
+    *
+    */
+  protected def mv(from: String, to: String, message: String): RevCommit = mv(testRepo, from, to, message)
+
+  /**
+    * Move file in test repository
+    *
+    * @param repo
+    * @param from
+    * @param to
+    * @param message
+    * @return commit
+    *
+    */
+  protected def mv(repo: File, from: String, to: String, message: String): RevCommit = {
+    val file = new File(repo.getParentFile, from)
+    file.renameTo(new File(repo.getParentFile, to))
+
+    use(Git.open(testRepo)) { git =>
+      git.rm.addFilepattern(from)
+      git.add.addFilepattern(to).call
+      git.commit.setAll(true).setMessage(message).setAuthor(author).setCommitter(committer).call
+    }
+  } ensuring (_ != null, "Unable to commit MV operation")
+
+  /**
+    * Add files to test repository
+    *
+    * @param contents iterable of file names and associated content
+    * @return commit
+    *
+    */
+  protected def add(contents: Iterable[(String, String)]): RevCommit = add(testRepo, contents, "Committing multiple files")
+
+  /**
+    * Add files to test repository
+    *
+    * @param repo
+    * @param contents iterable of file names and associated content
+    * @param message
+    * @return commit
+    *
+    */
+  protected def add(repo: File, contents: Iterable[(String, String)], message: String): RevCommit = {
+    use(Git.open(testRepo)) { git =>
+      var i = 0
+      contents.foreach { case (path, content) =>
+        val file = new File(repo.getParentFile, path)
+        if (!file.getParentFile.exists) require(file.getParentFile.mkdirs, s"Cannot create parent dir '${file.getParent}'")
+        if (!file.exists) require(file.createNewFile, s"Cannot create file '$file'")
+        val writer = new PrintWriter(file)
+        try
+          writer.print(content)
+        finally writer.close()
+        git.add.addFilepattern(path).call
+      }
+
+      git.commit.setMessage(message).setAuthor(author).setCommitter(committer).call
+    }
+  } ensuring (_ != null, "Unable to commit content addition")
+
+  /**
+    * Merge given branch into current branch
+    *
+    * @param branch
+    * @return result
+    *
+    */
+  protected def mergeBranch(branch: String, withCommit: Boolean): MergeResult = {
+    use(Git.open(testRepo)) { git =>
+      git.merge.setStrategy(MergeStrategy.RESOLVE).include(CommitUtils.getRef(git.getRepository, branch)).setCommit(withCommit).setFastForward(FastForwardMode.NO_FF).setMessage(s"merging branch $branch").call
+    }
+  }
+
+    /**
+    * Merge ref into current branch
+    *
+    * @param ref
+    * @return result
+    *
+    */
+  protected def merge(ref: String): MergeResult = {
+    use(Git.open(testRepo)) { git =>
+      git.merge.setStrategy(MergeStrategy.RESOLVE).include(CommitUtils.getCommit(git.getRepository, ref)).call
+    }
+  }
+
+  /**
+    * Add note to latest commit with given content
+    *
+    * @param content
+    * @return note
+    *
+    */
+  protected def note(content: String): Note = note(content, "commits")
+
+  /**
+    * Add note to latest commit with given content
+    *
+    * @param content
+    * @param ref
+    * @return note
+    *
+    */
+  protected def note(content: String, ref: String): Note = {
+    use(Git.open(testRepo)) { git =>
+      git.notesAdd.setMessage(content).setNotesRef(Constants.R_NOTES + ref).setObjectId(CommitUtils.getHead(git.getRepository)).call
+    }
+  } ensuring (_ != null, "Unable to add note")
+
+  /**
+    * Delete and commit file at path
+    *
+    * @param path
+    * @return commit
+    *
+    */
+  protected def delete(path: String): RevCommit = {
+    val message = MessageFormat.format("Committing {0} at {1}", path, new Date)
+    use(Git.open(testRepo)) { git =>
+      git.rm.addFilepattern(path).call
+      git.commit.setOnly(path).setMessage(message).setAuthor(author).setCommitter(committer).call
+    }
+  } ensuring (_ != null, "Unable to commit delete operation")
+}
\ No newline at end of file