diff --git a/.scalafix.conf b/.scalafix.conf new file mode 100644 index 0000000..bba46aa --- /dev/null +++ b/.scalafix.conf @@ -0,0 +1,8 @@ +rule = SortImports +SortImports.blocks = [ + "java.", + "scala.", + "akka.", + "*", + "lt.dvim.authors.", +] diff --git a/.travis.yml b/.travis.yml index 7b6a88e..d8263fb 100644 --- a/.travis.yml +++ b/.travis.yml @@ -12,6 +12,8 @@ jobs: name: "Code style check (fixed with `sbt Test/compile`)" - script: sbt scalafmtSbtCheck || { echo "[error] Unformatted sbt code found. Please run 'scalafmtSbt' and commit the reformatted code."; false; } name: "Build code style check (fixed with `sbt scalafmtSbt`)" + - script: sbt "scalafix --check" || { echo "[error] Unformatted code found. Please run 'scalafix' and commit the reformatted code."; false; } + name: "Code style check (fixed with `sbt scalafix`)" - stage: test script: sbt test diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..aed790e --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,10 @@ +## Prepare the test repo + +This project uses a prepared Git repository when running some of the tests. +The test git repository is registered as a git submodule. +You will need to initialize and update the submodule before running the tests: + +```sh +git submodule init +git submodule update +``` diff --git a/build.sbt b/build.sbt index 205909a..f346593 100644 --- a/build.sbt +++ b/build.sbt @@ -1,11 +1,13 @@ +val ScalaVersion = "2.12.10" + lazy val authors = project .in(file(".")) - .aggregate(core, plugin) + .aggregate(core, plugin, cli) lazy val core = project .settings( name := "authors-core", - scalaVersion := "2.12.10", + scalaVersion := ScalaVersion, resolvers += Resolver.bintrayRepo("jypma", "maven"), { val Akka = "2.6.3" val AkkaHttp = "10.1.11" @@ -47,6 +49,17 @@ lazy val plugin = project scriptedBufferLog := false ) +lazy val cli = project + .dependsOn(core) + .enablePlugins(AutomateHeaderPlugin) + .settings( + name := "authors-cli", + scalaVersion := ScalaVersion, + libraryDependencies ++= Seq( + "org.rogach" %% "scallop" % "3.3.2" + ) + ) + inThisBuild( Seq( organization := "lt.dvim.authors", @@ -63,6 +76,9 @@ inThisBuild( ), bintrayOrganization := Some("2m"), scalafmtOnCompile := true, + scalafixDependencies ++= Seq( + "com.nequissimus" %% "sort-imports" % "0.3.1" + ), // show full stack traces and test case durations testOptions in Test += Tests.Argument("-oDF") ) diff --git a/cli/src/main/scala/AuthorsCli.scala b/cli/src/main/scala/AuthorsCli.scala new file mode 100644 index 0000000..c668f3b --- /dev/null +++ b/cli/src/main/scala/AuthorsCli.scala @@ -0,0 +1,63 @@ +/* + * Copyright 2016 Martynas Mickevičius + * + * 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 lt.dvim.authors + +import scala.concurrent.Await +import scala.concurrent.duration._ + +import org.rogach.scallop._ + +object AuthorsCli { + import ScallopOpts._ + + class Config(args: Seq[String]) extends ScallopConf(args) { + banner("""|Fetches a summary of authors that contributed to a project between two points in git history. + |Usage: authors [-p ] [-r ] + | + |From and to git references can be: + | * commit short hash + | * tag name + | * HEAD + | + |If is not specified, current directory "." is used by default. + | + |If is not specified, it is parsed from the origin url. + """.stripMargin) + + val path = opt[String](default = Some("."), descr = "Path to the local project directory") + val repo = opt[String](default = None, descr = "org/repo of the project") + val timeout = opt[FiniteDuration](default = Some(30.seconds), descr = "Timeout for the command") + val fromRef = trailArg[String]() + val toRef = trailArg[String]() + + verify() + } + + def main(args: Array[String]) = { + val config = new Config(args.toIndexedSeq) + + val future = + Authors.summary( + config.repo.toOption, + config.fromRef.toOption.get, + config.toRef.toOption.get, + config.path.toOption.get + ) + + println(Await.result(future, config.timeout.toOption.get)) + } +} diff --git a/cli/src/main/scala/ScallopOps.scala b/cli/src/main/scala/ScallopOps.scala new file mode 100644 index 0000000..a5b831d --- /dev/null +++ b/cli/src/main/scala/ScallopOps.scala @@ -0,0 +1,31 @@ +/* + * Copyright 2016 Martynas Mickevičius + * + * 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 lt.dvim.authors + +import scala.concurrent.duration.Duration +import scala.concurrent.duration.FiniteDuration + +import org.rogach.scallop._ + +object ScallopOpts { + implicit val finiteDurationConverter = singleArgConverter[FiniteDuration] { arg => + Duration(arg) match { + case d: FiniteDuration => d + case d => throw new IllegalArgumentException(s"'$d' is not a FiniteDuration.") + } + } +} diff --git a/core/src/main/scala/Authors.scala b/core/src/main/scala/Authors.scala index f853e14..a7fff75 100644 --- a/core/src/main/scala/Authors.scala +++ b/core/src/main/scala/Authors.scala @@ -18,6 +18,9 @@ package lt.dvim.authors import java.io.File +import scala.collection.JavaConverters._ +import scala.concurrent.{ExecutionContext, Future} + import akka.NotUsed import akka.actor.ActorSystem import akka.event.{Logging, LoggingAdapter} @@ -27,47 +30,31 @@ import akka.http.scaladsl.marshalling.PredefinedToRequestMarshallers._ import akka.http.scaladsl.model.{HttpRequest, Uri} import akka.stream.scaladsl.{Flow, Source} import akka.util.ByteString + +import com.madgag.git._ import com.tradeshift.reaktive.marshal.stream.{ActsonReader, ProtocolReader} import org.eclipse.jgit.diff.DiffFormatter import org.eclipse.jgit.internal.storage.file.FileRepository -import org.eclipse.jgit.util.io.DisabledOutputStream -import com.madgag.git._ -import lt.dvim.authors.GithubProtocol.{AuthorStats, Commit, Stats} import org.eclipse.jgit.storage.file.FileRepositoryBuilder +import org.eclipse.jgit.util.io.DisabledOutputStream -import scala.collection.JavaConverters._ -import scala.concurrent.{Await, ExecutionContext, Future} -import scala.concurrent.duration._ +import lt.dvim.authors.GithubProtocol.{AuthorStats, Commit, Stats} object Authors { final val MaxAuthors = 1024 final val GithubApiUrl = "api.github.com" - def main(args: Array[String]) = { - val (repo, from, to, path) = args.toList match { - case repo :: from :: to :: path :: Nil => (repo, from, to, path) - case _ => - println(""" - |Usage: - | - """.stripMargin) - System.exit(1) - ??? - } - - val future = summary(repo, from, to, path) - println(Await.result(future, 30.seconds)) - } - - def summary(repo: String, from: String, to: String, path: String): Future[String] = { + def summary(repo: Option[String], from: String, to: String, path: String): Future[String] = { val cld = classOf[ActorSystem].getClassLoader implicit val sys = ActorSystem("Authors", classLoader = Some(cld)) implicit val gitRepository = Authors.gitRepo(path) implicit val log = Logging(sys, this.getClass) import sys.dispatcher + def parsedRepo = + parseRepo(gitRepository.getConfig().getString("remote", "origin", "url")) - DiffSource(repo, from, to) + DiffSource(repo.getOrElse(parsedRepo), from, to) .via(ActsonReader.instance) .via(ProtocolReader.of(GithubProtocol.compareProto)) .via(StatsAggregator()) @@ -82,6 +69,9 @@ object Authors { } } + def parseRepo(originUrl: String): String = + originUrl.split("github.com").tail.head.drop(1).split(".git").head + def gitRepo(path: String): FileRepository = FileRepositoryBuilder .create(new File(if (path.contains(".git")) path else path + "/.git")) diff --git a/core/src/main/scala/GithubProtocol.scala b/core/src/main/scala/GithubProtocol.scala index d17ad7c..6673a4e 100644 --- a/core/src/main/scala/GithubProtocol.scala +++ b/core/src/main/scala/GithubProtocol.scala @@ -18,8 +18,8 @@ package lt.dvim.authors import com.tradeshift.reaktive.json.JSONProtocol._ import com.tradeshift.reaktive.marshal.Protocol._ -import lt.dvim.scala.compat.vavr.OptionConverters._ import io.vavr.control.{Option => VavrOption} +import lt.dvim.scala.compat.vavr.OptionConverters._ object GithubProtocol { final case class GithubAuthor(login: String, url: String, avatar: String) diff --git a/core/src/test/scala/ParseRepoSpec.scala b/core/src/test/scala/ParseRepoSpec.scala new file mode 100644 index 0000000..17a6f1f --- /dev/null +++ b/core/src/test/scala/ParseRepoSpec.scala @@ -0,0 +1,34 @@ +/* + * Copyright 2016 Martynas Mickevičius + * + * 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 lt.dvim.authors + +import org.scalatest.wordspec.AnyWordSpec +import org.scalatest.matchers.should.Matchers + +class ParseRepoSpec extends AnyWordSpec with Matchers { + + "repo parser" must { + "parse https url" in { + Authors.parseRepo("https://github.com/2m/authors.git") shouldBe "2m/authors" + } + + "parse ssh url" in { + Authors.parseRepo("git@github.com:2m/authors.git") shouldBe "2m/authors" + } + } + +} diff --git a/plugin/src/main/scala/AuthorsPlugin.scala b/plugin/src/main/scala/AuthorsPlugin.scala index f9a33b4..68a5969 100644 --- a/plugin/src/main/scala/AuthorsPlugin.scala +++ b/plugin/src/main/scala/AuthorsPlugin.scala @@ -16,13 +16,13 @@ package lt.dvim.authors -import sbt._ +import scala.concurrent.duration._ +import scala.concurrent.{Await, Future} + import sbt.Keys._ +import sbt._ import sbt.complete.DefaultParsers._ -import scala.concurrent.{Await, Future} -import scala.concurrent.duration._ - object AuthorsPlugin extends AutoPlugin { object autoImport extends AuthorsKeys import autoImport._ @@ -106,6 +106,6 @@ object AuthorsPlugin extends AutoPlugin { streams.log.info(s"Fetching authors summary for $repo between $from and $to") - Authors.summary(repo, from, to, baseDirectory.getAbsolutePath) + Authors.summary(Some(repo), from, to, baseDirectory.getAbsolutePath) } } diff --git a/project/plugins.sbt b/project/plugins.sbt index 7cb5ece..8761e9b 100644 --- a/project/plugins.sbt +++ b/project/plugins.sbt @@ -3,3 +3,4 @@ addSbtPlugin("org.scalameta" % "sbt-scalafmt" % "2.3.1") addSbtPlugin("org.foundweekends" % "sbt-bintray" % "0.5.6") addSbtPlugin("io.github.davidgregory084" % "sbt-tpolecat" % "0.1.11") addSbtPlugin("de.heikoseeberger" % "sbt-header" % "5.4.0") +addSbtPlugin("ch.epfl.scala" % "sbt-scalafix" % "0.9.11")