Repository: scala-steward-org/scala-steward (scala)

Issue:
Attribute Scalafix commits via "Co-authored-by" to the rule author?
Scalafix commits are currently attributed in the Git history to the Scala Steward user although it only did the easy part of running the rule. The hard part of writing the rule is done by someone else who's currently not attributed in any way. Wouldn't it be nice if we append [Co-authored-by](https://stackoverflow.com/a/7442255) attributions to the Scalafix commit if the rule author(s) opts into it. We could support this by adding a `authors: Option[Nel[GitAuthor]]` field to [`Migration`](https://github.com/scala-steward-org/scala-steward/blob/b5cacf994919cd95c08369518a99a389deb3c6c4/modules/core/src/main/scala/org/scalasteward/core/scalafix/Migration.scala).

Pull request description:
Closes #1914.

Patch:
diff --git a/modules/core/src/main/resources/scalafix-migrations.conf b/modules/core/src/main/resources/scalafix-migrations.conf
index 3837f63001..2bf1108c7e 100644
--- a/modules/core/src/main/resources/scalafix-migrations.conf
+++ b/modules/core/src/main/resources/scalafix-migrations.conf
@@ -59,7 +59,8 @@ migrations = [
     groupId: "io.chrisdavenport",
     artifactIds: ["log4cats-.*"],
     newVersion: "1.2.0",
-    rewriteRules: ["https://gist.githubusercontent.com/fthomas/ea14d58e1b445d8038a5bc22acf751e8/raw/e1180c91dab0b36d8f9ec37d413ef9507bee67ff/log4cats-1.2.0.scala"]
+    rewriteRules: ["https://gist.githubusercontent.com/fthomas/ea14d58e1b445d8038a5bc22acf751e8/raw/e1180c91dab0b36d8f9ec37d413ef9507bee67ff/log4cats-1.2.0.scala"],
+    authors: ["Frank Thomas <frank@timepit.eu>"]
   },
   {
     groupId: "org.http4s",
diff --git a/modules/core/src/main/scala/org/scalasteward/core/edit/EditAlg.scala b/modules/core/src/main/scala/org/scalasteward/core/edit/EditAlg.scala
index 423ffebb1c..b4de4769c1 100644
--- a/modules/core/src/main/scala/org/scalasteward/core/edit/EditAlg.scala
+++ b/modules/core/src/main/scala/org/scalasteward/core/edit/EditAlg.scala
@@ -101,10 +101,8 @@ final class EditAlg[F[_]](implicit
       result <- logger.attemptWarn.log("Scalafix migration failed")(
         buildToolDispatcher.runMigration(repo, config, migration)
       )
-      verb = if (result.isRight) "Applied" else "Failed"
-      msg1 = s"$verb Scalafix rule(s) ${migration.rewriteRules.mkString_(", ")}"
-      msg2 = migration.doc.map(url => s"See $url for details").toList
-      maybeCommit <- gitAlg.commitAllIfDirty(repo, msg1, msg2: _*)
+      msg = migration.commitMessage(result)
+      maybeCommit <- gitAlg.commitAllIfDirty(repo, msg.head, msg.tail: _*)
     } yield ScalafixEdit(migration, result, maybeCommit)
 
   private def bumpVersion(update: Update, files: Nel[File]): F[Boolean] = {
diff --git a/modules/core/src/main/scala/org/scalasteward/core/edit/scalafix/ScalafixMigration.scala b/modules/core/src/main/scala/org/scalasteward/core/edit/scalafix/ScalafixMigration.scala
index e0e136bad6..e11546c005 100644
--- a/modules/core/src/main/scala/org/scalasteward/core/edit/scalafix/ScalafixMigration.scala
+++ b/modules/core/src/main/scala/org/scalasteward/core/edit/scalafix/ScalafixMigration.scala
@@ -16,9 +16,11 @@
 
 package org.scalasteward.core.edit.scalafix
 
+import cats.syntax.all._
 import io.circe.Decoder
 import io.circe.generic.semiauto._
 import org.scalasteward.core.data.{GroupId, Version}
+import org.scalasteward.core.git.Author
 import org.scalasteward.core.util.Nel
 
 final case class ScalafixMigration(
@@ -26,11 +28,20 @@ final case class ScalafixMigration(
     artifactIds: Nel[String],
     newVersion: Version,
     rewriteRules: Nel[String],
-    doc: Option[String],
-    scalacOptions: Option[Nel[String]]
-)
+    doc: Option[String] = None,
+    scalacOptions: Option[Nel[String]] = None,
+    authors: Option[Nel[Author]] = None
+) {
+  def commitMessage(result: Either[Throwable, Unit]): Nel[String] = {
+    val verb = if (result.isRight) "Applied" else "Failed"
+    val msg1 = s"$verb Scalafix rule(s) ${rewriteRules.mkString_(", ")}"
+    val msg2 = doc.map(url => s"See $url for details")
+    val msg3 = authors.map(_.map(author => s"Co-authored-by: ${author.show}").mkString_("\n"))
+    Nel(msg1, msg2.toList ++ msg3.toList)
+  }
+}
 
 object ScalafixMigration {
   implicit val scalafixMigrationDecoder: Decoder[ScalafixMigration] =
-    deriveDecoder[ScalafixMigration]
+    deriveDecoder
 }
diff --git a/modules/core/src/main/scala/org/scalasteward/core/git/Author.scala b/modules/core/src/main/scala/org/scalasteward/core/git/Author.scala
index 42ed1ae2e2..f983a22bef 100644
--- a/modules/core/src/main/scala/org/scalasteward/core/git/Author.scala
+++ b/modules/core/src/main/scala/org/scalasteward/core/git/Author.scala
@@ -16,4 +16,21 @@
 
 package org.scalasteward.core.git
 
-final case class Author(name: String, email: String, signingKey: Option[String])
+import io.circe.Decoder
+
+final case class Author(name: String, email: String, signingKey: Option[String] = None) {
+  def show: String = s"$name <$email>"
+}
+
+object Author {
+  def parse(s: String): Either[String, Author] = {
+    val regex = """(.*)\s+<(.*)>\s*""".r
+    s match {
+      case regex(name, email) => Right(Author(name.trim, email.trim))
+      case _ => Left(s"Could not parse '$s' as Author. Expected format is: $$name <$$email>")
+    }
+  }
+
+  implicit val authorDecoder: Decoder[Author] =
+    Decoder[String].emap(parse)
+}


Test patch:
diff --git a/modules/core/src/test/scala/org/scalasteward/core/buildtool/sbt/SbtAlgTest.scala b/modules/core/src/test/scala/org/scalasteward/core/buildtool/sbt/SbtAlgTest.scala
index 89670b4dbe..fe9f10bed7 100644
--- a/modules/core/src/test/scala/org/scalasteward/core/buildtool/sbt/SbtAlgTest.scala
+++ b/modules/core/src/test/scala/org/scalasteward/core/buildtool/sbt/SbtAlgTest.scala
@@ -69,9 +69,7 @@ class SbtAlgTest extends FunSuite {
       GroupId("co.fs2"),
       Nel.of("fs2-core"),
       Version("1.0.0"),
-      Nel.of("github:functional-streams-for-scala/fs2/v1?sha=v1.0.5"),
-      None,
-      None
+      Nel.of("github:functional-streams-for-scala/fs2/v1?sha=v1.0.5")
     )
     val state = sbtAlg.runMigration(buildRoot, migration).runS(MockState.empty).unsafeRunSync()
     val expected = MockState.empty.copy(
@@ -107,8 +105,7 @@ class SbtAlgTest extends FunSuite {
       Nel.of("cats-core"),
       Version("2.2.0"),
       Nel.of("github:cb372/cats/Cats_v2_2_0?sha=235bd7c92e431ab1902db174cf4665b05e08f2f1"),
-      None,
-      Some(Nel.of("-P:semanticdb:synthetics:on"))
+      scalacOptions = Some(Nel.of("-P:semanticdb:synthetics:on"))
     )
     val state = sbtAlg.runMigration(buildRoot, migration).runS(MockState.empty).unsafeRunSync()
     val expected = MockState.empty.copy(
diff --git a/modules/core/src/test/scala/org/scalasteward/core/edit/scalafix/ScalafixMigrationTest.scala b/modules/core/src/test/scala/org/scalasteward/core/edit/scalafix/ScalafixMigrationTest.scala
new file mode 100644
index 0000000000..85a2f40d33
--- /dev/null
+++ b/modules/core/src/test/scala/org/scalasteward/core/edit/scalafix/ScalafixMigrationTest.scala
@@ -0,0 +1,28 @@
+package org.scalasteward.core.edit.scalafix
+
+import io.circe.config.parser
+import munit.FunSuite
+import org.scalasteward.core.util.Nel
+
+class ScalafixMigrationTest extends FunSuite {
+  test("commitMessage") {
+    val migration = parser.decode[ScalafixMigration](
+      """|{
+         |  groupId: "org.typelevel",
+         |  artifactIds: ["cats-core"],
+         |  newVersion: "2.2.0",
+         |  rewriteRules: ["github:typelevel/cats/Cats_v2_2_0?sha=v2.2.0"],
+         |  doc: "https://github.com/typelevel/cats/blob/v2.2.0/scalafix/README.md#migration-to-cats-v220",
+         |  scalacOptions: ["-P:semanticdb:synthetics:on"],
+         |  authors: ["Jane Doe <jane@example.com>"]
+         |}""".stripMargin
+    )
+    val obtained = migration.map(_.commitMessage(Right(())))
+    val expected = Nel.of(
+      "Applied Scalafix rule(s) github:typelevel/cats/Cats_v2_2_0?sha=v2.2.0",
+      "See https://github.com/typelevel/cats/blob/v2.2.0/scalafix/README.md#migration-to-cats-v220 for details",
+      "Co-authored-by: Jane Doe <jane@example.com>"
+    )
+    assertEquals(obtained, Right(expected))
+  }
+}
diff --git a/modules/core/src/test/scala/org/scalasteward/core/edit/scalafix/ScalafixMigrationsLoaderTest.scala b/modules/core/src/test/scala/org/scalasteward/core/edit/scalafix/ScalafixMigrationsLoaderTest.scala
index c11644ed2b..64923234e9 100644
--- a/modules/core/src/test/scala/org/scalasteward/core/edit/scalafix/ScalafixMigrationsLoaderTest.scala
+++ b/modules/core/src/test/scala/org/scalasteward/core/edit/scalafix/ScalafixMigrationsLoaderTest.scala
@@ -5,6 +5,7 @@ import munit.FunSuite
 import org.http4s.Uri
 import org.scalasteward.core.application.Config.ScalafixCfg
 import org.scalasteward.core.data.{GroupId, Version}
+import org.scalasteward.core.git.Author
 import org.scalasteward.core.mock.MockConfig.mockRoot
 import org.scalasteward.core.mock.MockContext.context.scalafixMigrationsLoader
 import org.scalasteward.core.mock.MockContext.mockState
@@ -19,7 +20,8 @@ class ScalafixMigrationsLoaderTest extends FunSuite {
        |    artifactIds: ["yumyum-.*"],
        |    newVersion: "1.0.0",
        |    rewriteRules: ["awesome rewrite rule"],
-       |    doc: "https://scalacenter.github.io/scalafix/"
+       |    doc: "https://scalacenter.github.io/scalafix/",
+       |    authors: ["Jane Doe <jane@example.com>"]
        |  }
        |]""".stripMargin
   val migration: ScalafixMigration = ScalafixMigration(
@@ -28,7 +30,7 @@ class ScalafixMigrationsLoaderTest extends FunSuite {
     Version("1.0.0"),
     Nel.of("awesome rewrite rule"),
     Some("https://scalacenter.github.io/scalafix/"),
-    None
+    authors = Some(Nel.of(Author("Jane Doe", "jane@example.com")))
   )
 
   test("loadAll: without extra file, without defaults") {
diff --git a/modules/core/src/test/scala/org/scalasteward/core/vcs/data/NewPullRequestDataTest.scala b/modules/core/src/test/scala/org/scalasteward/core/vcs/data/NewPullRequestDataTest.scala
index 614ea4bae5..6dda95abdd 100644
--- a/modules/core/src/test/scala/org/scalasteward/core/vcs/data/NewPullRequestDataTest.scala
+++ b/modules/core/src/test/scala/org/scalasteward/core/vcs/data/NewPullRequestDataTest.scala
@@ -110,9 +110,7 @@ class NewPullRequestDataTest extends FunSuite {
       update.groupId,
       Nel.of(update.artifactId.name),
       Version("0.7.0"),
-      Nel.of("I am a rewrite rule"),
-      None,
-      None
+      Nel.of("I am a rewrite rule")
     )
     val (label, appliedMigrations) = NewPullRequestData.migrationNote(List(migration))
 
@@ -135,8 +133,7 @@ class NewPullRequestDataTest extends FunSuite {
       Nel.of(update.artifactId.name),
       Version("0.7.0"),
       Nel.of("I am a rewrite rule"),
-      Some("https://scalacenter.github.io/scalafix/"),
-      None
+      Some("https://scalacenter.github.io/scalafix/")
     )
     val (label, appliedMigrations) = NewPullRequestData.migrationNote(List(migration))