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

Issue:
Use GitHub to install Scala steward in a repo
... instead of adding it to `repos.md`. I guess this requires making some kind of GitHub app for Scala steward. The only thing I'd need from GitHub would be a list of repos that have Scala steward "installed".

Pull request:
fix https://github.com/scala-steward-org/scala-steward/issues/102

## How to use

### 1. create `GitHub App`

https://github.com/settings/apps/new

#### `GitHub App name` set some appropriate name. e.g. `scala-steward-app` ?
#### `Homepage URL`  set some appropriate url. e.g. `https://github.com/scala-steward-org` ?

![img1](https://user-images.githubusercontent.com/389787/100263364-c4e3ed80-2f90-11eb-9611-ed1952e1535b.png)

#### not require `Webhook`

![webhook](https://user-images.githubusercontent.com/389787/100263424-d6c59080-2f90-11eb-965e-c906a5bfa337.png)

#### use `Metadata` `Read-only` permission only

not require another permissions

![permission](https://user-images.githubusercontent.com/389787/100263457-e1802580-2f90-11eb-9907-dffd197bdccb.png)

#### select `Any account`

![any account](https://user-images.githubusercontent.com/389787/100263630-17bda500-2f91-11eb-93b6-b40d7e3019e0.png)

### 2. Generate and save `Private Key`

go to `https://github.com/settings/apps/{your application name}`

`Generate a private key`

![private-key](https://user-images.githubusercontent.com/389787/100263844-5d7a6d80-2f91-11eb-9fc7-8185104582d3.png)

### 3. Install

`https://github.com/apps/{your application name}/installations/new`

select some organizations and/or repositories

![install](https://user-images.githubusercontent.com/389787/100264426-2ce70380-2f92-11eb-97b0-f9231e85cd92.png)

### 4. run

`--github-app-id` is

![app-id](https://user-images.githubusercontent.com/389787/100264609-64ee4680-2f92-11eb-866e-247a29916988.png)

`--github-app-key-file` is generated private key.

`{application name}.{date}.private-key.pem`

Patch:
diff --git a/build.sbt b/build.sbt
index a256ab6bdb..650dce7b28 100644
--- a/build.sbt
+++ b/build.sbt
@@ -69,6 +69,7 @@ lazy val core = myCrossProject("core")
       compilerPlugin(Dependencies.betterMonadicFor),
       compilerPlugin(Dependencies.kindProjector.cross(CrossVersion.full)),
       Dependencies.attoCore,
+      Dependencies.bcprovJdk15to18,
       Dependencies.betterFiles,
       Dependencies.caseApp,
       Dependencies.catsCore,
@@ -88,6 +89,9 @@ lazy val core = myCrossProject("core")
       Dependencies.http4sClient,
       Dependencies.http4sCore,
       Dependencies.http4sOkhttpClient,
+      Dependencies.jjwtApi,
+      Dependencies.jjwtImpl % Runtime,
+      Dependencies.jjwtJackson % Runtime,
       Dependencies.log4catsSlf4j,
       Dependencies.monocleCore,
       Dependencies.refined,
diff --git a/modules/core/src/main/scala/org/scalasteward/core/application/Cli.scala b/modules/core/src/main/scala/org/scalasteward/core/application/Cli.scala
index f3c3987042..ee04dfdbac 100644
--- a/modules/core/src/main/scala/org/scalasteward/core/application/Cli.scala
+++ b/modules/core/src/main/scala/org/scalasteward/core/application/Cli.scala
@@ -51,7 +51,9 @@ object Cli {
       artifactMigrations: Option[File] = None,
       cacheTtl: FiniteDuration = 2.hours,
       bitbucketServerUseDefaultReviewers: Boolean = false,
-      gitlabMergeWhenPipelineSucceeds: Boolean = false
+      gitlabMergeWhenPipelineSucceeds: Boolean = false,
+      githubAppKeyFile: Option[File] = None,
+      githubAppId: Option[Long] = None
   )
 
   final case class EnvVar(name: String, value: String)
diff --git a/modules/core/src/main/scala/org/scalasteward/core/application/Config.scala b/modules/core/src/main/scala/org/scalasteward/core/application/Config.scala
index 62b7c7bab0..47f875b527 100644
--- a/modules/core/src/main/scala/org/scalasteward/core/application/Config.scala
+++ b/modules/core/src/main/scala/org/scalasteward/core/application/Config.scala
@@ -17,6 +17,7 @@
 package org.scalasteward.core.application
 
 import better.files.File
+import cats.Apply
 import cats.effect.Sync
 import org.http4s.Uri
 import org.http4s.Uri.UserInfo
@@ -25,6 +26,7 @@ import org.scalasteward.core.application.Config.{ProcessCfg, ScalafixCfg}
 import org.scalasteward.core.git.Author
 import org.scalasteward.core.util
 import org.scalasteward.core.vcs.data.AuthenticatedUser
+import org.scalasteward.core.vcs.github.GitHubApp
 import scala.concurrent.duration.FiniteDuration
 import scala.sys.process.Process
 
@@ -69,7 +71,8 @@ final case class Config(
     artifactMigrations: Option[File],
     cacheTtl: FiniteDuration,
     bitbucketServerUseDefaultReviewers: Boolean,
-    gitlabMergeWhenPipelineSucceeds: Boolean
+    gitlabMergeWhenPipelineSucceeds: Boolean,
+    githubApp: Option[GitHubApp]
 ) {
   def vcsUser[F[_]](implicit F: Sync[F]): F[AuthenticatedUser] = {
     val urlWithUser = util.uri.withUserInfo.set(UserInfo(vcsLogin, None))(vcsApiHost).renderString
@@ -128,6 +131,7 @@ object Config {
       artifactMigrations = args.artifactMigrations,
       cacheTtl = args.cacheTtl,
       bitbucketServerUseDefaultReviewers = args.bitbucketServerUseDefaultReviewers,
-      gitlabMergeWhenPipelineSucceeds = args.gitlabMergeWhenPipelineSucceeds
+      gitlabMergeWhenPipelineSucceeds = args.gitlabMergeWhenPipelineSucceeds,
+      githubApp = Apply[Option].map2(args.githubAppId, args.githubAppKeyFile)(GitHubApp)
     )
 }
diff --git a/modules/core/src/main/scala/org/scalasteward/core/application/Context.scala b/modules/core/src/main/scala/org/scalasteward/core/application/Context.scala
index c3e82a337b..90e580e80f 100644
--- a/modules/core/src/main/scala/org/scalasteward/core/application/Context.scala
+++ b/modules/core/src/main/scala/org/scalasteward/core/application/Context.scala
@@ -42,6 +42,7 @@ import org.scalasteward.core.update.{ArtifactMigrations, FilterAlg, PruningAlg,
 import org.scalasteward.core.util._
 import org.scalasteward.core.util.uri._
 import org.scalasteward.core.vcs.data.AuthenticatedUser
+import org.scalasteward.core.vcs.github.{GitHubAppApiAlg, GitHubAuthAlg}
 import org.scalasteward.core.vcs.{VCSApiAlg, VCSExtraAlg, VCSRepoAlg, VCSSelection}
 
 object Context {
@@ -70,6 +71,7 @@ object Context {
       implicit val repoConfigAlg: RepoConfigAlg[F] = new RepoConfigAlg[F](config)
       implicit val filterAlg: FilterAlg[F] = new FilterAlg[F]
       implicit val gitAlg: GitAlg[F] = GenGitAlg.create[F](config)
+      implicit val gitHubAuthAlg: GitHubAuthAlg[F] = GitHubAuthAlg.create[F]
       implicit val hookExecutor: HookExecutor[F] = new HookExecutor[F]
       implicit val httpJsonClient: HttpJsonClient[F] = new HttpJsonClient[F]
       implicit val repoCacheRepository: RepoCacheRepository[F] =
@@ -96,6 +98,7 @@ object Context {
       implicit val editAlg: EditAlg[F] = new EditAlg[F]
       implicit val nurtureAlg: NurtureAlg[F] = new NurtureAlg[F](config)
       implicit val pruningAlg: PruningAlg[F] = new PruningAlg[F]
+      implicit val gitHubAppApiAlg: GitHubAppApiAlg[F] = new GitHubAppApiAlg[F](config.vcsApiHost)
       new StewardAlg[F](config)
     }
 
diff --git a/modules/core/src/main/scala/org/scalasteward/core/application/StewardAlg.scala b/modules/core/src/main/scala/org/scalasteward/core/application/StewardAlg.scala
index a93ee69ea9..b1550567a7 100644
--- a/modules/core/src/main/scala/org/scalasteward/core/application/StewardAlg.scala
+++ b/modules/core/src/main/scala/org/scalasteward/core/application/StewardAlg.scala
@@ -31,11 +31,15 @@ import org.scalasteward.core.util
 import org.scalasteward.core.util.logger.LoggerOps
 import org.scalasteward.core.util.{BracketThrow, DateTimeAlg}
 import org.scalasteward.core.vcs.data.Repo
+import org.scalasteward.core.vcs.github.{GitHubApp, GitHubAppApiAlg, GitHubAuthAlg}
+import scala.concurrent.duration._
 
 final class StewardAlg[F[_]](config: Config)(implicit
     dateTimeAlg: DateTimeAlg[F],
     fileAlg: FileAlg[F],
     gitAlg: GitAlg[F],
+    githubAppApiAlg: GitHubAppApiAlg[F],
+    githubAuthAlg: GitHubAuthAlg[F],
     logger: Logger[F],
     nurtureAlg: NurtureAlg[F],
     pruningAlg: PruningAlg[F],
@@ -57,6 +61,26 @@ final class StewardAlg[F[_]](config: Config)(implicit
       }
     }
 
+  private def getGitHubAppRepos(githubApp: GitHubApp): Stream[F, Repo] =
+    Stream.evals {
+      for {
+        jwt <- githubAuthAlg.createJWT(githubApp, 2.minutes)
+        installations <- githubAppApiAlg.installations(jwt)
+        repositories <- installations.traverse { installation =>
+          githubAppApiAlg
+            .accessToken(jwt, installation.id)
+            .flatMap(token => githubAppApiAlg.repositories(token.token))
+        }
+      } yield repositories
+        .flatMap(_.repositories)
+        .map(repo =>
+          repo.full_name.split('/') match {
+            case Array(owner, name) =>
+              Repo(owner, name)
+          }
+        )
+    }
+
   private def steward(repo: Repo): F[Either[Throwable, Unit]] = {
     val label = s"Steward ${repo.show}"
     logger.infoTotalTime(label) {
@@ -78,7 +102,8 @@ final class StewardAlg[F[_]](config: Config)(implicit
         _ <- selfCheckAlg.checkAll
         _ <- workspaceAlg.cleanWorkspace
         exitCode <- sbtAlg.addGlobalPlugins {
-          readRepos(config.reposFile)
+          (config.githubApp.map(getGitHubAppRepos).getOrElse(Stream.empty) ++
+            readRepos(config.reposFile))
             .evalMap(steward)
             .compile
             .foldMonoid
diff --git a/modules/core/src/main/scala/org/scalasteward/core/vcs/github/GitHubApp.scala b/modules/core/src/main/scala/org/scalasteward/core/vcs/github/GitHubApp.scala
new file mode 100644
index 0000000000..73598b0b2e
--- /dev/null
+++ b/modules/core/src/main/scala/org/scalasteward/core/vcs/github/GitHubApp.scala
@@ -0,0 +1,21 @@
+/*
+ * Copyright 2018-2020 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.vcs.github
+
+import better.files.File
+
+case class GitHubApp(id: Long, keyFile: File)
diff --git a/modules/core/src/main/scala/org/scalasteward/core/vcs/github/GitHubAppApiAlg.scala b/modules/core/src/main/scala/org/scalasteward/core/vcs/github/GitHubAppApiAlg.scala
new file mode 100644
index 0000000000..bc19d1dcdf
--- /dev/null
+++ b/modules/core/src/main/scala/org/scalasteward/core/vcs/github/GitHubAppApiAlg.scala
@@ -0,0 +1,74 @@
+/*
+ * Copyright 2018-2020 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.vcs.github
+
+import cats.Applicative
+import org.http4s.{Header, Uri}
+import org.scalasteward.core.util.HttpJsonClient
+
+class GitHubAppApiAlg[F[_]: Applicative](
+    gitHubApiHost: Uri
+)(implicit
+    client: HttpJsonClient[F]
+) {
+
+  private[this] val acceptHeader =
+    Header("Accept", "application/vnd.github.v3+json")
+
+  private[this] def addHeaders(jwt: String): client.ModReq =
+    req =>
+      Applicative[F].point(
+        req.putHeaders(
+          Header("Authorization", s"Bearer $jwt"),
+          acceptHeader
+        )
+      )
+
+  /** [[https://docs.github.com/en/free-pro-team@latest/rest/reference/apps#list-installations-for-the-authenticated-app]]
+    *
+    * TODO pagination use `page` query param
+    */
+  def installations(jwt: String): F[List[InstallationOut]] =
+    client.get(
+      (gitHubApiHost / "app" / "installations").withQueryParam("per_page", 100),
+      addHeaders(jwt)
+    )
+
+  /** [[https://docs.github.com/en/free-pro-team@latest/rest/reference/apps#create-an-installation-access-token-for-an-app]] */
+  def accessToken(jwt: String, installationId: Long): F[TokenOut] =
+    client.post(
+      gitHubApiHost / "app" / "installations" / installationId.toString / "access_tokens",
+      addHeaders(jwt)
+    )
+
+  /** [[https://docs.github.com/en/free-pro-team@latest/rest/reference/apps#list-repositories-accessible-to-the-app-installation]]
+    *
+    * TODO pagination use `page` query param
+    */
+  def repositories(token: String): F[RepositoriesOut] =
+    client
+      .get(
+        (gitHubApiHost / "installation" / "repositories").withQueryParam("per_page", 100),
+        req =>
+          Applicative[F].point(
+            req.putHeaders(
+              Header("Authorization", s"token ${token}"),
+              acceptHeader
+            )
+          )
+      )
+}
diff --git a/modules/core/src/main/scala/org/scalasteward/core/vcs/github/GitHubAuthAlg.scala b/modules/core/src/main/scala/org/scalasteward/core/vcs/github/GitHubAuthAlg.scala
new file mode 100644
index 0000000000..8b8d52245e
--- /dev/null
+++ b/modules/core/src/main/scala/org/scalasteward/core/vcs/github/GitHubAuthAlg.scala
@@ -0,0 +1,77 @@
+/*
+ * Copyright 2018-2020 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.vcs.github
+
+import java.io.FileReader
+import java.security.{KeyFactory, PrivateKey, Security}
+import java.security.spec.PKCS8EncodedKeySpec
+import java.util.Date
+
+import better.files.File
+import cats.effect.Sync
+import io.jsonwebtoken.{Jwts, SignatureAlgorithm}
+import org.bouncycastle.jce.provider.BouncyCastleProvider
+import org.bouncycastle.util.io.pem.PemReader
+import scala.concurrent.duration.FiniteDuration
+import scala.util.Using
+
+trait GitHubAuthAlg[F[_]] {
+
+  /** [[https://docs.github.com/en/free-pro-team@latest/developers/apps/authenticating-with-github-apps#authenticating-as-a-github-app]] */
+  def createJWT(app: GitHubApp, ttl: FiniteDuration): F[String]
+}
+
+object GitHubAuthAlg {
+  def create[F[_]](implicit F: Sync[F]): GitHubAuthAlg[F] =
+    new GitHubAuthAlg[F] {
+      private[this] def parsePEMFile(pemFile: File): Array[Byte] =
+        Using.resource(new PemReader(new FileReader(pemFile.toJava))) { reader =>
+          reader.readPemObject().getContent
+        }
+
+      private[this] def getPrivateKey(keyBytes: Array[Byte]): PrivateKey = {
+        val kf = KeyFactory.getInstance("RSA")
+        val keySpec = new PKCS8EncodedKeySpec(keyBytes)
+        kf.generatePrivate(keySpec)
+      }
+
+      private[this] def readPrivateKey(file: File): PrivateKey = {
+        val bytes = parsePEMFile(file)
+        getPrivateKey(bytes)
+      }
+
+      /** [[https://docs.github.com/en/free-pro-team@latest/developers/apps/authenticating-with-github-apps#authenticating-as-a-github-app]] */
+      def createJWT(app: GitHubApp, ttl: FiniteDuration): F[String] = F.delay {
+        Security.addProvider(new BouncyCastleProvider())
+        val ttlMillis = ttl.toMillis
+        val nowMillis = System.currentTimeMillis()
+        val now = new Date(nowMillis)
+        val signingKey = readPrivateKey(app.keyFile)
+        val builder = Jwts
+          .builder()
+          .setIssuedAt(now)
+          .setIssuer(app.id.toString)
+          .signWith(signingKey, SignatureAlgorithm.RS256)
+        if (ttlMillis > 0) {
+          val expMillis = nowMillis + ttlMillis
+          val exp = new Date(expMillis)
+          builder.setExpiration(exp)
+        }
+        builder.compact()
+      }
+    }
+}
diff --git a/modules/core/src/main/scala/org/scalasteward/core/vcs/github/InstallationOut.scala b/modules/core/src/main/scala/org/scalasteward/core/vcs/github/InstallationOut.scala
new file mode 100644
index 0000000000..88c80019e2
--- /dev/null
+++ b/modules/core/src/main/scala/org/scalasteward/core/vcs/github/InstallationOut.scala
@@ -0,0 +1,25 @@
+/*
+ * Copyright 2018-2020 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.vcs.github
+
+import io.circe.Decoder
+import io.circe.generic.semiauto.deriveDecoder
+
+case class InstallationOut(id: Long)
+object InstallationOut {
+  implicit val installationDecoder: Decoder[InstallationOut] = deriveDecoder
+}
diff --git a/modules/core/src/main/scala/org/scalasteward/core/vcs/github/RepositoriesOut.scala b/modules/core/src/main/scala/org/scalasteward/core/vcs/github/RepositoriesOut.scala
new file mode 100644
index 0000000000..b4c9e0b4e5
--- /dev/null
+++ b/modules/core/src/main/scala/org/scalasteward/core/vcs/github/RepositoriesOut.scala
@@ -0,0 +1,30 @@
+/*
+ * Copyright 2018-2020 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.vcs.github
+
+import io.circe.Decoder
+import io.circe.generic.semiauto.deriveDecoder
+
+case class RepositoriesOut(repositories: List[Repository])
+object RepositoriesOut {
+  implicit val repositoriesDecoder: Decoder[RepositoriesOut] = deriveDecoder
+}
+
+case class Repository(full_name: String)
+object Repository {
+  implicit val repositoryDecoder: Decoder[Repository] = deriveDecoder
+}
diff --git a/modules/core/src/main/scala/org/scalasteward/core/vcs/github/TokenOut.scala b/modules/core/src/main/scala/org/scalasteward/core/vcs/github/TokenOut.scala
new file mode 100644
index 0000000000..d6504ab8b6
--- /dev/null
+++ b/modules/core/src/main/scala/org/scalasteward/core/vcs/github/TokenOut.scala
@@ -0,0 +1,25 @@
+/*
+ * Copyright 2018-2020 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.vcs.github
+
+import io.circe.Decoder
+import io.circe.generic.semiauto.deriveDecoder
+
+case class TokenOut(token: String)
+object TokenOut {
+  implicit val tokenDecoder: Decoder[TokenOut] = deriveDecoder
+}
diff --git a/project/Dependencies.scala b/project/Dependencies.scala
index 587c7191ca..0026e54f87 100644
--- a/project/Dependencies.scala
+++ b/project/Dependencies.scala
@@ -4,6 +4,7 @@ import sbt.librarymanagement.syntax.ExclusionRule
 
 object Dependencies {
   val attoCore = "org.tpolecat" %% "atto-core" % "0.8.0"
+  val bcprovJdk15to18 = "org.bouncycastle" % "bcprov-jdk15to18" % "1.67"
   val betterFiles = "com.github.pathikrit" %% "better-files" % "3.9.1"
   val betterMonadicFor = "com.olegpy" %% "better-monadic-for" % "0.3.1"
   val caseApp = "com.github.alexarchambault" %% "case-app" % "2.0.4"
@@ -32,6 +33,9 @@ object Dependencies {
   val kindProjector = "org.typelevel" % "kind-projector" % "0.11.1"
   val log4catsSlf4j = "io.chrisdavenport" %% "log4cats-slf4j" % "1.1.1"
   val logbackClassic = "ch.qos.logback" % "logback-classic" % "1.2.3"
+  val jjwtApi = "io.jsonwebtoken" % "jjwt-api" % "0.11.2"
+  val jjwtImpl = "io.jsonwebtoken" % "jjwt-impl" % jjwtApi.revision
+  val jjwtJackson = "io.jsonwebtoken" % "jjwt-jackson" % jjwtApi.revision
   val millVersion = Def.setting(if (scalaBinaryVersion.value == "2.12") "0.6.3" else "0.9.3")
   val millScalalib = Def.setting("com.lihaoyi" %% "mill-scalalib" % millVersion.value)
   val monocleCore = "com.github.julien-truffaut" %% "monocle-core" % "2.1.0"


Test patch:
diff --git a/modules/core/src/test/scala/org/scalasteward/core/application/CliTest.scala b/modules/core/src/test/scala/org/scalasteward/core/application/CliTest.scala
index b28cff00b2..bfcf073866 100644
--- a/modules/core/src/test/scala/org/scalasteward/core/application/CliTest.scala
+++ b/modules/core/src/test/scala/org/scalasteward/core/application/CliTest.scala
@@ -27,7 +27,9 @@ class CliTest extends AnyFunSuite with Matchers with EitherValues {
         List("--env-var", "i=j"),
         List("--process-timeout", "30min"),
         List("--scalafix-migrations", "/opt/scala-steward/extra-scalafix-migrations.conf"),
-        List("--artifact-migrations", "/opt/scala-steward/extra-artifact-migrations.conf")
+        List("--artifact-migrations", "/opt/scala-steward/extra-artifact-migrations.conf"),
+        List("--github-app-id", "12345678"),
+        List("--github-app-key-file", "example_app_key")
       ).flatten
     ) shouldBe Success(
       Cli.Args(
@@ -43,7 +45,9 @@ class CliTest extends AnyFunSuite with Matchers with EitherValues {
         envVar = List(EnvVar("g", "h"), EnvVar("i", "j")),
         processTimeout = 30.minutes,
         scalafixMigrations = List(uri"/opt/scala-steward/extra-scalafix-migrations.conf"),
-        artifactMigrations = Some(File("/opt/scala-steward/extra-artifact-migrations.conf"))
+        artifactMigrations = Some(File("/opt/scala-steward/extra-artifact-migrations.conf")),
+        githubAppId = Some(12345678),
+        githubAppKeyFile = Some(File("example_app_key"))
       )
     )
   }
diff --git a/modules/core/src/test/scala/org/scalasteward/core/mock/MockContext.scala b/modules/core/src/test/scala/org/scalasteward/core/mock/MockContext.scala
index b9aa3fe8a1..18a0ad8494 100644
--- a/modules/core/src/test/scala/org/scalasteward/core/mock/MockContext.scala
+++ b/modules/core/src/test/scala/org/scalasteward/core/mock/MockContext.scala
@@ -48,7 +48,9 @@ object MockContext {
           EnvVar("VAR1", "val1"),
           EnvVar("VAR2", "val2")
         ),
-        cacheTtl = 1.hour
+        cacheTtl = 1.hour,
+        githubAppId = Some(12345678),
+        githubAppKeyFile = Some(File("example_app_key"))
       )
     )