Skip to content
Snippets Groups Projects
Commit e421d3ac authored by Martin Lowe's avatar Martin Lowe :flag_ca:
Browse files

Merge branch 'zacharysabourin/master/80' into 'master'

feat: Add support for "project_destroy" and "project_rename" hooks

Closes #80

See merge request !108
parents d5428cc5 5ce595c4
No related branches found
No related tags found
1 merge request!108feat: Add support for "project_destroy" and "project_rename" hooks
Pipeline #12186 passed
Showing with 251 additions and 47 deletions
......@@ -42,4 +42,4 @@ test-post-git-eca:;
test-dev-post-git-eca:;
curl http://api.eclipse.dev.docker:8080/git/eca -v -H 'Content-Type: application/json' -d @config/json/post-git-eca.json
gitlab-root-pw-reset:;
docker exec -it $$(docker-compose ps -q gitlab) gitlab-rake "gitlab:password:reset[root]"
\ No newline at end of file
docker exec -it $$(docker compose ps -q gitlab) gitlab-rake "gitlab:password:reset[root]"
\ No newline at end of file
......@@ -329,3 +329,8 @@ components:
project_visibility:
type: string
description: "The project's visibility (public, private)"
old_path_with_namespace:
type:
- 'null'
- string
description: The old path with namespace, only used in 'project_rename' hooks
......@@ -154,14 +154,14 @@ public class PrivateProjectEvent extends BareNode {
String userId = params.getFirst(GitEcaParameterNames.USER_ID.getName());
if (StringUtils.isNumeric(userId)) {
statement.addClause(new ParameterizedSQLStatement.Clause(TABLE.getAlias() + ".compositeId.userId = ?",
new Object[] { userId }));
new Object[] { Integer.valueOf(userId) }));
}
String projectId = params.getFirst(GitEcaParameterNames.PROJECT_ID.getName());
if (StringUtils.isNumeric(projectId)) {
statement
.addClause(new ParameterizedSQLStatement.Clause(TABLE.getAlias() + ".compositeId.projectId = ?",
new Object[] { projectId }));
new Object[] { Integer.valueOf(projectId) }));
}
String projectPath = params.getFirst(GitEcaParameterNames.PROJECT_PATH.getName());
......@@ -172,10 +172,10 @@ public class PrivateProjectEvent extends BareNode {
}
String parentProject = params.getFirst(GitEcaParameterNames.PARENT_PROJECT.getName());
if (StringUtils.isNotBlank(parentProject)) {
if (StringUtils.isNumeric(parentProject)) {
statement.addClause(
new ParameterizedSQLStatement.Clause(TABLE.getAlias() + ".parentProject = ?",
new Object[] { parentProject }));
new Object[] { Integer.valueOf(parentProject) }));
}
return statement;
......
......@@ -14,6 +14,8 @@ package org.eclipsefoundation.git.eca.model;
import java.time.ZonedDateTime;
import java.util.List;
import javax.annotation.Nullable;
import com.fasterxml.jackson.databind.annotation.JsonDeserialize;
import com.fasterxml.jackson.databind.annotation.JsonPOJOBuilder;
import com.google.auto.value.AutoValue;
......@@ -44,6 +46,9 @@ public abstract class SystemHook {
public abstract String getProjectVisibility();
@Nullable
public abstract String getOldPathWithNamespace();
public static Builder builder() {
return new AutoValue_SystemHook.Builder();
}
......@@ -73,6 +78,8 @@ public abstract class SystemHook {
public abstract Builder setProjectVisibility(String visibility);
public abstract Builder setOldPathWithNamespace(@Nullable String oldPath);
public abstract SystemHook build();
}
......
/*********************************************************************
* Copyright (c) 2022 Eclipse Foundation.
*
* This program and the accompanying materials are made
* available under the terms of the Eclipse Public License 2.0
* which is available at https://www.eclipse.org/legal/epl-2.0/
*
* Author: Zachary Sabourin <zachary.sabourin@eclipse-foundation.org>
*
* SPDX-License-Identifier: EPL-2.0
**********************************************************************/
package org.eclipsefoundation.git.eca.namespace;
import java.util.stream.Stream;
public enum EventType {
PROJECT_CREATE, PROJECT_DESTROY, PROJECT_RENAME, UNSUPPORTED;
public String getName() {
return this.name().toLowerCase();
}
public static EventType getType(String name) {
if(name == null) {
return UNSUPPORTED;
}
return Stream.of(values()).filter(e -> name.equalsIgnoreCase(e.getName())).findFirst().orElse(UNSUPPORTED);
}
}
......@@ -27,7 +27,7 @@ public final class GitEcaParameterNames implements UrlParameterNamespace {
public static final String REPO_URL_RAW = "repo_url";
public static final String FINGERPRINT_RAW = "fingerprint";
public static final String USER_ID_RAW = "user_id";
public static final String PROJECT_PATH_RAW = "project_id";
public static final String PROJECT_PATH_RAW = "project_path";
public static final String PARENT_PROJECT_RAW = "parent_project";
public static final UrlParameter COMMIT_ID = new UrlParameter(COMMIT_ID_RAW);
public static final UrlParameter SHA = new UrlParameter(SHA_RAW);
......
......@@ -18,6 +18,7 @@ import javax.ws.rs.core.Response;
import org.eclipsefoundation.core.model.RequestWrapper;
import org.eclipsefoundation.git.eca.model.SystemHook;
import org.eclipsefoundation.git.eca.namespace.EventType;
import org.eclipsefoundation.git.eca.service.SystemHookService;
import org.jboss.resteasy.annotations.jaxrs.HeaderParam;
import org.slf4j.Logger;
......@@ -30,7 +31,7 @@ import com.fasterxml.jackson.databind.ObjectMapper;
public class WebhooksResource {
private static final Logger LOGGER = LoggerFactory.getLogger(WebhooksResource.class);
@Inject
RequestWrapper wrapper;
......@@ -44,20 +45,31 @@ public class WebhooksResource {
@Path("/gitlab/system")
public Response processGitlabHook(@HeaderParam("X-Gitlab-Event") String eventHeader, String jsonBody) {
// Do not process if not a valid event
String eventName = readEventName(jsonBody);
if (eventName == null) {
// Do not process if header is incorrect
if (!hasValidHeader(eventHeader)) {
return Response.ok().build();
}
// Do not process if the event isn't being tracked
if (!isValidHook(eventHeader, eventName, "project_create")) {
return Response.ok().build();
}
String eventName = readEventName(jsonBody);
EventType type = EventType.getType(eventName);
switch (type) {
case PROJECT_CREATE:
hookService.processProjectCreateHook(wrapper, convertRequestToHook(jsonBody));
break;
case PROJECT_DESTROY:
hookService.processProjectDeleteHook(wrapper, convertRequestToHook(jsonBody));
break;
case PROJECT_RENAME:
hookService.processProjectRenameHook(wrapper, convertRequestToHook(jsonBody));
break;
SystemHook hook = convertRequestToHook(jsonBody);
if (hook != null) {
hookService.processProjectCreateHook(wrapper, hook);
case UNSUPPORTED:
default:
LOGGER.trace("Dropped event: {}", eventName);
break;
}
return Response.ok().build();
......@@ -97,14 +109,12 @@ public class WebhooksResource {
}
/**
* Validates that the hook header and body contain the required hook type.
* Validates that the hook header contains the required value
*
* @param eventHeader the event header value
* @param eventName the event name
* @param hookType the desired hook type to compare against
* @return true if valid, false if not
*/
private boolean isValidHook(String eventHeader, String eventName, String eventType) {
return eventHeader.equalsIgnoreCase("system hook") && eventName.equalsIgnoreCase(eventType);
private boolean hasValidHeader(String eventHeader) {
return eventHeader != null && eventHeader.equalsIgnoreCase("system hook");
}
}
......@@ -26,4 +26,20 @@ public interface SystemHookService {
* @param hook
*/
void processProjectCreateHook(RequestWrapper wrapper, SystemHook hook);
/**
* Processes a project_destroy hook
*
* @param wrapper
* @param hook
*/
void processProjectDeleteHook(RequestWrapper wrapper, SystemHook hook);
/**
* Processes a project_rename hook
*
* @param wrapper
* @param hook
*/
void processProjectRenameHook(RequestWrapper wrapper, SystemHook hook);
}
......@@ -11,11 +11,15 @@
**********************************************************************/
package org.eclipsefoundation.git.eca.service.impl;
import java.time.LocalDateTime;
import java.util.Arrays;
import java.util.List;
import java.util.Optional;
import javax.enterprise.context.ApplicationScoped;
import javax.inject.Inject;
import javax.transaction.Transactional;
import javax.ws.rs.core.MultivaluedMap;
import org.eclipse.microprofile.config.inject.ConfigProperty;
import org.eclipse.microprofile.context.ManagedExecutor;
......@@ -26,6 +30,7 @@ import org.eclipsefoundation.git.eca.api.GitlabAPI;
import org.eclipsefoundation.git.eca.dto.PrivateProjectEvent;
import org.eclipsefoundation.git.eca.model.GitlabProjectResponse;
import org.eclipsefoundation.git.eca.model.SystemHook;
import org.eclipsefoundation.git.eca.namespace.GitEcaParameterNames;
import org.eclipsefoundation.git.eca.service.SystemHookService;
import org.eclipsefoundation.persistence.dao.PersistenceDao;
import org.eclipsefoundation.persistence.model.RDBMSQuery;
......@@ -59,23 +64,38 @@ public class DefaultSystemHookService implements SystemHookService {
@Override
public void processProjectCreateHook(RequestWrapper wrapper, SystemHook hook) {
managedExecutor.execute(new Runnable() {
@Override
public void run() {
trackPrivateProject(wrapper, hook);
}
});
if (hook != null) {
managedExecutor.execute(() -> trackPrivateProjectCreation(wrapper, hook));
}
}
@Override
public void processProjectDeleteHook(RequestWrapper wrapper, SystemHook hook) {
if (hook != null) {
managedExecutor.execute(() -> trackPrivateProjectDeletion(wrapper, hook));
}
}
@Override
public void processProjectRenameHook(RequestWrapper wrapper, SystemHook hook) {
if (hook != null) {
managedExecutor.execute(() -> trackPrivateProjectRenaming(wrapper, hook));
}
}
/**
* Gathers all relevant data related to the created project and persists the
* information into a database
*
* @param wrapper the request wrapper containing all query params
* @param wrapper the request wrapper containing all uri, ip, and query params
* @param hook the incoming system hook
*/
private void trackPrivateProject(RequestWrapper wrapper, SystemHook hook) {
@Transactional
void trackPrivateProjectCreation(RequestWrapper wrapper, SystemHook hook) {
try {
LOGGER.debug("Tracking creation of project: [id: {}, path: {}]", hook.getProjectId(),
hook.getPathWithNamespace());
Optional<GitlabProjectResponse> response = cache.get(Integer.toString(hook.getProjectId()),
new MultivaluedMapImpl<>(), GitlabProjectResponse.class,
() -> api.getProjectInfo(apiToken, hook.getProjectId()));
......@@ -84,7 +104,8 @@ public class DefaultSystemHookService implements SystemHookService {
PrivateProjectEvent dto = mapToDto(hook, response.get());
dao.add(new RDBMSQuery<>(wrapper, filters.get(PrivateProjectEvent.class)), Arrays.asList(dto));
} else {
LOGGER.error("No info for project with id: {}", hook.getProjectId());
LOGGER.error("No info for project: [id: {}, path: {}]", hook.getProjectId(),
hook.getPathWithNamespace());
}
} catch (Exception e) {
......@@ -92,6 +113,78 @@ public class DefaultSystemHookService implements SystemHookService {
}
}
/**
* Retrieves the event record for the project that has been deleted. Adds the
* deletionDate field and updates the DB record.
*
* @param wrapper the request wrapper containing all uri, ip, and query params
* @param hook the incoming system hook
*/
@Transactional
void trackPrivateProjectDeletion(RequestWrapper wrapper, SystemHook hook) {
try {
LOGGER.debug("Tracking deletion of project: [id: {}, path: {}]", hook.getProjectId(),
hook.getPathWithNamespace());
MultivaluedMap<String, String> params = new MultivaluedMapImpl<>();
params.add(GitEcaParameterNames.PROJECT_ID.getName(), Integer.toString(hook.getProjectId()));
params.add(GitEcaParameterNames.PROJECT_PATH.getName(), hook.getPathWithNamespace());
List<PrivateProjectEvent> results = dao
.get(new RDBMSQuery<>(wrapper, filters.get(PrivateProjectEvent.class), params));
if (results.isEmpty()) {
LOGGER.error("No results for project event: [id: {}, path: {}]", hook.getProjectId(),
hook.getPathWithNamespace());
} else {
results.get(0).setDeletionDate(hook.getUpdatedAt().toLocalDateTime());
dao.add(new RDBMSQuery<>(wrapper, filters.get(PrivateProjectEvent.class)), results);
}
} catch (Exception e) {
LOGGER.error(String.format("Error updating project: [id: %s, path: %s]", hook.getProjectId(),
hook.getPathWithNamespace()), e);
}
}
/**
* Retrieves the event record for the project that has been renamed. Updates the
* projectPath field and creates a new DB record.
*
* @param wrapper the request wrapper containing all uri, ip, and query params
* @param hook the incoming system hook
*/
@Transactional
void trackPrivateProjectRenaming(RequestWrapper wrapper, SystemHook hook) {
try {
LOGGER.debug("Tracking renaming of project: [id: {}, path: {}]", hook.getProjectId(),
hook.getOldPathWithNamespace());
MultivaluedMap<String, String> params = new MultivaluedMapImpl<>();
params.add(GitEcaParameterNames.PROJECT_ID.getName(), Integer.toString(hook.getProjectId()));
params.add(GitEcaParameterNames.PROJECT_PATH.getName(), hook.getOldPathWithNamespace());
List<PrivateProjectEvent> results = dao
.get(new RDBMSQuery<>(wrapper, filters.get(PrivateProjectEvent.class), params));
if (results.isEmpty()) {
LOGGER.error("No results for project event: [id: {}, path: {}]", hook.getProjectId(),
hook.getOldPathWithNamespace());
} else {
// Create a new event and track in the DB
PrivateProjectEvent event = new PrivateProjectEvent(results.get(0).getCompositeId().getUserId(),
results.get(0).getCompositeId().getProjectId(), hook.getPathWithNamespace());
event.setCreationDate(LocalDateTime.now());
event.setParentProject(results.get(0).getParentProject());
dao.add(new RDBMSQuery<>(wrapper, filters.get(PrivateProjectEvent.class)), Arrays.asList(event));
}
} catch (Exception e) {
LOGGER.error(String.format("Error updating project: [id: %s, path: %s]", hook.getProjectId(),
hook.getOldPathWithNamespace()), e);
}
}
/**
* Takes the received hook and Gitlab api response body and maps the values to a
* PrivateProjectEvent dto.
......
......@@ -26,7 +26,7 @@ import org.junit.jupiter.api.Test;
import io.quarkus.test.junit.QuarkusTest;
@QuarkusTest
public class WebhoooksResourceTest {
class WebhoooksResourceTest {
public static final String WEBHOOKS_BASE_URL = "/webhooks";
public static final String GITLAB_HOOK_URL = WEBHOOKS_BASE_URL + "/gitlab/system";
......@@ -47,7 +47,42 @@ public class WebhoooksResourceTest {
.setProjectVisibility("private")
.build();
public static final SystemHook PROJECT_CREATE_HOOK_NOT_TRACKED = SystemHook.builder()
public static final SystemHook PROJECT_DESTROY_HOOK_VALID = SystemHook.builder()
.setCreatedAt(ZonedDateTime.now())
.setUpdatedAt(ZonedDateTime.now())
.setEventName("project_destroy")
.setName("TestProject")
.setOwnerEmail("testuser@eclipse-foundation.org")
.setOwnerName("Test User")
.setOwners(Arrays.asList(Owner.builder()
.setEmail("projectmaker@eclipse-foundation.org")
.setName("Project Maker")
.build()))
.setPath("TestProject")
.setPathWithNamespace("testuser/testproject")
.setProjectId(69)
.setProjectVisibility("private")
.build();
public static final SystemHook PROJECT_RENAME_HOOK_VALID = SystemHook.builder()
.setCreatedAt(ZonedDateTime.now())
.setUpdatedAt(ZonedDateTime.now())
.setEventName("project_rename")
.setName("TestProject")
.setOwnerEmail("testuser@eclipse-foundation.org")
.setOwnerName("Test User")
.setOwners(Arrays.asList(Owner.builder()
.setEmail("projectmaker@eclipse-foundation.org")
.setName("Project Maker")
.build()))
.setPath("TestProject")
.setPathWithNamespace("testuser/newname")
.setProjectId(69)
.setProjectVisibility("private")
.setOldPathWithNamespace("testuser/testproject")
.build();
public static final SystemHook HOOK_NOT_TRACKED = SystemHook.builder()
.setCreatedAt(ZonedDateTime.now())
.setUpdatedAt(ZonedDateTime.now())
.setEventName("project_update")
......@@ -64,7 +99,7 @@ public class WebhoooksResourceTest {
.setProjectVisibility("private")
.build();
public static final SystemHook PROJECT_CREATE_HOOK_MISSING_EVENT = SystemHook.builder()
public static final SystemHook HOOK_MISSING_EVENT = SystemHook.builder()
.setCreatedAt(ZonedDateTime.now())
.setUpdatedAt(ZonedDateTime.now())
.setEventName("")
......@@ -81,33 +116,41 @@ public class WebhoooksResourceTest {
.setProjectVisibility("private")
.build();
public static final EndpointTestCase PROJECT_CREATE_SUCCESS = TestCaseHelper
public static final EndpointTestCase CASE_HOOK_SUCCESS = TestCaseHelper
.prepareTestCase(GITLAB_HOOK_URL, new String[] {}, null)
.setHeaderParams(Optional.of(Map.of("X-Gitlab-Event", "system hook")))
.build();
public static final EndpointTestCase PROJECT_CREATE_MISSING_HEADER = TestCaseHelper
.prepareTestCase(GITLAB_HOOK_URL, new String[] {}, null)
.setStatusCode(500)
.build();
public static final EndpointTestCase CASE_HOOK_MISSING_HEADER = TestCaseHelper.buildSuccessCase(
GITLAB_HOOK_URL, new String[] {}, null);
@Test
void processCreateHook_success() {
RestAssuredTemplates.testPost(CASE_HOOK_SUCCESS, PROJECT_CREATE_HOOK_VALID);
}
@Test
void processDeleteHook_success() {
RestAssuredTemplates.testPost(CASE_HOOK_SUCCESS, PROJECT_DESTROY_HOOK_VALID);
}
@Test
void processGitlabHook_success() {
RestAssuredTemplates.testPost(PROJECT_CREATE_SUCCESS, PROJECT_CREATE_HOOK_VALID);
void processRenameHook_success() {
RestAssuredTemplates.testPost(CASE_HOOK_SUCCESS, PROJECT_RENAME_HOOK_VALID);
}
@Test
void processGitlabHook_success_untrackedEvent() {
RestAssuredTemplates.testPost(PROJECT_CREATE_SUCCESS, PROJECT_CREATE_HOOK_NOT_TRACKED);
void processCreateHook_success_untrackedEvent() {
RestAssuredTemplates.testPost(CASE_HOOK_SUCCESS, HOOK_NOT_TRACKED);
}
@Test
void processGitlabHook_success_missingEventName() {
RestAssuredTemplates.testPost(PROJECT_CREATE_SUCCESS, PROJECT_CREATE_HOOK_MISSING_EVENT);
void processCreateHook_success_missingEventName() {
RestAssuredTemplates.testPost(CASE_HOOK_SUCCESS, HOOK_MISSING_EVENT);
}
@Test
void processGitlabHook_failure_missingHeaderParam() {
RestAssuredTemplates.testPost(PROJECT_CREATE_MISSING_HEADER, PROJECT_CREATE_HOOK_VALID);
void processCreateHook_success_missingHeaderParam() {
RestAssuredTemplates.testPost(CASE_HOOK_MISSING_HEADER, PROJECT_CREATE_HOOK_VALID);
}
}
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment