diff --git a/build.gradle b/build.gradle index 4e1b841..1a0b1a9 100644 --- a/build.gradle +++ b/build.gradle @@ -42,6 +42,7 @@ dependencies { annotationProcessor 'org.springframework.boot:spring-boot-configuration-processor' implementation 'org.springframework.boot:spring-boot-starter-actuator' implementation 'com.azure.spring:spring-cloud-azure-starter-keyvault-secrets' + implementation 'javax.xml.bind:jaxb-api:2.1' developmentOnly 'org.springframework.boot:spring-boot-devtools' testImplementation 'org.springframework.boot:spring-boot-starter-test' testImplementation 'org.springframework.security:spring-security-test' diff --git a/src/main/java/io/spring/renderer/RendererProperties.java b/src/main/java/io/spring/renderer/RendererProperties.java index a9cfef0..ab306da 100644 --- a/src/main/java/io/spring/renderer/RendererProperties.java +++ b/src/main/java/io/spring/renderer/RendererProperties.java @@ -43,6 +43,13 @@ public static class Github { @Pattern(regexp = "([0-9a-zA-Z_]*)?") private String token; + /** + * Name of the Github organization to fetch guides from. + */ + private String organization = "spring-guides"; + + private Webhook webhook = new Webhook(); + public String getToken() { return this.token; } @@ -51,11 +58,6 @@ public void setToken(String token) { this.token = token; } - /** - * Name of the Github organization to fetch guides from. - */ - private String organization = "spring-guides"; - public String getOrganization() { return this.organization; } @@ -64,6 +66,66 @@ public void setOrganization(String organization) { this.organization = organization; } + public Webhook getWebhook() { + return this.webhook; + } + + } + + public static class Webhook { + + /** + * Token configured in GitHub webhooks for this application. + */ + private String secret = "changeme"; + + /** + * Org name for dispatching Github Action. + */ + private String actionOrg; + + /** + * Repository name for dispatching Github Action. + */ + private String actionRepo; + + /** + * Token with repo scope for for dispatching Github Action. + */ + private String dispatchToken; + + public String getSecret() { + return this.secret; + } + + public void setSecret(String secret) { + this.secret = secret; + } + + public String getActionOrg() { + return this.actionOrg; + } + + public void setActionOrg(String actionOrg) { + this.actionOrg = actionOrg; + } + + public String getActionRepo() { + return this.actionRepo; + } + + public void setActionRepo(String actionRepo) { + this.actionRepo = actionRepo; + } + + public String getDispatchToken() { + return this.dispatchToken; + } + + public void setDispatchToken(String dispatchToken) { + this.dispatchToken = dispatchToken; + } + } } diff --git a/src/main/java/io/spring/renderer/SecurityConfiguration.java b/src/main/java/io/spring/renderer/SecurityConfiguration.java new file mode 100644 index 0000000..e24049d --- /dev/null +++ b/src/main/java/io/spring/renderer/SecurityConfiguration.java @@ -0,0 +1,42 @@ +/* + * Copyright 2022-2023 the original author or authors. + * + * 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 + * + * https://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 io.spring.renderer; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.web.SecurityFilterChain; + +/** + * Security configuration for the application. + * + * @author Madhura Bhave + */ +@Configuration +public class SecurityConfiguration { + + @Bean + public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { + http.authorizeHttpRequests((request) -> { + request.mvcMatchers("/webhook/guides").permitAll(); + request.anyRequest().authenticated(); + }); + http.csrf(csrf -> csrf.ignoringAntMatchers("/webhook/**")); + http.httpBasic(); + return http.build(); + } + +} diff --git a/src/main/java/io/spring/renderer/guides/webhook/GithubActionsService.java b/src/main/java/io/spring/renderer/guides/webhook/GithubActionsService.java new file mode 100644 index 0000000..2289fea --- /dev/null +++ b/src/main/java/io/spring/renderer/guides/webhook/GithubActionsService.java @@ -0,0 +1,57 @@ +/* + * Copyright 2022-2023 the original author or authors. + * + * 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 + * + * https://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 io.spring.renderer.guides.webhook; + +import org.springframework.boot.web.client.RestTemplateBuilder; +import org.springframework.http.RequestEntity; +import org.springframework.http.ResponseEntity; +import org.springframework.stereotype.Service; +import org.springframework.web.client.HttpClientErrorException; +import org.springframework.web.client.RestTemplate; + +/** + * Service to connect to Github Actions. + * + * @author Madhura Bhave + */ +@Service +class GithubActionsService { + + private final RestTemplate restTemplate; + + private static final String ACCEPT_HEADER = "application/vnd.github+json"; + + private static final String DISPATCH_PATH_TEMPLATE = "https://api.github.com/repos/{org}/{repo}/dispatches"; + + GithubActionsService(RestTemplateBuilder builder) { + this.restTemplate = builder.build(); + } + + void triggerRespositoryDispatch(String org, String repo, String token) { + RequestEntity entity = RequestEntity.post(DISPATCH_PATH_TEMPLATE, org, repo) + .header("Authorization", "Bearer " + token).header("Accept", ACCEPT_HEADER) + .body("{\"event_type\": \"guides\"}"); + try { + this.restTemplate.exchange(entity, Void.class); + } + catch (HttpClientErrorException ex) { + throw new RepositoryDispatchFailedException(ex.getRawStatusCode()); + } + + } + +} diff --git a/src/main/java/io/spring/renderer/guides/webhook/GuidesWebhookController.java b/src/main/java/io/spring/renderer/guides/webhook/GuidesWebhookController.java new file mode 100644 index 0000000..5f5bc3c --- /dev/null +++ b/src/main/java/io/spring/renderer/guides/webhook/GuidesWebhookController.java @@ -0,0 +1,135 @@ +/* + * Copyright 2022-2023 the original author or authors. + * + * 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 + * + * https://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 io.spring.renderer.guides.webhook; + +import java.io.IOException; +import java.nio.charset.Charset; +import java.nio.charset.StandardCharsets; +import java.security.InvalidKeyException; +import java.security.NoSuchAlgorithmException; +import java.util.Map; + +import javax.crypto.Mac; +import javax.crypto.spec.SecretKeySpec; +import javax.xml.bind.DatatypeConverter; + +import com.fasterxml.jackson.databind.ObjectMapper; +import io.spring.renderer.RendererProperties; +import org.apache.commons.logging.Log; +import org.apache.commons.logging.LogFactory; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestHeader; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +/** + * Controller that handles requests from GitHub webhook set up at + * the org level and triggers the Github + * Action to update the website. Github requests are signed with a shared secret, using an + * HMAC sha-1 algorithm. + */ +@RestController +@RequestMapping("/webhook/") +class GuidesWebhookController { + + private static final Log logger = LogFactory.getLog(GuidesWebhookController.class); + + private static final Charset CHARSET = StandardCharsets.UTF_8; + + private static final String HMAC_ALGORITHM = "HmacSHA1"; + + private static final String PING_EVENT = "ping"; + + private final ObjectMapper objectMapper; + + private final Mac hmac; + + private final GithubActionsService service; + + private final RendererProperties properties; + + @Autowired + public GuidesWebhookController(ObjectMapper objectMapper, RendererProperties properties, + GithubActionsService service) throws NoSuchAlgorithmException, InvalidKeyException { + this.objectMapper = objectMapper; + this.service = service; + // initialize HMAC with SHA1 algorithm and secret + SecretKeySpec secret = new SecretKeySpec(properties.getGithub().getWebhook().getSecret().getBytes(CHARSET), + HMAC_ALGORITHM); + this.hmac = Mac.getInstance(HMAC_ALGORITHM); + this.hmac.init(secret); + this.properties = properties; + } + + @PostMapping(path = "guides", consumes = "application/json", produces = "application/json") + public ResponseEntity processGuidesUpdate(@RequestBody String payload, + @RequestHeader("X-Hub-Signature") String signature, + @RequestHeader(name = "X-GitHub-Event", required = false, defaultValue = "push") String event) + throws IOException { + verifyHmacSignature(payload, signature); + if (PING_EVENT.equals(event)) { + return ResponseEntity.ok("{ \"message\": \"Successfully processed ping event\" }"); + } + Map push = this.objectMapper.readValue(payload, Map.class); + logPayload(push); + RendererProperties.Webhook webhook = this.properties.getGithub().getWebhook(); + this.service.triggerRespositoryDispatch(webhook.getActionOrg(), webhook.getActionRepo(), + webhook.getDispatchToken()); + return ResponseEntity.ok("{ \"message\": \"Successfully processed update\" }"); + } + + @ExceptionHandler(WebhookAuthenticationException.class) + public ResponseEntity handleWebhookAuthenticationFailure(WebhookAuthenticationException exception) { + logger.error("Webhook authentication failure: " + exception.getMessage()); + return ResponseEntity.status(HttpStatus.FORBIDDEN).body("{ \"message\": \"Forbidden\" }"); + } + + @ExceptionHandler(IOException.class) + public ResponseEntity handlePayloadParsingException(IOException exception) { + logger.error("Payload parsing exception", exception); + return ResponseEntity.badRequest().body("{ \"message\": \"Bad Request\" }"); + } + + private void verifyHmacSignature(String message, String signature) { + byte[] sig = hmac.doFinal(message.getBytes(CHARSET)); + String computedSignature = "sha1=" + DatatypeConverter.printHexBinary(sig); + if (!computedSignature.equalsIgnoreCase(signature)) { + throw new WebhookAuthenticationException(computedSignature, signature); + } + } + + private void logPayload(Map push) { + if (push.containsKey("head_commit")) { + final Object headCommit = push.get("head_commit"); + if (headCommit != null) { + final Map headCommitMap = (Map) headCommit; + logger.info("Received new webhook payload for push with head_commit message: " + + headCommitMap.get("message")); + } + } + else { + logger.info("Received new webhook payload for push, but with no head_commit"); + } + } + +} diff --git a/src/main/java/io/spring/renderer/guides/webhook/RepositoryDispatchFailedException.java b/src/main/java/io/spring/renderer/guides/webhook/RepositoryDispatchFailedException.java new file mode 100644 index 0000000..268c7c3 --- /dev/null +++ b/src/main/java/io/spring/renderer/guides/webhook/RepositoryDispatchFailedException.java @@ -0,0 +1,29 @@ +/* + * Copyright 2022-2023 the original author or authors. + * + * 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 + * + * https://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 io.spring.renderer.guides.webhook; + +/** + * Exception raised when the request to trigger a repository dispatch event returns + * anything other than an HTTP 2xx status. + */ +class RepositoryDispatchFailedException extends RuntimeException { + + RepositoryDispatchFailedException(int statusCode) { + super(String.format("Repository Dispatch failed with status code: '%d'", statusCode)); + } + +} diff --git a/src/main/java/io/spring/renderer/guides/webhook/WebhookAuthenticationException.java b/src/main/java/io/spring/renderer/guides/webhook/WebhookAuthenticationException.java new file mode 100644 index 0000000..00a64a1 --- /dev/null +++ b/src/main/java/io/spring/renderer/guides/webhook/WebhookAuthenticationException.java @@ -0,0 +1,29 @@ +/* + * Copyright 2022-2023 the original author or authors. + * + * 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 + * + * https://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 io.spring.renderer.guides.webhook; + +/** + * Exception raised when a github webhook message is received but its HMAC signature does + * not match the one computed with the shared secret. + */ +class WebhookAuthenticationException extends RuntimeException { + + WebhookAuthenticationException(String expected, String actual) { + super(String.format("Could not verify signature: '%s', expected '%s'", actual, expected)); + } + +} diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index ba180b7..c5c32ea 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -1,3 +1,4 @@ +spring.config.import: optional:secret.properties renderer: github: organization: spring-guides @@ -9,6 +10,11 @@ renderer: # locally, this lower default is fine. When running the app in production, the # token is a must. See http://developer.github.com/v3/#rate-limiting. token: ${renderer-github-token:} + webhook: + secret: ${renderer-github-webhook-secret:changeme} + action-org: ${renderer-github-webhook-action-org:} + action-repo: ${renderer-github-webhook-action-repo:} + dispatch-token: ${renderer-github-webhook-dispatch-token:} spring: security: user: diff --git a/src/test/java/io/spring/renderer/guides/webhook/GithubActionsServiceTests.java b/src/test/java/io/spring/renderer/guides/webhook/GithubActionsServiceTests.java new file mode 100644 index 0000000..247fbea --- /dev/null +++ b/src/test/java/io/spring/renderer/guides/webhook/GithubActionsServiceTests.java @@ -0,0 +1,82 @@ +/* + * Copyright 2022-2023 the original author or authors. + * + * 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 + * + * https://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 io.spring.renderer.guides.webhook; + +import org.assertj.core.api.Assertions; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import org.springframework.boot.test.web.client.MockServerRestTemplateCustomizer; +import org.springframework.boot.web.client.RestTemplateBuilder; +import org.springframework.http.HttpStatus; +import org.springframework.test.web.client.MockRestServiceServer; + +import static org.springframework.test.web.client.match.MockRestRequestMatchers.content; +import static org.springframework.test.web.client.match.MockRestRequestMatchers.header; +import static org.springframework.test.web.client.match.MockRestRequestMatchers.requestTo; +import static org.springframework.test.web.client.response.MockRestResponseCreators.withNoContent; +import static org.springframework.test.web.client.response.MockRestResponseCreators.withStatus; + +/** + * Test for {@link GithubActionsService}. + * + * @author Madhura Bhave + */ +class GithubActionsServiceTests { + + private static final String ORG_NAME = "test-org"; + + private static final String REPO_NAME = "test-repo"; + + private static final String TOKEN = "token"; + + private MockRestServiceServer server; + + private RestTemplateBuilder restTemplateBuilder; + + private GithubActionsService service; + + private static final String DISPATCH_PATH = "https://api.github.com/repos/test-org/test-repo/dispatches"; + + @BeforeEach + void setup() { + MockServerRestTemplateCustomizer mockServerCustomizer = new MockServerRestTemplateCustomizer(); + this.restTemplateBuilder = new RestTemplateBuilder(mockServerCustomizer); + this.service = new GithubActionsService(this.restTemplateBuilder); + this.server = mockServerCustomizer.getServer(); + } + + @Test + void triggerRepositoryDispatchWhenSuccessful() { + this.server.expect(requestTo(DISPATCH_PATH)).andExpect(header("Authorization", "Bearer token")) + .andExpect(header("Accept", "application/vnd.github+json")) + .andExpect(content().json("{\"event_type\": \"guides\"}")).andRespond(withNoContent()); + this.service.triggerRespositoryDispatch(ORG_NAME, REPO_NAME, TOKEN); + this.server.verify(); + } + + @Test + void triggerRepositoryDispatchWhenUnsuccessful() { + this.server.expect(requestTo(DISPATCH_PATH)).andExpect(header("Authorization", "Bearer token")) + .andExpect(header("Accept", "application/vnd.github+json")) + .andExpect(content().json("{\"event_type\": \"guides\"}")).andRespond(withStatus(HttpStatus.NOT_FOUND)); + Assertions.assertThatExceptionOfType(RepositoryDispatchFailedException.class) + .isThrownBy(() -> this.service.triggerRespositoryDispatch(ORG_NAME, REPO_NAME, TOKEN)); + this.server.verify(); + } + +} \ No newline at end of file diff --git a/src/test/java/io/spring/renderer/guides/webhook/GuidesWebhookControllerTests.java b/src/test/java/io/spring/renderer/guides/webhook/GuidesWebhookControllerTests.java new file mode 100644 index 0000000..df0ac0e --- /dev/null +++ b/src/test/java/io/spring/renderer/guides/webhook/GuidesWebhookControllerTests.java @@ -0,0 +1,106 @@ +/* + * Copyright 2022-2023 the original author or authors. + * + * 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 + * + * https://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 io.spring.renderer.guides.webhook; + +import java.nio.charset.StandardCharsets; + +import io.spring.renderer.SecurityConfiguration; +import org.junit.jupiter.api.Test; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.context.annotation.Import; +import org.springframework.core.io.ClassPathResource; +import org.springframework.http.MediaType; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders; +import org.springframework.test.web.servlet.result.MockMvcResultMatchers; +import org.springframework.util.StreamUtils; + +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; + +/** + * Tests for {@link GuidesWebhookController}. + */ +@WebMvcTest(value = GuidesWebhookController.class, + properties = { "renderer.github.webhook.secret=token", "renderer.github.webhook.action-org=test-org", + "renderer.github.webhook.action-repo=test-repo", + "renderer.github.webhook.dispatch-token=dispatch-token" }) +@Import(SecurityConfiguration.class) +class GuidesWebhookControllerTests { + + @Autowired + private MockMvc mockMvc; + + @MockBean + private GithubActionsService service; + + @Test + void missingHeadersShouldBeRejected() throws Exception { + mockMvc.perform(MockMvcRequestBuilders.post("/webhook/guides").accept(MediaType.APPLICATION_JSON) + .contentType(MediaType.APPLICATION_JSON).content("{\"message\": \"this is a test\"")) + .andExpect(MockMvcResultMatchers.status().isBadRequest()); + } + + @Test + void invalidHmacSignatureShouldBeRejected() throws Exception { + mockMvc.perform(MockMvcRequestBuilders.post("/webhook/guides").accept(MediaType.APPLICATION_JSON) + .contentType(MediaType.APPLICATION_JSON).header("X-Hub-Signature", "sha1=wronghmacvalue") + .header("X-GitHub-Event", "push").content("{\"message\": \"this is a test\"")) + .andExpect(MockMvcResultMatchers.status().isForbidden()) + .andExpect(MockMvcResultMatchers.content().string("{ \"message\": \"Forbidden\" }")); + } + + @Test + void pingEventShouldHaveResponse() throws Exception { + mockMvc.perform(MockMvcRequestBuilders.post("/webhook/guides").accept(MediaType.APPLICATION_JSON) + .contentType(MediaType.APPLICATION_JSON) + .header("X-Hub-Signature", "sha1=9BBB4C351EF0D50F93372CA787F338385981AA41") + .header("X-GitHub-Event", "ping").content(getTestPayload("ping"))) + .andExpect(MockMvcResultMatchers.status().isOk()).andExpect(MockMvcResultMatchers.content() + .string("{ \"message\": \"Successfully processed ping event\" }")); + } + + @Test + void invalidJsonPushEventShouldBeRejected() throws Exception { + mockMvc.perform(MockMvcRequestBuilders.post("/webhook/guides").accept(MediaType.APPLICATION_JSON) + .contentType(MediaType.APPLICATION_JSON) + .header("X-Hub-Signature", "sha1=8FCA101BFF427372C4DB6B9B6E48C8E2D2092ADC") + .header("X-GitHub-Event", "push").content("this is a test message")) + .andExpect(MockMvcResultMatchers.status().isBadRequest()) + .andExpect(MockMvcResultMatchers.content().string("{ \"message\": \"Bad Request\" }")); + } + + @Test + void shouldTriggerRepositoryDispatch() throws Exception { + mockMvc.perform(MockMvcRequestBuilders.post("/webhook/guides").accept(MediaType.APPLICATION_JSON) + .contentType(MediaType.APPLICATION_JSON) + .header("X-Hub-Signature", "sha1=C8D5B1C972E8DCFB69AB7124678D4C91E11D6F23") + .header("X-GitHub-Event", "push").content(getTestPayload("push"))) + .andExpect(MockMvcResultMatchers.status().isOk()).andExpect( + MockMvcResultMatchers.content().string("{ \"message\": \"Successfully processed update\" }")); + verify(this.service, times(1)).triggerRespositoryDispatch("test-org", "test-repo", "dispatch-token"); + } + + private String getTestPayload(String fileName) throws Exception { + ClassPathResource resource = new ClassPathResource(fileName + ".json", getClass()); + return StreamUtils.copyToString(resource.getInputStream(), StandardCharsets.UTF_8).replaceAll("[\\n|\\r]", ""); + } + +} \ No newline at end of file diff --git a/src/test/resources/io/spring/renderer/guides/webhook/ping.json b/src/test/resources/io/spring/renderer/guides/webhook/ping.json new file mode 100644 index 0000000..76a3c91 --- /dev/null +++ b/src/test/resources/io/spring/renderer/guides/webhook/ping.json @@ -0,0 +1,134 @@ +{ + "zen": "Encourage flow.", + "hook_id": 4465829, + "hook": { + "url": "https://api.github.com/repos/spring-guides/gs-rest-service/hooks/4465829", + "test_url": "https://api.github.com/repos/spring-guides/gs-rest-service/hooks/4465829/test", + "ping_url": "https://api.github.com/repos/spring-guides/gs-rest-service/hooks/4465829/pings", + "id": 4465829, + "name": "web", + "active": true, + "events": [ + "push" + ], + "config": { + "url": "http://spring.io/webhook/docs/guides", + "content_type": "json", + "insecure_ssl": "0", + "secret": "********" + }, + "last_response": { + "code": null, + "status": "unused", + "message": null + }, + "updated_at": "2015-03-30T20:08:56Z", + "created_at": "2015-03-30T20:08:56Z" + }, + "repository": { + "id": 9396682, + "name": "gs-rest-service", + "full_name": "spring-guides/gs-rest-service", + "owner": { + "login": "spring-guides", + "id": 4161866, + "avatar_url": "https://avatars.githubusercontent.com/u/4161866?v=3", + "gravatar_id": "", + "url": "https://api.github.com/users/spring-guides", + "html_url": "https://github.com/spring-guides", + "followers_url": "https://api.github.com/users/spring-guides/followers", + "following_url": "https://api.github.com/users/spring-guides/following{/other_user}", + "gists_url": "https://api.github.com/users/spring-guides/gists{/gist_id}", + "starred_url": "https://api.github.com/users/spring-guides/starred{/owner}{/repo}", + "subscriptions_url": "https://api.github.com/users/spring-guides/subscriptions", + "organizations_url": "https://api.github.com/users/spring-guides/orgs", + "repos_url": "https://api.github.com/users/spring-guides/repos", + "events_url": "https://api.github.com/users/spring-guides/events{/privacy}", + "received_events_url": "https://api.github.com/users/spring-guides/received_events", + "type": "Organization", + "site_admin": false + }, + "private": false, + "html_url": "https://github.com/spring-guides/gs-rest-service", + "description": "Building a RESTful Web Service :: Learn how to create a RESTful web service with Spring.", + "fork": false, + "url": "https://api.github.com/repos/spring-guides/gs-rest-service", + "forks_url": "https://api.github.com/repos/spring-guides/gs-rest-service/forks", + "keys_url": "https://api.github.com/repos/spring-guides/gs-rest-service/keys{/key_id}", + "collaborators_url": "https://api.github.com/repos/spring-guides/gs-rest-service/collaborators{/collaborator}", + "teams_url": "https://api.github.com/repos/spring-guides/gs-rest-service/teams", + "hooks_url": "https://api.github.com/repos/spring-guides/gs-rest-service/hooks", + "issue_events_url": "https://api.github.com/repos/spring-guides/gs-rest-service/issues/events{/number}", + "events_url": "https://api.github.com/repos/spring-guides/gs-rest-service/events", + "assignees_url": "https://api.github.com/repos/spring-guides/gs-rest-service/assignees{/user}", + "branches_url": "https://api.github.com/repos/spring-guides/gs-rest-service/branches{/branch}", + "tags_url": "https://api.github.com/repos/spring-guides/gs-rest-service/tags", + "blobs_url": "https://api.github.com/repos/spring-guides/gs-rest-service/git/blobs{/sha}", + "git_tags_url": "https://api.github.com/repos/spring-guides/gs-rest-service/git/tags{/sha}", + "git_refs_url": "https://api.github.com/repos/spring-guides/gs-rest-service/git/refs{/sha}", + "trees_url": "https://api.github.com/repos/spring-guides/gs-rest-service/git/trees{/sha}", + "statuses_url": "https://api.github.com/repos/spring-guides/gs-rest-service/statuses/{sha}", + "languages_url": "https://api.github.com/repos/spring-guides/gs-rest-service/languages", + "stargazers_url": "https://api.github.com/repos/spring-guides/gs-rest-service/stargazers", + "contributors_url": "https://api.github.com/repos/spring-guides/gs-rest-service/contributors", + "subscribers_url": "https://api.github.com/repos/spring-guides/gs-rest-service/subscribers", + "subscription_url": "https://api.github.com/repos/spring-guides/gs-rest-service/subscription", + "commits_url": "https://api.github.com/repos/spring-guides/gs-rest-service/commits{/sha}", + "git_commits_url": "https://api.github.com/repos/spring-guides/gs-rest-service/git/commits{/sha}", + "comments_url": "https://api.github.com/repos/spring-guides/gs-rest-service/comments{/number}", + "issue_comment_url": "https://api.github.com/repos/spring-guides/gs-rest-service/issues/comments{/number}", + "contents_url": "https://api.github.com/repos/spring-guides/gs-rest-service/contents/{+path}", + "compare_url": "https://api.github.com/repos/spring-guides/gs-rest-service/compare/{base}...{head}", + "merges_url": "https://api.github.com/repos/spring-guides/gs-rest-service/merges", + "archive_url": "https://api.github.com/repos/spring-guides/gs-rest-service/{archive_format}{/ref}", + "downloads_url": "https://api.github.com/repos/spring-guides/gs-rest-service/downloads", + "issues_url": "https://api.github.com/repos/spring-guides/gs-rest-service/issues{/number}", + "pulls_url": "https://api.github.com/repos/spring-guides/gs-rest-service/pulls{/number}", + "milestones_url": "https://api.github.com/repos/spring-guides/gs-rest-service/milestones{/number}", + "notifications_url": "https://api.github.com/repos/spring-guides/gs-rest-service/notifications{?since,all,participating}", + "labels_url": "https://api.github.com/repos/spring-guides/gs-rest-service/labels{/name}", + "releases_url": "https://api.github.com/repos/spring-guides/gs-rest-service/releases{/id}", + "created_at": "2013-04-12T14:54:36Z", + "updated_at": "2015-03-27T03:42:52Z", + "pushed_at": "2015-03-17T19:52:45Z", + "git_url": "git://github.com/spring-guides/gs-rest-service.git", + "ssh_url": "git@github.com:spring-guides/gs-rest-service.git", + "clone_url": "https://github.com/spring-guides/gs-rest-service.git", + "svn_url": "https://github.com/spring-guides/gs-rest-service", + "homepage": "http://spring.io/guides/gs/rest-service/", + "size": 1674, + "stargazers_count": 92, + "watchers_count": 92, + "language": "Java", + "has_issues": true, + "has_downloads": true, + "has_wiki": false, + "has_pages": false, + "forks_count": 268, + "mirror_url": null, + "open_issues_count": 0, + "forks": 268, + "open_issues": 0, + "watchers": 92, + "default_branch": "master" + }, + "sender": { + "login": "bclozel", + "id": 103264, + "avatar_url": "https://avatars.githubusercontent.com/u/103264?v=3", + "gravatar_id": "", + "url": "https://api.github.com/users/bclozel", + "html_url": "https://github.com/bclozel", + "followers_url": "https://api.github.com/users/bclozel/followers", + "following_url": "https://api.github.com/users/bclozel/following{/other_user}", + "gists_url": "https://api.github.com/users/bclozel/gists{/gist_id}", + "starred_url": "https://api.github.com/users/bclozel/starred{/owner}{/repo}", + "subscriptions_url": "https://api.github.com/users/bclozel/subscriptions", + "organizations_url": "https://api.github.com/users/bclozel/orgs", + "repos_url": "https://api.github.com/users/bclozel/repos", + "events_url": "https://api.github.com/users/bclozel/events{/privacy}", + "received_events_url": "https://api.github.com/users/bclozel/received_events", + "type": "User", + "site_admin": false + } +} \ No newline at end of file diff --git a/src/test/resources/io/spring/renderer/guides/webhook/push.json b/src/test/resources/io/spring/renderer/guides/webhook/push.json new file mode 100644 index 0000000..d9c8a86 --- /dev/null +++ b/src/test/resources/io/spring/renderer/guides/webhook/push.json @@ -0,0 +1,112 @@ +{ + "ref": "refs/heads/master", + "after": "a427f3f4db6c96ae5e86b610d0eef149393c9996", + "before": "3193db21394092a84d973bef8c4a3741aec1b814", + "created": false, + "deleted": false, + "forced": false, + "compare": "https://github.com/spring-guides/gs-test-guide/compare/3193db213940...a427f3f4db6c", + "commits": [ + { + "id": "6bdc0feaec5ccc85a9c4fe2a3e62bebe5a69e8fc", + "distinct": true, + "message": "Touch index-common to trigger gh-pages build", + "timestamp": "2013-08-10T15:56:23-07:00", + "url": "https://github.com/spring-projects/gs-test-guide/commit/6bdc0feaec5ccc85a9c4fe2a3e62bebe5a69e8fc", + "author": { + "name": "Chris Beams", + "email": "cbeams@gopivotal.com", + "username": "cbeams" + }, + "committer": { + "name": "Chris Beams", + "email": "cbeams@gopivotal.com", + "username": "cbeams" + }, + "added": [ + ], + "removed": [ + ], + "modified": [ + "index-common.html" + ] + }, + { + "id": "a427f3f4db6c96ae5e86b610d0eef149393c9996", + "distinct": true, + "message": "Remove empty newline at end of index-common", + "timestamp": "2013-08-10T16:12:39-07:00", + "url": "https://github.com/spring-guides/gs-test-guide/commit/a427f3f4db6c96ae5e86b610d0eef149393c9996", + "author": { + "name": "Chris Beams", + "email": "cbeams@gopivotal.com", + "username": "cbeams" + }, + "committer": { + "name": "Chris Beams", + "email": "cbeams@gopivotal.com", + "username": "cbeams" + }, + "added": [ + ], + "removed": [ + ], + "modified": [ + "index-common.html" + ] + } + ], + "head_commit": { + "id": "a427f3f4db6c96ae5e86b610d0eef149393c9996", + "distinct": true, + "message": "Remove empty newline at end of index-common", + "timestamp": "2013-08-10T16:12:39-07:00", + "url": "https://github.com/spring-guides/gs-test-guide/commit/a427f3f4db6c96ae5e86b610d0eef149393c9996", + "author": { + "name": "Chris Beams", + "email": "cbeams@gopivotal.com", + "username": "cbeams" + }, + "committer": { + "name": "Chris Beams", + "email": "cbeams@gopivotal.com", + "username": "cbeams" + }, + "added": [ + ], + "removed": [ + ], + "modified": [ + "index-common.html" + ] + }, + "repository": { + "id": 11634233, + "name": "gs-test-guide", + "url": "https://github.com/spring-guides/gs-test-guide", + "description": "A test guide", + "homepage": "", + "watchers": 0, + "stargazers": 0, + "forks": 0, + "fork": false, + "size": 452, + "owner": { + "name": "spring-guides", + "email": null + }, + "private": false, + "open_issues": 0, + "has_issues": true, + "has_downloads": true, + "has_wiki": true, + "language": "Java", + "created_at": 1374668910, + "pushed_at": 1376176368, + "master_branch": "master", + "organization": "spring-guides" + }, + "pusher": { + "name": "none" + } +} \ No newline at end of file