Split merge commits related stats from the ones about non-merge commits.

Did some small code refactoring to better support the separation
Post review changes, refactored some of the test library code to streamline postconditions using ensuring statement

Jira-Id: GERICS-610
Change-Id: Iaeda01475dd4616cf64ec8e4c28c26e49f019be3
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 23ff03c..6e16763 100644
--- a/src/main/scala/com/googlesource/gerrit/plugins/analytics/Contributors.scala
+++ b/src/main/scala/com/googlesource/gerrit/plugins/analytics/Contributors.scala
@@ -132,7 +132,8 @@
       histogram.get(repo, new AggregatedHistogramFilterByDates(startDate, stopDate,
         aggregationStrategy))
         .par
-        .map(UserActivitySummary.apply(stats)).toStream
+        .flatMap(UserActivitySummary.apply(stats))
+        .toStream
     }
   }
 }
@@ -150,27 +151,27 @@
                                addedLines: Integer,
                                deletedLines: Integer,
                                commits: Array[CommitInfo],
-                               lastCommitDate: Long)
+                               lastCommitDate: Long,
+                               isMerge: Boolean
+                              )
 
 object UserActivitySummary {
-  def apply(statisticsHandler: Statistics)(uca: AggregatedUserCommitActivity): UserActivitySummary = {
+  def apply(statisticsHandler: Statistics)(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 a@Array(email, year, month, day, hour) =>
-        val commits = getCommits(uca.getIds, uca.getTimes, uca.getMerges)
-        val stats = statisticsHandler.find(uca.getIds.toSeq)
-        UserActivitySummary(year, month, day, hour, uca.getName, uca.getEmail, uca.getCount, stats.numFiles,
-          stats.addedLines, stats.deletedLines, commits, uca.getLatest)
+      case Array(email, year, month, day, hour) =>
+        statisticsHandler.forCommits(uca.getIds: _*).map { stat =>
+          UserActivitySummary(
+            year, month, day, hour, uca.getName, uca.getEmail, uca.getCount,
+            stat.numFiles, stat.addedLines, stat.deletedLines, stat.commits.toArray, uca.getLatest, stat.isForMergeCommits
+          )
+        }
+
       case _ => throw new Exception(s"invalid key format found ${uca.key}")
     }
   }
-
-  private def getCommits(ids: Array[ObjectId], times: Array[Long], merges: Array[Boolean]):
-  Array[CommitInfo] = {
-    (ids, times, merges).zipped.map((id, time, merge) => CommitInfo(id.name, time, merge))
-  }
 }
 
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
index efff4cb..7d434b1 100644
--- a/src/main/scala/com/googlesource/gerrit/plugins/analytics/common/CommitsStatistics.scala
+++ b/src/main/scala/com/googlesource/gerrit/plugins/analytics/common/CommitsStatistics.scala
@@ -14,32 +14,59 @@
 
 package com.googlesource.gerrit.plugins.analytics.common
 
+import com.googlesource.gerrit.plugins.analytics.CommitInfo
+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.revwalk.{RevCommit, RevWalk}
 import org.eclipse.jgit.treewalk.{CanonicalTreeParser, EmptyTreeIterator}
 import org.eclipse.jgit.util.io.DisabledOutputStream
-import ManagedResource.use
 
 import scala.collection.JavaConversions._
 
-case class CommitsStatistics(numFiles: Int, addedLines: Int, deletedLines: Int)
+case class CommitsStatistics(numFiles: Int, addedLines: Int, deletedLines: Int, isForMergeCommits: Boolean, commits: List[CommitInfo]) {
+  require(commits.forall(_.merge == isForMergeCommits), s"Creating a stats object with isMergeCommit = $isForMergeCommits but containing commits of different type")
+
+  def isEmpty = commits.isEmpty
+
+  // Is not a proper monoid since we cannot sum a MergeCommit with a non merge one but it would overkill to define two classes
+  protected [common] def + (that: CommitsStatistics) = {
+    require(this.isForMergeCommits == that.isForMergeCommits, "Cannot sum a merge commit stats with a non merge commit stats")
+    this.copy(
+      numFiles = this.numFiles + that.numFiles,
+      addedLines = this.addedLines + that.addedLines,
+      deletedLines = this.deletedLines + that.deletedLines,
+      commits = this.commits ++ that.commits
+    )
+  }
+}
 
 object CommitsStatistics {
-  val Empty = CommitsStatistics(0, 0, 0)
+  val Empty = CommitsStatistics(0, 0, 0, false, List.empty)
+  val EmptyMerge = Empty.copy(isForMergeCommits = true)
 }
 
 class Statistics(repo: Repository) {
 
-  def find(objectIds: Seq[ObjectId]): CommitsStatistics =
-    objectIds.foldLeft(CommitsStatistics(0, 0, 0)) {
-      (acc, objectId) => {
-        val stats = find(objectId)
-        CommitsStatistics(acc.numFiles + stats.numFiles, acc.addedLines + stats.addedLines, acc.deletedLines + stats.deletedLines)
-      }
-    }
+  /**
+    * 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] = {
 
-  def find(objectId: ObjectId): 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 =>
@@ -69,7 +96,10 @@
         edit <- df.toFileHeader(diff).toEditList
       } yield Lines(edit.getEndA - edit.getBeginA, edit.getEndB - edit.getBeginB)).fold(Lines(0, 0))(_ + _)
 
-      CommitsStatistics(diffs.size, lines.added, lines.deleted)
+      val commitInfo = CommitInfo(objectId.getName, commit.getAuthorIdent.getWhen.getTime, commit.isMerge)
+
+      CommitsStatistics(diffs.size, lines.added, lines.deleted, commitInfo.merge, List(commitInfo))
     }
   }
+
 }
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/test/scala/com/googlesource/gerrit/plugins/analytics/test/CommitStatisticsSpec.scala b/src/test/scala/com/googlesource/gerrit/plugins/analytics/test/CommitStatisticsSpec.scala
index 315369f..6912e7b 100644
--- a/src/test/scala/com/googlesource/gerrit/plugins/analytics/test/CommitStatisticsSpec.scala
+++ b/src/test/scala/com/googlesource/gerrit/plugins/analytics/test/CommitStatisticsSpec.scala
@@ -16,13 +16,14 @@
 
 import java.util.Date
 
+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.lib.ObjectId
 import org.eclipse.jgit.revwalk.RevCommit
 import org.scalatest.{FlatSpec, Inside, Matchers}
 
-import scala.collection.JavaConverters._
-
 class CommitStatisticsSpec extends FlatSpec with GitTestCase with Matchers with Inside {
 
 
@@ -31,16 +32,32 @@
     val stats = new Statistics(repo)
   }
 
-  def commit(committer: String, fname: String, content: String): RevCommit = {
+  def commit(committer: String, fileName: String, content: String): RevCommit = {
     val date = new Date()
     val person = newPersonIdent(committer, committer, date)
-    add(testRepo, "afile.txt", content, author = person, committer = author)
+    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)
+    }
+  }
+
+  def idWithInfo(revCommit: RevCommit): (ObjectId, CommitInfo) = {
+    revCommit.getId -> CommitInfo(revCommit.getId.getName, revCommit.getCommitterIdent.getWhen.getTime, revCommit.getParentCount > 1)
   }
 
   "CommitStatistics" should "stats a single file added" in new TestEnvironment {
     val change = commit("user", "file1.txt", "line1\nline2")
 
-    inside(stats.find(change)) { case s: CommitsStatistics =>
+    inside(stats.forCommits(change)) { case List(s: CommitsStatistics) =>
       s.numFiles should be(1)
       s.addedLines should be(2)
       s.deletedLines should be(0)
@@ -50,19 +67,22 @@
   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", "file2.txt").asJava,
-      List("line1\n", "line1\nline2\n").asJava, "second commit")
-    inside(stats.find(second)) { case s: CommitsStatistics =>
+      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(3)
-      s.deletedLines should be(0)
+      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.find(second)) { case s: CommitsStatistics =>
+    inside(stats.forCommits(second)) { case List(s: CommitsStatistics) =>
       s.numFiles should be(1)
       s.addedLines should be(0)
       s.deletedLines should be(2)
@@ -71,22 +91,53 @@
 
   it should "stats a Seq[RevCommit]" in new TestEnvironment {
     val initial = add(testRepo,
-      List("file1.txt", "file3.txt").asJava,
-      List("line1\n", "line1\nline2\n").asJava, "first commit")
+      List(
+        "file1.txt" -> "line1\n",
+        "file3.txt" -> "line1\nline2\n"),
+      "first commit")
+
     val second = add(testRepo,
-      List("file1.txt", "file2.txt").asJava,
-      List("line1a\n", "line1\nline2\n").asJava, "second commit")
-    inside(stats.find(List(initial, second))) { case s: CommitsStatistics =>
-      s.numFiles should be(4)
-      s.addedLines should be(6)
-      s.deletedLines should be(1)
+      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)     //this is wrong? shouldn't we consider just three files?
+      nonMergeStats.addedLines should be(6)
+      nonMergeStats.deletedLines should be(1)
     }
   }
 
-  it should "not return any stats if the commit does not include any file" in new TestEnvironment {
-    val emptyCommit = add(testRepo,
-      List.empty[String].asJava,
-      List.empty[String].asJava, "Empty commit")
-    stats.find(emptyCommit) shouldBe CommitsStatistics.Empty
+  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 instad 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
index 6375b7f..423f92e 100644
--- a/src/test/scala/com/googlesource/gerrit/plugins/analytics/test/GitTestCase.scala
+++ b/src/test/scala/com/googlesource/gerrit/plugins/analytics/test/GitTestCase.scala
@@ -41,7 +41,10 @@
 import java.util.Date
 import java.util
 
+import com.googlesource.gerrit.plugins.analytics.common.ManagedResource
+import com.googlesource.gerrit.plugins.analytics.common.ManagedResource.use
 import org.eclipse.jgit.api.Git
+import org.eclipse.jgit.api.MergeCommand.FastForwardMode
 import org.eclipse.jgit.api.MergeResult
 import org.eclipse.jgit.api.errors.GitAPIException
 import org.eclipse.jgit.lib.Constants
@@ -116,6 +119,15 @@
   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
@@ -124,9 +136,24 @@
     *
     */
   protected def branch(repo: File, name: String): Ref = {
-    val git = Git.open(repo)
-    git.branchCreate.setName(name).call
-    checkout(repo, name)
+    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)
+    }
   }
 
   /**
@@ -148,11 +175,10 @@
     */
   @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
-  }
+    use(Git.open(repo)) { git =>
+      git.checkout.setName(name).call
+    }
+  } ensuring(_ != null, "Unable to checkout result")
 
   /**
     * Create tag with name
@@ -172,12 +198,11 @@
     *
     */
   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
-  }
+    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
@@ -218,16 +243,17 @@
     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
-  }
+
+    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
@@ -263,63 +289,64 @@
   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
-  }
+
+    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 paths
-    * @param contents
+    * @param contents iterable of file names and associated content
     * @return commit
     *
     */
-  protected def add(paths: util.List[String], contents: util.List[String]): RevCommit = add(testRepo, paths, contents, "Committing multiple files")
+  protected def add(contents: Iterable[(String, String)]): RevCommit = add(testRepo, contents, "Committing multiple files")
 
   /**
     * Add files to test repository
     *
     * @param repo
-    * @param paths
-    * @param contents
+    * @param contents iterable of file names and associated content
     * @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
+  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
     }
-    val commit = git.commit.setMessage(message).setAuthor(author).setCommitter(committer).call
-    assert(null != commit)
-    commit
-  }
+  } 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
@@ -327,8 +354,9 @@
     *
     */
   protected def merge(ref: String): MergeResult = {
-    val git = Git.open(testRepo)
-    git.merge.setStrategy(MergeStrategy.RESOLVE).include(CommitUtils.getCommit(git.getRepository, ref)).call
+    use(Git.open(testRepo)) { git =>
+      git.merge.setStrategy(MergeStrategy.RESOLVE).include(CommitUtils.getCommit(git.getRepository, ref)).call
+    }
   }
 
   /**
@@ -349,11 +377,10 @@
     *
     */
   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
-  }
+    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
@@ -364,10 +391,9 @@
     */
   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
-  }
+    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