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

Issue:
sbt-github-actions: run githubWorkflowGenerate after build.sbt changes
Follow-up to https://github.com/scala-steward-org/scala-steward/issues/1745.

Some changes in build.sbt may cause a change to the workflow files, e.g. a Scala version upgrade.

In these situations, Steward could regenerate the YAML configs as well. The exact keys that can influence that result might be hard to detect, so I guess it could run automatically on every change to `.sbt` / `.scala` build files if there's `sbt-github-actions` in the build.

Pull request description:
Example PR: https://github.com/scala-steward-org/test-repo-2/pull/25

Closes #1984.

Patch:
diff --git a/modules/core/src/main/scala/org/scalasteward/core/data/package.scala b/modules/core/src/main/scala/org/scalasteward/core/data/package.scala
new file mode 100644
index 0000000000..e309f37efd
--- /dev/null
+++ b/modules/core/src/main/scala/org/scalasteward/core/data/package.scala
@@ -0,0 +1,31 @@
+/*
+ * 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
+
+package object data {
+  val scalaLangGroupId: GroupId = GroupId("org.scala-lang")
+
+  val scalaLangModules: List[(GroupId, ArtifactId)] =
+    List(
+      (scalaLangGroupId, ArtifactId("scala-compiler")),
+      (scalaLangGroupId, ArtifactId("scala-library")),
+      (scalaLangGroupId, ArtifactId("scala-reflect")),
+      (scalaLangGroupId, ArtifactId("scalap")),
+      (scalaLangGroupId, ArtifactId("scala3-compiler")),
+      (scalaLangGroupId, ArtifactId("scala3-library"))
+    )
+}
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 8022bd8b03..832fd8ee00 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
@@ -20,18 +20,23 @@ import better.files.File
 import cats.effect.MonadThrow
 import cats.syntax.all._
 import fs2.Stream
-import org.typelevel.log4cats.Logger
 import org.scalasteward.core.buildtool.BuildToolDispatcher
+import org.scalasteward.core.data.RepoData
 import org.scalasteward.core.data.Update
 import org.scalasteward.core.edit.hooks.HookExecutor
+import org.scalasteward.core.edit.scalafix.ScalafixMigration
+import org.scalasteward.core.edit.scalafix.ScalafixMigrationsFinder
 import org.scalasteward.core.git
-import org.scalasteward.core.git.{Commit, GitAlg}
-import org.scalasteward.core.io.{isSourceFile, FileAlg, WorkspaceAlg}
+import org.scalasteward.core.git.Commit
+import org.scalasteward.core.git.GitAlg
+import org.scalasteward.core.io.FileAlg
+import org.scalasteward.core.io.WorkspaceAlg
+import org.scalasteward.core.io.isSourceFile
 import org.scalasteward.core.repoconfig.RepoConfig
-import org.scalasteward.core.edit.scalafix.{ScalafixMigration, ScalafixMigrationsFinder}
 import org.scalasteward.core.util._
 import org.scalasteward.core.util.logger._
 import org.scalasteward.core.vcs.data.Repo
+import org.typelevel.log4cats.Logger
 
 final class EditAlg[F[_]](implicit
     buildToolDispatcher: BuildToolDispatcher[F],
@@ -44,13 +49,8 @@ final class EditAlg[F[_]](implicit
     workspaceAlg: WorkspaceAlg[F],
     F: MonadThrow[F]
 ) {
-  def applyUpdate(
-      repo: Repo,
-      repoConfig: RepoConfig,
-      update: Update,
-      preCommit: F[Unit] = F.unit
-  ): F[List[Commit]] =
-    findFilesContainingCurrentVersion(repo, repoConfig, update).flatMap {
+  def applyUpdate(data: RepoData, update: Update, preCommit: F[Unit] = F.unit): F[List[Commit]] =
+    findFilesContainingCurrentVersion(data.repo, data.config, update).flatMap {
       case None =>
         logger.warn("No files found that contain the current version").as(Nil)
       case Some(files) =>
@@ -59,6 +59,7 @@ final class EditAlg[F[_]](implicit
           case true =>
             for {
               _ <- preCommit
+              repo = data.repo
               migrations = scalafixMigrationsFinder.findMigrations(update)
               cs1 <-
                 if (migrations.isEmpty) F.pure(Nil)
@@ -66,8 +67,8 @@ final class EditAlg[F[_]](implicit
                   gitAlg.discardChanges(repo) *>
                     runScalafixMigrations(repo, migrations) <*
                     bumpVersion(update, files)
-              cs2 <- gitAlg.commitAllIfDirty(repo, git.commitMsgFor(update, repoConfig.commits))
-              cs3 <- hookExecutor.execPostUpdateHooks(repo, repoConfig, update)
+              cs2 <- gitAlg.commitAllIfDirty(repo, git.commitMsgFor(update, data.config.commits))
+              cs3 <- hookExecutor.execPostUpdateHooks(data, update)
             } yield cs1 ++ cs2 ++ cs3
         }
     }
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 7d938064c4..6cda0d524b 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
@@ -18,10 +18,10 @@ package org.scalasteward.core.edit.hooks
 
 import cats.MonadThrow
 import cats.syntax.all._
-import org.scalasteward.core.data.{ArtifactId, GroupId, Update}
+import org.scalasteward.core.data._
 import org.scalasteward.core.git.{Commit, GitAlg}
 import org.scalasteward.core.io.{ProcessAlg, WorkspaceAlg}
-import org.scalasteward.core.repoconfig.RepoConfig
+import org.scalasteward.core.repocache.RepoCache
 import org.scalasteward.core.scalafmt.{scalafmtArtifactId, scalafmtBinary, scalafmtGroupId}
 import org.scalasteward.core.util.Nel
 import org.scalasteward.core.util.logger._
@@ -35,14 +35,16 @@ final class HookExecutor[F[_]](implicit
     workspaceAlg: WorkspaceAlg[F],
     F: MonadThrow[F]
 ) {
-  def execPostUpdateHooks(repo: Repo, repoConfig: RepoConfig, update: Update): F[List[Commit]] =
+  def execPostUpdateHooks(data: RepoData, update: Update): F[List[Commit]] =
     HookExecutor.postUpdateHooks
       .filter { hook =>
-        hook.enabledByConfig(repoConfig) &&
+        hook.enabledByCache(data.cache) &&
+        hook.enabledByConfig(data.config) &&
         update.groupId === hook.groupId &&
         update.artifactIds.exists(_.name === hook.artifactId.name)
       }
-      .flatTraverse(execPostUpdateHook(repo, update, _))
+      .distinctBy(_.command)
+      .flatTraverse(execPostUpdateHook(data.repo, update, _))
 
   private def execPostUpdateHook(
       repo: Repo,
@@ -60,8 +62,7 @@ final class HookExecutor[F[_]](implicit
 }
 
 object HookExecutor {
-
-  private[this] val knownGitHubActions = List(
+  private val sbtGitHubActionsModules = List(
     (GroupId("com.codecommit"), ArtifactId("sbt-github-actions")),
     (GroupId("com.codecommit"), ArtifactId("sbt-spiewak")),
     (GroupId("com.codecommit"), ArtifactId("sbt-spiewak-sonatype")),
@@ -69,27 +70,41 @@ object HookExecutor {
     (GroupId("org.http4s"), ArtifactId("sbt-http4s-org"))
   )
 
-  val postUpdateHooks: List[PostUpdateHook] = {
-    val actionsActions = knownGitHubActions.map { case (gid, aid) =>
-      PostUpdateHook(
-        groupId = gid,
-        artifactId = aid,
-        command = Nel.of("sbt", "githubWorkflowGenerate"),
-        useSandbox = true,
-        commitMessage = _ => "Regenerate workflow with sbt-github-actions",
-        enabledByConfig = _ => true
-      )
-    }
+  private def dependsOnSbtGithubActions(cache: RepoCache): Boolean =
+    cache.dependencyInfos.exists(_.value.exists { info =>
+      val module = (info.dependency.groupId, info.dependency.artifactId)
+      sbtGitHubActionsModules.contains(module)
+    })
 
-    actionsActions ++ List(
-      PostUpdateHook(
-        groupId = scalafmtGroupId,
-        artifactId = scalafmtArtifactId,
-        command = Nel.of(scalafmtBinary, "--non-interactive"),
-        useSandbox = false,
-        commitMessage = update => s"Reformat with scalafmt ${update.nextVersion}",
-        enabledByConfig = _.scalafmt.runAfterUpgradingOrDefault
-      )
+  private def sbtGithubActionsHook(
+      groupId: GroupId,
+      artifactId: ArtifactId,
+      enabledByCache: RepoCache => Boolean
+  ): PostUpdateHook =
+    PostUpdateHook(
+      groupId = groupId,
+      artifactId = artifactId,
+      command = Nel.of("sbt", "githubWorkflowGenerate"),
+      useSandbox = true,
+      commitMessage = _ => "Regenerate workflow with sbt-github-actions",
+      enabledByCache = enabledByCache,
+      enabledByConfig = _ => true
     )
-  }
+
+  val postUpdateHooks: List[PostUpdateHook] =
+    PostUpdateHook(
+      groupId = scalafmtGroupId,
+      artifactId = scalafmtArtifactId,
+      command = Nel.of(scalafmtBinary, "--non-interactive"),
+      useSandbox = false,
+      commitMessage = update => s"Reformat with scalafmt ${update.nextVersion}",
+      enabledByCache = _ => true,
+      enabledByConfig = _.scalafmt.runAfterUpgradingOrDefault
+    ) ::
+      sbtGitHubActionsModules.map { case (gid, aid) =>
+        sbtGithubActionsHook(gid, aid, _ => true)
+      } ++
+      scalaLangModules.map { case (gid, aid) =>
+        sbtGithubActionsHook(gid, aid, dependsOnSbtGithubActions)
+      }
 }
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 9ebcf84dc6..a8efb90c13 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
@@ -17,6 +17,7 @@
 package org.scalasteward.core.edit.hooks
 
 import org.scalasteward.core.data.{ArtifactId, GroupId, Update}
+import org.scalasteward.core.repocache.RepoCache
 import org.scalasteward.core.repoconfig.RepoConfig
 import org.scalasteward.core.util.Nel
 
@@ -26,5 +27,6 @@ final case class PostUpdateHook(
     command: Nel[String],
     useSandbox: Boolean,
     commitMessage: Update => String,
+    enabledByCache: RepoCache => Boolean,
     enabledByConfig: RepoConfig => Boolean
 )
diff --git a/modules/core/src/main/scala/org/scalasteward/core/nurture/NurtureAlg.scala b/modules/core/src/main/scala/org/scalasteward/core/nurture/NurtureAlg.scala
index 1d0f61b134..1ef56fe630 100644
--- a/modules/core/src/main/scala/org/scalasteward/core/nurture/NurtureAlg.scala
+++ b/modules/core/src/main/scala/org/scalasteward/core/nurture/NurtureAlg.scala
@@ -150,10 +150,9 @@ final class NurtureAlg[F[_]](config: Config)(implicit
     gitAlg.returnToCurrentBranch(data.repo) {
       val createBranch = logger.info(s"Create branch ${data.updateBranch.name}") >>
         gitAlg.createBranch(data.repo, data.updateBranch)
-      editAlg.applyUpdate(data.repo, data.repoConfig, data.update, createBranch).flatMap {
-        editCommits =>
-          if (editCommits.isEmpty) logger.warn("No commits created").as(Ignored)
-          else pushCommits(data, editCommits) >> createPullRequest(data)
+      editAlg.applyUpdate(data.repoData, data.update, createBranch).flatMap { editCommits =>
+        if (editCommits.isEmpty) logger.warn("No commits created").as(Ignored)
+        else pushCommits(data, editCommits) >> createPullRequest(data)
       }
     }
 
@@ -240,7 +239,7 @@ final class NurtureAlg[F[_]](config: Config)(implicit
         s"Merge branch ${data.baseBranch.name} into ${data.updateBranch.name} and apply again"
       )
       maybeMergeCommit <- gitAlg.mergeTheirs(data.repo, data.baseBranch)
-      editCommits <- editAlg.applyUpdate(data.repo, data.repoConfig, data.update)
+      editCommits <- editAlg.applyUpdate(data.repoData, data.update)
       result <- pushCommits(data, maybeMergeCommit.toList ++ editCommits)
     } yield result
 }
diff --git a/modules/core/src/main/scala/org/scalasteward/core/update/FilterAlg.scala b/modules/core/src/main/scala/org/scalasteward/core/update/FilterAlg.scala
index f171b92e1a..a69616204d 100644
--- a/modules/core/src/main/scala/org/scalasteward/core/update/FilterAlg.scala
+++ b/modules/core/src/main/scala/org/scalasteward/core/update/FilterAlg.scala
@@ -74,13 +74,8 @@ object FilterAlg {
       .flatMap(checkVersionOrdering)
 
   def isScalaDependency(dependency: Dependency): Boolean =
-    (dependency.groupId.value, dependency.artifactId.name) match {
-      case ("org.scala-lang", "scala-compiler") => true
-      case ("org.scala-lang", "scala-library")  => true
-      case ("org.scala-lang", "scala-reflect")  => true
-      case ("org.scala-lang", "scalap")         => true
-      case ("org.typelevel", "scala-library")   => true
-      case _                                    => false
+    scalaLangModules.exists { case (groupId, artifactId) =>
+      groupId === dependency.groupId && artifactId.name === dependency.artifactId.name
     }
 
   def isScalaDependencyIgnored(dependency: Dependency, ignoreScalaDependency: Boolean): Boolean =


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 59b1e90233..5b1ed83d71 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
@@ -1,8 +1,9 @@
 package org.scalasteward.core.edit
 
 import munit.FunSuite
+import org.scalasteward.core.TestInstances.dummyRepoCache
 import org.scalasteward.core.TestSyntax._
-import org.scalasteward.core.data.{GroupId, Update}
+import org.scalasteward.core.data.{GroupId, RepoData, Update}
 import org.scalasteward.core.mock.MockContext.context.editAlg
 import org.scalasteward.core.mock.MockContext.{config, envVars}
 import org.scalasteward.core.mock.MockState
@@ -18,6 +19,7 @@ class EditAlgTest extends FunSuite {
 
   test("applyUpdate") {
     val repo = Repo("edit-alg", "test-1")
+    val data = RepoData(repo, dummyRepoCache, RepoConfig.empty)
     val repoDir = config.workspace / repo.show
     val update = Update.Single("org.typelevel" % "cats-core" % "1.2.0", Nel.of("1.3.0"))
     val file1 = repoDir / "build.sbt"
@@ -25,7 +27,7 @@ class EditAlgTest extends FunSuite {
 
     val state = MockState.empty
       .addFiles(file1 -> """val catsVersion = "1.2.0"""", file2 -> "")
-      .flatMap(editAlg.applyUpdate(repo, RepoConfig.empty, update).runS)
+      .flatMap(editAlg.applyUpdate(data, update).runS)
       .unsafeRunSync()
 
     val expected = MockState.empty.copy(
@@ -53,6 +55,7 @@ class EditAlgTest extends FunSuite {
 
   test("applyUpdate with scalafmt update") {
     val repo = Repo("edit-alg", "test-2")
+    val data = RepoData(repo, dummyRepoCache, RepoConfig.empty)
     val repoDir = config.workspace / repo.show
     val update = Update.Single("org.scalameta" % "scalafmt-core" % "2.0.0", Nel.of("2.1.0"))
     val scalafmtConf = repoDir / ".scalafmt.conf"
@@ -64,7 +67,7 @@ class EditAlgTest extends FunSuite {
 
     val state = MockState.empty
       .addFiles(scalafmtConf -> scalafmtConfContent, buildSbt -> "")
-      .flatMap(editAlg.applyUpdate(repo, RepoConfig.empty, update).runS)
+      .flatMap(editAlg.applyUpdate(data, update).runS)
       .unsafeRunSync()
 
     val expected = MockState.empty.copy(
@@ -110,6 +113,7 @@ class EditAlgTest extends FunSuite {
 
   test("apply update to ammonite file") {
     val repo = Repo("edit-alg", "test-3")
+    val data = RepoData(repo, dummyRepoCache, RepoConfig.empty)
     val repoDir = config.workspace / repo.show
     val update = Update.Single("org.typelevel" % "cats-core" % "1.2.0", Nel.of("1.3.0"))
     val file1 = repoDir / "script.sc"
@@ -120,7 +124,7 @@ class EditAlgTest extends FunSuite {
         file1 -> """import $ivy.`org.typelevel::cats-core:1.2.0`, cats.implicits._"""",
         file2 -> """"org.typelevel" %% "cats-core" % "1.2.0""""
       )
-      .flatMap(editAlg.applyUpdate(repo, RepoConfig.empty, update).runS)
+      .flatMap(editAlg.applyUpdate(data, update).runS)
       .unsafeRunSync()
 
     val expected = MockState.empty.copy(
@@ -251,11 +255,12 @@ class EditAlgTest extends FunSuite {
       update: Update,
       files: Map[String, String]
   ): Map[String, String] = {
+    val data = RepoData(repo, dummyRepoCache, RepoConfig.empty)
     val repoDir = config.workspace / repo.show
     val filesInRepoDir = files.map { case (file, content) => repoDir / file -> content }
     MockState.empty
       .addFiles(filesInRepoDir.toSeq: _*)
-      .flatMap(editAlg.applyUpdate(repo, RepoConfig.empty, update).runS)
+      .flatMap(editAlg.applyUpdate(data, update).runS)
       .map(_.files)
       .unsafeRunSync()
       .map { case (file, content) => file.toString.replace(repoDir.toString + "/", "") -> content }
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 be3c5bc5d1..fc4e270c79 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
@@ -1,8 +1,9 @@
 package org.scalasteward.core.edit.hooks
 
 import munit.FunSuite
+import org.scalasteward.core.TestInstances.dummyRepoCache
 import org.scalasteward.core.TestSyntax._
-import org.scalasteward.core.data.Update
+import org.scalasteward.core.data.{RepoData, Update}
 import org.scalasteward.core.mock.MockContext.context.{hookExecutor, workspaceAlg}
 import org.scalasteward.core.mock.MockContext.envVars
 import org.scalasteward.core.mock.MockState
@@ -14,14 +15,13 @@ import org.scalasteward.core.vcs.data.Repo
 
 class HookExecutorTest extends FunSuite {
   private val repo = Repo("scala-steward-org", "scala-steward")
+  private val data = RepoData(repo, dummyRepoCache, RepoConfig.empty)
   private val repoDir = workspaceAlg.repoDir(repo).runA(MockState.empty).unsafeRunSync()
 
   test("no hook") {
+
     val update = Update.Single("org.typelevel" % "cats-core" % "1.2.0", Nel.of("1.3.0"))
-    val state = hookExecutor
-      .execPostUpdateHooks(repo, RepoConfig.empty, update)
-      .runS(MockState.empty)
-      .unsafeRunSync()
+    val state = hookExecutor.execPostUpdateHooks(data, update).runS(MockState.empty).unsafeRunSync()
 
     assertEquals(state, MockState.empty)
   }
@@ -35,10 +35,7 @@ class HookExecutorTest extends FunSuite {
           List("build.sbt")
       )
     )
-    val state = hookExecutor
-      .execPostUpdateHooks(repo, RepoConfig.empty, update)
-      .runS(initial)
-      .unsafeRunSync()
+    val state = hookExecutor.execPostUpdateHooks(data, update).runS(initial).unsafeRunSync()
 
     val expected = initial.copy(
       trace = Vector(
@@ -72,22 +69,17 @@ class HookExecutorTest extends FunSuite {
   test("scalafmt: disabled by config") {
     val repoConfig =
       RepoConfig.empty.copy(scalafmt = ScalafmtConfig(runAfterUpgrading = Some(false)))
+    val data = RepoData(repo, dummyRepoCache, repoConfig)
     val update =
       Update.Single(scalafmtGroupId.value % scalafmtArtifactId % "2.7.4", Nel.of("2.7.5"))
-    val state = hookExecutor
-      .execPostUpdateHooks(repo, repoConfig, update)
-      .runS(MockState.empty)
-      .unsafeRunSync()
+    val state = hookExecutor.execPostUpdateHooks(data, update).runS(MockState.empty).unsafeRunSync()
 
     assertEquals(state, MockState.empty)
   }
 
   test("sbt-github-actions") {
     val update = Update.Single("com.codecommit" % "sbt-github-actions" % "0.9.4", Nel.of("0.9.5"))
-    val state = hookExecutor
-      .execPostUpdateHooks(repo, RepoConfig.empty, update)
-      .runS(MockState.empty)
-      .unsafeRunSync()
+    val state = hookExecutor.execPostUpdateHooks(data, update).runS(MockState.empty).unsafeRunSync()
 
     val expected = MockState.empty.copy(
       trace = Vector(