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

Issue:
Feature request: run arbitrary (sbt) action after the update
I would like to run a custom sbt task after each update, before creating the update commit, that can make additional changes in the repository.

This is a very specific use case for zio-aws where scala-steward updates the full AWS SDK BOM that sometimes get new aws services included. In that case zio-aws's github actions definition must be regenerated to include the new aws libraries. I have an sbt task to do that, but it would be really nice to be able to trigger it automatically for each update.

If implemented as a general purpose extension point it would be probably useful for others too. For example it could also solve #1889

Pull request description:
Fixes #1997 
fixes #2097

Patch:
diff --git a/docs/repo-specific-configuration.md b/docs/repo-specific-configuration.md
index 872413e93e..b0e639955f 100644
--- a/docs/repo-specific-configuration.md
+++ b/docs/repo-specific-configuration.md
@@ -84,6 +84,16 @@ scalafmt.runAfterUpgrading = false
 # are specified using the buildRoots property. Note that the paths used there are relative and if the repo directory itself also contains a build.sbt the dot can be used to specify it.
 # Default: ["."]
 buildRoots = [ ".", "subfolder/projectA" ]
+
+# Define commands that are executed after an update via a hook.
+# A groupId and/or artifactId can be defined to only execute after certain dependencies are updated. If neither is defined, the hook runs for every update.
+postUpdateHooks = [{
+  command = "sbt protobufGenerate",
+  useSandbox = true,
+  commitMessage = "Regenerated protobuf files",
+  groupId = "com.github.sbt",
+  artifiactId = "sbt-protobuf"
+}]
 ```
 
 The version information given in the patterns above can be in two formats:
diff --git a/modules/core/src/main/scala/org/scalasteward/core/edit/hooks/HookExecutor.scala b/modules/core/src/main/scala/org/scalasteward/core/edit/hooks/HookExecutor.scala
index edb3508051..d23ec8d8e6 100644
--- a/modules/core/src/main/scala/org/scalasteward/core/edit/hooks/HookExecutor.scala
+++ b/modules/core/src/main/scala/org/scalasteward/core/edit/hooks/HookExecutor.scala
@@ -39,10 +39,10 @@ final class HookExecutor[F[_]](implicit
     F: MonadThrow[F]
 ) {
   def execPostUpdateHooks(data: RepoData, update: Update): F[List[EditAttempt]] =
-    HookExecutor.postUpdateHooks
+    (HookExecutor.postUpdateHooks ++ data.config.postUpdateHooks.map(_.toHook))
       .filter { hook =>
-        update.groupId === hook.groupId &&
-        update.artifactIds.exists(_.name === hook.artifactId.name) &&
+        hook.groupId.forall(update.groupId === _) &&
+        hook.artifactId.forall(aid => update.artifactIds.exists(_.name === aid.name)) &&
         hook.enabledByCache(data.cache) &&
         hook.enabledByConfig(data.config)
       }
@@ -51,7 +51,10 @@ final class HookExecutor[F[_]](implicit
 
   private def execPostUpdateHook(repo: Repo, update: Update, hook: PostUpdateHook): F[EditAttempt] =
     for {
-      _ <- logger.info(s"Executing post-update hook for ${hook.groupId}:${hook.artifactId.name}")
+      _ <- logger.info(
+        s"Executing post-update hook for ${update.groupId}:${update.mainArtifactId} with command " +
+          s"${hook.command.mkString_("'", " ", "'")}"
+      )
       repoDir <- workspaceAlg.repoDir(repo)
       result <- logger.attemptWarn.log("Post-update hook failed") {
         processAlg.execMaybeSandboxed(hook.useSandbox)(hook.command, repoDir)
@@ -82,8 +85,8 @@ object HookExecutor {
       enabledByCache: RepoCache => Boolean
   ): PostUpdateHook =
     PostUpdateHook(
-      groupId = groupId,
-      artifactId = artifactId,
+      groupId = Some(groupId),
+      artifactId = Some(artifactId),
       command = Nel.of("sbt", "githubWorkflowGenerate"),
       useSandbox = true,
       commitMessage = _ => CommitMsg("Regenerate workflow with sbt-github-actions"),
@@ -93,8 +96,8 @@ object HookExecutor {
 
   private val scalafmtHook =
     PostUpdateHook(
-      groupId = scalafmtGroupId,
-      artifactId = scalafmtArtifactId,
+      groupId = Some(scalafmtGroupId),
+      artifactId = Some(scalafmtArtifactId),
       command = ScalafmtAlg.postUpdateHookCommand,
       useSandbox = false,
       commitMessage = update => CommitMsg(s"Reformat with scalafmt ${update.nextVersion}"),
@@ -104,8 +107,8 @@ object HookExecutor {
 
   private val sbtJavaFormatterHook =
     PostUpdateHook(
-      groupId = GroupId("com.lightbend.sbt"),
-      artifactId = ArtifactId("sbt-java-formatter"),
+      groupId = Some(GroupId("com.lightbend.sbt")),
+      artifactId = Some(ArtifactId("sbt-java-formatter")),
       command = Nel.of("sbt", "javafmtAll"),
       useSandbox = true,
       commitMessage =
diff --git a/modules/core/src/main/scala/org/scalasteward/core/edit/hooks/PostUpdateHook.scala b/modules/core/src/main/scala/org/scalasteward/core/edit/hooks/PostUpdateHook.scala
index 9ca53d2e0b..2c45c7c100 100644
--- a/modules/core/src/main/scala/org/scalasteward/core/edit/hooks/PostUpdateHook.scala
+++ b/modules/core/src/main/scala/org/scalasteward/core/edit/hooks/PostUpdateHook.scala
@@ -23,8 +23,8 @@ import org.scalasteward.core.repoconfig.RepoConfig
 import org.scalasteward.core.util.Nel
 
 final case class PostUpdateHook(
-    groupId: GroupId,
-    artifactId: ArtifactId,
+    groupId: Option[GroupId],
+    artifactId: Option[ArtifactId],
     command: Nel[String],
     useSandbox: Boolean,
     commitMessage: Update => CommitMsg,
diff --git a/modules/core/src/main/scala/org/scalasteward/core/repoconfig/PostUpdateHookConfig.scala b/modules/core/src/main/scala/org/scalasteward/core/repoconfig/PostUpdateHookConfig.scala
new file mode 100644
index 0000000000..2f200ae267
--- /dev/null
+++ b/modules/core/src/main/scala/org/scalasteward/core/repoconfig/PostUpdateHookConfig.scala
@@ -0,0 +1,60 @@
+/*
+ * Copyright 2018-2021 Scala Steward contributors
+ *
+ * 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 org.scalasteward.core.repoconfig
+
+import io.circe.Codec
+import io.circe.generic.extras.Configuration
+import io.circe.generic.extras.semiauto.deriveConfiguredCodec
+import org.scalasteward.core.data.{ArtifactId, GroupId}
+import org.scalasteward.core.edit.hooks.PostUpdateHook
+import org.scalasteward.core.git.CommitMsg
+import org.scalasteward.core.util.Nel
+
+final case class PostUpdateHookConfig(
+    groupId: Option[GroupId],
+    artifactId: Option[String],
+    command: String,
+    useSandbox: Boolean,
+    commitMessage: String
+) {
+  def toHook: PostUpdateHook =
+    Nel
+      .fromList(command.split(' ').toList)
+      .fold(
+        throw new Exception("Post update hooks must have a command defined.")
+      ) { cmd =>
+        PostUpdateHook(
+          groupId,
+          artifactId.map(ArtifactId(_)),
+          command = cmd,
+          useSandbox = useSandbox,
+          commitMessage = _ => CommitMsg(commitMessage),
+          enabledByCache = _ => true,
+          enabledByConfig = _ => true
+        )
+      }
+}
+
+object PostUpdateHookConfig {
+
+  implicit val postUpdateHooksConfiguration: Configuration =
+    Configuration.default.withDefaults
+
+  implicit val postUpdateHooksConfigCodec: Codec[PostUpdateHookConfig] =
+    deriveConfiguredCodec
+
+}
diff --git a/modules/core/src/main/scala/org/scalasteward/core/repoconfig/RepoConfig.scala b/modules/core/src/main/scala/org/scalasteward/core/repoconfig/RepoConfig.scala
index 1ce6bf00f8..4a64da56d8 100644
--- a/modules/core/src/main/scala/org/scalasteward/core/repoconfig/RepoConfig.scala
+++ b/modules/core/src/main/scala/org/scalasteward/core/repoconfig/RepoConfig.scala
@@ -28,6 +28,7 @@ final case class RepoConfig(
     pullRequests: PullRequestsConfig = PullRequestsConfig(),
     scalafmt: ScalafmtConfig = ScalafmtConfig(),
     updates: UpdatesConfig = UpdatesConfig(),
+    postUpdateHooks: List[PostUpdateHookConfig] = Nil,
     updatePullRequests: Option[PullRequestUpdateStrategy] = None,
     buildRoots: Option[List[BuildRootConfig]] = None
 ) {
@@ -68,6 +69,7 @@ object RepoConfig {
               pullRequests = x.pullRequests |+| y.pullRequests,
               scalafmt = x.scalafmt |+| y.scalafmt,
               updates = x.updates |+| y.updates,
+              postUpdateHooks = x.postUpdateHooks ++ y.postUpdateHooks,
               updatePullRequests = x.updatePullRequests.orElse(y.updatePullRequests),
               buildRoots = x.buildRoots |+| y.buildRoots
             )


Test patch:
diff --git a/modules/core/src/test/scala/org/scalasteward/core/edit/EditAlgTest.scala b/modules/core/src/test/scala/org/scalasteward/core/edit/EditAlgTest.scala
index 6e68f87a2a..395a417937 100644
--- a/modules/core/src/test/scala/org/scalasteward/core/edit/EditAlgTest.scala
+++ b/modules/core/src/test/scala/org/scalasteward/core/edit/EditAlgTest.scala
@@ -105,7 +105,9 @@ class EditAlgTest extends FunSuite {
           "VAR1=val1" :: "VAR2=val2" :: repoDir.toString :: scalafmtBinary :: opts.nonInteractive :: opts.modeChanged
         ),
         Cmd(gitStatus(repoDir)),
-        Log("Executing post-update hook for org.scalameta:scalafmt-core"),
+        Log(
+          "Executing post-update hook for org.scalameta:scalafmt-core with command 'scalafmt --non-interactive'"
+        ),
         Cmd(
           "VAR1=val1" :: "VAR2=val2" :: repoDir.toString :: scalafmtBinary :: opts.nonInteractive :: Nil
         ),
diff --git a/modules/core/src/test/scala/org/scalasteward/core/edit/hooks/HookExecutorTest.scala b/modules/core/src/test/scala/org/scalasteward/core/edit/hooks/HookExecutorTest.scala
index f0f0c707bb..e9811e8e97 100644
--- a/modules/core/src/test/scala/org/scalasteward/core/edit/hooks/HookExecutorTest.scala
+++ b/modules/core/src/test/scala/org/scalasteward/core/edit/hooks/HookExecutorTest.scala
@@ -9,7 +9,7 @@ import org.scalasteward.core.mock.MockConfig.gitCmd
 import org.scalasteward.core.mock.MockContext.context.{hookExecutor, workspaceAlg}
 import org.scalasteward.core.mock.MockState
 import org.scalasteward.core.mock.MockState.TraceEntry.{Cmd, Log}
-import org.scalasteward.core.repoconfig.{RepoConfig, ScalafmtConfig}
+import org.scalasteward.core.repoconfig.{PostUpdateHookConfig, RepoConfig, ScalafmtConfig}
 import org.scalasteward.core.scalafmt.ScalafmtAlg.opts
 import org.scalasteward.core.scalafmt.{scalafmtArtifactId, scalafmtBinary, scalafmtGroupId}
 import org.scalasteward.core.vcs.data.Repo
@@ -38,7 +38,9 @@ class HookExecutorTest extends CatsEffectSuite {
 
     val expected = initial.copy(
       trace = Vector(
-        Log("Executing post-update hook for org.scalameta:scalafmt-core"),
+        Log(
+          "Executing post-update hook for org.scalameta:scalafmt-core with command 'scalafmt --non-interactive'"
+        ),
         Cmd(
           "VAR1=val1" :: "VAR2=val2" :: repoDir.toString :: scalafmtBinary :: opts.nonInteractive :: Nil
         ),
@@ -79,7 +81,9 @@ class HookExecutorTest extends CatsEffectSuite {
 
     val expected = MockState.empty.copy(
       trace = Vector(
-        Log("Executing post-update hook for com.codecommit:sbt-github-actions"),
+        Log(
+          "Executing post-update hook for com.codecommit:sbt-github-actions with command 'sbt githubWorkflowGenerate'"
+        ),
         Cmd(
           repoDir.toString,
           "firejail",
@@ -96,4 +100,40 @@ class HookExecutorTest extends CatsEffectSuite {
 
     state.map(assertEquals(_, expected))
   }
+
+  test("hook from config") {
+    val update = ("com.random".g % "cool-lib".a % "1.0" %> "1.1").single
+    val config = RepoConfig(
+      postUpdateHooks = List(
+        PostUpdateHookConfig(
+          groupId = None,
+          artifactId = None,
+          command = "sbt mySbtCommand",
+          useSandbox = true,
+          commitMessage = "Updated with a hook!"
+        )
+      )
+    )
+    val data = RepoData(repo, dummyRepoCache, config)
+    val state = hookExecutor.execPostUpdateHooks(data, update).runS(MockState.empty)
+
+    val expected = MockState.empty.copy(
+      trace = Vector(
+        Log("Executing post-update hook for com.random:cool-lib with command 'sbt mySbtCommand'"),
+        Cmd(
+          repoDir.toString,
+          "firejail",
+          "--quiet",
+          s"--whitelist=$repoDir",
+          "--env=VAR1=val1",
+          "--env=VAR2=val2",
+          "sbt",
+          "mySbtCommand"
+        ),
+        Cmd(gitCmd(repoDir), "status", "--porcelain", "--untracked-files=no", "--ignore-submodules")
+      )
+    )
+
+    state.map(assertEquals(_, expected))
+  }
 }
diff --git a/modules/core/src/test/scala/org/scalasteward/core/repoconfig/RepoConfigAlgTest.scala b/modules/core/src/test/scala/org/scalasteward/core/repoconfig/RepoConfigAlgTest.scala
index 1024800062..eda6167307 100644
--- a/modules/core/src/test/scala/org/scalasteward/core/repoconfig/RepoConfigAlgTest.scala
+++ b/modules/core/src/test/scala/org/scalasteward/core/repoconfig/RepoConfigAlgTest.scala
@@ -10,6 +10,7 @@ import org.scalasteward.core.mock.MockState.TraceEntry.Log
 import org.scalasteward.core.mock.{MockConfig, MockState}
 import org.scalasteward.core.util.Nel
 import org.scalasteward.core.vcs.data.Repo
+
 import scala.concurrent.duration._
 
 class RepoConfigAlgTest extends FunSuite {
@@ -190,4 +191,53 @@ class RepoConfigAlgTest extends FunSuite {
       RepoConfig(updates = UpdatesConfig(ignore = List(UpdatePattern("a".g, None, None))))
     assertEquals(config, expected)
   }
+
+  test("config with postUpdateHook without group and artifact id") {
+    val content =
+      """|postUpdateHooks = [{
+         |  command = "sbt mySbtCommand"
+         |  useSandbox = false,
+         |  commitMessage = "Updated with a hook!"
+         |  }]
+         |""".stripMargin
+    val config = RepoConfigAlg.parseRepoConfig(content)
+    val expected = RepoConfig(
+      postUpdateHooks = List(
+        PostUpdateHookConfig(
+          groupId = None,
+          artifactId = None,
+          command = "sbt mySbtCommand",
+          useSandbox = false,
+          commitMessage = "Updated with a hook!"
+        )
+      )
+    )
+
+    assertEquals(config, Right(expected))
+  }
+
+  test("config with postUpdateHook with group and artifact id") {
+    val content =
+      """|postUpdateHooks = [{
+         |  groupId = "eu.timepit"
+         |  artifactId = "refined.1"
+         |  command = "sbt mySbtCommand"
+         |  useSandbox = false,
+         |  commitMessage = "Updated with a hook!"
+         |  }]
+         |""".stripMargin
+    val config = RepoConfigAlg.parseRepoConfig(content)
+    val expected = RepoConfig(
+      postUpdateHooks = List(
+        PostUpdateHookConfig(
+          groupId = Some("eu.timepit".g),
+          artifactId = Some("refined.1"),
+          command = "sbt mySbtCommand",
+          useSandbox = false,
+          commitMessage = "Updated with a hook!"
+        )
+      )
+    )
+    assertEquals(config, Right(expected))
+  }
 }