Skip to content

Commit

Permalink
support GitHub Apps
Browse files Browse the repository at this point in the history
  • Loading branch information
xuwei-k committed Nov 28, 2020
1 parent 8274524 commit b2743f7
Show file tree
Hide file tree
Showing 16 changed files with 280 additions and 8 deletions.
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
@@ -0,0 +1 @@
null
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
null
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
import org.scalasteward.core.vcs.{VCSApiAlg, VCSExtraAlg, VCSRepoAlg, VCSSelection}

object Context {
Expand Down Expand Up @@ -96,6 +97,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,6 +31,9 @@ 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}
import org.scalasteward.core.vcs.github
import scala.concurrent.duration._

final class StewardAlg[F[_]](config: Config)(implicit
dateTimeAlg: DateTimeAlg[F],
Expand All @@ -44,7 +47,8 @@ final class StewardAlg[F[_]](config: Config)(implicit
selfCheckAlg: SelfCheckAlg[F],
streamCompiler: Stream.Compiler[F, F],
workspaceAlg: WorkspaceAlg[F],
F: BracketThrow[F]
F: BracketThrow[F],
githubAppApiAlg: GitHubAppApiAlg[F]
) {
private def readRepos(reposFile: File): Stream[F, Repo] =
Stream.evals {
Expand All @@ -57,6 +61,26 @@ final class StewardAlg[F[_]](config: Config)(implicit
}
}

private def getGitHubAppRepos(githubApp: GitHubApp): Stream[F, Repo] =
Stream.evals {
val jwt = github.authentication.createJWT(githubApp, 2.minutes)
for {
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
*/
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,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
}
Original file line number Diff line number Diff line change
Expand Up @@ -16,13 +16,61 @@

package org.scalasteward.core.vcs.github

import java.io.FileReader
import java.security.spec.PKCS8EncodedKeySpec
import java.security.{KeyFactory, PrivateKey, Security}
import java.util.Date

import better.files.File
import cats.Applicative
import cats.syntax.all._
import io.jsonwebtoken.{Jwts, SignatureAlgorithm}
import org.bouncycastle.jce.provider.BouncyCastleProvider
import org.bouncycastle.util.io.pem.PemReader
import org.http4s.headers.Authorization
import org.http4s.{BasicCredentials, Request}
import org.scalasteward.core.vcs.data.AuthenticatedUser
import scala.concurrent.duration.Duration
import scala.util.Using

object authentication {
def addCredentials[F[_]: Applicative](user: AuthenticatedUser): Request[F] => F[Request[F]] =
_.putHeaders(Authorization(BasicCredentials(user.login, user.accessToken))).pure[F]

Security.addProvider(new BouncyCastleProvider())

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: Duration): String = {
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()
}
}
Loading

0 comments on commit b2743f7

Please sign in to comment.