diff --git a/src/main/java/com/spotify/github/v3/clients/RepositoryClient.java b/src/main/java/com/spotify/github/v3/clients/RepositoryClient.java index 7ed5b144..ab635b51 100644 --- a/src/main/java/com/spotify/github/v3/clients/RepositoryClient.java +++ b/src/main/java/com/spotify/github/v3/clients/RepositoryClient.java @@ -45,6 +45,7 @@ import java.lang.invoke.MethodHandles; import java.util.Iterator; import java.util.List; +import java.util.Optional; import java.util.concurrent.CompletableFuture; import java.util.concurrent.CompletionException; import org.slf4j.Logger; @@ -56,6 +57,8 @@ public class RepositoryClient { private static final Logger log = LoggerFactory.getLogger(MethodHandles.lookup().lookupClass()); private static final int CONFLICT = 409; private static final int UNPROCESSABLE_ENTITY = 422; + private static final int NO_CONTENT = 204; + private static final String REPOSITORY_URI_TEMPLATE = "/repos/%s/%s"; private static final String HOOK_URI_TEMPLATE = "/repos/%s/%s/hooks"; private static final String CONTENTS_URI_TEMPLATE = "/repos/%s/%s/contents/%s%s"; @@ -69,6 +72,7 @@ public class RepositoryClient { private static final String CREATE_COMMENT_TEMPLATE = "/repos/%s/%s/commits/%s/comments"; private static final String COMMENT_TEMPLATE = "/repos/%s/%s/comments/%s"; private static final String LANGUAGES_TEMPLATE = "/repos/%s/%s/languages"; + private static final String MERGE_TEMPLATE = "/repos/%s/%s/merges"; private final String owner; private final String repo; @@ -370,6 +374,58 @@ public CompletableFuture getLanguages() { return github.request(path, Languages.class); } + /** + * Perform a merge. + * + * @see "https://developer.github.com/enterprise/2.18/v3/repos/merging/" + * + * @param base branch name or sha + * @param head branch name or sha + * @return resulting merge commit, or empty if base already contains the head (nothing to merge) + */ + public CompletableFuture> merge(final String base, final String head) { + return merge(base, head, null); + } + + /** + * Perform a merge. + * + * @see "https://developer.github.com/enterprise/2.18/v3/repos/merging/" + * + * @param base branch name that the head will be merged into + * @param head branch name or sha to merge + * @param commitMessage commit message to use for the merge commit + * @return resulting merge commit, or empty if base already contains the head (nothing to merge) + */ + public CompletableFuture> merge( + final String base, final String head, final String commitMessage) { + final String path = String.format(MERGE_TEMPLATE, owner, repo); + final ImmutableMap params = + (commitMessage == null) + ? ImmutableMap.of("base", base, "head", head) + : ImmutableMap.of("base", base, "head", head, "commit_message", commitMessage); + final String body = github.json().toJsonUnchecked(params); + + return github + .post(path, body) + .thenApply( + response -> { + // Non-successful statuses result in an RequestNotOkException exception and this code + // not being called. + + if (response.code() == NO_CONTENT) { + // Base already contains the head, nothing to merge + return Optional.empty(); + } + final CommitItem commitItem = + github + .json() + .fromJsonUnchecked( + GitHubClient.responseBodyUnchecked(response), CommitItem.class); + return Optional.of(commitItem); + }); + } + private String getContentPath(final String path, final String query) { if (path.startsWith("/") || path.endsWith("/")) { throw new IllegalArgumentException(path + " starts or ends with '/'"); diff --git a/src/test/java/com/spotify/github/v3/clients/GitHubClientTest.java b/src/test/java/com/spotify/github/v3/clients/GitHubClientTest.java index d49f5894..182a7a96 100644 --- a/src/test/java/com/spotify/github/v3/clients/GitHubClientTest.java +++ b/src/test/java/com/spotify/github/v3/clients/GitHubClientTest.java @@ -20,14 +20,22 @@ package com.spotify.github.v3.clients; +import static org.hamcrest.CoreMatchers.containsString; +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.core.Is.is; +import static org.junit.Assert.fail; import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.doNothing; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.when; import com.spotify.github.v3.exceptions.ReadOnlyRepositoryException; +import com.spotify.github.v3.exceptions.RequestNotOkException; +import com.spotify.github.v3.repos.CommitItem; import java.net.URI; +import java.util.Optional; import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ExecutionException; import okhttp3.Call; import okhttp3.Callback; import okhttp3.MediaType; @@ -90,4 +98,38 @@ public void testSearchIssue() throws Throwable { throw e.getCause(); } } + + @Test + public void testRequestNotOkException() throws Throwable { + final Call call = mock(Call.class); + final ArgumentCaptor capture = ArgumentCaptor.forClass(Callback.class); + doNothing().when(call).enqueue(capture.capture()); + + final Response response = new okhttp3.Response.Builder() + .code(409) // Conflict + .body( + ResponseBody.create( + MediaType.get("application/json"), + "{\n \"message\": \"Merge Conflict\"\n}" + )) + .message("") + .protocol(Protocol.HTTP_1_1) + .request(new Request.Builder().url("http://localhost/").build()) + .build(); + + when(client.newCall(any())).thenReturn(call); + RepositoryClient repoApi = github.createRepositoryClient("testorg", "testrepo"); + + CompletableFuture> future = repoApi.merge("basebranch", "headbranch"); + capture.getValue().onResponse(call, response); + try { + future.get(); + fail("Did not throw"); + } catch (ExecutionException e) { + assertThat(e.getCause() instanceof RequestNotOkException, is(true)); + RequestNotOkException e1 = (RequestNotOkException) e.getCause(); + assertThat(e1.statusCode(), is(409)); + assertThat(e1.getMessage(), containsString("Merge Conflict")); + } + } } diff --git a/src/test/java/com/spotify/github/v3/clients/RepositoryClientTest.java b/src/test/java/com/spotify/github/v3/clients/RepositoryClientTest.java index f97826f1..19a46577 100644 --- a/src/test/java/com/spotify/github/v3/clients/RepositoryClientTest.java +++ b/src/test/java/com/spotify/github/v3/clients/RepositoryClientTest.java @@ -32,6 +32,7 @@ import static java.util.concurrent.CompletableFuture.completedFuture; import static org.hamcrest.MatcherAssert.assertThat; import static org.hamcrest.core.Is.is; +import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.when; @@ -52,8 +53,12 @@ import com.spotify.github.v3.repos.Status; import java.io.IOException; import java.util.List; +import java.util.Optional; import java.util.concurrent.CompletableFuture; import okhttp3.Headers; +import okhttp3.MediaType; +import okhttp3.Protocol; +import okhttp3.Request; import okhttp3.Response; import okhttp3.ResponseBody; import org.junit.Before; @@ -253,4 +258,45 @@ public void testStatusesPaginationForeach() throws Exception { assertThat(listStatuses.get(0).id(), is(61764535L)); assertThat(listStatuses.get(listStatuses.size() - 1).id(), is(61756641L)); } + + @Test + public void merge() throws IOException { + CompletableFuture okResponse = completedFuture( + new Response.Builder() + .request(new Request.Builder().url("http://example.com/whatever").build()) + .protocol(Protocol.HTTP_1_1) + .message("") + .code(201) + .body( + ResponseBody.create( + MediaType.get("application/json"), + getFixture("merge_commit_item.json") + )) + .build()); + final String expectedRequestBody = json.toJsonUnchecked(ImmutableMap.of( + "base", "basebranch", + "head", "headbranch")); + when(github + .post("/repos/someowner/somerepo/merges", expectedRequestBody)) + .thenReturn(okResponse); + final CommitItem commit = repoClient.merge("basebranch", "headbranch").join().get(); + + assertThat(commit.parents().size(), is(2)); + assertThat(commit.parents().get(0).sha(), is("553c2077f0edc3d5dc5d17262f6aa498e69d6f8e")); + assertThat(commit.parents().get(1).sha(), is("762941318ee16e59dabbacb1b4049eec22f0d303")); + } + + @Test + public void mergeNoop() { + CompletableFuture okResponse = completedFuture( + new Response.Builder() + .request(new Request.Builder().url("http://example.com/whatever").build()) + .protocol(Protocol.HTTP_1_1) + .message("") + .code(204) // No Content + .build()); + when(github.post(any(), any())).thenReturn(okResponse); + final Optional maybeCommit = repoClient.merge("basebranch", "headbranch").join(); + assertThat(maybeCommit, is(Optional.empty())); + } } diff --git a/src/test/resources/com/spotify/github/v3/repos/merge_commit_item.json b/src/test/resources/com/spotify/github/v3/repos/merge_commit_item.json new file mode 100644 index 00000000..26408650 --- /dev/null +++ b/src/test/resources/com/spotify/github/v3/repos/merge_commit_item.json @@ -0,0 +1,82 @@ +{ + "sha": "7fd1a60b01f91b314f59955a4e4d4e80d8edf11d", + "node_id": "MDY6Q29tbWl0N2ZkMWE2MGIwMWY5MWIzMTRmNTk5NTVhNGU0ZDRlODBkOGVkZjExZA==", + "commit": { + "author": { + "name": "The Octocat", + "date": "2012-03-06T15:06:50Z", + "email": "octocat@nowhere.com" + }, + "committer": { + "name": "The Octocat", + "date": "2012-03-06T15:06:50Z", + "email": "octocat@nowhere.com" + }, + "message": "Shipped cool_feature!", + "tree": { + "sha": "b4eecafa9be2f2006ce1b709d6857b07069b4608", + "url": "https://api.github.com/repos/octocat/Hello-World/git/trees/b4eecafa9be2f2006ce1b709d6857b07069b4608" + }, + "url": "https://api.github.com/repos/octocat/Hello-World/git/commits/7fd1a60b01f91b314f59955a4e4d4e80d8edf11d", + "comment_count": 0, + "verification": { + "verified": false, + "reason": "unsigned", + "signature": null, + "payload": null + } + }, + "url": "https://api.github.com/repos/octocat/Hello-World/commits/7fd1a60b01f91b314f59955a4e4d4e80d8edf11d", + "html_url": "https://github.com/octocat/Hello-World/commit/7fd1a60b01f91b314f59955a4e4d4e80d8edf11d", + "comments_url": "https://api.github.com/repos/octocat/Hello-World/commits/7fd1a60b01f91b314f59955a4e4d4e80d8edf11d/comments", + "author": { + "login": "octocat", + "id": 1, + "node_id": "MDQ6VXNlcjE=", + "avatar_url": "https://github.com/images/error/octocat_happy.gif", + "gravatar_id": "", + "url": "https://api.github.com/users/octocat", + "html_url": "https://github.com/octocat", + "followers_url": "https://api.github.com/users/octocat/followers", + "following_url": "https://api.github.com/users/octocat/following{/other_user}", + "gists_url": "https://api.github.com/users/octocat/gists{/gist_id}", + "starred_url": "https://api.github.com/users/octocat/starred{/owner}{/repo}", + "subscriptions_url": "https://api.github.com/users/octocat/subscriptions", + "organizations_url": "https://api.github.com/users/octocat/orgs", + "repos_url": "https://api.github.com/users/octocat/repos", + "events_url": "https://api.github.com/users/octocat/events{/privacy}", + "received_events_url": "https://api.github.com/users/octocat/received_events", + "type": "User", + "site_admin": false + }, + "committer": { + "login": "octocat", + "id": 1, + "node_id": "MDQ6VXNlcjE=", + "avatar_url": "https://github.com/images/error/octocat_happy.gif", + "gravatar_id": "", + "url": "https://api.github.com/users/octocat", + "html_url": "https://github.com/octocat", + "followers_url": "https://api.github.com/users/octocat/followers", + "following_url": "https://api.github.com/users/octocat/following{/other_user}", + "gists_url": "https://api.github.com/users/octocat/gists{/gist_id}", + "starred_url": "https://api.github.com/users/octocat/starred{/owner}{/repo}", + "subscriptions_url": "https://api.github.com/users/octocat/subscriptions", + "organizations_url": "https://api.github.com/users/octocat/orgs", + "repos_url": "https://api.github.com/users/octocat/repos", + "events_url": "https://api.github.com/users/octocat/events{/privacy}", + "received_events_url": "https://api.github.com/users/octocat/received_events", + "type": "User", + "site_admin": false + }, + "parents": [ + { + "sha": "553c2077f0edc3d5dc5d17262f6aa498e69d6f8e", + "url": "https://api.github.com/repos/octocat/Hello-World/commits/553c2077f0edc3d5dc5d17262f6aa498e69d6f8e" + }, + { + "sha": "762941318ee16e59dabbacb1b4049eec22f0d303", + "url": "https://api.github.com/repos/octocat/Hello-World/commits/762941318ee16e59dabbacb1b4049eec22f0d303" + } + ] +} \ No newline at end of file