diff --git a/docs/docs/repository.md b/docs/docs/repository.md index 85532b88d..4d0ea9d01 100644 --- a/docs/docs/repository.md +++ b/docs/docs/repository.md @@ -19,6 +19,9 @@ with Github4s, you can interact with: - [List commits on a repository](#list-commits-on-a-repository) - [Contents](#contents) - [Get contents](#get-contents) + - [Create a File](#create-a-file) + - [Update a File](#update-a-file) + - [Delete a File](#delete-a-file) - [Releases](#releases) - [Create a release](#create-a-release) - [Statuses](#statuses) @@ -266,6 +269,85 @@ The `result` on the right is the corresponding [NonEmptyList[Content]][repositor See [the API doc](https://developer.github.com/v3/repos/contents/#get-contents) for full reference. +### Create a File + +This method creates a new file in an existing repository. + +You can create a new file using `createFile`, it takes as arguments: + +- the repository coordinates (`owner` and `name` of the repository). +- `path`: The path of the new file to be created, *without* a leading slash. +- `message`: The message to use for creating the commit. +- `content`: The content of the new file, as an array of bytes. +- `branch`: The branch to add the commit to. If omitted, this defaults to the repository's default branch. +- `committer`: An optional committer to associate with the commit. If omitted, the authenticated user's information is used for the commit. +- `author`: An optional author to associate with the commit. If omitted, the committer is used (if present). + +To create a file: +```scala mdoc:compile-only +val getContents = gh.repos.createFile("47degrees", "github4s", "new-file.txt", "create a new file", "file contents".getBytes) + +getContents.unsafeRunSync().result match { + case Left(e) => println(s"We could not create your file because ${e.getMessage}") + case Right(r) => println(r) +} +``` + +See [the API doc](https://developer.github.com/v3/repos/contents/#create-or-update-a-file) for full reference. + +### Update a File + +This method updates an existing file in a repository. + +You can create a new file using `createFile`, it takes as arguments: + +- the repository coordinates (`owner` and `name` of the repository). +- `path`: The path of the new file to be created, *without* a leading slash. +- `message`: The message to use for creating the commit. +- `content`: The content of the new file, as an array of bytes. +- `sha`: The blob SHA of the file being replaced. (This is returned as part of a `getContents` call). GitHub uses this value to perform optimistic locking. If the file has been updated since, the update call will fail. +- `branch`: The branch to add the commit to. If omitted, this defaults to the repository's default branch. +- `committer`: An optional committer to associate with the commit. If omitted, the authenticated user's information is used for the commit. +- `author`: An optional author to associate with the commit. If omitted, the committer is used (if present). + +To create a file: +```scala mdoc:compile-only +val getContents = gh.repos.updateFile("47degrees", "github4s", "README.md", "A terser README.", "You read me right.".getBytes,"a52d080d2cf85e08bfcb441b437d3982398e8f8f6a58388f55d6b6cf51cb5365") + +getContents.unsafeRunSync().result match { + case Left(e) => println(s"We could not update your file because ${e.getMessage}") + case Right(r) => println(r) +} +``` + +See [the API doc](https://developer.github.com/v3/repos/contents/#create-or-update-a-file) for full reference. + +### Delete a File + +This method deletes an existing file in a repository. + +You can create a new file using `deleteFile`, it takes as arguments: + +- the repository coordinates (`owner` and `name` of the repository). +- `path`: The path of the new file to be created, *without* a leading slash. +- `message`: The message to use for creating the commit. +- `sha`: The blob SHA of the file being replaced. (This is returned as part of a `getContents` call). GitHub uses this value to perform optimistic locking. If the file has been updated since, the update call will fail. +- `branch`: The branch to add the commit to. If omitted, this defaults to the repository's default branch. +- `committer`: An optional committer to associate with the commit. If omitted, the authenticated user's information is used for the commit. +- `author`: An optional author to associate with the commit. If omitted, the committer is used (if present). + +To create a file: +```scala mdoc:compile-only +val getContents = gh.repos.deleteFile("47degrees", "github4s", "README.md", "Actually, we don't need a README.", "a52d080d2cf85e08bfcb441b437d3982398e8f8f6a58388f55d6b6cf51cb5365") + +getContents.unsafeRunSync().result match { + case Left(e) => println(s"We could not delete this file because ${e.getMessage}") + case Right(r) => println(r) +} +``` + +See [the API doc](https://developer.github.com/v3/repos/contents/#delete-a-file) for full reference. + ## Releases ### Create a release diff --git a/github4s/src/main/scala/github4s/Decoders.scala b/github4s/src/main/scala/github4s/Decoders.scala index f28dda3bf..e93148571 100644 --- a/github4s/src/main/scala/github4s/Decoders.scala +++ b/github4s/src/main/scala/github4s/Decoders.scala @@ -241,6 +241,8 @@ object Decoders { } } + implicit val decoderWriteFileResponse: Decoder[WriteFileResponse] = + deriveDecoder[WriteFileResponse] implicit val decoderPullRequestFile: Decoder[PullRequestFile] = deriveDecoder[PullRequestFile] implicit val decoderPullRequestReview: Decoder[PullRequestReview] = deriveDecoder[PullRequestReview] diff --git a/github4s/src/main/scala/github4s/Encoders.scala b/github4s/src/main/scala/github4s/Encoders.scala index cc6280f01..3e81a7d4f 100644 --- a/github4s/src/main/scala/github4s/Encoders.scala +++ b/github4s/src/main/scala/github4s/Encoders.scala @@ -43,6 +43,11 @@ object Encoders { ) } + implicit val encoderCommiter: Encoder[Committer] = deriveEncoder[Committer] + implicit val encoderDeleteFileRequest: Encoder[DeleteFileRequest] = + deriveEncoder[DeleteFileRequest] + implicit val encoderWriteFileContentRequest: Encoder[WriteFileRequest] = + deriveEncoder[WriteFileRequest] implicit val encoderCreateReferenceRequest: Encoder[CreateReferenceRequest] = deriveEncoder[CreateReferenceRequest] implicit val encoderNewCommitRequest: Encoder[NewCommitRequest] = deriveEncoder[NewCommitRequest] diff --git a/github4s/src/main/scala/github4s/algebras/Repositories.scala b/github4s/src/main/scala/github4s/algebras/Repositories.scala index ca4c0c1b2..302402bdc 100644 --- a/github4s/src/main/scala/github4s/algebras/Repositories.scala +++ b/github4s/src/main/scala/github4s/algebras/Repositories.scala @@ -103,6 +103,83 @@ trait Repositories[F[_]] { headers: Map[String, String] = Map() ): F[GHResponse[NonEmptyList[Content]]] + /** + * Creates a new file in a repository. + * + * @param owner of the repo + * @param repo name of the repo + * @param path the content path + * @param content content in bytes, as they should be written to GitHub. + * @param message the message to be included in the commit. + * @param branch the branch name (defaults to the repository's default branch) + * @param committer object containing information about the committer (filled in with authenticated user information if omitted) + * @param author object containing information about the author (filled in with committer information if omitted) + * @return GHResponse[WriteFileResponse] with details about the content created and the commit + */ + def createFile( + owner: String, + repo: String, + path: String, + message: String, + content: Array[Byte], + branch: Option[String] = None, + committer: Option[Committer] = None, + author: Option[Committer] = None, + headers: Map[String, String] = Map() + ): F[GHResponse[WriteFileResponse]] + + /** + * Updates an existing file in a repository. + * + * @param owner of the repo + * @param repo name of the repo + * @param path the content path + * @param message the message to be included in the commit. + * @param content the content of the file as it should be written to GitHub + * @param sha the blob sha of the file being replaced. + * @param branch the branch name (defaults to the repository's default branch) + * @param committer object containing information about the committer (filled in with authenticated user information if omitted) + * @param author object containing information about the author (filled in with committer information if omitted) + * @return GHResponse[WriteFileResponse] with details about the content updated and the commit + */ + def updateFile( + owner: String, + repo: String, + path: String, + message: String, + content: Array[Byte], + sha: String, + branch: Option[String] = None, + committer: Option[Committer] = None, + author: Option[Committer] = None, + headers: Map[String, String] = Map() + ): F[GHResponse[WriteFileResponse]] + + /** + * Deletes a file in a particular repo, resulting in a new commit. + * + * @param owner of the repo + * @param repo name of the repo + * @param path the content path + * @param message the message to be included in the commit. + * @param sha the blob sha of the file being replaced. + * @param branch the branch name (defaults to the repository's default branch) + * @param committer object containing information about the committer (filled in with authenticated user information if omitted) + * @param author object containing information about the author (filled in with committer information if omitted) + * @return GHResponse[WriteFileResponse] with no content and details about the commit which was added. + */ + def deleteFile( + owner: String, + repo: String, + path: String, + message: String, + sha: String, + branch: Option[String] = None, + committer: Option[Committer] = None, + author: Option[Committer] = None, + headers: Map[String, String] = Map() + ): F[GHResponse[WriteFileResponse]] + /** * Retrieve the list of commits for a particular repo * diff --git a/github4s/src/main/scala/github4s/domain/Repository.scala b/github4s/src/main/scala/github4s/domain/Repository.scala index 411c17795..ea524ed66 100644 --- a/github4s/src/main/scala/github4s/domain/Repository.scala +++ b/github4s/src/main/scala/github4s/domain/Repository.scala @@ -166,6 +166,42 @@ case class CombinedStatus( statuses: List[Status], repository: StatusRepository ) + +case class WriteFileRequest( + message: String, + content: String, + sha: Option[String] = None, + branch: Option[String] = None, + committer: Option[Committer] = None, + author: Option[Committer] = None +) + +case class DeleteFileRequest( + message: String, + sha: String, + branch: Option[String] = None, + committer: Option[Committer] = None, + author: Option[Committer] = None +) + +case class WriteResponseCommit( + sha: String, + url: String, + html_url: String, + author: Option[Committer], + committer: Option[Committer], + message: String +) + +case class WriteFileResponse( + content: Option[Content], + commit: WriteResponseCommit +) + +case class Committer( + name: String, + email: String +) object RepoUrlKeys { val forks_url = "forks_url" diff --git a/github4s/src/main/scala/github4s/http/HttpClient.scala b/github4s/src/main/scala/github4s/http/HttpClient.scala index 1fa6856b8..ad46a7986 100644 --- a/github4s/src/main/scala/github4s/http/HttpClient.scala +++ b/github4s/src/main/scala/github4s/http/HttpClient.scala @@ -107,11 +107,11 @@ class HttpClient[F[_]: Sync](client: Client[F], val config: GithubConfig) { def delete( accessToken: Option[String] = None, - method: String, + url: String, headers: Map[String, String] = Map.empty ): F[GHResponse[Unit]] = run[Unit, Unit]( - RequestBuilder(buildURL(method)).deleteMethod.withHeaders(headers).withAuth(accessToken) + RequestBuilder(buildURL(url)).deleteMethod.withHeaders(headers).withAuth(accessToken) ) def deleteWithResponse[Res: Decoder]( @@ -125,6 +125,19 @@ class HttpClient[F[_]: Sync](client: Client[F], val config: GithubConfig) { .withHeaders(headers) ) + def deleteWithBody[Req: Encoder, Res: Decoder]( + accessToken: Option[String] = None, + url: String, + headers: Map[String, String] = Map.empty, + data: Req + ): F[GHResponse[Res]] = + run[Req, Res]( + RequestBuilder(buildURL(url)).deleteMethod + .withAuth(accessToken) + .withHeaders(headers) + .withData(data) + ) + val defaultPagination = Pagination(1, 1000) val defaultPage: Int = 1 val defaultPerPage: Int = 30 diff --git a/github4s/src/main/scala/github4s/interpreters/RepositoriesInterpreter.scala b/github4s/src/main/scala/github4s/interpreters/RepositoriesInterpreter.scala index f2727bc73..97109c4ad 100644 --- a/github4s/src/main/scala/github4s/interpreters/RepositoriesInterpreter.scala +++ b/github4s/src/main/scala/github4s/interpreters/RepositoriesInterpreter.scala @@ -23,6 +23,7 @@ import github4s.GithubResponses.GHResponse import github4s.domain._ import github4s.Decoders._ import github4s.Encoders._ +import com.github.marklister.base64.Base64._ class RepositoriesInterpreter[F[_]](implicit client: HttpClient[F], accessToken: Option[String]) extends Repositories[F] { @@ -75,6 +76,61 @@ class RepositoriesInterpreter[F[_]](implicit client: HttpClient[F], accessToken: ref.fold(Map.empty[String, String])(r => Map("ref" -> r)) ) + override def createFile( + owner: String, + repo: String, + path: String, + message: String, + content: Array[Byte], + branch: Option[String], + committer: Option[Committer], + author: Option[Committer], + headers: Map[String, String] = Map() + ): F[GHResponse[WriteFileResponse]] = + client.put[WriteFileRequest, WriteFileResponse]( + accessToken, + s"repos/$owner/$repo/contents/$path", + headers, + WriteFileRequest(message, content.toBase64, None, branch, committer, author) + ) + + override def updateFile( + owner: String, + repo: String, + path: String, + message: String, + content: Array[Byte], + sha: String, + branch: Option[String], + committer: Option[Committer], + author: Option[Committer], + headers: Map[String, String] = Map() + ): F[GHResponse[WriteFileResponse]] = + client.put[WriteFileRequest, WriteFileResponse]( + accessToken, + s"repos/$owner/$repo/contents/$path", + headers, + WriteFileRequest(message, content.toBase64, Some(sha), branch, committer, author) + ) + + override def deleteFile( + owner: String, + repo: String, + path: String, + message: String, + sha: String, + branch: Option[String], + committer: Option[Committer], + author: Option[Committer], + headers: Map[String, String] = Map() + ): F[GHResponse[WriteFileResponse]] = + client.deleteWithBody[DeleteFileRequest, WriteFileResponse]( + accessToken, + s"repos/$owner/$repo/contents/$path", + headers, + DeleteFileRequest(message, sha, branch, committer, author) + ) + override def listCommits( owner: String, repo: String, diff --git a/github4s/src/test/scala/github4s/unit/ReposSpec.scala b/github4s/src/test/scala/github4s/unit/ReposSpec.scala index 1d2df4f3b..22f5751ba 100644 --- a/github4s/src/test/scala/github4s/unit/ReposSpec.scala +++ b/github4s/src/test/scala/github4s/unit/ReposSpec.scala @@ -23,6 +23,7 @@ import github4s.GithubResponses.GHResponse import github4s.domain._ import github4s.interpreters.RepositoriesInterpreter import github4s.utils.BaseSpec +import com.github.marklister.base64.Base64._ class ReposSpec extends BaseSpec { @@ -85,6 +86,109 @@ class ReposSpec extends BaseSpec { repos.getContents(validRepoOwner, validRepoName, validFilePath, Some("master"), headerUserAgent) } + "Repos.createFile" should "call to httpClient.put with the right parameters" in { + val response: IO[GHResponse[WriteFileResponse]] = + IO(GHResponse(writeFileResponse.asRight, okStatusCode, Map.empty)) + + val request = WriteFileRequest( + validNote, + validFileContent.getBytes.toBase64, + None, + Some(validBranchName), + Some(validCommitter), + Some(validCommitter) + ) + + implicit val httpClientMock = httpClientMockPut[WriteFileRequest, WriteFileResponse]( + url = s"repos/$validRepoOwner/$validRepoName/contents/$validFilePath", + req = request, + response = response + ) + + val repos = new RepositoriesInterpreter[IO] + + repos.createFile( + validRepoOwner, + validRepoName, + validFilePath, + validNote, + validFileContent.getBytes, + Some(validBranchName), + Some(validCommitter), + Some(validCommitter), + headerUserAgent + ) + } + + "Repos.updateFile" should "call to httpClient.put with the right parameters" in { + val response: IO[GHResponse[WriteFileResponse]] = + IO(GHResponse(writeFileResponse.asRight, okStatusCode, Map.empty)) + + val request = WriteFileRequest( + validNote, + validFileContent.getBytes.toBase64, + Some(validCommitSha), + Some(validBranchName), + Some(validCommitter), + Some(validCommitter) + ) + + implicit val httpClientMock = httpClientMockPut[WriteFileRequest, WriteFileResponse]( + url = s"repos/$validRepoOwner/$validRepoName/contents/$validFilePath", + req = request, + response = response + ) + + val repos = new RepositoriesInterpreter[IO] + + repos.updateFile( + validRepoOwner, + validRepoName, + validFilePath, + validNote, + validFileContent.getBytes, + validCommitSha, + Some(validBranchName), + Some(validCommitter), + Some(validCommitter), + headerUserAgent + ) + } + + "Repos.deleteFile" should "call to httpClient.delete with the right parameters" in { + val response: IO[GHResponse[WriteFileResponse]] = + IO(GHResponse(writeFileResponse.asRight, okStatusCode, Map.empty)) + + val request = DeleteFileRequest( + validNote, + validCommitSha, + Some(validBranchName), + Some(validCommitter), + Some(validCommitter) + ) + + implicit val httpClientMock = + httpClientMockDeleteWithBody[DeleteFileRequest, WriteFileResponse]( + url = s"repos/$validRepoOwner/$validRepoName/contents/$validFilePath", + req = request, + response = response + ) + + val repos = new RepositoriesInterpreter[IO] + + repos.deleteFile( + validRepoOwner, + validRepoName, + validFilePath, + validNote, + validCommitSha, + Some(validBranchName), + Some(validCommitter), + Some(validCommitter), + headerUserAgent + ) + } + "Repos.createRelease" should "call to httpClient.post with the right parameters" in { val response: IO[GHResponse[Release]] = IO(GHResponse(release.asRight, okStatusCode, Map.empty)) diff --git a/github4s/src/test/scala/github4s/utils/BaseSpec.scala b/github4s/src/test/scala/github4s/utils/BaseSpec.scala index 1aa4d6f08..044199a82 100644 --- a/github4s/src/test/scala/github4s/utils/BaseSpec.scala +++ b/github4s/src/test/scala/github4s/utils/BaseSpec.scala @@ -164,4 +164,20 @@ trait BaseSpec extends AnyFlatSpec with Matchers with TestData with MockFactory httpClientMock } + def httpClientMockDeleteWithBody[In, Out]( + url: String, + req: In, + response: IO[GHResponse[Out]] + ): HttpClient[IO] = { + val httpClientMock = mock[HttpClientTest] + (httpClientMock + .deleteWithBody[In, Out](_: Option[String], _: String, _: Map[String, String], _: In)( + _: Encoder[In], + _: Decoder[Out] + )) + .expects(sampleToken, url, headerUserAgent, req, *, *) + .returns(response) + httpClientMock + } + } diff --git a/github4s/src/test/scala/github4s/utils/TestData.scala b/github4s/src/test/scala/github4s/utils/TestData.scala index 0655461fa..abd291a86 100644 --- a/github4s/src/test/scala/github4s/utils/TestData.scala +++ b/github4s/src/test/scala/github4s/utils/TestData.scala @@ -85,6 +85,8 @@ trait TestData { val validGistOldFilename = "fest.scala" val validGistDeletedFilename = "rest.scala" + val validFileContent = "def hack(target: String): Option[Int] = None" + val validSearchQuery = "Scala 2.12" val nonExistentSearchQuery = "nonExistentSearchQueryString" val validSearchParams = List( @@ -420,6 +422,26 @@ trait TestData { pull_request_url = "" ) + val validCommitter = Committer( + validUsername, + "email@example.com" + ) + + val writeResponseCommit = WriteResponseCommit( + sha = validCommitSha, + url = + "https://api.github.com/repos/47degrees/test-repo2/contents/metrics/calc/test?ref=m3-changes", + html_url = "https://github.com/47degrees/test-repo2/blob/m3-changes/metrics/calc/test", + author = Some(validCommitter), + committer = Some(validCommitter), + message = validNote + ) + + val writeFileResponse = WriteFileResponse( + Some(content), + writeResponseCommit + ) + val validNameTeam = "47 Devs" val validSlug = "47-devs"