diff --git a/github4s/shared/src/main/scala/github4s/Decoders.scala b/github4s/shared/src/main/scala/github4s/Decoders.scala index 3257021f..c7f1a4ba 100644 --- a/github4s/shared/src/main/scala/github4s/Decoders.scala +++ b/github4s/shared/src/main/scala/github4s/Decoders.scala @@ -200,6 +200,9 @@ object Decoders { source <- Decoder[Option[RepositoryBase]].at("source") } yield Repository.fromBaseRepos(base, parent, source) + implicit val decodeRepositoryMinimal: Decoder[RepositoryMinimal] = + deriveDecoder[RepositoryMinimal] + implicit val decodePRStatus: Decoder[PullRequestReviewState] = Decoder.decodeString.emap { case PRRStateApproved.value => PRRStateApproved.asRight @@ -412,4 +415,13 @@ object Decoders { deriveDecoder[BranchUpdateResponse] implicit val decodeCommitComparisonResponse: Decoder[CommitComparisonResponse] = deriveDecoder[CommitComparisonResponse] + + implicit val decodeSearchResultTextMatch: Decoder[SearchResultTextMatch] = + deriveDecoder[SearchResultTextMatch] + implicit val decodeSearchResultTextMatchLocation: Decoder[SearchResultTextMatchLocation] = + deriveDecoder[SearchResultTextMatchLocation] + implicit val decodeSearchCodeResult: Decoder[SearchCodeResult] = + deriveDecoder[SearchCodeResult] + implicit val decodeSearchCodeResultItem: Decoder[SearchCodeResultItem] = + deriveDecoder[SearchCodeResultItem] } diff --git a/github4s/shared/src/main/scala/github4s/Encoders.scala b/github4s/shared/src/main/scala/github4s/Encoders.scala index 8177873a..7cfc0de6 100644 --- a/github4s/shared/src/main/scala/github4s/Encoders.scala +++ b/github4s/shared/src/main/scala/github4s/Encoders.scala @@ -238,6 +238,8 @@ object Encoders { case None => repo } } + implicit val encodeRepositoryMinimal: Encoder[RepositoryMinimal] = + deriveEncoder[RepositoryMinimal] implicit val encoderPublicGitHubEvents: Encoder[PublicGitHubEvent] = Encoder.instance { e => @@ -282,4 +284,13 @@ object Encoders { } implicit val encodeCommitComparisonResponse: Encoder[CommitComparisonResponse] = deriveEncoder[CommitComparisonResponse] + + implicit val encodeSearchResultTextMatch: Encoder[SearchResultTextMatch] = + deriveEncoder[SearchResultTextMatch] + implicit val encodeSearchResultTextMatchLocation: Encoder[SearchResultTextMatchLocation] = + deriveEncoder[SearchResultTextMatchLocation] + implicit val encodeSearchCodeResult: Encoder[SearchCodeResult] = + deriveEncoder[SearchCodeResult] + implicit val encodeSearchCodeResultItem: Encoder[SearchCodeResultItem] = + deriveEncoder[SearchCodeResultItem] } diff --git a/github4s/shared/src/main/scala/github4s/Github.scala b/github4s/shared/src/main/scala/github4s/Github.scala index 52899d8c..bb4e77a6 100644 --- a/github4s/shared/src/main/scala/github4s/Github.scala +++ b/github4s/shared/src/main/scala/github4s/Github.scala @@ -41,6 +41,7 @@ class Github[F[_]: Concurrent]( lazy val organizations: Organizations[F] = module.organizations lazy val teams: Teams[F] = module.teams lazy val projects: Projects[F] = module.projects + lazy val search: Search[F] = module.search } object Github { diff --git a/github4s/shared/src/main/scala/github4s/algebras/GithubAPIs.scala b/github4s/shared/src/main/scala/github4s/algebras/GithubAPIs.scala index c78de7a7..6ab8d8d6 100644 --- a/github4s/shared/src/main/scala/github4s/algebras/GithubAPIs.scala +++ b/github4s/shared/src/main/scala/github4s/algebras/GithubAPIs.scala @@ -74,4 +74,9 @@ trait GithubAPIs[F[_]] { * Project-related operations. */ def projects: Projects[F] + + /** + * Search-related operations. + */ + def search: Search[F] } diff --git a/github4s/shared/src/main/scala/github4s/algebras/Search.scala b/github4s/shared/src/main/scala/github4s/algebras/Search.scala new file mode 100644 index 00000000..42c288f5 --- /dev/null +++ b/github4s/shared/src/main/scala/github4s/algebras/Search.scala @@ -0,0 +1,42 @@ +/* + * Copyright 2016-2022 47 Degrees Open Source + * + * 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 github4s.algebras + +import github4s.GHResponse +import github4s.domain._ + +trait Search[F[_]] { + + /** + * Search code + * + * @param query query string + * @param searchParams search parameters + * @param textMatches enable text matches + * @param pagination Limit and Offset for pagination + * @param headers optional user headers to include in the request + * @return GHResponse[SearchCodeResult] the search results + */ + def searchCode( + query: String, + searchParams: List[SearchCodeParam], + textMatches: Boolean = false, + pagination: Option[Pagination] = None, + headers: Map[String, String] = Map() + ): F[GHResponse[SearchCodeResult]] + +} diff --git a/github4s/shared/src/main/scala/github4s/domain/Repository.scala b/github4s/shared/src/main/scala/github4s/domain/Repository.scala index 9445b74a..33511b42 100644 --- a/github4s/shared/src/main/scala/github4s/domain/Repository.scala +++ b/github4s/shared/src/main/scala/github4s/domain/Repository.scala @@ -39,6 +39,54 @@ final case class RepositoryBase( topics: List[String] = Nil ) +/** A `Repository` but with some information omitted (this is used by the search API) */ +final case class RepositoryMinimal( + id: Long, + node_id: String, + name: String, + full_name: String, + `private`: Boolean, + owner: User, + html_url: String, + description: Option[String] = None, + fork: Boolean, + url: String, + forks_url: String, + keys_url: String, + collaborators_url: String, + teams_url: String, + hooks_url: String, + issue_events_url: String, + events_url: String, + assignees_url: String, + branches_url: String, + blobs_url: String, + git_tags_url: String, + git_refs_url: String, + trees_url: String, + statuses_url: String, + languages_url: String, + stargazers_url: String, + contributors_url: String, + subscribers_url: String, + subscription_url: String, + commits_url: String, + git_commits_url: String, + comments_url: String, + issue_comment_url: String, + contents_url: String, + compare_url: String, + merges_url: String, + archive_url: String, + downloads_url: String, + issues_url: String, + pulls_url: String, + milestones_url: String, + notifications_url: String, + releases_url: String, + deployments_url: String +) + final case class Repository( id: Long, name: String, diff --git a/github4s/shared/src/main/scala/github4s/domain/Search.scala b/github4s/shared/src/main/scala/github4s/domain/Search.scala new file mode 100644 index 00000000..ed644f08 --- /dev/null +++ b/github4s/shared/src/main/scala/github4s/domain/Search.scala @@ -0,0 +1,20 @@ +package github4s.domain + +final case class SearchResultTextMatch( + object_url: String, + object_type: Option[String], + property: String, + fragment: String, + matches: List[SearchResultTextMatchLocation] +) + +final case class SearchResultTextMatchLocation( + text: String, + indices: List[Int] +) + +sealed abstract class ComparisonOperator(val value: String) +case object LesserThan extends ComparisonOperator("<=") +case object StrictlyLesserThan extends ComparisonOperator("<") +case object GreaterThan extends ComparisonOperator(">=") +case object StrictlyGreaterThan extends ComparisonOperator(">") diff --git a/github4s/shared/src/main/scala/github4s/domain/SearchCode.scala b/github4s/shared/src/main/scala/github4s/domain/SearchCode.scala new file mode 100644 index 00000000..f03012bf --- /dev/null +++ b/github4s/shared/src/main/scala/github4s/domain/SearchCode.scala @@ -0,0 +1,88 @@ +package github4s.domain + +final case class SearchCodeResult( + total_count: Int, + incomplete_results: Boolean, + items: List[SearchCodeResultItem] +) + +final case class SearchCodeResultItem( + name: String, + path: String, + sha: String, + url: String, + git_url: String, + html_url: String, + repository: RepositoryMinimal, + score: Double, + file_size: Option[Long], + language: Option[String], + last_modified_at: Option[String], + line_numbers: Option[List[String]], + text_matches: Option[List[SearchResultTextMatch]] +) + +sealed trait SearchCodeParam { + protected def paramName: String + protected def paramValue: String + def value: String = s"$paramName:$paramValue" +} + +object SearchCodeParam { + + final case class In(values: Set[In.Value]) extends SearchCodeParam { + override def paramName: String = "in" + override def paramValue: String = values.map(_.value).mkString(",") + } + object In { + sealed trait Value { + def value: String + } + final case object File extends Value { + override def value: String = "file" + } + final case object Path extends Value { + override def value: String = "path" + } + } + + final case class User(name: String) extends SearchCodeParam { + override def paramName: String = "user" + override def paramValue: String = name + } + + final case class Organization(name: String) extends SearchCodeParam { + override def paramName: String = "org" + override def paramValue: String = name + } + + final case class Repository(user: String, repository: String) extends SearchCodeParam { + override def paramName: String = "repo" + override def paramValue: String = s"$user/$repository" + } + + final case class Path(path: String) extends SearchCodeParam { + override def paramName: String = "path" + override def paramValue: String = path + } + + final case class Language(language: String) extends SearchCodeParam { + override def paramName: String = "language" + override def paramValue: String = language + } + + final case class Size(op: Option[ComparisonOperator] = None, size: Long) extends SearchCodeParam { + override def paramName: String = "size" + override def paramValue: String = s"${op.getOrElse("")}$size" + } + + final case class Filename(filename: String) extends SearchCodeParam { + override def paramName: String = "filename" + override def paramValue: String = filename + } + + final case class Extension(extension: String) extends SearchCodeParam { + override def paramName: String = "extension" + override def paramValue: String = extension + } +} diff --git a/github4s/shared/src/main/scala/github4s/interpreters/SearchInterpreter.scala b/github4s/shared/src/main/scala/github4s/interpreters/SearchInterpreter.scala new file mode 100644 index 00000000..de1ad667 --- /dev/null +++ b/github4s/shared/src/main/scala/github4s/interpreters/SearchInterpreter.scala @@ -0,0 +1,42 @@ +/* + * Copyright 2016-2022 47 Degrees Open Source + * + * 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 github4s.interpreters + +import github4s.Decoders._ +import github4s.GHResponse +import github4s.algebras.Search +import github4s.domain._ +import github4s.http.HttpClient + +class SearchInterpreter[F[_]](implicit client: HttpClient[F]) extends Search[F] { + + private val textMatchesHeader = "Accept" -> "application/vnd.github.text-match+json" + + override def searchCode( + query: String, + searchParams: List[SearchCodeParam] = Nil, + textMatches: Boolean = false, + pagination: Option[Pagination] = None, + headers: Map[String, String] = Map.empty + ): F[GHResponse[SearchCodeResult]] = + client.get[SearchCodeResult]( + method = s"search/code", + if (textMatches) headers + textMatchesHeader else headers, + params = Map("q" -> s"$query+${searchParams.map(_.value).mkString("+")}"), + pagination + ) +} diff --git a/github4s/shared/src/main/scala/github4s/modules/GithubAPIs.scala b/github4s/shared/src/main/scala/github4s/modules/GithubAPIs.scala index 716d5bae..6e18cd5b 100644 --- a/github4s/shared/src/main/scala/github4s/modules/GithubAPIs.scala +++ b/github4s/shared/src/main/scala/github4s/modules/GithubAPIs.scala @@ -42,6 +42,7 @@ class GithubAPIv3[F[_]: Concurrent]( override val organizations: Organizations[F] = new OrganizationsInterpreter[F] override val teams: Teams[F] = new TeamsInterpreter[F] override val projects: Projects[F] = new ProjectsInterpreter[F] + override val search: Search[F] = new SearchInterpreter[F] } diff --git a/github4s/shared/src/test/scala-2/github4s/unit/EncoderDecoderSpec.scala b/github4s/shared/src/test/scala-2/github4s/unit/EncoderDecoderSpec.scala index 8aeb3c46..42b04831 100644 --- a/github4s/shared/src/test/scala-2/github4s/unit/EncoderDecoderSpec.scala +++ b/github4s/shared/src/test/scala-2/github4s/unit/EncoderDecoderSpec.scala @@ -148,6 +148,7 @@ class EncoderDecoderSpec extends AnyFlatSpec with ScalaCheckPropertyChecks { test[RepoPermissions] test[RepositoryBase] test[Repository] + test[RepositoryMinimal] test[ReviewersRequest] test[ReviewersResponse] test[BranchUpdateRequest] @@ -172,5 +173,8 @@ class EncoderDecoderSpec extends AnyFlatSpec with ScalaCheckPropertyChecks { test[WriteFileRequest] test[WriteFileResponse] test[WriteResponseCommit] - + test[SearchResultTextMatch] + test[SearchResultTextMatchLocation] + test[SearchCodeResult] + test[SearchCodeResultItem] } diff --git a/github4s/shared/src/test/scala/github4s/integration/SearchSpec.scala b/github4s/shared/src/test/scala/github4s/integration/SearchSpec.scala new file mode 100644 index 00000000..75ea4b86 --- /dev/null +++ b/github4s/shared/src/test/scala/github4s/integration/SearchSpec.scala @@ -0,0 +1,107 @@ +/* + * Copyright 2016-2022 47 Degrees Open Source + * + * 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 github4s.integration + +import cats.effect.IO +import github4s.Github +import github4s.domain._ +import github4s.utils.{BaseIntegrationSpec, Integration} + +trait SearchSpec extends BaseIntegrationSpec { + + behavior of "Search >> Code" + + it should "return zero match for a non existent search query" taggedAs Integration in { + val response = clientResource + .use { client => + Github[IO](client, accessToken).search + .searchCode( + query = nonExistentSearchQuery, + searchParams = List( + SearchCodeParam.Repository(validRepoOwner, validRepoName), + SearchCodeParam.Extension("xml") + ), + headers = headerUserAgent + ) + } + .unsafeRunSync() + + testIsRight[SearchCodeResult]( + response, + { r => + r.total_count shouldBe 0 + r.items shouldBe empty + } + ) + response.statusCode shouldBe okStatusCode + } + + it should "return at least one match for a valid query" taggedAs Integration in { + val response = clientResource + .use { client => + Github[IO](client, accessToken).search + .searchCode( + query = "github", + searchParams = List( + SearchCodeParam.Repository(validRepoOwner, validRepoName), + SearchCodeParam.Extension("scala") + ), + headers = headerUserAgent + ) + } + .unsafeRunSync() + + testIsRight[SearchCodeResult]( + response, + { r => + r.total_count > 0 shouldBe true + r.items.nonEmpty shouldBe true + } + ) + response.statusCode shouldBe okStatusCode + } + + it should "return at least one match for a valid query with text matches enabled" taggedAs Integration in { + val response = clientResource + .use { client => + Github[IO](client, accessToken).search + .searchCode( + query = "github", + searchParams = List( + SearchCodeParam.Repository(validRepoOwner, validRepoName), + SearchCodeParam.Extension("md") + ), + textMatches = true, + headers = headerUserAgent + ) + } + .unsafeRunSync() + + testIsRight[SearchCodeResult]( + response, + { r => + r.total_count > 0 shouldBe true + r.items.nonEmpty shouldBe true + forEvery(r.items.map(_.text_matches)) { textMatches => + textMatches.isDefined shouldBe true + textMatches.get should not be empty + } + } + ) + response.statusCode shouldBe okStatusCode + } +} diff --git a/github4s/shared/src/test/scala/github4s/unit/SearchSpec.scala b/github4s/shared/src/test/scala/github4s/unit/SearchSpec.scala new file mode 100644 index 00000000..c93f934a --- /dev/null +++ b/github4s/shared/src/test/scala/github4s/unit/SearchSpec.scala @@ -0,0 +1,51 @@ +/* + * Copyright 2016-2022 47 Degrees Open Source + * + * 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 github4s.unit + +import cats.effect.IO +import github4s.Encoders._ +import github4s.domain.SearchCodeParam._ +import github4s.domain._ +import github4s.http.HttpClient +import github4s.interpreters.SearchInterpreter +import github4s.utils.BaseSpec + +class SearchSpec extends BaseSpec { + + "Search.searchCode" should "call to httpClient.get with the right parameters" in { + + val query = "foobar" + val searchParams = List( + In(Set(In.File, In.Path)), + Filename(validFilePath), + Size(Some(LesserThan), 1024) + ) + + implicit val httpClientMock: HttpClient[IO] = httpClientMockGet[SearchCodeResult]( + url = s"search/code", + params = Map("q" -> s"$query+${searchParams.map(_.value).mkString("+")}"), + response = IO.pure(SearchCodeResult(total_count = 0, incomplete_results = false, items = Nil)) + ) + + val search = new SearchInterpreter[IO] + + search + .searchCode("foobar", searchParams, headers = headerUserAgent) + .shouldNotFail + } + +} diff --git a/github4s/shared/src/test/scala/github4s/utils/BaseIntegrationSpec.scala b/github4s/shared/src/test/scala/github4s/utils/BaseIntegrationSpec.scala index 955d9c3a..1a602289 100644 --- a/github4s/shared/src/test/scala/github4s/utils/BaseIntegrationSpec.scala +++ b/github4s/shared/src/test/scala/github4s/utils/BaseIntegrationSpec.scala @@ -40,6 +40,7 @@ class IntegrationSpec with UsersSpec with TeamsSpec with ProjectsSpec + with SearchSpec object Integration extends Tag( diff --git a/microsite/docs/search.md b/microsite/docs/search.md new file mode 100644 index 00000000..ad63d984 --- /dev/null +++ b/microsite/docs/search.md @@ -0,0 +1,59 @@ +--- +layout: docs +title: Search API +permalink: search +--- + +# Search API + +Github4s supports the [Search API](https://docs.github.com/rest/search). As a result, +with Github4s, you can interact with: + +- [Search code](#search-code) + +The following examples assume the following code: + +```scala mdoc:silent + +import cats.effect.IO +import github4s.Github +import org.http4s.client.{Client, JavaNetClientBuilder} + + +val httpClient: Client[IO] = JavaNetClientBuilder[IO].create // You can use any http4s backend + +val accessToken = sys.env.get("GITHUB_TOKEN") +val gh = Github[IO](httpClient, accessToken) +``` + +## Search code + +You can search code with `searchCode`; it takes as arguments: + +- `query`: query parameters (at least one must be provided). +- `searchParams`: search parameters (see ["Searching code"](https://docs.github.com/en/search-github/searching-on-github/searching-code)) +- `textMatches`: enable text matches location (see ["Text match metadata"](https://docs.github.com/en/rest/search#text-match-metadata)) +- `pagination`: Limit and Offset for pagination, optional. + +To search `github` in the scala code within this project repository: + +```scala mdoc:compile-only +val results = gh.search.searchCode( + query = "github", + searchParams = List( + SearchCodeParam.Repository("47degrees", "github4s"), + SearchCodeParam.Extension("scala") + ) +) +results.flatMap(_.result match { + case Left(e) => IO.println(s"Something went wrong: ${e.getMessage}") + case Right(r) => IO.println(r) +}) +``` + +The `result` on the right is the corresponding [SearchCodeResult][search-code-scala]. + +See [the API doc](https://docs.github.com/en/rest/search#search-code) for full reference. + + +[search-code-scala]: https://github.com/47degrees/github4s/blob/main/github4s/shared/src/main/scala/github4s/domain/SearchCode.scala