diff --git a/docs/src/main/tut/issue.md b/docs/src/main/tut/issue.md index c9eff4282..5cfd25d38 100644 --- a/docs/src/main/tut/issue.md +++ b/docs/src/main/tut/issue.md @@ -90,7 +90,7 @@ See [the API doc](https://developer.github.com/v3/issues/#edit-an-issue) for ful ### List issues -You can also list issues for a repository through `listIssues`; it take as arguments: +You can also list issues for a repository through `listIssues`; it takes as arguments: - the repository coordinates (`owner` and `name` of the repository). @@ -111,6 +111,29 @@ contain pull requests as Github considers pull requests as issues. See [the API doc](https://developer.github.com/v3/issues/#list-issues-for-a-repository) for full reference. +### Get a single issue + +You can also get a single issue of a repository through `getIssue`; it takes as arguments: + +- the repository coordinates (`owner` and `name` of the repository). +- `number`: The issue number. + +To get a single issue from a repository: + +```tut:silent +val issue = Github(accessToken).issues.getIssue("47deg", "github4s", 123) + +issue.exec[cats.Id, HttpResponse[String]]() match { + case Left(e) => println(s"Something went wrong: ${e.getMessage}") + case Right(r) => println(r.result) +} +``` + +The `result` on the right is the corresponding [Issue][issue-scala]. Note that it will +return pull requests as Github considers pull requests as issues. + +See [the API doc](https://developer.github.com/v3/issues/#get-a-single-issue) +for full reference. ### Search issues diff --git a/github4s/jvm/src/test/scala/github4s/unit/ApiSpec.scala b/github4s/jvm/src/test/scala/github4s/unit/ApiSpec.scala index 093a7a361..e8a31ac58 100644 --- a/github4s/jvm/src/test/scala/github4s/unit/ApiSpec.scala +++ b/github4s/jvm/src/test/scala/github4s/unit/ApiSpec.scala @@ -804,6 +804,26 @@ class ApiSpec response should be('left) } + "Issues >> Get" should "return the expected issue when a valid owner/repo is provided" in { + val response = + issues.get(accessToken, headerUserAgent, validRepoOwner, validRepoName, validIssueNumber) + response should be('right) + + response.toOption map { r ⇒ + r.statusCode shouldBe okStatusCode + } + } + it should "return an error if an invalid issue number is provided" in { + val response = + issues.get(accessToken, headerUserAgent, validRepoOwner, validRepoName, invalidIssueNumber) + response should be('left) + } + it should "return an error if no tokens are provided" in { + val response = + issues.get(None, headerUserAgent, validRepoOwner, validRepoName, validIssueNumber) + response should be('left) + } + "Issues >> Create" should "return the created issue if valid data is provided" in { val response = issues.create( accessToken, diff --git a/github4s/jvm/src/test/scala/github4s/utils/MockGithubApiServer.scala b/github4s/jvm/src/test/scala/github4s/utils/MockGithubApiServer.scala index 93c82fbab..c8aac7923 100644 --- a/github4s/jvm/src/test/scala/github4s/utils/MockGithubApiServer.scala +++ b/github4s/jvm/src/test/scala/github4s/utils/MockGithubApiServer.scala @@ -674,6 +674,23 @@ trait MockGithubApiServer extends MockServerService with FakeResponses with Test .withHeader("Authorization", tokenHeader)) .respond(response.withStatusCode(notFoundStatusCode).withBody(notFoundResponse)) + //Issues >> get + mockServer + .when( + request + .withMethod("GET") + .withPath(s"/repos/$validRepoOwner/$validRepoName/issues/$validIssueNumber") + .withHeader("Authorization", tokenHeader)) + .respond(response.withStatusCode(okStatusCode).withBody(getIssueValidResponse)) + + mockServer + .when( + request + .withMethod("GET") + .withPath(s"/repos/$validRepoOwner/$validRepoName/issues/$invalidIssueNumber") + .withHeader("Authorization", tokenHeader)) + .respond(response.withStatusCode(notFoundStatusCode).withBody(notFoundResponse)) + //Issues >> create mockServer .when( diff --git a/github4s/shared/src/main/scala/github4s/GithubAPIs.scala b/github4s/shared/src/main/scala/github4s/GithubAPIs.scala index 8a27a50c6..26c88f1ac 100644 --- a/github4s/shared/src/main/scala/github4s/GithubAPIs.scala +++ b/github4s/shared/src/main/scala/github4s/GithubAPIs.scala @@ -164,6 +164,13 @@ class GHIssues(accessToken: Option[String] = None)(implicit O: IssueOps[GitHub4s ): GHIO[GHResponse[List[Issue]]] = O.listIssues(owner, repo, accessToken) + def getIssue( + owner: String, + repo: String, + number: Int + ): GHIO[GHResponse[Issue]] = + O.getIssue(owner, repo, number, accessToken) + def searchIssues( query: String, searchParams: List[SearchParam] @@ -365,4 +372,4 @@ class GHOrganizations(accessToken: Option[String] = None)(implicit O: Organizati pagination: Option[Pagination] = None ): GHIO[GHResponse[List[User]]] = O.listMembers(org, filter, role, pagination, accessToken) -} \ No newline at end of file +} diff --git a/github4s/shared/src/main/scala/github4s/api/Issues.scala b/github4s/shared/src/main/scala/github4s/api/Issues.scala index e2eca0555..39c0e7b8d 100644 --- a/github4s/shared/src/main/scala/github4s/api/Issues.scala +++ b/github4s/shared/src/main/scala/github4s/api/Issues.scala @@ -55,6 +55,31 @@ class Issues[C, M[_]]( repo: String): M[GHResponse[List[Issue]]] = httpClient.get[List[Issue]](accessToken, s"repos/$owner/$repo/issues", headers) + /** + * Get a single issue of a repository + * + * Note: In the past, pull requests and issues were more closely aligned than they are now. + * As far as the API is concerned, every pull request is an issue, but not every issue is a + * pull request. + * + * This endpoint may also return pull requests in the response. If an issue is a pull request, + * the object will include a `pull_request` key. + * + * @param accessToken to identify the authenticated user + * @param headers optional user headers to include in the request + * @param owner of the repo + * @param repo name of the repo + * @param number Issue number + * @return a GHResponse with the issue list. + */ + def get( + accessToken: Option[String] = None, + headers: Map[String, String] = Map(), + owner: String, + repo: String, + number: Int): M[GHResponse[Issue]] = + httpClient.get[Issue](accessToken, s"repos/$owner/$repo/issues/$number", headers) + /** * Search for issues * diff --git a/github4s/shared/src/main/scala/github4s/free/algebra/IssuesOps.scala b/github4s/shared/src/main/scala/github4s/free/algebra/IssuesOps.scala index 689da2392..db414280b 100644 --- a/github4s/shared/src/main/scala/github4s/free/algebra/IssuesOps.scala +++ b/github4s/shared/src/main/scala/github4s/free/algebra/IssuesOps.scala @@ -32,6 +32,13 @@ final case class ListIssues( accessToken: Option[String] = None ) extends IssueOp[GHResponse[List[Issue]]] +final case class GetIssue( + owner: String, + repo: String, + number: Int, + accessToken: Option[String] = None +) extends IssueOp[GHResponse[Issue]] + final case class SearchIssues( query: String, searchParams: List[SearchParam], @@ -98,6 +105,14 @@ class IssueOps[F[_]](implicit I: InjectK[IssueOp, F]) { ): Free[F, GHResponse[List[Issue]]] = Free.inject[IssueOp, F](ListIssues(owner, repo, accessToken)) + def getIssue( + owner: String, + repo: String, + number: Int, + accessToken: Option[String] = None + ): Free[F, GHResponse[Issue]] = + Free.inject[IssueOp, F](GetIssue(owner, repo, number, accessToken)) + def searchIssues( query: String, searchParams: List[SearchParam], diff --git a/github4s/shared/src/main/scala/github4s/free/interpreters/Interpreters.scala b/github4s/shared/src/main/scala/github4s/free/interpreters/Interpreters.scala index e43e8a9ab..4446b584c 100644 --- a/github4s/shared/src/main/scala/github4s/free/interpreters/Interpreters.scala +++ b/github4s/shared/src/main/scala/github4s/free/interpreters/Interpreters.scala @@ -187,6 +187,8 @@ class Interpreters[M[_], C]( fa match { case ListIssues(owner, repo, accessToken) ⇒ issues.list(accessToken, headers, owner, repo) + case GetIssue(owner, repo, number, accessToken) ⇒ + issues.get(accessToken, headers, owner, repo, number) case SearchIssues(query, searchParams, accessToken) ⇒ issues.search(accessToken, headers, query, searchParams) case CreateIssue(owner, repo, title, body, milestone, labels, assignees, accessToken) ⇒ diff --git a/github4s/shared/src/test/scala/github4s/unit/GHIssuesSpec.scala b/github4s/shared/src/test/scala/github4s/unit/GHIssuesSpec.scala index 5e4b53036..79da0fb9f 100644 --- a/github4s/shared/src/test/scala/github4s/unit/GHIssuesSpec.scala +++ b/github4s/shared/src/test/scala/github4s/unit/GHIssuesSpec.scala @@ -37,6 +37,18 @@ class GHIssuesSpec extends BaseSpec { ghIssues.listIssues(validRepoOwner, validRepoName) } + "GHIssues.getIssue" should "call to IssuesOps with the right parameters" in { + val response: Free[GitHub4s, GHResponse[Issue]] = + Free.pure(Right(GHResult(issue, okStatusCode, Map.empty))) + + val issuesOps = mock[IssueOpsTest] + (issuesOps.getIssue _) + .expects(validRepoOwner, validRepoName, validIssueNumber, sampleToken) + .returns(response) + val ghIssues = new GHIssues(sampleToken)(issuesOps) + ghIssues.getIssue(validRepoOwner, validRepoName, validIssueNumber) + } + "GHIssues.searchIssues" should "call to IssuesOps with the right parameters" in { val response: Free[GitHub4s, GHResponse[SearchIssuesResult]] = Free.pure(Right(GHResult(searchIssuesResult, okStatusCode, Map.empty))) diff --git a/github4s/shared/src/test/scala/github4s/unit/IssuesSpec.scala b/github4s/shared/src/test/scala/github4s/unit/IssuesSpec.scala index c5e7e16da..98f0cd533 100644 --- a/github4s/shared/src/test/scala/github4s/unit/IssuesSpec.scala +++ b/github4s/shared/src/test/scala/github4s/unit/IssuesSpec.scala @@ -40,6 +40,21 @@ class IssuesSpec extends BaseSpec { issues.list(sampleToken, headerUserAgent, validRepoOwner, validRepoName) } + "Issues.get" should "call httpClient.get with the right parameters" in { + val response: GHResponse[Issue] = + Right(GHResult(issue, okStatusCode, Map.empty)) + + val httpClientMock = httpClientMockGet[Issue]( + url = s"repos/$validRepoOwner/$validRepoName/issues/$validIssueNumber", + response = response + ) + + val issues = new Issues[String, Id] { + override val httpClient: HttpClient[String, Id] = httpClientMock + } + issues.get(sampleToken, headerUserAgent, validRepoOwner, validRepoName, validIssueNumber) + } + "Issues.search" should "call htppClient.get with the right parameters" in { val response: GHResponse[SearchIssuesResult] = Right(GHResult(searchIssuesResult, okStatusCode, Map.empty)) diff --git a/github4s/shared/src/test/scala/github4s/utils/FakeResponses.scala b/github4s/shared/src/test/scala/github4s/utils/FakeResponses.scala index 42c597ba5..8e8652649 100644 --- a/github4s/shared/src/test/scala/github4s/utils/FakeResponses.scala +++ b/github4s/shared/src/test/scala/github4s/utils/FakeResponses.scala @@ -1791,6 +1791,125 @@ trait FakeResponses { | } |]""".stripMargin + val getIssueValidResponse = + """ + |{ + | "url": "https://api.github.com/repos/47deg/github4s/issues/48", + | "repository_url": "https://api.github.com/repos/47deg/github4s", + | "labels_url": "https://api.github.com/repos/47deg/github4s/issues/48/labels{/name}", + | "comments_url": "https://api.github.com/repos/47deg/github4s/issues/48/comments", + | "events_url": "https://api.github.com/repos/47deg/github4s/issues/48/events", + | "html_url": "https://github.com/47deg/github4s/issues/48", + | "id": 198471647, + | "number": 48, + | "title": "Sample Title", + | "user": { + | "login": "fedefernandez", + | "id": 720923, + | "avatar_url": "https://avatars0.githubusercontent.com/u/720923?v=4", + | "gravatar_id": "", + | "url": "https://api.github.com/users/fedefernandez", + | "html_url": "https://github.com/fedefernandez", + | "followers_url": "https://api.github.com/users/fedefernandez/followers", + | "following_url": "https://api.github.com/users/fedefernandez/following{/other_user}", + | "gists_url": "https://api.github.com/users/fedefernandez/gists{/gist_id}", + | "starred_url": "https://api.github.com/users/fedefernandez/starred{/owner}{/repo}", + | "subscriptions_url": "https://api.github.com/users/fedefernandez/subscriptions", + | "organizations_url": "https://api.github.com/users/fedefernandez/orgs", + | "repos_url": "https://api.github.com/users/fedefernandez/repos", + | "events_url": "https://api.github.com/users/fedefernandez/events{/privacy}", + | "received_events_url": "https://api.github.com/users/fedefernandez/received_events", + | "type": "User", + | "site_admin": false + | }, + | "labels": [ + | { + | "id": 337667889, + | "url": "https://api.github.com/repos/47deg/github4s/labels/bug", + | "name": "bug", + | "color": "fc2929", + | "default": true + | }, + | { + | "id": 511000525, + | "url": "https://api.github.com/repos/47deg/github4s/labels/code%20review", + | "name": "code review", + | "color": "ededed", + | "default": false + | } + | ], + | "state": "closed", + | "locked": false, + | "assignee": { + | "login": "rafaparadela", + | "id": 315070, + | "avatar_url": "https://avatars3.githubusercontent.com/u/315070?v=4", + | "gravatar_id": "", + | "url": "https://api.github.com/users/rafaparadela", + | "html_url": "https://github.com/rafaparadela", + | "followers_url": "https://api.github.com/users/rafaparadela/followers", + | "following_url": "https://api.github.com/users/rafaparadela/following{/other_user}", + | "gists_url": "https://api.github.com/users/rafaparadela/gists{/gist_id}", + | "starred_url": "https://api.github.com/users/rafaparadela/starred{/owner}{/repo}", + | "subscriptions_url": "https://api.github.com/users/rafaparadela/subscriptions", + | "organizations_url": "https://api.github.com/users/rafaparadela/orgs", + | "repos_url": "https://api.github.com/users/rafaparadela/repos", + | "events_url": "https://api.github.com/users/rafaparadela/events{/privacy}", + | "received_events_url": "https://api.github.com/users/rafaparadela/received_events", + | "type": "User", + | "site_admin": false + | }, + | "assignees": [ + | { + | "login": "rafaparadela", + | "id": 315070, + | "avatar_url": "https://avatars3.githubusercontent.com/u/315070?v=4", + | "gravatar_id": "", + | "url": "https://api.github.com/users/rafaparadela", + | "html_url": "https://github.com/rafaparadela", + | "followers_url": "https://api.github.com/users/rafaparadela/followers", + | "following_url": "https://api.github.com/users/rafaparadela/following{/other_user}", + | "gists_url": "https://api.github.com/users/rafaparadela/gists{/gist_id}", + | "starred_url": "https://api.github.com/users/rafaparadela/starred{/owner}{/repo}", + | "subscriptions_url": "https://api.github.com/users/rafaparadela/subscriptions", + | "organizations_url": "https://api.github.com/users/rafaparadela/orgs", + | "repos_url": "https://api.github.com/users/rafaparadela/repos", + | "events_url": "https://api.github.com/users/rafaparadela/events{/privacy}", + | "received_events_url": "https://api.github.com/users/rafaparadela/received_events", + | "type": "User", + | "site_admin": false + | } + | ], + | "milestone": null, + | "comments": 2, + | "created_at": "2017-01-03T13:12:55Z", + | "updated_at": "2018-01-09T18:45:33Z", + | "closed_at": "2017-01-03T13:13:30Z", + | "author_association": "CONTRIBUTOR", + | "body": "Sample Body", + | "closed_by": { + | "login": "fedefernandez", + | "id": 720923, + | "avatar_url": "https://avatars0.githubusercontent.com/u/720923?v=4", + | "gravatar_id": "", + | "url": "https://api.github.com/users/fedefernandez", + | "html_url": "https://github.com/fedefernandez", + | "followers_url": "https://api.github.com/users/fedefernandez/followers", + | "following_url": "https://api.github.com/users/fedefernandez/following{/other_user}", + | "gists_url": "https://api.github.com/users/fedefernandez/gists{/gist_id}", + | "starred_url": "https://api.github.com/users/fedefernandez/starred{/owner}{/repo}", + | "subscriptions_url": "https://api.github.com/users/fedefernandez/subscriptions", + | "organizations_url": "https://api.github.com/users/fedefernandez/orgs", + | "repos_url": "https://api.github.com/users/fedefernandez/repos", + | "events_url": "https://api.github.com/users/fedefernandez/events{/privacy}", + | "received_events_url": "https://api.github.com/users/fedefernandez/received_events", + | "type": "User", + | "site_admin": false + | } + |} + """.stripMargin + + val listReviewsValidResponse = """ |[