Add since/until limits to the contributors query Allow specifying since/until time-stamps for selecting a subset of commits grouped by contributor. Parameters are similar to Gerrit's time-stamp based query parameters. See: https://gerrit-review.googlesource.com/Documentation/user-search.html Change-Id: I47923130deca53c3a7ff86145597d43b704cbd5e
diff --git a/README.md b/README.md index 1097fd7..ab9945c 100644 --- a/README.md +++ b/README.md
@@ -52,11 +52,18 @@ *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]]] *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 + +NOTE: Timestamp format is consistent with Gerrit's query syntax, see /Documentation/user-search.html for details. REST Example: @@ -70,7 +77,7 @@ SSH Example: ``` - $ ssh -p 29418 admin@gerrit.mycompany.com analytics contributors myproject + $ ssh -p 29418 admin@gerrit.mycompany.com analytics contributors myproject --since 2017-08-01 --until 2017-12-31 {"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}]}
diff --git a/src/main/scala/com/googlesource/gerrit/plugins/analytics/Contributors.scala b/src/main/scala/com/googlesource/gerrit/plugins/analytics/Contributors.scala index df3bbe9..184f550 100644 --- a/src/main/scala/com/googlesource/gerrit/plugins/analytics/Contributors.scala +++ b/src/main/scala/com/googlesource/gerrit/plugins/analytics/Contributors.scala
@@ -14,14 +14,17 @@ package com.googlesource.gerrit.plugins.analytics -import com.google.gerrit.extensions.restapi.{Response, RestReadView} +import com.google.gerrit.extensions.restapi.{BadRequestException, Response, RestReadView} import com.google.gerrit.server.git.GitRepositoryManager import com.google.gerrit.server.project.{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.eclipse.jgit.lib.ObjectId -import org.gitective.core.stat.{AuthorHistogramFilter, UserCommitActivity} +import org.gitective.core.stat.UserCommitActivity +import org.kohsuke.args4j.{Option => ArgOption} +import org.slf4j.LoggerFactory @CommandMetaData(name = "contributors", description = "Extracts the list of contributors to a project") @@ -30,30 +33,78 @@ val gsonFmt: GsonFormatter) extends SshCommand with ProjectResourceParser { - override protected def run = gsonFmt.format(executor.get(projectRes), stdout) + private var beginDate: Option[Long] = None + + @ArgOption(name = "--since", aliases = Array("--after", "-b"), usage = "(included) begin timestamp. Must be in the format 2006-01-02[ 15:04:05[.890][ -0700]]") + def setBeginDate(date: String) { + try { + beginDate = Some(date) + } catch { + case e: Exception => throw die(s"Invalid begin date ${e.getMessage}") + } + } + + private var endDate: Option[Long] = None + + @ArgOption(name = "--until", aliases = Array("--before", "-e"), usage = "(excluded) end timestamp. Must be in the format 2006-01-02[ 15:04:05[.890][ -0700]]") + def setEndDate(date: String) { + try { + endDate = Some(date) + } catch { + case e: Exception => throw die(s"Invalid end date ${e.getMessage}") + } + } + + override protected def run = + gsonFmt.format(executor.get(projectRes, beginDate, endDate), stdout) + } class ContributorsResource @Inject()(val executor: ContributorsService, val gson: GsonFormatter) extends RestReadView[ProjectResource] { - override def apply(projectRes: ProjectResource) = Response.ok( - new GsonStreamedResult[UserActivitySummary](gson, executor.get(projectRes))) + private var beginDate: Option[Long] = None + + @ArgOption(name = "--since", aliases = Array("--after", "-b"), metaVar = "QUERY", usage = "(included) begin timestamp. Must be in the format 2006-01-02[ 15:04:05[.890][ -0700]]") + def setBeginDate(date: String) { + try { + beginDate = Some(date) + } catch { + case e: Exception => throw new BadRequestException(s"Invalid begin date ${e.getMessage}") + } + } + + private var endDate: Option[Long] = None + + @ArgOption(name = "--until", aliases = Array("--before", "-e"), metaVar = "QUERY", usage = "(excluded) end timestamp. Must be in the format 2006-01-02[ 15:04:05[.890][ -0700]]") + def setEndDate(date: String) { + try { + endDate = Some(date) + } catch { + case e: Exception => throw new BadRequestException(s"Invalid end date ${e.getMessage}") + } + } + + override def apply(projectRes: ProjectResource) = + Response.ok( + new GsonStreamedResult[UserActivitySummary](gson, executor.get(projectRes, beginDate, endDate))) } class ContributorsService @Inject()(repoManager: GitRepositoryManager, histogram: UserActivityHistogram, gsonFmt: GsonFormatter) { - def get(projectRes: ProjectResource): TraversableOnce[UserActivitySummary] = + def get(projectRes: ProjectResource, startDate: Option[Long], stopDate: Option[Long]): TraversableOnce[UserActivitySummary] = { ManagedResource.use(repoManager.openRepository(projectRes.getNameKey)) { - histogram.get(_, new AuthorHistogramFilter) + histogram.get(_, new AuthorHistogramFilterByDates(startDate, stopDate)) .par .map(UserActivitySummary.apply).toStream } + } } -case class CommitInfo(val sha1: String, val date: Long, val merge: Boolean) +case class CommitInfo(sha1: String, date: Long, merge: Boolean) case class UserActivitySummary(name: String, email: String, numCommits: Int, commits: Array[CommitInfo], lastCommitDate: Long)
diff --git a/src/main/scala/com/googlesource/gerrit/plugins/analytics/common/AuthorHistogramFilterByDates.scala b/src/main/scala/com/googlesource/gerrit/plugins/analytics/common/AuthorHistogramFilterByDates.scala new file mode 100644 index 0000000..b9cf786 --- /dev/null +++ b/src/main/scala/com/googlesource/gerrit/plugins/analytics/common/AuthorHistogramFilterByDates.scala
@@ -0,0 +1,41 @@ +// 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.common + +import java.util.Date + +import org.eclipse.jgit.revwalk.{RevCommit, RevWalk} +import org.gitective.core.stat.CommitHistogramFilter + +/** + * Commit filter that includes commits only on the specified interval + * starting from and to excluded + */ +class AuthorHistogramFilterByDates(val from: Option[Long] = None, val to: Option[Long] = None) + extends CommitHistogramFilter { + + override def include(walker: RevWalk, commit: RevCommit) = { + val commitDate = commit.getCommitterIdent.getWhen.getTime + val author = commit.getAuthorIdent + if (from.fold(true)(commitDate >=) && to.fold(true)(commitDate <)) { + histogram.include(commit, author) + true + } else { + false + } + } + + override def clone = new AuthorHistogramFilterByDates(from, to) +} \ No newline at end of file
diff --git a/src/main/scala/com/googlesource/gerrit/plugins/analytics/common/DateConversions.scala b/src/main/scala/com/googlesource/gerrit/plugins/analytics/common/DateConversions.scala new file mode 100644 index 0000000..4aa31a7 --- /dev/null +++ b/src/main/scala/com/googlesource/gerrit/plugins/analytics/common/DateConversions.scala
@@ -0,0 +1,22 @@ +// 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.common + +import com.google.gwtjsonrpc.common.JavaSqlTimestampHelper + +object DateConversions { + + implicit def isoStringToLongDate(s: String): Long = JavaSqlTimestampHelper.parseTimestamp(s).getTime +}
diff --git a/src/test/scala/com/googlesource/gerrit/plugins/analytics/test/AuthorHistogramFilterByDatesSpec.scala b/src/test/scala/com/googlesource/gerrit/plugins/analytics/test/AuthorHistogramFilterByDatesSpec.scala new file mode 100644 index 0000000..6f4a8bc --- /dev/null +++ b/src/test/scala/com/googlesource/gerrit/plugins/analytics/test/AuthorHistogramFilterByDatesSpec.scala
@@ -0,0 +1,106 @@ +// 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.AuthorHistogramFilterByDates +import org.eclipse.jgit.lib.PersonIdent +import org.gitective.core.CommitFinder +import org.scalatest.{BeforeAndAfterEach, FlatSpec, Matchers} + +class AuthorHistogramFilterByDatesSpec extends FlatSpec with GitTestCase with BeforeAndAfterEach with Matchers { + + "Author history filter" should + "select one commit without intervals restriction" in { + + add("file.txt", "some content") + val filter = new AuthorHistogramFilterByDates + 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 AuthorHistogramFilterByDates(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 AuthorHistogramFilterByDates(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 AuthorHistogramFilterByDates(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/GitTestCase.scala b/src/test/scala/com/googlesource/gerrit/plugins/analytics/test/GitTestCase.scala new file mode 100644 index 0000000..6375b7f --- /dev/null +++ b/src/test/scala/com/googlesource/gerrit/plugins/analytics/test/GitTestCase.scala
@@ -0,0 +1,373 @@ +// +// 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 +import java.io.PrintWriter +import java.text.MessageFormat +import java.util.Date +import java.util + +import org.eclipse.jgit.api.Git +import org.eclipse.jgit.api.MergeResult +import org.eclipse.jgit.api.errors.GitAPIException +import org.eclipse.jgit.lib.Constants +import org.eclipse.jgit.lib.PersonIdent +import org.eclipse.jgit.lib.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 tmpDir = System.getProperty("java.io.tmpdir") + assert(tmpDir != null, "java.io.tmpdir was null") + val dir = new File(tmpDir, "git-test-case-" + System.nanoTime) + assert(dir.mkdir) + Git.init.setDirectory(dir).setBare(false).call + val repo = new File(dir, Constants.DOT_GIT) + assert(repo.exists) + repo.deleteOnExit() + repo + } + + /** + * Create branch with name and checkout + * + * @param name + * @return branch ref + * + */ + protected def branch(name: String): Ref = branch(testRepo, name) + + /** + * Create branch with name and checkout + * + * @param repo + * @param name + * @return branch ref + * + */ + protected def branch(repo: File, name: String): Ref = { + val git = Git.open(repo) + git.branchCreate.setName(name).call + checkout(repo, name) + } + + /** + * 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 = { + val git = Git.open(repo) + val ref = git.checkout.setName(name).call + assert(ref != null) + ref + } + + /** + * 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 = { + val git = Git.open(repo) + git.tag.setName(name).setMessage(name).call + val tagRef = git.getRepository.getTags.get(name) + assert(tagRef != null) + tagRef + } + + /** + * 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() + val git = Git.open(repo) + git.add.addFilepattern(path).call + val commit = git.commit.setOnly(path).setMessage(message).setAuthor(author).setCommitter(committer).call + assert(null != commit) + commit + } + + /** + * 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)) + val git = Git.open(repo) + git.rm.addFilepattern(from) + git.add.addFilepattern(to).call + val commit = git.commit.setAll(true).setMessage(message).setAuthor(author).setCommitter(committer).call + assert(null != commit) + commit + } + + /** + * Add files to test repository + * + * @param paths + * @param contents + * @return commit + * + */ + protected def add(paths: util.List[String], contents: util.List[String]): RevCommit = add(testRepo, paths, contents, "Committing multiple files") + + /** + * Add files to test repository + * + * @param repo + * @param paths + * @param contents + * @param message + * @return commit + * + */ + protected def add(repo: File, paths: util.List[String], contents: util.List[String], message: String): RevCommit = { + val git = Git.open(repo) + var i = 0 + while ( { + i < paths.size + }) { + val path = paths.get(i) + var content = contents.get(i) + 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) + if (content == null) content = "" + try + writer.print(content) + finally writer.close() + git.add.addFilepattern(path).call + + { + i += 1; + i - 1 + } + } + val commit = git.commit.setMessage(message).setAuthor(author).setCommitter(committer).call + assert(null != commit) + commit + } + + /** + * Merge ref into current branch + * + * @param ref + * @return result + * + */ + protected def merge(ref: String): MergeResult = { + val git = Git.open(testRepo) + 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 = { + val git = Git.open(testRepo) + val note = git.notesAdd.setMessage(content).setNotesRef(Constants.R_NOTES + ref).setObjectId(CommitUtils.getHead(git.getRepository)).call + assert(null != note) + 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) + val git = Git.open(testRepo) + git.rm.addFilepattern(path).call + val commit = git.commit.setOnly(path).setMessage(message).setAuthor(author).setCommitter(committer).call + assert(null != commit) + commit + } +} \ No newline at end of file