Skip to content

Commit a501de1

Browse files
Support repo merging API (spotify#16)
* Support repo merging API API docs: https://developer.github.com/enterprise/2.18/v3/repos/merging/ * checkstyle
1 parent 9e8fc49 commit a501de1

4 files changed

Lines changed: 226 additions & 0 deletions

File tree

src/main/java/com/spotify/github/v3/clients/RepositoryClient.java

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@
4545
import java.lang.invoke.MethodHandles;
4646
import java.util.Iterator;
4747
import java.util.List;
48+
import java.util.Optional;
4849
import java.util.concurrent.CompletableFuture;
4950
import java.util.concurrent.CompletionException;
5051
import org.slf4j.Logger;
@@ -56,6 +57,8 @@ public class RepositoryClient {
5657
private static final Logger log = LoggerFactory.getLogger(MethodHandles.lookup().lookupClass());
5758
private static final int CONFLICT = 409;
5859
private static final int UNPROCESSABLE_ENTITY = 422;
60+
private static final int NO_CONTENT = 204;
61+
5962
private static final String REPOSITORY_URI_TEMPLATE = "/repos/%s/%s";
6063
private static final String HOOK_URI_TEMPLATE = "/repos/%s/%s/hooks";
6164
private static final String CONTENTS_URI_TEMPLATE = "/repos/%s/%s/contents/%s%s";
@@ -69,6 +72,7 @@ public class RepositoryClient {
6972
private static final String CREATE_COMMENT_TEMPLATE = "/repos/%s/%s/commits/%s/comments";
7073
private static final String COMMENT_TEMPLATE = "/repos/%s/%s/comments/%s";
7174
private static final String LANGUAGES_TEMPLATE = "/repos/%s/%s/languages";
75+
private static final String MERGE_TEMPLATE = "/repos/%s/%s/merges";
7276

7377
private final String owner;
7478
private final String repo;
@@ -370,6 +374,58 @@ public CompletableFuture<Languages> getLanguages() {
370374
return github.request(path, Languages.class);
371375
}
372376

377+
/**
378+
* Perform a merge.
379+
*
380+
* @see "https://developer.github.com/enterprise/2.18/v3/repos/merging/"
381+
*
382+
* @param base branch name or sha
383+
* @param head branch name or sha
384+
* @return resulting merge commit, or empty if base already contains the head (nothing to merge)
385+
*/
386+
public CompletableFuture<Optional<CommitItem>> merge(final String base, final String head) {
387+
return merge(base, head, null);
388+
}
389+
390+
/**
391+
* Perform a merge.
392+
*
393+
* @see "https://developer.github.com/enterprise/2.18/v3/repos/merging/"
394+
*
395+
* @param base branch name that the head will be merged into
396+
* @param head branch name or sha to merge
397+
* @param commitMessage commit message to use for the merge commit
398+
* @return resulting merge commit, or empty if base already contains the head (nothing to merge)
399+
*/
400+
public CompletableFuture<Optional<CommitItem>> merge(
401+
final String base, final String head, final String commitMessage) {
402+
final String path = String.format(MERGE_TEMPLATE, owner, repo);
403+
final ImmutableMap<String, String> params =
404+
(commitMessage == null)
405+
? ImmutableMap.of("base", base, "head", head)
406+
: ImmutableMap.of("base", base, "head", head, "commit_message", commitMessage);
407+
final String body = github.json().toJsonUnchecked(params);
408+
409+
return github
410+
.post(path, body)
411+
.thenApply(
412+
response -> {
413+
// Non-successful statuses result in an RequestNotOkException exception and this code
414+
// not being called.
415+
416+
if (response.code() == NO_CONTENT) {
417+
// Base already contains the head, nothing to merge
418+
return Optional.empty();
419+
}
420+
final CommitItem commitItem =
421+
github
422+
.json()
423+
.fromJsonUnchecked(
424+
GitHubClient.responseBodyUnchecked(response), CommitItem.class);
425+
return Optional.of(commitItem);
426+
});
427+
}
428+
373429
private String getContentPath(final String path, final String query) {
374430
if (path.startsWith("/") || path.endsWith("/")) {
375431
throw new IllegalArgumentException(path + " starts or ends with '/'");

src/test/java/com/spotify/github/v3/clients/GitHubClientTest.java

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,14 +20,22 @@
2020

2121
package com.spotify.github.v3.clients;
2222

23+
import static org.hamcrest.CoreMatchers.containsString;
24+
import static org.hamcrest.MatcherAssert.assertThat;
25+
import static org.hamcrest.core.Is.is;
26+
import static org.junit.Assert.fail;
2327
import static org.mockito.ArgumentMatchers.any;
2428
import static org.mockito.Mockito.doNothing;
2529
import static org.mockito.Mockito.mock;
2630
import static org.mockito.Mockito.when;
2731

2832
import com.spotify.github.v3.exceptions.ReadOnlyRepositoryException;
33+
import com.spotify.github.v3.exceptions.RequestNotOkException;
34+
import com.spotify.github.v3.repos.CommitItem;
2935
import java.net.URI;
36+
import java.util.Optional;
3037
import java.util.concurrent.CompletableFuture;
38+
import java.util.concurrent.ExecutionException;
3139
import okhttp3.Call;
3240
import okhttp3.Callback;
3341
import okhttp3.MediaType;
@@ -90,4 +98,38 @@ public void testSearchIssue() throws Throwable {
9098
throw e.getCause();
9199
}
92100
}
101+
102+
@Test
103+
public void testRequestNotOkException() throws Throwable {
104+
final Call call = mock(Call.class);
105+
final ArgumentCaptor<Callback> capture = ArgumentCaptor.forClass(Callback.class);
106+
doNothing().when(call).enqueue(capture.capture());
107+
108+
final Response response = new okhttp3.Response.Builder()
109+
.code(409) // Conflict
110+
.body(
111+
ResponseBody.create(
112+
MediaType.get("application/json"),
113+
"{\n \"message\": \"Merge Conflict\"\n}"
114+
))
115+
.message("")
116+
.protocol(Protocol.HTTP_1_1)
117+
.request(new Request.Builder().url("http://localhost/").build())
118+
.build();
119+
120+
when(client.newCall(any())).thenReturn(call);
121+
RepositoryClient repoApi = github.createRepositoryClient("testorg", "testrepo");
122+
123+
CompletableFuture<Optional<CommitItem>> future = repoApi.merge("basebranch", "headbranch");
124+
capture.getValue().onResponse(call, response);
125+
try {
126+
future.get();
127+
fail("Did not throw");
128+
} catch (ExecutionException e) {
129+
assertThat(e.getCause() instanceof RequestNotOkException, is(true));
130+
RequestNotOkException e1 = (RequestNotOkException) e.getCause();
131+
assertThat(e1.statusCode(), is(409));
132+
assertThat(e1.getMessage(), containsString("Merge Conflict"));
133+
}
134+
}
93135
}

src/test/java/com/spotify/github/v3/clients/RepositoryClientTest.java

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@
3232
import static java.util.concurrent.CompletableFuture.completedFuture;
3333
import static org.hamcrest.MatcherAssert.assertThat;
3434
import static org.hamcrest.core.Is.is;
35+
import static org.mockito.ArgumentMatchers.any;
3536
import static org.mockito.Mockito.mock;
3637
import static org.mockito.Mockito.when;
3738

@@ -52,8 +53,12 @@
5253
import com.spotify.github.v3.repos.Status;
5354
import java.io.IOException;
5455
import java.util.List;
56+
import java.util.Optional;
5557
import java.util.concurrent.CompletableFuture;
5658
import okhttp3.Headers;
59+
import okhttp3.MediaType;
60+
import okhttp3.Protocol;
61+
import okhttp3.Request;
5762
import okhttp3.Response;
5863
import okhttp3.ResponseBody;
5964
import org.junit.Before;
@@ -253,4 +258,45 @@ public void testStatusesPaginationForeach() throws Exception {
253258
assertThat(listStatuses.get(0).id(), is(61764535L));
254259
assertThat(listStatuses.get(listStatuses.size() - 1).id(), is(61756641L));
255260
}
261+
262+
@Test
263+
public void merge() throws IOException {
264+
CompletableFuture<Response> okResponse = completedFuture(
265+
new Response.Builder()
266+
.request(new Request.Builder().url("http://example.com/whatever").build())
267+
.protocol(Protocol.HTTP_1_1)
268+
.message("")
269+
.code(201)
270+
.body(
271+
ResponseBody.create(
272+
MediaType.get("application/json"),
273+
getFixture("merge_commit_item.json")
274+
))
275+
.build());
276+
final String expectedRequestBody = json.toJsonUnchecked(ImmutableMap.of(
277+
"base", "basebranch",
278+
"head", "headbranch"));
279+
when(github
280+
.post("/repos/someowner/somerepo/merges", expectedRequestBody))
281+
.thenReturn(okResponse);
282+
final CommitItem commit = repoClient.merge("basebranch", "headbranch").join().get();
283+
284+
assertThat(commit.parents().size(), is(2));
285+
assertThat(commit.parents().get(0).sha(), is("553c2077f0edc3d5dc5d17262f6aa498e69d6f8e"));
286+
assertThat(commit.parents().get(1).sha(), is("762941318ee16e59dabbacb1b4049eec22f0d303"));
287+
}
288+
289+
@Test
290+
public void mergeNoop() {
291+
CompletableFuture<Response> okResponse = completedFuture(
292+
new Response.Builder()
293+
.request(new Request.Builder().url("http://example.com/whatever").build())
294+
.protocol(Protocol.HTTP_1_1)
295+
.message("")
296+
.code(204) // No Content
297+
.build());
298+
when(github.post(any(), any())).thenReturn(okResponse);
299+
final Optional<CommitItem> maybeCommit = repoClient.merge("basebranch", "headbranch").join();
300+
assertThat(maybeCommit, is(Optional.empty()));
301+
}
256302
}
Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
{
2+
"sha": "7fd1a60b01f91b314f59955a4e4d4e80d8edf11d",
3+
"node_id": "MDY6Q29tbWl0N2ZkMWE2MGIwMWY5MWIzMTRmNTk5NTVhNGU0ZDRlODBkOGVkZjExZA==",
4+
"commit": {
5+
"author": {
6+
"name": "The Octocat",
7+
"date": "2012-03-06T15:06:50Z",
8+
"email": "octocat@nowhere.com"
9+
},
10+
"committer": {
11+
"name": "The Octocat",
12+
"date": "2012-03-06T15:06:50Z",
13+
"email": "octocat@nowhere.com"
14+
},
15+
"message": "Shipped cool_feature!",
16+
"tree": {
17+
"sha": "b4eecafa9be2f2006ce1b709d6857b07069b4608",
18+
"url": "https://api.github.com/repos/octocat/Hello-World/git/trees/b4eecafa9be2f2006ce1b709d6857b07069b4608"
19+
},
20+
"url": "https://api.github.com/repos/octocat/Hello-World/git/commits/7fd1a60b01f91b314f59955a4e4d4e80d8edf11d",
21+
"comment_count": 0,
22+
"verification": {
23+
"verified": false,
24+
"reason": "unsigned",
25+
"signature": null,
26+
"payload": null
27+
}
28+
},
29+
"url": "https://api.github.com/repos/octocat/Hello-World/commits/7fd1a60b01f91b314f59955a4e4d4e80d8edf11d",
30+
"html_url": "https://github.com/octocat/Hello-World/commit/7fd1a60b01f91b314f59955a4e4d4e80d8edf11d",
31+
"comments_url": "https://api.github.com/repos/octocat/Hello-World/commits/7fd1a60b01f91b314f59955a4e4d4e80d8edf11d/comments",
32+
"author": {
33+
"login": "octocat",
34+
"id": 1,
35+
"node_id": "MDQ6VXNlcjE=",
36+
"avatar_url": "https://github.com/images/error/octocat_happy.gif",
37+
"gravatar_id": "",
38+
"url": "https://api.github.com/users/octocat",
39+
"html_url": "https://github.com/octocat",
40+
"followers_url": "https://api.github.com/users/octocat/followers",
41+
"following_url": "https://api.github.com/users/octocat/following{/other_user}",
42+
"gists_url": "https://api.github.com/users/octocat/gists{/gist_id}",
43+
"starred_url": "https://api.github.com/users/octocat/starred{/owner}{/repo}",
44+
"subscriptions_url": "https://api.github.com/users/octocat/subscriptions",
45+
"organizations_url": "https://api.github.com/users/octocat/orgs",
46+
"repos_url": "https://api.github.com/users/octocat/repos",
47+
"events_url": "https://api.github.com/users/octocat/events{/privacy}",
48+
"received_events_url": "https://api.github.com/users/octocat/received_events",
49+
"type": "User",
50+
"site_admin": false
51+
},
52+
"committer": {
53+
"login": "octocat",
54+
"id": 1,
55+
"node_id": "MDQ6VXNlcjE=",
56+
"avatar_url": "https://github.com/images/error/octocat_happy.gif",
57+
"gravatar_id": "",
58+
"url": "https://api.github.com/users/octocat",
59+
"html_url": "https://github.com/octocat",
60+
"followers_url": "https://api.github.com/users/octocat/followers",
61+
"following_url": "https://api.github.com/users/octocat/following{/other_user}",
62+
"gists_url": "https://api.github.com/users/octocat/gists{/gist_id}",
63+
"starred_url": "https://api.github.com/users/octocat/starred{/owner}{/repo}",
64+
"subscriptions_url": "https://api.github.com/users/octocat/subscriptions",
65+
"organizations_url": "https://api.github.com/users/octocat/orgs",
66+
"repos_url": "https://api.github.com/users/octocat/repos",
67+
"events_url": "https://api.github.com/users/octocat/events{/privacy}",
68+
"received_events_url": "https://api.github.com/users/octocat/received_events",
69+
"type": "User",
70+
"site_admin": false
71+
},
72+
"parents": [
73+
{
74+
"sha": "553c2077f0edc3d5dc5d17262f6aa498e69d6f8e",
75+
"url": "https://api.github.com/repos/octocat/Hello-World/commits/553c2077f0edc3d5dc5d17262f6aa498e69d6f8e"
76+
},
77+
{
78+
"sha": "762941318ee16e59dabbacb1b4049eec22f0d303",
79+
"url": "https://api.github.com/repos/octocat/Hello-World/commits/762941318ee16e59dabbacb1b4049eec22f0d303"
80+
}
81+
]
82+
}

0 commit comments

Comments
 (0)