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