From 28668aeacc7d7a4014fb0cabd86ed3d01f92bd49 Mon Sep 17 00:00:00 2001 From: rschoene <rene.schoene@tu-dresden.de> Date: Thu, 30 Apr 2020 11:31:35 +0200 Subject: [PATCH] Update parser and grammar according to latest discussion. - allow multiple mappings for update definitions - in mappings, remove target variable name and prepare for more types, e.g., arrays - add required option to specify name of root node in Compiler - copy MqttUpdater in compile step --- build.gradle | 1 - src/main/jastadd/MqttUpdater.java_class | 236 ++++++++++++++++++ src/main/jastadd/NameResolution.jrag | 2 +- src/main/jastadd/Ros2Rag.flex | 1 - src/main/jastadd/Ros2Rag.parser | 54 ++-- src/main/jastadd/Ros2Rag.relast | 8 +- src/main/jastadd/backend/Aspect.jadd | 13 +- .../jastadd/ros2rag/compiler/Compiler.java | 31 ++- .../jastadd/ros2rag/compiler/SimpleMain.java | 14 +- .../jastadd/ros2rag/tests/RosToRagTest.java | 6 +- src/test/resources/Example.ros2rag | 24 +- 11 files changed, 330 insertions(+), 60 deletions(-) create mode 100644 src/main/jastadd/MqttUpdater.java_class diff --git a/build.gradle b/build.gradle index 5cd8f59..c08852a 100644 --- a/build.gradle +++ b/build.gradle @@ -33,7 +33,6 @@ dependencies { sourceSets { main { java.srcDir "src/gen/java" - java.srcDir "buildSrc/gen/java" } } diff --git a/src/main/jastadd/MqttUpdater.java_class b/src/main/jastadd/MqttUpdater.java_class new file mode 100644 index 0000000..835a731 --- /dev/null +++ b/src/main/jastadd/MqttUpdater.java_class @@ -0,0 +1,236 @@ +package org.jastadd.ros2rag.compiler.mqtt; + +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; +import org.fusesource.hawtbuf.Buffer; +import org.fusesource.hawtbuf.UTF8Buffer; +import org.fusesource.mqtt.client.*; + +import java.io.IOException; +import java.net.URI; +import java.util.HashMap; +import java.util.Map; +import java.util.Objects; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicReference; +import java.util.concurrent.locks.Condition; +import java.util.concurrent.locks.Lock; +import java.util.concurrent.locks.ReentrantLock; +import java.util.function.Consumer; + +/** + * Helper class to receive updates via MQTT and use callbacks to handle those messages. + * + * @author rschoene - Initial contribution + */ +public class MqttUpdater { + + private final Logger logger; + private final String name; + + /** The host running the MQTT broker. */ + private URI host; + /** The connection to the MQTT broker. */ + private CallbackConnection connection; + /** Whether we are subscribed to the topics yet */ + private final Condition readyCondition; + private final Lock readyLock; + private boolean ready; + private QoS qos; + /** Dispatch knowledge */ + private final Map<String, Consumer<byte[]>> callbacks; + + public MqttUpdater() { + this("Ros2Rag"); + } + + public MqttUpdater(String name) { + this.name = Objects.requireNonNull(name, "Name must be set"); + this.logger = LogManager.getLogger(MqttUpdater.class); + this.callbacks = new HashMap<>(); + this.readyLock = new ReentrantLock(); + this.readyCondition = readyLock.newCondition(); + this.ready = false; + this.qos = QoS.AT_LEAST_ONCE; + } + + /** + * Sets the host to receive messages from, and connects to it. + * @throws IOException if could not connect, or could not subscribe to a topic + * @return self + */ + public MqttUpdater setHost(String host, int port) throws IOException { + this.host = URI.create("tcp://" + host + ":" + port); + logger.debug("Host for {} is {}", this.name, this.host); + + Objects.requireNonNull(this.host, "Host need to be set!"); + MQTT mqtt = new MQTT(); + mqtt.setHost(this.host); + connection = mqtt.callbackConnection(); + AtomicReference<Throwable> error = new AtomicReference<>(); + + // add the listener to dispatch messages later + connection.listener(new ExtendedListener() { + public void onConnected() { + logger.debug("Connected"); + } + + @Override + public void onDisconnected() { + logger.debug("Disconnected"); + } + + @Override + public void onPublish(UTF8Buffer topic, Buffer body, Callback<Callback<Void>> ack) { + String topicString = topic.toString(); + Consumer<byte[]> callback = callbacks.get(topicString); + if (callback == null) { + logger.debug("Got a message, but no callback to call. Forgot to unsubscribe?"); + } else { + byte[] message = body.toByteArray(); +// System.out.println("message = " + Arrays.toString(message)); + callback.accept(message); + } + ack.onSuccess(null); // always acknowledge message + } + + @Override + public void onPublish(UTF8Buffer topicBuffer, Buffer body, Runnable ack) { + logger.warn("onPublish should not be called"); + } + + @Override + public void onFailure(Throwable cause) { +// logger.catching(cause); + error.set(cause); + } + }); + throwIf(error); + + // actually establish the connection + connection.connect(new Callback<Void>() { + @Override + public void onSuccess(Void value) { + connection.publish("components", (name + " is connected").getBytes(), QoS.AT_LEAST_ONCE, false, new Callback<Void>() { + @Override + public void onSuccess(Void value) { + logger.debug("success sending welcome message"); + try { + readyLock.lock(); + ready = true; + readyCondition.signalAll(); + } finally { + readyLock.unlock(); + } + } + + @Override + public void onFailure(Throwable value) { + logger.debug("failure sending welcome message", value); + } + }); + } + + @Override + public void onFailure(Throwable cause) { +// logger.error("Could not connect", cause); + error.set(cause); + } + }); + throwIf(error); + return this; + } + + public URI getHost() { + return host; + } + + private void throwIf(AtomicReference<Throwable> error) throws IOException { + if (error.get() != null) { + throw new IOException(error.get()); + } + } + + public void setQoSForSubscription(QoS qos) { + this.qos = qos; + } + + public void newConnection(String topic, Consumer<byte[]> callback) { + if (!ready) { + // TODO should maybe be something more kind than throwing an exception here + throw new IllegalStateException("Updater not ready"); + } + // register callback + callbacks.put(topic, callback); + + // subscribe at broker + Topic[] topicArray = { new Topic(topic, this.qos) }; + connection.subscribe(topicArray, new Callback<byte[]>() { + @Override + public void onSuccess(byte[] qoses) { + logger.debug("Subscribed to {}, qoses: {}", topic, qoses); + } + + @Override + public void onFailure(Throwable cause) { + logger.error("Could not subscribe to {}", topic, cause); + } + }); + } + + /** + * Waits until this updater is ready to receive MQTT messages. + * If it already is ready, return immediately with the value <code>true</code>. + * Otherwise waits for the given amount of time, and either return <code>true</code> within the timespan, + * if it got ready, or <code>false</code> upon a timeout. + * @param time the maximum time to wait + * @param unit the time unit of the time argument + * @return whether this updater is ready + */ + public boolean waitUntilReady(long time, TimeUnit unit) { + try { + readyLock.lock(); + if (ready) { + return true; + } + return readyCondition.await(time, unit); + } catch (InterruptedException e) { + e.printStackTrace(); + } finally { + readyLock.unlock(); + } + return false; + } + + public void close() { + if (connection == null) { + logger.warn("Stopping without connection. Was setHost() called?"); + return; + } + connection.disconnect(new Callback<Void>() { + @Override + public void onSuccess(Void value) { + logger.info("Disconnected {} from {}", name, host); + } + + @Override + public void onFailure(Throwable ignored) { + // Disconnects never fail. And we do not care either. + } + }); + } + + public void publish(String topic, byte[] bytes) { + connection.publish(topic, bytes, qos, false, new Callback<Void>() { + @Override + public void onSuccess(Void value) { + logger.debug("Published some bytes to {}", topic); + } + + @Override + public void onFailure(Throwable value) { + logger.warn("Could not publish on topic '{}'", topic); + } + }); + } +} diff --git a/src/main/jastadd/NameResolution.jrag b/src/main/jastadd/NameResolution.jrag index 72b8522..314d9d5 100644 --- a/src/main/jastadd/NameResolution.jrag +++ b/src/main/jastadd/NameResolution.jrag @@ -27,7 +27,7 @@ aspect NameResolution { return null; } - refine RefResolverStubs eq UpdateDefinition.resolveMappingByToken(String id) { + refine RefResolverStubs eq UpdateDefinition.resolveMappingByToken(String id, int position) { // return a MappingDefinition for (MappingDefinition mappingDefinition : ros2rag().getMappingDefinitionList()) { if (mappingDefinition.getID().equals(id)) { diff --git a/src/main/jastadd/Ros2Rag.flex b/src/main/jastadd/Ros2Rag.flex index 1c0d48d..2d44774 100644 --- a/src/main/jastadd/Ros2Rag.flex +++ b/src/main/jastadd/Ros2Rag.flex @@ -59,7 +59,6 @@ ID = [a-zA-Z$_][a-zA-Z0-9$_]* "maps" { return sym(Terminals.MAPS); } "to" { return sym(Terminals.TO); } "as" { return sym(Terminals.AS); } -"with" { return sym(Terminals.WITH); } ";" { return sym(Terminals.SCOL); } ":" { return sym(Terminals.COL); } diff --git a/src/main/jastadd/Ros2Rag.parser b/src/main/jastadd/Ros2Rag.parser index 9531cc5..750e0ce 100644 --- a/src/main/jastadd/Ros2Rag.parser +++ b/src/main/jastadd/Ros2Rag.parser @@ -6,6 +6,13 @@ Ros2Rag ros2rag | {: return new Ros2Rag(); :} ; +%embed {: + private Iterable<String> makeMappingDefs(ArrayList<?> raw_mapping_defs) { + java.util.Collections.reverse(raw_mapping_defs); + return () -> raw_mapping_defs.stream().map(raw -> ((Symbol) raw).value.toString()).iterator(); + } +:} ; + // read Joint.CurrentPosition using LinkStateToIntPosition ; // write RobotArm._AppropriateSpeed using CreateSpeedMessage ; UpdateDefinition update_definition @@ -15,11 +22,13 @@ UpdateDefinition update_definition result.setToken(TokenComponent.createRef(type_name + "." + token_name)); return result; :} - | READ ID.type_name DOT ID.token_name USING ID.mapping_def SCOL + | READ ID.type_name DOT ID.token_name USING string_list.mapping_defs SCOL {: ReadFromMqttDefinition result = new ReadFromMqttDefinition(); result.setToken(TokenComponent.createRef(type_name + "." + token_name)); - result.setMapping(MappingDefinition.createRef(mapping_def)); + for (String mapping_def : makeMappingDefs(mapping_defs)) { + result.addMapping(MappingDefinition.createRef(mapping_def)); + } return result; :} | WRITE ID.type_name DOT ID.token_name SCOL @@ -28,15 +37,22 @@ UpdateDefinition update_definition result.setToken(TokenComponent.createRef(type_name + "." + token_name)); return result; :} - | WRITE ID.type_name DOT ID.token_name USING ID.mapping_def SCOL + | WRITE ID.type_name DOT ID.token_name USING string_list.mapping_defs SCOL {: WriteToMqttDefinition result = new WriteToMqttDefinition(); result.setToken(TokenComponent.createRef(type_name + "." + token_name)); - result.setMapping(MappingDefinition.createRef(mapping_def)); + for (String mapping_def : makeMappingDefs(mapping_defs)) { + result.addMapping(MappingDefinition.createRef(mapping_def)); + } return result; :} ; +ArrayList string_list + = ID + | string_list COMMA ID +; + // RobotArm._AppropriateSpeed canDependOn Joint.CurrentPosition as dependency1 ; DependencyDefinition dependency_definition = ID.target_type DOT ID.target_token CAN_DEPEND_ON ID.source_type DOT ID.source_token AS ID.id SCOL @@ -54,43 +70,29 @@ DependencyDefinition dependency_definition // y = IntPosition.of((int) p.getPositionX(), (int) p.getPositionY(), (int) p.getPositionZ()); //} MappingDefinition mapping_definition - = ID.id MAPS mapping_type.from TO mapping_type.to MAPPING_CONTENT.content + = ID.id MAPS mapping_type.from_type ID.from_name TO mapping_type.to_type MAPPING_CONTENT.content {: MappingDefinition result = new MappingDefinition(); result.setID(id); - result.setFrom(from); - result.setTo(to); + result.setFromType(from_type); + result.setFromVariableName(from_name); + result.setToType(to_type); result.setContent(content.substring(2, content.length() - 2)); return result; :} ; MappingDefinitionType mapping_type - = java_type_use.type ID.variable + = java_type_use.type {: - MappingDefinitionType result = new MappingDefinitionType(); + JavaMappingDefinitionType result = new JavaMappingDefinitionType(); result.setType(type); - result.setVariableName(variable); return result; :} - | java_type_use.type ID.variable WITH ID.method + | java_type_use.type LBRACKET RBRACKET {: - MappingDefinitionType result = new MappingDefinitionType(); + JavaArrayMappingDefinitionType result = new JavaArrayMappingDefinitionType(); result.setType(type); - result.setVariableName(variable); - result.setSerializationMethodName(method); return result; :} ; - -//String mapping_def_content -// = LB_CURLY COL mapping_def_content_body.b {: return b.stream().collect(java.util.stream.Collectors.joining("\n")); :} -//; -// -//ArrayList mapping_def_content_body -// = COL RB_CURLY {: return new ArrayList(); :} -// | TEXT.text mapping_def_content_body.b {: b.add(0, text); return b; :} -//; -//String mapping_def_content -// = MappingContent.c {: int length = c.length(); return c.substring(2, length - 2); :} -//; diff --git a/src/main/jastadd/Ros2Rag.relast b/src/main/jastadd/Ros2Rag.relast index f730ea3..f8465bb 100644 --- a/src/main/jastadd/Ros2Rag.relast +++ b/src/main/jastadd/Ros2Rag.relast @@ -2,7 +2,7 @@ Ros2Rag ::= UpdateDefinition* DependencyDefinition* MappingDefinition* Program; abstract UpdateDefinition ::= <AlwaysApply:Boolean> ; -rel UpdateDefinition.Mapping? -> MappingDefinition; +rel UpdateDefinition.Mapping* -> MappingDefinition; abstract TokenUpdateDefinition : UpdateDefinition; rel TokenUpdateDefinition.Token -> TokenComponent; @@ -15,5 +15,7 @@ DependencyDefinition ::= <ID> ; rel DependencyDefinition.Source -> TokenComponent ; rel DependencyDefinition.Target -> TokenComponent ; -MappingDefinition ::= <ID> From:MappingDefinitionType To:MappingDefinitionType <Content> ; -MappingDefinitionType ::= Type:JavaTypeUse <VariableName> <SerializationMethodName> ; // SerializationMethodName may be empty +MappingDefinition ::= <ID> FromType:MappingDefinitionType <FromVariableName> ToType:MappingDefinitionType <Content> ; +abstract MappingDefinitionType ::= ; +JavaMappingDefinitionType : MappingDefinitionType ::= Type:JavaTypeUse ; +JavaArrayMappingDefinitionType : MappingDefinitionType ::= Type:JavaTypeUse ; diff --git a/src/main/jastadd/backend/Aspect.jadd b/src/main/jastadd/backend/Aspect.jadd index d403bbc..1cf2fd9 100644 --- a/src/main/jastadd/backend/Aspect.jadd +++ b/src/main/jastadd/backend/Aspect.jadd @@ -18,18 +18,23 @@ aspect Aspect { sb.append("}\n"); } - public String Ros2Rag.generateAspect() { + public String Ros2Rag.generateAspect(String rootNodeName) { StringBuilder sb = new StringBuilder(); - generateAspect(sb); + generateMqttAspect(sb); + generateGrammarExtension(sb); return sb.toString(); } + public void Ros2Rag.generateMqttAspect(StringBuilder sb) { + + } + // from "[always] read Joint.CurrentPosition using PoseToPosition;" generate method connectTo // Joint j; // j.getCurrentPosition().connectTo("/robot/joint2/pos"); - public void Ros2Rag.generateAspect(StringBuilder sb) { - sb.append("aspect ROS2RAG {\n"); + public void Ros2Rag.generateGrammarExtension(StringBuilder sb) { + sb.append("aspect ros2rag.GrammarExtension {\n"); for (UpdateDefinition def : getUpdateDefinitionList()) { def.generateAspect(sb); diff --git a/src/main/java/org/jastadd/ros2rag/compiler/Compiler.java b/src/main/java/org/jastadd/ros2rag/compiler/Compiler.java index f5811f6..006f1d1 100644 --- a/src/main/java/org/jastadd/ros2rag/compiler/Compiler.java +++ b/src/main/java/org/jastadd/ros2rag/compiler/Compiler.java @@ -19,6 +19,7 @@ public class Compiler { private StringOption optionOutputDir; private StringOption optionInputGrammar; + private StringOption optionRootNode; private StringOption optionInputRos2Rag; private ArrayList<Option<?>> options; @@ -44,12 +45,8 @@ public class Compiler { printMessage("Running ROS2RAG Preprocessor"); - if (!optionInputGrammar.isSet()) { - return error("specify an input grammar"); - } - - if (!optionInputRos2Rag.isSet()) { - return error("specify the ros2rag definition file"); + if (anyRequiredOptionIsUnset()) { + return error("Aborting."); } List<String> otherArgs = commandLine.getArguments(); @@ -59,11 +56,30 @@ public class Compiler { Ros2Rag ros2Rag = parseProgram(optionInputGrammar.getValue(), optionInputRos2Rag.getValue()); printMessage("Writing output files"); + // copy MqttUpdater into outputDir + try { + Files.copy(Paths.get("src", "main", "jastadd", "MqttUpdater.java_class"), + Paths.get(outputDir, "MqttUpdater.java")); + } catch (IOException e) { + throw new CompilerException("Could not copy MqttUpdater.java", e); + } writeToFile(outputDir + "/Grammar.relast", ros2Rag.getProgram().generateAbstractGrammar()); - writeToFile(outputDir + "/ROS2RAG.jadd", ros2Rag.generateAspect()); + writeToFile(outputDir + "/ROS2RAG.jadd", ros2Rag.generateAspect(optionRootNode.getValue())); return 0; } + private boolean anyRequiredOptionIsUnset() { + boolean foundError = false; + for (Option<?> option : options) { + if (option.hasArgument() == Option.HasArgument.YES && !option.isSet()) { + System.err.println("Option '" + option.getName() + + "' (" + option.getDescription() + ") is required but unset!"); + foundError = true; + } + } + return foundError; + } + public static int main(String[] args) { try { @@ -91,6 +107,7 @@ public class Compiler { private void addOptions() { optionOutputDir = addOption(new StringOption("outputDir", "target directory for the generated files.")); optionInputGrammar = addOption(new StringOption("inputGrammar", "base grammar.")); + optionRootNode = addOption(new StringOption("rootNode", "root node in the base grammar.")); optionInputRos2Rag = addOption(new StringOption("inputRos2Rag", "ros2rag definition file.")); } diff --git a/src/main/java/org/jastadd/ros2rag/compiler/SimpleMain.java b/src/main/java/org/jastadd/ros2rag/compiler/SimpleMain.java index a047453..7c54125 100644 --- a/src/main/java/org/jastadd/ros2rag/compiler/SimpleMain.java +++ b/src/main/java/org/jastadd/ros2rag/compiler/SimpleMain.java @@ -51,8 +51,9 @@ public class SimpleMain { MappingDefinition mappingDefinition = new MappingDefinition(); mappingDefinition.setID("PoseToPosition"); - mappingDefinition.setFrom(makeMappingDefinitionType("PBPose", "x")); - mappingDefinition.setTo(makeMappingDefinitionType("Position", "y")); + mappingDefinition.setFromType(makeMappingDefinitionType("PBPose")); + mappingDefinition.setFromVariableName("x"); + mappingDefinition.setToType(makeMappingDefinitionType("Position")); mappingDefinition.setContent(" pose.position.x += sqrt(.5 * size.x)\n" + " MAP round(2)\n" + " x = x / 100\n" + @@ -63,18 +64,17 @@ public class SimpleMain { ReadFromMqttDefinition readFromMqttDefinition = new ReadFromMqttDefinition(); readFromMqttDefinition.setAlwaysApply(false); readFromMqttDefinition.setToken(TokenComponent.createRef("Joint.CurrentPosition")); - readFromMqttDefinition.setMapping(mappingDefinition); + readFromMqttDefinition.addMapping(mappingDefinition); model.addUpdateDefinition(readFromMqttDefinition); model.treeResolveAll(); - System.out.println(model.generateAspect()); + System.out.println(model.generateAspect("Model")); } - private static MappingDefinitionType makeMappingDefinitionType(String type, String variable) { - MappingDefinitionType result = new MappingDefinitionType(); + private static MappingDefinitionType makeMappingDefinitionType(String type) { + JavaMappingDefinitionType result = new JavaMappingDefinitionType(); result.setType(new SimpleJavaTypeUse(type)); - result.setVariableName(variable); return result; } diff --git a/src/test/java/org/jastadd/ros2rag/tests/RosToRagTest.java b/src/test/java/org/jastadd/ros2rag/tests/RosToRagTest.java index a70ea52..d7d482a 100644 --- a/src/test/java/org/jastadd/ros2rag/tests/RosToRagTest.java +++ b/src/test/java/org/jastadd/ros2rag/tests/RosToRagTest.java @@ -12,7 +12,7 @@ import static org.junit.jupiter.api.Assertions.assertTrue; public class RosToRagTest { - void transform(String inputGrammar, String inputRos2Rag, String outputDir) throws CommandLine.CommandLineException, Compiler.CompilerException { + void transform(String inputGrammar, String inputRos2Rag, String rootNode, String outputDir) throws CommandLine.CommandLineException, Compiler.CompilerException { System.out.println(Paths.get(".").toAbsolutePath()); assertTrue(Paths.get(inputGrammar).toFile().exists(), "input grammar does not exist"); @@ -30,7 +30,8 @@ public class RosToRagTest { String[] args = { "--outputDir=" + outputDir, "--inputGrammar=" + inputGrammar, - "--inputRos2Rag=" + inputRos2Rag + "--inputRos2Rag=" + inputRos2Rag, + "--rootNode=" + rootNode }; new Compiler().run(args); @@ -40,6 +41,7 @@ public class RosToRagTest { void transformMinimalExample() throws CommandLine.CommandLineException, Compiler.CompilerException { transform("src/test/resources/Example.relast", "src/test/resources/Example.ros2rag", + "Model", "src/test/resources/out"); } } diff --git a/src/test/resources/Example.ros2rag b/src/test/resources/Example.ros2rag index b2dd93d..f17a9be 100644 --- a/src/test/resources/Example.ros2rag +++ b/src/test/resources/Example.ros2rag @@ -1,21 +1,29 @@ /* Version 2020-04-17 */ // --- update definitions --- -read Joint.CurrentPosition using LinkStateToIntPosition ; -write RobotArm._AppropriateSpeed using CreateSpeedMessage ; +read Joint.CurrentPosition using ParseLinkState, LinkStateToIntPosition ; +write RobotArm._AppropriateSpeed using CreateSpeedMessage, SerializeRobotConfig ; // --- dependency definitions --- RobotArm._AppropriateSpeed canDependOn Joint.CurrentPosition as dependency1 ; RobotArm._AppropriateSpeed canDependOn RobotArm._AttributeTestSource as dependency2 ; // --- mapping definitions --- -LinkStateToIntPosition maps panda.Linkstate.PandaLinkState x to IntPosition y {: - panda.Linkstate.PandaLinkState.Position p = x.getPos(); +ParseLinkState maps byte[] bytes to panda.Linkstate.PandaLinkState {: + return panda.Linkstate.PandaLinkState.parseFrom(bytes); +:} + +SerializeRobotConfig maps config.Robotconfig.RobotConfig rc to byte[] {: + return rc.toByteArray(); +:} + +LinkStateToIntPosition maps panda.Linkstate.PandaLinkState pls to IntPosition {: + panda.Linkstate.PandaLinkState.Position p = pls.getPos(); { int i = 0; } - y = IntPosition.of((int) p.getPositionX(), (int) p.getPositionY(), (int) p.getPositionZ()); + return IntPosition.of((int) p.getPositionX(), (int) p.getPositionY(), (int) p.getPositionZ()); :} -CreateSpeedMessage maps double x to config.Robotconfig.RobotConfig y {: - y = config.Robotconfig.RobotConfig.newBuilder() - .setSpeed(x) +CreateSpeedMessage maps double speed to config.Robotconfig.RobotConfig {: + return config.Robotconfig.RobotConfig.newBuilder() + .setSpeed(speed) .build(); :} -- GitLab