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