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

Add check if user is a collaborator #530

Merged
merged 12 commits into from Jul 23, 2020
23 changes: 23 additions & 0 deletions github4s/src/main/scala/github4s/algebras/Repositories.scala
Expand Up @@ -271,6 +271,29 @@ 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.
*
BenFradet marked this conversation as resolved.
Show resolved Hide resolved
* 204 No Content responses will produce a true result
* 404 Not Found responses will produce a false result
BenFradet marked this conversation as resolved.
Show resolved Hide resolved
*
BenFradet marked this conversation as resolved.
Show resolved Hide resolved
* @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(
BenFradet marked this conversation as resolved.
Show resolved Hide resolved
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
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
}
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
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 response is successful" taggedAs Integration in {
BenFradet marked this conversation as resolved.
Show resolved Hide resolved
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 not found error occurs" taggedAs Integration in {
BenFradet marked this conversation as resolved.
Show resolved Hide resolved
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,
zachkirlew marked this conversation as resolved.
Show resolved Hide resolved
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
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
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
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)
zachkirlew marked this conversation as resolved.
Show resolved Hide resolved
- [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