Skip to content

Commit

Permalink
Support checking if a user is a collaborator (#530)
Browse files Browse the repository at this point in the history
  • Loading branch information
zachkirlew authored Jul 23, 2020
1 parent 9d6c894 commit f125d6a
Show file tree
Hide file tree
Showing 7 changed files with 189 additions and 12 deletions.
21 changes: 21 additions & 0 deletions github4s/src/main/scala/github4s/algebras/Repositories.scala
Original file line number Diff line number Diff line change
Expand Up @@ -271,6 +271,27 @@ trait Repositories[F[_]] {
headers: Map[String, String] = Map()
): F[GHResponse[List[User]]]

/**
* Get whether a user is a repository collaborator
*
* For organization-owned repositories, the list of collaborators includes outside collaborators,
* organization members that are direct collaborators, organization members with access through team memberships,
* organization members with access through default organization permissions, and organization owners.
*
*
* @param owner of the repo
* @param repo name of the repo
* @param username Github username
* @param headers optional user headers to include in the request
* @return a Boolean GHResponse
*/
def userIsCollaborator(
owner: String,
repo: String,
username: String,
headers: Map[String, String] = Map()
): F[GHResponse[Boolean]]

/**
* Get the repository permission of a collaborator
*
Expand Down
43 changes: 36 additions & 7 deletions github4s/src/main/scala/github4s/http/HttpClient.scala
Original file line number Diff line number Diff line change
Expand Up @@ -17,19 +17,19 @@
package github4s.http

import cats.data.EitherT
import cats.effect.Sync
import cats.effect.{Resource, Sync}
import cats.instances.string._
import cats.syntax.either._
import cats.syntax.functor._
import github4s._
import github4s.GHError._
import github4s._
import github4s.domain.Pagination
import github4s.http.Http4sSyntax._
import io.circe.{Decoder, Encoder}
import org.http4s.{EntityDecoder, Request, Response, Status}
import org.http4s.client.Client
import org.http4s.circe.CirceEntityDecoder._
import org.http4s.circe.jsonOf
import org.http4s.client.Client
import org.http4s.{EntityDecoder, Request, Response, Status}

class HttpClient[F[_]: Sync](client: Client[F], val config: GithubConfig) {
import HttpClient._
Expand All @@ -52,6 +52,15 @@ class HttpClient[F[_]: Sync](client: Client[F], val config: GithubConfig) {
)
)

def getWithoutResponse(
accessToken: Option[String] = None,
url: String,
headers: Map[String, String] = Map.empty
): F[GHResponse[Unit]] =
runWithoutResponse[Unit](
RequestBuilder(buildURL(url)).withHeaders(headers).withAuth(accessToken)
)

def patch[Req: Encoder, Res: Decoder](
accessToken: Option[String] = None,
method: String,
Expand Down Expand Up @@ -145,6 +154,21 @@ class HttpClient[F[_]: Sync](client: Client[F], val config: GithubConfig) {
private def buildURL(method: String): String = s"${config.baseUrl}$method"

private def run[Req: Encoder, Res: Decoder](request: RequestBuilder[Req]): F[GHResponse[Res]] =
runRequest(request)
.use { response =>
buildResponse(response).map(GHResponse(_, response.status.code, response.headers.toMap))
}

private def runWithoutResponse[Req: Encoder](request: RequestBuilder[Req]): F[GHResponse[Unit]] =
runRequest(
request
).use { response =>
buildResponseFromEmpty(response).map(
GHResponse(_, response.status.code, response.headers.toMap)
)
}

private def runRequest[Req: Encoder](request: RequestBuilder[Req]): Resource[F, Response[F]] =
client
.run(
Request[F]()
Expand All @@ -153,9 +177,6 @@ class HttpClient[F[_]: Sync](client: Client[F], val config: GithubConfig) {
.withHeaders((config.toHeaderList ++ request.toHeaderList): _*)
.withJsonBody(request.data)
)
.use { response =>
buildResponse(response).map(GHResponse(_, response.status.code, response.headers.toMap))
}
}

object HttpClient {
Expand Down Expand Up @@ -187,6 +208,14 @@ object HttpClient {
_.leftMap[GHError](identity)
)

private[github4s] def buildResponseFromEmpty[F[_]: Sync](
response: Response[F]
): F[Either[GHError, Unit]] =
response.status.code match {
case i if Status(i).isSuccess => Sync[F].pure(().asRight)
case _ => buildResponse[F, Unit](response)
}

private def responseBody[F[_]: Sync](response: Response[F]): F[String] =
response.bodyText.compile.foldMonoid
}
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,9 @@

package github4s.interpreters

import cats.Functor
import cats.data.NonEmptyList
import cats.syntax.functor._
import com.github.marklister.base64.Base64._
import github4s.Decoders._
import github4s.Encoders._
Expand All @@ -25,8 +27,10 @@ import github4s.algebras.Repositories
import github4s.domain._
import github4s.http.HttpClient

class RepositoriesInterpreter[F[_]](implicit client: HttpClient[F], accessToken: Option[String])
extends Repositories[F] {
class RepositoriesInterpreter[F[_]: Functor](implicit
client: HttpClient[F],
accessToken: Option[String]
) extends Repositories[F] {
override def get(
owner: String,
repo: String,
Expand Down Expand Up @@ -217,6 +221,20 @@ class RepositoriesInterpreter[F[_]](implicit client: HttpClient[F], accessToken:
pagination
)

override def userIsCollaborator(
owner: String,
repo: String,
username: String,
headers: Map[String, String]
): F[GHResponse[Boolean]] =
client
.getWithoutResponse(
accessToken,
s"repos/$owner/$repo/collaborators/$username",
headers
)
.map(handleIsCollaboratorResponse)

override def getRepoPermissionForUser(
owner: String,
repo: String,
Expand Down Expand Up @@ -323,4 +341,13 @@ class RepositoriesInterpreter[F[_]](implicit client: HttpClient[F], accessToken:
headers,
NewStatusRequest(state, target_url, description, context)
)

private def handleIsCollaboratorResponse(response: GHResponse[Unit]): GHResponse[Boolean] =
response.result match {
case Right(_) => response.copy(result = Right(true))
case Left(_) if response.statusCode == 404 =>
response.copy(result = Right(false))
case Left(error) => GHResponse(Left(error), response.statusCode, response.headers)
}

}
53 changes: 52 additions & 1 deletion github4s/src/test/scala/github4s/integration/ReposSpec.scala
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ package github4s.integration
import cats.data.NonEmptyList
import cats.effect.{IO, Resource}
import cats.implicits._
import github4s.GHError.NotFoundError
import github4s.GHError.{NotFoundError, UnauthorizedError}
import github4s.domain._
import github4s.utils.{BaseIntegrationSpec, Integration}
import github4s.{GHResponse, Github}
Expand Down Expand Up @@ -313,6 +313,57 @@ trait ReposSpec extends BaseIntegrationSpec {
response.statusCode shouldBe notFoundStatusCode
}

"Repos >> UserIsCollaborator" should "return true when the user is a collaborator" taggedAs Integration in {
val response = clientResource
.use { client =>
Github[IO](client, accessToken).repos
.userIsCollaborator(
validRepoOwner,
validRepoName,
validUsername,
headers = headerUserAgent
)
}
.unsafeRunSync()

testIsRight[Boolean](response, r => r should be(true))
response.statusCode shouldBe noContentStatusCode
}

it should "return false when the user is not a collaborator" taggedAs Integration in {
val response = clientResource
.use { client =>
Github[IO](client, accessToken).repos
.userIsCollaborator(
validRepoOwner,
validRepoName,
invalidUsername,
headers = headerUserAgent
)
}
.unsafeRunSync()

testIsRight[Boolean](response, r => r should be(false))
response.statusCode shouldBe notFoundStatusCode
}

it should "return error when other errors occur" taggedAs Integration in {
val response = clientResource
.use { client =>
Github[IO](client, "invalid-access-token".some).repos
.userIsCollaborator(
validRepoOwner,
validRepoName,
validUsername,
headers = headerUserAgent
)
}
.unsafeRunSync()

testIsLeft[UnauthorizedError, Boolean](response)
response.statusCode shouldBe unauthorizedStatusCode
}

"Repos >> GetRepoPermissionForUser" should "return user repo permission" taggedAs Integration in {
val response = clientResource
.use { client =>
Expand Down
19 changes: 19 additions & 0 deletions github4s/src/test/scala/github4s/unit/ReposSpec.scala
Original file line number Diff line number Diff line change
Expand Up @@ -342,6 +342,25 @@ class ReposSpec extends BaseSpec {
repos.listCollaborators(validRepoOwner, validRepoName, headers = headerUserAgent)
}

"Repos.userIsCollaborator" should "call to httpClient.getWithoutResponse with the right parameters" in {
val response: IO[GHResponse[Unit]] =
IO(GHResponse(().asRight, noContentStatusCode, Map.empty))

implicit val httpClientMock = httpClientMockGetWithoutResponse(
url = s"repos/$validRepoOwner/$validRepoName/collaborators/$validUsername",
response = response
)

val repos = new RepositoriesInterpreter[IO]

repos.userIsCollaborator(
validRepoOwner,
validRepoName,
validUsername,
headers = headerUserAgent
)
}

"Repos.getRepoPermissionForUser" should "call to httpClient.get with the right parameters" in {
val response: IO[GHResponse[UserRepoPermission]] =
IO(GHResponse(userRepoPermission.asRight, okStatusCode, Map.empty))
Expand Down
15 changes: 13 additions & 2 deletions github4s/src/test/scala/github4s/utils/BaseSpec.scala
Original file line number Diff line number Diff line change
Expand Up @@ -17,10 +17,9 @@
package github4s.utils

import cats.effect.IO
import github4s.GithubConfig
import github4s.GHResponse
import github4s.domain.Pagination
import github4s.http.HttpClient
import github4s.{GHResponse, GithubConfig}
import io.circe.{Decoder, Encoder}
import org.http4s.client.Client
import org.scalamock.scalatest.MockFactory
Expand Down Expand Up @@ -61,6 +60,18 @@ trait BaseSpec extends AnyFlatSpec with Matchers with TestData with MockFactory
httpClientMock
}

def httpClientMockGetWithoutResponse(
url: String,
response: IO[GHResponse[Unit]]
): HttpClient[IO] = {
val httpClientMock = mock[HttpClientTest]
(httpClientMock
.getWithoutResponse(_: Option[String], _: String, _: Map[String, String]))
.expects(sampleToken, url, headerUserAgent)
.returns(response)
httpClientMock
}

def httpClientMockPost[In, Out](
url: String,
req: In,
Expand Down
19 changes: 19 additions & 0 deletions microsite/docs/repository.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ with Github4s, you can interact with:
- [List user repositories](#list-user-repositories)
- [List contributors](#list-contributors)
- [List collaborators](#list-collaborators)
- [Check user is a repository collaborator](#check-if-a-user-is-a-repository-collaborator)
- [Get repository permissions for a user](#get-repository-permissions-for-a-user)
- [Commits](#commits)
- [List commits on a repository](#list-commits-on-a-repository)
Expand Down Expand Up @@ -182,6 +183,24 @@ The `result` on the right is the corresponding [List[User]][user-scala].
See [the API doc](https://developer.github.com/v3/repos/collaborators/#list-collaborators) for full
reference.

### Check if a user is a repository collaborator

Returns whether a given user is a repository collaborator or not.

```scala mdoc:compile-only
val userIsCollaborator = gh.repos.userIsCollaborator("47degrees", "github4s", "rafaparadela")
val response = userIsCollaborator.unsafeRunSync()
response.result match {
case Left(e) => println(s"Something went wrong: ${e.getMessage}")
case Right(r) => println(r)
}
```

The `result` on the right is `Boolean`

See [the API doc](https://developer.github.com/v3/repos/collaborators/#check-if-a-user-is-a-repository-collaborator)
for full reference.

### Get repository permissions for a user

Checks the repository permission of a collaborator.
Expand Down

0 comments on commit f125d6a

Please sign in to comment.