diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..77f60d0 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +target +nb-configuration.xml 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/README.md b/README.md new file mode 100644 index 0000000..dadf0ab --- /dev/null +++ b/README.md @@ -0,0 +1,46 @@ +# Github Review Window + +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] +``` + +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 +``` + +## 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/ diff --git a/pom.xml b/pom.xml new file mode 100644 index 0000000..5baa19b --- /dev/null +++ b/pom.xml @@ -0,0 +1,107 @@ + + + 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.70 + + + + org.springframework.boot + spring-boot-starter-test + test + + + com.jayway.restassured + rest-assured + 2.5.0 + test + + + + + + + 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] 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..97300ab --- /dev/null +++ b/src/test/java/com/querydsl/webhooks/GithubReviewWindowTest.java @@ -0,0 +1,74 @@ +package com.querydsl.webhooks; + +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; + +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.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(); + + @Test + public void TestServerShouldStart() { + String pingPayload + = "{\n" + + " \"zen\": \"all good!\"\n" + + "}"; + + given().header("X-GitHub-Event", "ping") + .and().body(pingPayload).with().contentType(JSON) + .expect().statusCode(200) + .and().content(is("all good!")) + .when().post(); + } + + @Test + public void TestServerShouldStartAndAcceptPullRequestEvents() throws InterruptedException { + String pullRequestPayload + = "{\n" + + " \"action\": \"unlabeled\",\n" + + " \"number\": 1,\n" + + " \"pull_request\": {},\n" + + " \"label\": {},\n" + + " \"repository\": {},\n" + + " \"sender\": {}\n" + + "}"; + + given().header("X-GitHub-Event", "pull_request") + .and().body(pullRequestPayload).with().contentType(JSON) + .expect().statusCode(200) + .when().post(); + completion.await(); + } + + @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(); + }; + } + +}