diff --git a/src/main/scala/kevinlee/git/Git.scala b/src/main/scala/kevinlee/git/Git.scala index 9c05264..989fa04 100644 --- a/src/main/scala/kevinlee/git/Git.scala +++ b/src/main/scala/kevinlee/git/Git.scala @@ -1,13 +1,14 @@ package kevinlee.git -import java.io.File - import cats._ import cats.data._ import cats.implicits._ - -import effectie.cats._ import effectie.cats.Effectful._ +import effectie.cats._ +import io.circe.{Decoder, Encoder} +import io.estatico.newtype.macros.newtype + +import java.io.File /** @author Kevin Lee * @since 2019-01-01 @@ -80,6 +81,14 @@ trait Git[F[_]] { } +@SuppressWarnings( + Array( + "org.wartremover.warts.ExplicitImplicitTypes", + "org.wartremover.warts.ImplicitConversion", + "org.wartremover.warts.ImplicitParameter", + "org.wartremover.warts.PublicInference", + ) +) object Git { // $COVERAGE-OFF$ @@ -89,13 +98,20 @@ object Git { type CmdResult[F[_], A] = EitherT[CmdHistoryWriter[F, *], GitCommandError, A] - final case class BranchName(value: String) extends AnyVal - final case class TagName(value: String) extends AnyVal - final case class Repository(value: String) extends AnyVal + @newtype case class BranchName(value: String) + @newtype case class TagName(value: String) + object TagName { + implicit val encoder: Encoder[TagName] = deriving + implicit val decoder: Decoder[TagName] = deriving + } + final case class Repository(value: String) extends AnyVal final case class RemoteName(remoteName: String) extends AnyVal final case class RepoUrl(repoUrl: String) extends AnyVal final case class Description(value: String) extends AnyVal + @newtype case class TagMessage(tagMessage: String) + @newtype case class HashObject(hashObject: String) + def apply[F[_]: Git]: Git[F] = implicitly[Git[F]] implicit def gitF[F[_]: EffectConstructor: Monad]: Git[F] = new GitF[F] @@ -170,15 +186,17 @@ object Git { ): CmdResult[F, A] = EitherT { val fOf = r.map { eth => - val w: CmdHistory = eth match { - case Left(error) => - List.empty[GitCmdAndResult] - case Right((cmdResult, a)) => - List(GitCmdAndResult(gitCmd, cmdResult)) - } - val eth2: Either[GitCommandError, A] = eth.map { - case (_, a) => a + val w: CmdHistory = + eth match { + case Left(error) => + List.empty[GitCmdAndResult] + case Right((cmdResult, a)) => + List(GitCmdAndResult(gitCmd, cmdResult)) } + val eth2: Either[GitCommandError, A] = eth.map { + case (_, a) => + a + } (w, eth2) } WriterT(fOf) @@ -188,7 +206,7 @@ object Git { gitCmdSimpleWithWriter[BranchName]( baseDir, GitCmd.currentBranchName, - xs => BranchName(xs.mkString.trim) + xs => BranchName(xs.mkString.trim), ) override def checkIfCurrentBranchIsSame( diff --git a/src/main/scala/kevinlee/github/OldGitHubApi.scala b/src/main/scala/kevinlee/github/OldGitHubApi.scala index 6531fbc..3019705 100644 --- a/src/main/scala/kevinlee/github/OldGitHubApi.scala +++ b/src/main/scala/kevinlee/github/OldGitHubApi.scala @@ -41,7 +41,7 @@ trait OldGitHubApi[F[_]] { tagName: TagName, changelog: Changelog, assets: Seq[File] - ): F[Either[GitHubError, GitHubRelease]] + ): F[Either[GitHubError, OldGitHubRelease]] } object OldGitHubApi { @@ -127,16 +127,16 @@ object OldGitHubApi { tagName: TagName, changelog: Changelog, assets: Seq[File] - ): F[Either[GitHubError, GitHubRelease]] = (for { + ): F[Either[GitHubError, OldGitHubRelease]] = (for { exists <- EitherT(releaseExists(gitHub, repo, tagName)) gitHubRelease <- if (exists) { - eitherTLeftPure[GitHubRelease](GitHubError.releaseAlreadyExists(tagName)) + eitherTLeftPure[OldGitHubRelease](GitHubError.releaseAlreadyExists(tagName)) } else { for { gHRepository <- EitherT(this.getRepo(gitHub, repo)) release <- EitherT(createGHRelease(gHRepository, tagName, changelog)) - } yield GitHubRelease( + } yield OldGitHubRelease( tagName, changelog, assets.map { file => diff --git a/src/main/scala/kevinlee/github/data/GitHubError.scala b/src/main/scala/kevinlee/github/data/GitHubError.scala index 7f2122b..db85318 100644 --- a/src/main/scala/kevinlee/github/data/GitHubError.scala +++ b/src/main/scala/kevinlee/github/data/GitHubError.scala @@ -1,7 +1,14 @@ package kevinlee.github.data +import cats.Show +import cats.syntax.all._ +import io.circe.parser._ +import io.circe.{Decoder, Encoder, Json} import kevinlee.git.Git.{RepoUrl, TagName} import kevinlee.git.GitCommandError +import kevinlee.http.{HttpError, HttpRequest, HttpResponse} + +import java.time.Instant /** @author Kevin Lee * @since 2019-03-09 @@ -9,16 +16,35 @@ import kevinlee.git.GitCommandError sealed trait GitHubError object GitHubError { - final case object NoCredential extends GitHubError - final case object InvalidCredential extends GitHubError - final case class MalformedURL(url: String, errorMessage: Option[String]) extends GitHubError - final case class ConnectionFailure(error: String) extends GitHubError - final case class GitHubServerError(error: String) extends GitHubError - final case class ReleaseAlreadyExists(tagName: TagName) extends GitHubError - final case class ReleaseCreationError(message: String) extends GitHubError - final case class InvalidGitHubRepoUrl(repoUrl: RepoUrl) extends GitHubError - final case class ChangelogNotFound(changelogLocation: String, tagName: TagName) extends GitHubError - final case class CausedByGitCommandError(cause: GitCommandError) extends GitHubError + final case object NoCredential extends GitHubError + final case object InvalidCredential extends GitHubError + final case class MalformedURL(url: String, errorMessage: Option[String]) extends GitHubError + final case class ConnectionFailure(error: String) extends GitHubError + final case class GitHubServerError(error: String) extends GitHubError + final case class ReleaseAlreadyExists(tagName: TagName) extends GitHubError + final case class ReleaseCreationError(message: String) extends GitHubError + final case class InvalidGitHubRepoUrl(repoUrl: RepoUrl) extends GitHubError + final case class ChangelogNotFound(changelogLocation: String, tagName: TagName) extends GitHubError + final case class CausedByGitCommandError(cause: GitCommandError) extends GitHubError + case object NoReleaseCreated extends GitHubError + final case class AbuseRateLimits(message: String, documentationUrl: String) extends GitHubError + final case class RateLimitExceeded( + // "X-RateLimit-Limit" + rateLimit: Option[Int], + // X-RateLimit-Remaining + remaining: Option[Int], + // X-RateLimit-Reset + reset: Option[Instant], + message: String, + docUrl: String, + ) extends GitHubError + final case class ForbiddenRequest(httpRequest: HttpRequest, httpResponse: HttpResponse) extends GitHubError + final case class UnprocessableEntity( + httpRequest: HttpRequest, + httpResponse: HttpResponse, + responseBodyJson: Option[ResponseBodyJson], + ) extends GitHubError + final case class UnexpectedFailure(httpError: HttpError) extends GitHubError def noCredential: GitHubError = NoCredential @@ -46,6 +72,32 @@ object GitHubError { def causedByGitCommandError(cause: GitCommandError): GitHubError = CausedByGitCommandError(cause) + def noReleaseCreated: GitHubError = NoReleaseCreated + + def abuseRateLimits(message: String, documentationUrl: String): GitHubError = + AbuseRateLimits(message, documentationUrl) + + def rateLimitExceeded( + rateLimit: Option[Int], + remaining: Option[Int], + reset: Option[Instant], + message: String, + docUrl: String, + ): GitHubError = RateLimitExceeded(rateLimit, remaining, reset, message, docUrl) + + def forbiddenRequest(httpRequest: HttpRequest, httpResponse: HttpResponse): GitHubError = + ForbiddenRequest(httpRequest, httpResponse) + + def unprocessableEntity( + httpRequest: HttpRequest, + httpResponse: HttpResponse, + responseBodyJson: Option[ResponseBodyJson], + ): GitHubError = + UnprocessableEntity(httpRequest: HttpRequest, httpResponse: HttpResponse, responseBodyJson) + + def unexpectedFailure(httpError: HttpError): GitHubError = + UnexpectedFailure(httpError) + def render(gitHubError: GitHubError): String = gitHubError match { case NoCredential => "No GitHub access credential found - Check out the document for GitHub Auth Token" @@ -79,5 +131,116 @@ object GitHubError { | ${GitCommandError.render(cause)} |""".stripMargin + case NoReleaseCreated => + "No GitHub release has been created" + + case AbuseRateLimits(message, docUrl) => + s"""$message + |For more details, visit $docUrl + |""".stripMargin + + case RateLimitExceeded(rateLimit, remaining, reset, message, docUrl) => + s"""$message + |The maximum number of requests per hour: $rateLimit + |The number of requests remaining in the current rate limit window: $remaining + |The rate limit window resets at ${reset.fold("\"No reset info provided by GitHub\"")(_.toString)} + |For more details, visit $docUrl + |""".stripMargin + + case ForbiddenRequest(httpRequest, httpResponse) => + s"""The request has been forbidden by GitHub API. + | Request: ${httpRequest.show} + |Response: ${httpResponse.show} + |""".stripMargin + + case UnprocessableEntity(httpRequest, httpResponse, responseBodyJson) => + s"""Unprocessable Entity: + |responseBody: ${responseBodyJson.fold("")(_.show)} + |--- + |Request: ${httpRequest.show} + |Response: ${httpResponse.show} + |""".stripMargin + + case UnexpectedFailure(httpError) => + s"""Unexpected failure: + |${httpError.show} + |""".stripMargin + } + + final case class ResponseBodyJson(message: String, documentationUrl: Option[String]) + object ResponseBodyJson { + implicit val encoder: Encoder[ResponseBodyJson] = + responseBodyJson => + Json.obj( + (List("message" -> Json.fromString(responseBodyJson.message)) ++ + responseBodyJson + .documentationUrl + .toList + .map(documentationUrl => "documentation_url" -> Json.fromString(documentationUrl))): _* + ) + + implicit val decoder: Decoder[ResponseBodyJson] = + c => + for { + message <- c.downField("message").as[String] + documentationUrl <- c.downField("documentation_url").as[Option[String]] + } yield ResponseBodyJson(message, documentationUrl) + + implicit val show: Show[ResponseBodyJson] = encoder.apply(_).spaces2 + } + + def fromHttpError(httpError: HttpError): GitHubError = httpError match { + case HttpError.Forbidden(httpRequest, httpResponse @ HttpResponse(_, headers, Some(body))) => + decode[ResponseBodyJson](body.body) match { + case Right(ResponseBodyJson(message, Some(docUrl))) => + if ( + message.contains("You have triggered an abuse detection mechanism") || + docUrl.contains("abuse-rate-limits") + ) { + GitHubError.abuseRateLimits(message, docUrl) + } else if ( + message.contains("API rate limit exceeded") || + docUrl.contains("rate-limiting") + ) { + val rateLimit = httpResponse.findHeaderValueByName(_.equalsIgnoreCase("X-RateLimit-Limit")).map(_.toInt) + val remaining = httpResponse.findHeaderValueByName(_.equalsIgnoreCase("X-RateLimit-Remaining")).map(_.toInt) + val reset = httpResponse + .findHeaderValueByName(_.equalsIgnoreCase("X-RateLimit-Reset")) + .map(x => Instant.ofEpochSecond(x.toLong)) + + GitHubError.rateLimitExceeded( + rateLimit, + remaining, + reset, + message, + docUrl, + ) + } else { + GitHubError.forbiddenRequest(httpRequest, httpResponse) + } + case Right(ResponseBodyJson(message, None)) => + GitHubError.forbiddenRequest(httpRequest, httpResponse) + + case Left(_) => + GitHubError.forbiddenRequest(httpRequest, httpResponse) + } + + case HttpError.UnprocessableEntity(request, response) => + val responseBodyJson = response + .body + .flatMap(body => + decode[ResponseBodyJson](body.body) match { + case Right(responseBodyJson) => + responseBodyJson.some + case Left(err) => + none[ResponseBodyJson] + } + ) + GitHubError.unprocessableEntity(request, response, responseBodyJson) + + case error => + GitHubError.unexpectedFailure(error) + } + } diff --git a/src/main/scala/kevinlee/github/data/github.scala b/src/main/scala/kevinlee/github/data/github.scala index bcf4068..dbc3ba8 100644 --- a/src/main/scala/kevinlee/github/data/github.scala +++ b/src/main/scala/kevinlee/github/data/github.scala @@ -1,19 +1,19 @@ package kevinlee.github.data -import java.io.File +import io.estatico.newtype.macros.newtype +import java.io.File import kevinlee.git.Git.TagName import org.kohsuke.github.GHRelease -/** - * @author Kevin Lee +/** @author Kevin Lee * @since 2019-03-09 */ final case class OAuthToken(token: String) extends AnyVal { override def toString: String = "***Protected***" } -final case class RepoOrg(org: String) extends AnyVal +final case class RepoOrg(org: String) extends AnyVal final case class RepoName(name: String) extends AnyVal final case class Repo(repoOrg: RepoOrg, repoName: RepoName) @@ -21,12 +21,45 @@ object Repo { def repoNameString(repo: Repo): String = s"${repo.repoOrg.org}/${repo.repoName.name}" } -final case class Changelog(changelog: String) extends AnyVal +final case class Changelog(changelog: String) extends AnyVal final case class ChangelogLocation(changeLogLocation: String) extends AnyVal -final case class GitHubRelease( - tagName: TagName -, changelog: Changelog -, releasedFiles: Seq[File] -, gHRelease: GHRelease +final case class OldGitHubRelease( + tagName: TagName, + changelog: Changelog, + releasedFiles: Seq[File], + gHRelease: GHRelease, +) + +final case class GitHubRepo( + org: GitHubRepo.Org, + repo: GitHubRepo.Repo, ) + +@SuppressWarnings( + Array( + "org.wartremover.warts.ExplicitImplicitTypes", + "org.wartremover.warts.ImplicitConversion", + "org.wartremover.warts.ImplicitParameter", + "org.wartremover.warts.PublicInference", + ) +) +object GitHubRepo { + + @newtype case class Org(org: String) + @newtype case class Repo(repo: String) + +} + +final case class GitHubRepoWithAuth( + gitHubRepo: GitHubRepo, + accessToken: Option[GitHubRepoWithAuth.AccessToken], +) + +object GitHubRepoWithAuth { + + final case class AccessToken(accessToken: String) { + override val toString: String = "***Protected***" + } + +}