From 971ac09e8097c752fba83e0daee601c8afbaf219 Mon Sep 17 00:00:00 2001 From: Ruben Dijkstra Date: Thu, 3 Sep 2015 20:15:29 +0200 Subject: [PATCH 1/8] Add initial version of Review Window --- .gitignore | 1 + pom.xml | 95 +++++++++++ .../querydsl/webhooks/GithubReviewWindow.java | 154 ++++++++++++++++++ src/main/resources/application.properties | 5 + 4 files changed, 255 insertions(+) create mode 100644 .gitignore create mode 100644 pom.xml create mode 100644 src/main/java/com/querydsl/webhooks/GithubReviewWindow.java create mode 100644 src/main/resources/application.properties diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..eb5a316 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +target diff --git a/pom.xml b/pom.xml new file mode 100644 index 0000000..277e12d --- /dev/null +++ b/pom.xml @@ -0,0 +1,95 @@ + + + 4.0.0 + + + com.querydsl + querydsl-parent + 0.1.0 + + + GitHub Review Window + + A GitHub Wehook implementation for blocking Pull Requests until the review period has passed + + com.querydsl.webhooks + gh-review-window + 0.1.BUILD-SNAPSHOT + jar + + https://github.com/querydsl/gh-review-window + + + UTF-8 + + 1.2.7.RELEASE + + https://github.com/querydsl/gh-review-window + https://github.com/querydsl/gh-review-window + + 1.8 + 1.8 + + + + ${project.checkout} + ${project.checkout} + ${project.githubpage} + + + + + com.github.shredder121 + gh-event-api + 0.2 + + + org.kohsuke + github-api + 1.62 + + + + + + + org.apache.maven.plugins + maven-compiler-plugin + 2.3.2 + + ${maven.compiler.source} + ${maven.compiler.target} + + + + org.springframework.boot + spring-boot-maven-plugin + ${spring.boot.version} + + + + repackage + + + + + full + + + + + + + + + + org.springframework.boot + spring-boot-dependencies + ${spring.boot.version} + pom + import + + + + diff --git a/src/main/java/com/querydsl/webhooks/GithubReviewWindow.java b/src/main/java/com/querydsl/webhooks/GithubReviewWindow.java new file mode 100644 index 0000000..93fb80e --- /dev/null +++ b/src/main/java/com/querydsl/webhooks/GithubReviewWindow.java @@ -0,0 +1,154 @@ +/* + * Copyright 2015 The Querydsl Team. + * + * 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 com.querydsl.webhooks; + +import static java.time.ZonedDateTime.now; + +import java.io.IOException; +import java.time.Duration; +import java.time.ZonedDateTime; +import java.util.Collection; +import java.util.Date; +import java.util.Map; +import java.util.Objects; +import java.util.concurrent.ScheduledFuture; + +import org.kohsuke.github.*; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.context.annotation.Bean; +import org.springframework.core.env.Environment; +import org.springframework.scheduling.TaskScheduler; +import org.springframework.scheduling.concurrent.ConcurrentTaskScheduler; + +import com.github.shredder121.gh_event_api.GHEventApiServer; +import com.github.shredder121.gh_event_api.handler.pull_request.*; +import com.github.shredder121.gh_event_api.model.PullRequest; +import com.github.shredder121.gh_event_api.model.Ref; +import com.google.common.base.Throwables; +import com.google.common.collect.Maps; + +/** + * GitHub Review Window - A GitHub Webhook Implementation. + * + *

+ * When using Pull Requests, it's often necessary to review their contents. + * + *

+ * This piece of software adds a commit status to the PR's head commit, essentially blocking + * The mergability until the duration of the review window has passed. + * + *

+ * usage: + * {@code java -Dduration=(defaultDurationString) [-Dduration.(labelName)=(durationString)] -jar gh-review-window-(version)-full.jar } + * + * @author Shredder121 + */ +@SpringBootApplication +public class GithubReviewWindow { + + private static final Logger logger = LoggerFactory.getLogger(GithubReviewWindow.class); + + private static final GitHub github; + + private final TaskScheduler taskScheduler = new ConcurrentTaskScheduler(); + + static { + try { + github = GitHub.connect(); + } catch (IOException ex) { + throw Throwables.propagate(ex); + } + } + + public static void main(String... args) { + GHEventApiServer.start(GithubReviewWindow.class, args); + } + + @Bean + public PullRequestHandler reviewWindowHandler(Environment environment) { + Duration defaultReviewWindow = Duration.parse(environment.getRequiredProperty("duration")); //duration is the default window + Map> asyncTasks = Maps.newConcurrentMap(); + + return payload -> { + PullRequest pullRequest = payload.getPullRequest(); + Ref head = pullRequest.getHead(); + + try { + GHRepository repository = github.getRepository(payload.getRepository().getFullName()); + Collection labels = repository.getIssue(pullRequest.getNumber()).getLabels(); + + Duration reviewTime = labels.stream().map(label -> "duration." + label.getName()) //for all duration.[label] properties + .map(environment::getProperty).filter(Objects::nonNull) //look for a Duration + .findFirst().map(Duration::parse).orElse(defaultReviewWindow); //if none found, use the default window + + ZonedDateTime creationTime = pullRequest.getCreated_at(); + ZonedDateTime windowCloseTime = creationTime.plus(reviewTime); + + boolean windowPassed = now().isAfter(windowCloseTime); + logger.info("creationTime({}) + reviewTime({}) = windowCloseTime({}), so windowPassed = {}", + creationTime, reviewTime, windowCloseTime, windowPassed); + + if (windowPassed) { + completeAndCleanUp(asyncTasks, repository, head); + } else { + createPendingMessage(repository, head); + + ScheduledFuture scheduledTask = taskScheduler.schedule( + () -> completeAndCleanUp(asyncTasks, repository, head), + Date.from(windowCloseTime.toInstant())); + + replaceCompletionTask(asyncTasks, scheduledTask, head); + } + } catch (IOException ex) { + throw Throwables.propagate(ex); + } + }; + } + + private static void completeAndCleanUp(Map tasks, GHRepository repo, Ref head) { + createSuccessMessage(repo, head); + tasks.remove(head.getSha()); + } + + private static void replaceCompletionTask(Map> tasks, + ScheduledFuture completionTask, Ref head) { + + boolean interrupt = false; + tasks.merge(head.getSha(), completionTask, (oldTask, newTask) -> { + oldTask.cancel(interrupt); + return newTask; + }); + } + + private static void createSuccessMessage(GHRepository repo, Ref commit) { + createStatusMessage(repo, commit, GHCommitState.SUCCESS, "The review window has passed"); + } + + private static void createPendingMessage(GHRepository repo, Ref commit) { + createStatusMessage(repo, commit, GHCommitState.PENDING, "The review window has not passed"); + } + + private static void createStatusMessage(GHRepository repo, Ref commit, GHCommitState state, String message) { + try { + repo.createCommitStatus(commit.getSha(), state, null, message, "review-window"); + } catch (IOException ex) { + logger.warn("Exception updating status", ex); + } + } + +} diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties new file mode 100644 index 0000000..5aceafc --- /dev/null +++ b/src/main/resources/application.properties @@ -0,0 +1,5 @@ +# the default review window duration is 2 days (see java.time.Duration#parse for details on the pattern used) +duration=P2D + +# uncomment this line for GitHub MAC checking +#secret=[secret] From ef40565ece33d5f4a43890ff1431a0a87576559c Mon Sep 17 00:00:00 2001 From: Ruben Dijkstra Date: Sun, 11 Oct 2015 20:40:25 +0200 Subject: [PATCH 2/8] Add README.md --- README.md | 35 +++++++++++++++++++++++++++++++++++ 1 file changed, 35 insertions(+) create mode 100644 README.md diff --git a/README.md b/README.md new file mode 100644 index 0000000..a153d6a --- /dev/null +++ b/README.md @@ -0,0 +1,35 @@ +## Github Review Window + +A GitHub Webhook Implementation + +To run it via Maven and the spring-boot plugin: +```sh +$ mvn spring-boot:run [OPTIONS] +``` + +To run it on the command line: +```sh +$ java [OPTIONS] -jar gh-review-window-(version)-full.jar +``` + +Where `OPTIONS` include: + +| option | description | +|------------------|----------------------------------------------------------------------------| +| duration | **required** This is the default window duration | +| duration.`LABEL` | Additional durations for specific labels | +| secret | The secret that will be used to [compute the HMAC][securing your webhooks] | + +For the syntax of period strings, see the [`java.time.Duration` javadoc][javadoc duration]. + +This is a subset of Spring Boot's autoconfiguration, +see the list of [common application properties][properties] for other supported configuration options + +Example: +```sh +$ mvn spring-boot:run -Dduration=P2D +``` + +[javadoc duration]: http://docs.oracle.com/javase/8/docs/api/java/time/Duration.html +[properties]: http://docs.spring.io/spring-boot/docs/current/reference/html/common-application-properties.html +[securing your webhooks]: https://developer.github.com/webhooks/securing/ From 4be2001d2096d922a195a1e17e6b6a4f62a9d26f Mon Sep 17 00:00:00 2001 From: Ruben Dijkstra Date: Mon, 12 Oct 2015 07:08:41 +0200 Subject: [PATCH 3/8] Document the oauth token needed by Review Window --- README.md | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index a153d6a..8d3410c 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -## Github Review Window +# Github Review Window A GitHub Webhook Implementation @@ -23,13 +23,22 @@ Where `OPTIONS` include: For the syntax of period strings, see the [`java.time.Duration` javadoc][javadoc duration]. This is a subset of Spring Boot's autoconfiguration, -see the list of [common application properties][properties] for other supported configuration options +see the list of [common application properties][properties] for other supported configuration options. Example: ```sh $ mvn spring-boot:run -Dduration=P2D ``` +## Oauth + +In order to give Review Window the ability to add commit statuses, you need to specify +credentials that it can use to access those. + + - Generate an Oauth token that gives `repo:status` access. + - Add it to your environment or `~/.github` file as `github_oauth`. + + [javadoc duration]: http://docs.oracle.com/javase/8/docs/api/java/time/Duration.html [properties]: http://docs.spring.io/spring-boot/docs/current/reference/html/common-application-properties.html [securing your webhooks]: https://developer.github.com/webhooks/securing/ From 4ffeb042d3f2d154f0554f9c0fea73c4887da67b Mon Sep 17 00:00:00 2001 From: Ruben Dijkstra Date: Tue, 20 Oct 2015 15:17:00 +0200 Subject: [PATCH 4/8] Add integration test for Review Window --- .travis.yml | 12 +++ pom.xml | 6 ++ .../webhooks/GithubReviewWindowTest.java | 75 +++++++++++++++++++ 3 files changed, 93 insertions(+) create mode 100644 .travis.yml create mode 100644 src/test/java/com/querydsl/webhooks/GithubReviewWindowTest.java diff --git a/.travis.yml b/.travis.yml new file mode 100644 index 0000000..4debaf1 --- /dev/null +++ b/.travis.yml @@ -0,0 +1,12 @@ +language: java + +jdk: + - oraclejdk8 + +install: mvn -B -q install -DskipTests=true + +script: mvn -B test + +cache: + directories: + - $HOME/.m2 diff --git a/pom.xml b/pom.xml index 277e12d..4ffd920 100644 --- a/pom.xml +++ b/pom.xml @@ -49,6 +49,12 @@ github-api 1.62 + + + org.springframework.boot + spring-boot-starter-test + test + diff --git a/src/test/java/com/querydsl/webhooks/GithubReviewWindowTest.java b/src/test/java/com/querydsl/webhooks/GithubReviewWindowTest.java new file mode 100644 index 0000000..feaafa0 --- /dev/null +++ b/src/test/java/com/querydsl/webhooks/GithubReviewWindowTest.java @@ -0,0 +1,75 @@ +package com.querydsl.webhooks; + +import static org.hamcrest.CoreMatchers.is; + +import java.util.concurrent.CountDownLatch; + +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.ErrorCollector; +import org.junit.runner.RunWith; +import org.springframework.boot.test.*; +import org.springframework.core.env.Environment; +import org.springframework.http.*; +import org.springframework.test.annotation.DirtiesContext; +import org.springframework.test.context.junit4.SpringJUnit4ClassRunner; + +import com.github.shredder121.gh_event_api.GHEventApiServer; +import com.github.shredder121.gh_event_api.handler.pull_request.PullRequestHandler; + +@RunWith(SpringJUnit4ClassRunner.class) +@WebIntegrationTest("spring.main.show-banner=false") +@SpringApplicationConfiguration(classes = {GithubReviewWindowTest.class, GHEventApiServer.class}) +@DirtiesContext +public class GithubReviewWindowTest extends GithubReviewWindow { + + private static final CountDownLatch completion = new CountDownLatch(1); + + @Rule + public final ErrorCollector errorCollector = new ErrorCollector(); + + private final TestRestTemplate restTemplate = new TestRestTemplate(); + + @Test + public void TestServerShouldStart() { + ResponseEntity pingResponse = post("ping", "{\"zen\": \"all good!\"}"); + errorCollector.checkThat("Server should be started", + pingResponse.getStatusCode(), is(HttpStatus.OK)); + errorCollector.checkThat("Server should be started", + pingResponse.getBody(), is("all good!")); + } + + @Test + public void TestServerShouldStartAndAcceptPullRequests() throws InterruptedException { + ResponseEntity pullRequestResponse = post("pull_request", + "{\n" + + " \"action\": \"unlabeled\",\n" + + " \"number\": 1,\n" + + " \"pull_request\": {},\n" + + " \"label\": {},\n" + + " \"repository\": {},\n" + + " \"sender\": {}\n" + + "}"); + errorCollector.checkThat("Server should be started and accepting pull_request events", + pullRequestResponse.getStatusCode(), is(HttpStatus.OK)); + completion.await(); + } + + private ResponseEntity post(String event, String body) { + HttpHeaders headers = new HttpHeaders(); + headers.add("X-GitHub-Event", event); + headers.setContentType(MediaType.APPLICATION_JSON); + + return restTemplate.postForEntity("http://127.0.0.1:8080", new HttpEntity<>(body, headers), String.class); + } + + @Override + public PullRequestHandler reviewWindowHandler(Environment environment) { + // Compile time assertion that the handler bean method is invoked + return payload -> { + // don't actually handle the payload + completion.countDown(); + }; + } + +} From 14caba3b6d2052db967282eacdab3da846352f7b Mon Sep 17 00:00:00 2001 From: Ruben Dijkstra Date: Tue, 20 Oct 2015 15:40:54 +0200 Subject: [PATCH 5/8] Update .gitignore --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index eb5a316..77f60d0 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,2 @@ target +nb-configuration.xml From ec8a61692344f08bf8b794b4df0f6c3b7d7de360 Mon Sep 17 00:00:00 2001 From: Ruben Dijkstra Date: Tue, 20 Oct 2015 23:53:47 +0200 Subject: [PATCH 6/8] Convert integration test to readable rest-assured --- pom.xml | 6 ++ .../webhooks/GithubReviewWindowTest.java | 57 +++++++++---------- 2 files changed, 34 insertions(+), 29 deletions(-) diff --git a/pom.xml b/pom.xml index 4ffd920..d909c43 100644 --- a/pom.xml +++ b/pom.xml @@ -55,6 +55,12 @@ spring-boot-starter-test test + + com.jayway.restassured + rest-assured + 2.5.0 + test + diff --git a/src/test/java/com/querydsl/webhooks/GithubReviewWindowTest.java b/src/test/java/com/querydsl/webhooks/GithubReviewWindowTest.java index feaafa0..97300ab 100644 --- a/src/test/java/com/querydsl/webhooks/GithubReviewWindowTest.java +++ b/src/test/java/com/querydsl/webhooks/GithubReviewWindowTest.java @@ -1,6 +1,8 @@ package com.querydsl.webhooks; -import static org.hamcrest.CoreMatchers.is; +import static com.jayway.restassured.RestAssured.given; +import static com.jayway.restassured.http.ContentType.JSON; +import static org.hamcrest.Matchers.is; import java.util.concurrent.CountDownLatch; @@ -10,7 +12,6 @@ import org.junit.runner.RunWith; import org.springframework.boot.test.*; import org.springframework.core.env.Environment; -import org.springframework.http.*; import org.springframework.test.annotation.DirtiesContext; import org.springframework.test.context.junit4.SpringJUnit4ClassRunner; @@ -28,39 +29,37 @@ public class GithubReviewWindowTest extends GithubReviewWindow { @Rule public final ErrorCollector errorCollector = new ErrorCollector(); - private final TestRestTemplate restTemplate = new TestRestTemplate(); - @Test public void TestServerShouldStart() { - ResponseEntity pingResponse = post("ping", "{\"zen\": \"all good!\"}"); - errorCollector.checkThat("Server should be started", - pingResponse.getStatusCode(), is(HttpStatus.OK)); - errorCollector.checkThat("Server should be started", - pingResponse.getBody(), is("all good!")); - } + String pingPayload + = "{\n" + + " \"zen\": \"all good!\"\n" + + "}"; - @Test - public void TestServerShouldStartAndAcceptPullRequests() throws InterruptedException { - ResponseEntity pullRequestResponse = post("pull_request", - "{\n" + - " \"action\": \"unlabeled\",\n" + - " \"number\": 1,\n" + - " \"pull_request\": {},\n" + - " \"label\": {},\n" + - " \"repository\": {},\n" + - " \"sender\": {}\n" + - "}"); - errorCollector.checkThat("Server should be started and accepting pull_request events", - pullRequestResponse.getStatusCode(), is(HttpStatus.OK)); - completion.await(); + given().header("X-GitHub-Event", "ping") + .and().body(pingPayload).with().contentType(JSON) + .expect().statusCode(200) + .and().content(is("all good!")) + .when().post(); } - private ResponseEntity post(String event, String body) { - HttpHeaders headers = new HttpHeaders(); - headers.add("X-GitHub-Event", event); - headers.setContentType(MediaType.APPLICATION_JSON); + @Test + public void TestServerShouldStartAndAcceptPullRequestEvents() throws InterruptedException { + String pullRequestPayload + = "{\n" + + " \"action\": \"unlabeled\",\n" + + " \"number\": 1,\n" + + " \"pull_request\": {},\n" + + " \"label\": {},\n" + + " \"repository\": {},\n" + + " \"sender\": {}\n" + + "}"; - return restTemplate.postForEntity("http://127.0.0.1:8080", new HttpEntity<>(body, headers), String.class); + given().header("X-GitHub-Event", "pull_request") + .and().body(pullRequestPayload).with().contentType(JSON) + .expect().statusCode(200) + .when().post(); + completion.await(); } @Override From b1a0749f9a77a757a9bc9dc87bfff0fb7c461098 Mon Sep 17 00:00:00 2001 From: Ruben Dijkstra Date: Wed, 21 Oct 2015 21:31:30 +0200 Subject: [PATCH 7/8] Bump github-api to 1.70 --- pom.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pom.xml b/pom.xml index d909c43..5baa19b 100644 --- a/pom.xml +++ b/pom.xml @@ -47,7 +47,7 @@ org.kohsuke github-api - 1.62 + 1.70 From 0dbe67517d0242d8b1c514abc911857202d29760 Mon Sep 17 00:00:00 2001 From: Ruben Dijkstra Date: Wed, 21 Oct 2015 22:24:33 +0200 Subject: [PATCH 8/8] Add Travis badge --- README.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/README.md b/README.md index 8d3410c..dadf0ab 100644 --- a/README.md +++ b/README.md @@ -2,6 +2,8 @@ A GitHub Webhook Implementation +[![Build Status](https://travis-ci.org/querydsl/gh-review-window.svg)](https://travis-ci.org/querydsl/gh-review-window) + To run it via Maven and the spring-boot plugin: ```sh $ mvn spring-boot:run [OPTIONS]