Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

support GitHub Apps #1766

Merged
merged 1 commit into from
Nov 28, 2020
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions build.sbt
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)
)
}
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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] =
Expand All @@ -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)
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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],
Expand All @@ -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) {
Expand All @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -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)
Original file line number Diff line number Diff line change
@@ -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
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

*/
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
)
)
)
}
Original file line number Diff line number Diff line change
@@ -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()
}
}
}
Original file line number Diff line number Diff line change
@@ -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
}
Original file line number Diff line number Diff line change
@@ -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
}
Original file line number Diff line number Diff line change
@@ -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
}
Loading