()
+}
diff --git a/buildSrc/build.gradle.kts b/buildSrc/build.gradle.kts
new file mode 100644
index 00000000..c07f4329
--- /dev/null
+++ b/buildSrc/build.gradle.kts
@@ -0,0 +1,42 @@
+/*
+ * Copyright 2020, TeamDev. All rights reserved.
+ *
+ * Redistribution and use in source and/or binary forms, with or without
+ * modification, must retain the above copyright notice and the following
+ * disclaimer.
+ *
+ * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
+ * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
+ * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
+ * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
+ * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
+ * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
+ * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
+ * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
+ * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
+ * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+ * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+ */
+
+plugins {
+ `kotlin-dsl`
+}
+
+repositories {
+ mavenLocal()
+ jcenter()
+ gradlePluginPortal()
+}
+
+dependencies {
+ implementation("net.ltgt.gradle:gradle-errorprone-plugin:1.2.1")
+ implementation("net.ltgt.gradle:gradle-apt-plugin:0.21")
+ implementation("com.github.jengelman.gradle.plugins:shadow:6.0.0")
+ implementation("gradle.plugin.com.google.cloud.tools:jib-gradle-plugin:2.4.0")
+ implementation("io.spine.tools:spine-bootstrap:1.5.24")
+ implementation("net.saliman:gradle-properties-plugin:1.5.1")
+}
+
+kotlinDslPluginOptions {
+ experimentalWarning.set(false)
+}
diff --git a/buildSrc/src/main/kotlin/deps.kt b/buildSrc/src/main/kotlin/deps.kt
new file mode 100644
index 00000000..bcceae7b
--- /dev/null
+++ b/buildSrc/src/main/kotlin/deps.kt
@@ -0,0 +1,109 @@
+/*
+ * Copyright 2020, TeamDev. All rights reserved.
+ *
+ * Redistribution and use in source and/or binary forms, with or without
+ * modification, must retain the above copyright notice and the following
+ * disclaimer.
+ *
+ * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
+ * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
+ * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
+ * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
+ * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
+ * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
+ * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
+ * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
+ * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
+ * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+ * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+ */
+
+object Versions {
+ const val checkerFramework = "3.4.1"
+ const val errorProne = "2.4.0"
+ const val pmd = "6.24.0"
+ const val checkstyle = "8.33"
+ const val findBugs = "3.0.2"
+ const val guava = "29.0-jre"
+ const val flogger = "0.5.1"
+ const val junit5 = "5.6.2"
+ const val truth = "1.0.1"
+ const val micronaut = "2.0.0"
+ const val spineGcloud = "1.5.22"
+ const val googleSecretManager = "1.1.0"
+ const val googleChat = "v1-rev20200617-1.30.9"
+ const val googleAuth = "0.20.0"
+ const val log4j2 = "2.13.3"
+}
+
+object Build {
+ val errorProneAnnotations = listOf(
+ "com.google.errorprone:error_prone_annotations:${Versions.errorProne}",
+ "com.google.errorprone:error_prone_type_annotations:${Versions.errorProne}"
+ )
+ const val errorProneCheckApi = "com.google.errorprone:error_prone_check_api:${Versions.errorProne}"
+ const val errorProneCore = "com.google.errorprone:error_prone_core:${Versions.errorProne}"
+ const val errorProneTestHelpers = "com.google.errorprone:error_prone_test_helpers:${Versions.errorProne}"
+ const val checkerAnnotations = "org.checkerframework:checker-qual:${Versions.checkerFramework}"
+ val checkerDataflow = listOf(
+ "org.checkerframework:dataflow:${Versions.checkerFramework}",
+ "org.checkerframework:javacutil:${Versions.checkerFramework}"
+ )
+ const val jsr305Annotations = "com.google.code.findbugs:jsr305:${Versions.findBugs}"
+ const val guava = "com.google.guava:guava:${Versions.guava}"
+ const val flogger = "com.google.flogger:flogger:${Versions.flogger}"
+ val ci = "true".equals(System.getenv("CI"))
+ val micronaut = Micronaut
+ val google = Google
+ val log4j2 = Log4j2
+ var spine = Spine
+}
+
+object Spine {
+ const val datastore = "io.spine.gcloud:spine-datastore:${Versions.spineGcloud}"
+ const val pubsub = "io.spine.gcloud:spine-pubsub:${Versions.spineGcloud}"
+}
+
+object Log4j2 {
+ const val core = "org.apache.logging.log4j:log4j-core:${Versions.log4j2}"
+ const val api = "org.apache.logging.log4j:log4j-api:${Versions.log4j2}"
+ const val slf4jBridge = "org.apache.logging.log4j:log4j-slf4j-impl:${Versions.log4j2}"
+}
+
+object Google {
+ const val secretManager = "com.google.cloud:google-cloud-secretmanager:${Versions.googleSecretManager}"
+ const val chat = "com.google.apis:google-api-services-chat:${Versions.googleChat}"
+ const val auth = "com.google.auth:google-auth-library-oauth2-http:${Versions.googleAuth}"
+}
+
+object Micronaut {
+ const val bom = "io.micronaut:micronaut-bom:${Versions.micronaut}"
+ const val inject = "io.micronaut:micronaut-inject"
+ const val injectJava = "io.micronaut:micronaut-inject-java"
+ const val validation = "io.micronaut:micronaut-validation"
+ const val runtime = "io.micronaut:micronaut-runtime"
+ const val netty = "io.micronaut:micronaut-http-server-netty"
+ const val testJUnit5 = "io.micronaut.test:micronaut-test-junit5"
+ const val httpClient = "io.micronaut:micronaut-http-client"
+ const val annotationApi = "javax.annotation:javax.annotation-api"
+}
+
+object Test {
+ val junit5Api = listOf(
+ "org.junit.jupiter:junit-jupiter-api:${Versions.junit5}",
+ "org.junit.jupiter:junit-jupiter-params:${Versions.junit5}"
+ )
+ const val junit5Runner = "org.junit.jupiter:junit-jupiter-engine:${Versions.junit5}"
+ const val guavaTestlib = "com.google.guava:guava-testlib:${Versions.guava}"
+ val truth = listOf(
+ "com.google.truth:truth:${Versions.truth}",
+ "com.google.truth.extensions:truth-java8-extension:${Versions.truth}",
+ "com.google.truth.extensions:truth-proto-extension:${Versions.truth}"
+ )
+}
+
+object Deps {
+ val build = Build
+ val test = Test
+ val versions = Versions
+}
diff --git a/buildSrc/src/main/kotlin/java-convention.gradle.kts b/buildSrc/src/main/kotlin/java-convention.gradle.kts
new file mode 100644
index 00000000..11eca08e
--- /dev/null
+++ b/buildSrc/src/main/kotlin/java-convention.gradle.kts
@@ -0,0 +1,76 @@
+/*
+ * Copyright 2020, TeamDev. All rights reserved.
+ *
+ * Redistribution and use in source and/or binary forms, with or without
+ * modification, must retain the above copyright notice and the following
+ * disclaimer.
+ *
+ * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
+ * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
+ * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
+ * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
+ * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
+ * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
+ * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
+ * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
+ * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
+ * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+ * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+ */
+
+import net.ltgt.gradle.errorprone.errorprone
+
+plugins {
+ `java-library`
+ id("net.ltgt.errorprone")
+ id("net.ltgt.apt-idea")
+}
+
+java {
+ sourceCompatibility = JavaVersion.VERSION_11
+ targetCompatibility = JavaVersion.VERSION_11
+}
+
+dependencies {
+ errorprone(Deps.build.errorProneCore)
+ implementation(Deps.build.guava)
+ implementation(Deps.build.jsr305Annotations)
+ implementation(Deps.build.checkerAnnotations)
+ Deps.build.errorProneAnnotations.forEach { implementation(it) }
+
+ testImplementation(Deps.test.guavaTestlib)
+ Deps.test.junit5Api.forEach { testImplementation(it) }
+ Deps.test.truth.forEach { testImplementation(it) }
+ testRuntimeOnly(Deps.test.junit5Runner)
+}
+
+tasks.test {
+ useJUnitPlatform {
+ includeEngines("junit-jupiter")
+ }
+}
+
+tasks.compileJava {
+ options.errorprone.disableWarningsInGeneratedCode.set(true)
+ // Explicitly states the encoding of the source and test source files, ensuring
+ // correct execution of the `javac` task.
+ options.encoding = "UTF-8"
+ options.compilerArgs.addAll(listOf("-Xlint:unchecked", "-Xlint:deprecation", "-Werror"))
+
+ // Configure Error Prone:
+ // 1. Exclude generated sources from being analyzed by Error Prone.
+ // 2. Turn the check off until Error Prone can handle `@Nested` JUnit classes.
+ // See issue: https://github.com/google/error-prone/issues/956
+ // 3. Turn off checks which report unused methods and unused method parameters.
+ // See issue: https://github.com/SpineEventEngine/config/issues/61
+ //
+ // For more config details see:
+ // https://github.com/tbroyer/gradle-errorprone-plugin/tree/master#usage
+ options.errorprone.errorproneArgs.addAll(listOf(
+ "-XepExcludedPaths:.*/generated/.*",
+ "-Xep:ClassCanBeStatic:OFF",
+ "-Xep:UnusedMethod:OFF",
+ "-Xep:UnusedVariable:OFF",
+ "-Xep:CheckReturnValue:OFF"
+ ))
+}
diff --git a/cloudbuild.yaml b/cloudbuild.yaml
new file mode 100644
index 00000000..0da28f53
--- /dev/null
+++ b/cloudbuild.yaml
@@ -0,0 +1,22 @@
+steps:
+ # The following step starts the build process using Gradle official image, performs the build
+ # and runs `jib` for the specified `gcpProject` in order to build and deploy the container
+ # image to the Google Container Registry.
+ - name: 'gradle:6.4.1-jdk11'
+ entrypoint: 'gradle'
+ args: [ 'build', 'jib', '-PgcpProject=${PROJECT_ID}' ]
+ # The following step deploys the previously produced container image to the Cloud Run environment
+ # with the pre-configured `GCP_PROJECT_ID` environment variable.
+ - name: 'google/cloud-sdk:slim'
+ entrypoint: 'gcloud'
+ args: [
+ 'run', 'deploy', '${_SERVICE_NAME}',
+ '--image', 'gcr.io/${PROJECT_ID}/${_SERVICE_NAME}',
+ '--set-env-vars', 'GCP_PROJECT_ID=${PROJECT_ID}',
+ '--region', 'europe-west1',
+ '--platform', 'managed',
+ '--project', '${PROJECT_ID}'
+ ]
+timeout: 1200s
+substitutions:
+ _SERVICE_NAME: "chat-bot-server"
diff --git a/google-chat-bot/build.gradle.kts b/google-chat-bot/build.gradle.kts
new file mode 100644
index 00000000..a79d9134
--- /dev/null
+++ b/google-chat-bot/build.gradle.kts
@@ -0,0 +1,87 @@
+/*
+ * Copyright 2020, TeamDev. All rights reserved.
+ *
+ * Redistribution and use in source and/or binary forms, with or without
+ * modification, must retain the above copyright notice and the following
+ * disclaimer.
+ *
+ * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
+ * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
+ * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
+ * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
+ * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
+ * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
+ * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
+ * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
+ * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
+ * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+ * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+ */
+
+plugins {
+ application
+ id("com.github.johnrengelman.shadow")
+ id("com.google.cloud.tools.jib")
+ id("io.spine.tools.gradle.bootstrap")
+}
+
+/** The GCP project ID used for deployment of the application. **/
+val gcpProject: String by project
+
+spine {
+ enableJava().server()
+}
+
+dependencies {
+ annotationProcessor(enforcedPlatform(Deps.build.micronaut.bom))
+ annotationProcessor(Deps.build.micronaut.injectJava)
+ annotationProcessor(Deps.build.micronaut.validation)
+
+ compileOnly(enforcedPlatform(Deps.build.micronaut.bom))
+
+ implementation(enforcedPlatform(Deps.build.micronaut.bom))
+ implementation(Deps.build.micronaut.inject)
+ implementation(Deps.build.micronaut.validation)
+ implementation(Deps.build.micronaut.runtime)
+ implementation(Deps.build.micronaut.netty)
+ implementation(Deps.build.micronaut.annotationApi)
+
+ implementation(Deps.build.log4j2.core)
+ runtimeOnly(Deps.build.log4j2.api)
+ runtimeOnly(Deps.build.log4j2.slf4jBridge)
+ implementation(Deps.build.flogger)
+ implementation("com.google.flogger:flogger-log4j2-backend:${Deps.versions.flogger}") {
+ exclude("org.apache.logging.log4j:log4j-api")
+ exclude("org.apache.logging.log4j:log4j-core")
+ }
+
+ implementation(Deps.build.spine.datastore)
+ implementation(Deps.build.spine.pubsub)
+
+ implementation(Deps.build.google.secretManager)
+
+ implementation(Deps.build.google.chat)
+ implementation(Deps.build.google.auth)
+
+ testAnnotationProcessor(enforcedPlatform(Deps.build.micronaut.bom))
+ testAnnotationProcessor(Deps.build.micronaut.injectJava)
+
+ testImplementation("io.spine:spine-testutil-server:${spine.version()}")
+ testImplementation(enforcedPlatform(Deps.build.micronaut.bom))
+ testImplementation(Deps.build.micronaut.testJUnit5)
+ testImplementation(Deps.build.micronaut.httpClient)
+}
+
+application {
+ mainClassName = "io.spine.chatbot.Application"
+}
+
+jib {
+ to {
+ image = "gcr.io/${gcpProject}/chat-bot-server"
+ tags = setOf("latest")
+ }
+ container {
+ mainClass = application.mainClassName
+ }
+}
diff --git a/google-chat-bot/src/main/java/io/spine/chatbot/Application.java b/google-chat-bot/src/main/java/io/spine/chatbot/Application.java
new file mode 100644
index 00000000..5e7c2061
--- /dev/null
+++ b/google-chat-bot/src/main/java/io/spine/chatbot/Application.java
@@ -0,0 +1,81 @@
+/*
+ * Copyright 2020, TeamDev. All rights reserved.
+ *
+ * Redistribution and use in source and/or binary forms, with or without
+ * modification, must retain the above copyright notice and the following
+ * disclaimer.
+ *
+ * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
+ * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
+ * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
+ * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
+ * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
+ * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
+ * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
+ * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
+ * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
+ * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+ * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+ */
+
+package io.spine.chatbot;
+
+import io.micronaut.runtime.Micronaut;
+import io.spine.chatbot.server.Server;
+import io.spine.chatbot.server.github.GitHubContext;
+import io.spine.chatbot.server.google.chat.GoogleChatContext;
+import io.spine.logging.Logging;
+
+/**
+ * The entry point to the Spine ChatBot application.
+ *
+ * The application exposes a number of REST endpoints accessible for the clients such as:
+ *
+ *
+ * - {@code /chat/incoming/event} — handles incoming events from the Google Chat space;
+ *
- {@code /repositories/check} — triggers checking of the repositories build statuses.
+ *
+ *
+ * @see IncomingEventsController
+ * @see RepositoriesController
+ **/
+public final class Application implements Logging {
+
+ static {
+ useLog4j2FloggerBackend();
+ }
+
+ /**
+ * Prevents direct instantiation.
+ */
+ private Application() {
+ }
+
+ /**
+ * Starts the application.
+ *
+ * Performs bounded contexts initialization, starts Spine {@link Server} and runs
+ * the {@link Micronaut}.
+ */
+ public static void main(String[] args) {
+ var application = new Application();
+ application.start();
+ }
+
+ private void start() {
+ Server.withContexts(GitHubContext.newInstance(), GoogleChatContext.newInstance())
+ .start();
+ _config().log("Starting Micronaut application.");
+ Micronaut.run(Application.class);
+ }
+
+ /**
+ * Configures Log4j2 as the Flogger backend.
+ */
+ private static void useLog4j2FloggerBackend() {
+ System.setProperty(
+ "flogger.backend_factory",
+ "com.google.common.flogger.backend.log4j2.Log4j2BackendFactory#getInstance"
+ );
+ }
+}
diff --git a/google-chat-bot/src/main/java/io/spine/chatbot/BeanFactory.java b/google-chat-bot/src/main/java/io/spine/chatbot/BeanFactory.java
new file mode 100644
index 00000000..c49a1989
--- /dev/null
+++ b/google-chat-bot/src/main/java/io/spine/chatbot/BeanFactory.java
@@ -0,0 +1,99 @@
+/*
+ * Copyright 2020, TeamDev. All rights reserved.
+ *
+ * Redistribution and use in source and/or binary forms, with or without
+ * modification, must retain the above copyright notice and the following
+ * disclaimer.
+ *
+ * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
+ * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
+ * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
+ * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
+ * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
+ * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
+ * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
+ * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
+ * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
+ * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+ * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+ */
+
+package io.spine.chatbot;
+
+import com.fasterxml.jackson.core.JsonParser;
+import com.fasterxml.jackson.databind.DeserializationContext;
+import com.fasterxml.jackson.databind.JsonDeserializer;
+import com.fasterxml.jackson.databind.node.ObjectNode;
+import com.google.common.annotations.VisibleForTesting;
+import com.google.pubsub.v1.PubsubMessage;
+import io.micronaut.context.annotation.Bean;
+import io.micronaut.context.annotation.Factory;
+import io.spine.json.Json;
+import io.spine.pubsub.PubsubPushRequest;
+
+import javax.inject.Singleton;
+import java.io.IOException;
+
+import static io.spine.util.Exceptions.newIllegalArgumentException;
+
+/**
+ * Creates Micronaut context bean definitions.
+ */
+@Factory
+final class BeanFactory {
+
+ /**
+ * Registers {@linkplain PubsubPushRequest push request} Jackson deserializer.
+ */
+ @Singleton
+ @Bean
+ PubsubPushRequestDeserializer pubsubDeserializer() {
+ return new PubsubPushRequestDeserializer();
+ }
+
+ /**
+ * Deserializes JSON arriving with {@link PubsubPushRequest} into Spine-compatible
+ * data structures.
+ *
+ * @see
+ * Jackson Deserialization
+ */
+ @VisibleForTesting
+ static final class PubsubPushRequestDeserializer extends JsonDeserializer {
+
+ /**
+ * Deserializes {@link PubsubPushRequest} JSON string into a Protobuf message.
+ *
+ * While Protobuf JSON parser is not able to handle same fields that are set using
+ * {@code lowerCamelCase} and {@code snake_case} notations, we manually drop duplicate
+ * fields.
+ *
+ * @see
+ * JsonFormat fails to parse JSON with both `lowerCamelCase` and `snake_case`
+ * fields
+ */
+ @Override
+ public PubsubPushRequest deserialize(JsonParser parser, DeserializationContext ctxt) {
+ try {
+ var jsonNode = ctxt.readTree(parser);
+ var messageNode = (ObjectNode) jsonNode.get("message");
+ messageNode.remove("message_id");
+ messageNode.remove("publish_time");
+ var pubsubMessage = Json.fromJson(messageNode.toString(), PubsubMessage.class);
+ var subscription = jsonNode.get("subscription")
+ .asText();
+ var result = PubsubPushRequest
+ .newBuilder()
+ .setMessage(pubsubMessage)
+ .setSubscription(subscription)
+ .vBuild();
+ return result;
+ } catch (IOException e) {
+ throw newIllegalArgumentException(
+ e, "Unable to deserialize `%s` json.",
+ PubsubPushRequest.class.getSimpleName()
+ );
+ }
+ }
+ }
+}
diff --git a/google-chat-bot/src/main/java/io/spine/chatbot/IncomingEventsController.java b/google-chat-bot/src/main/java/io/spine/chatbot/IncomingEventsController.java
new file mode 100644
index 00000000..705ac0c8
--- /dev/null
+++ b/google-chat-bot/src/main/java/io/spine/chatbot/IncomingEventsController.java
@@ -0,0 +1,91 @@
+/*
+ * Copyright 2020, TeamDev. All rights reserved.
+ *
+ * Redistribution and use in source and/or binary forms, with or without
+ * modification, must retain the above copyright notice and the following
+ * disclaimer.
+ *
+ * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
+ * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
+ * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
+ * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
+ * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
+ * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
+ * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
+ * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
+ * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
+ * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+ * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+ */
+
+package io.spine.chatbot;
+
+import io.micronaut.context.event.ShutdownEvent;
+import io.micronaut.http.annotation.Body;
+import io.micronaut.http.annotation.Controller;
+import io.micronaut.http.annotation.Post;
+import io.micronaut.runtime.event.annotation.EventListener;
+import io.spine.chatbot.google.chat.incoming.ChatEvent;
+import io.spine.chatbot.google.chat.incoming.User;
+import io.spine.chatbot.google.chat.incoming.event.ChatEventReceived;
+import io.spine.core.UserId;
+import io.spine.json.Json;
+import io.spine.logging.Logging;
+import io.spine.pubsub.PubsubPushRequest;
+import io.spine.server.integration.ThirdPartyContext;
+
+import static io.micronaut.http.MediaType.APPLICATION_JSON;
+
+/**
+ * A REST controller for handling incoming events from Google Chat.
+ */
+@Controller("/chat")
+final class IncomingEventsController implements Logging {
+
+ private static final String GOOGLE_CHAT_SERVER_CONTEXT_NAME = "GoogleChatServer";
+ private static final ThirdPartyContext GOOGLE_CHAT_SERVER =
+ ThirdPartyContext.singleTenant(GOOGLE_CHAT_SERVER_CONTEXT_NAME);
+
+ /**
+ * Processes an incoming Google Chat event.
+ *
+ *
Dispatches the event using {@link ThirdPartyContext}.
+ */
+ @Post(value = "/incoming/event", consumes = APPLICATION_JSON)
+ String on(@Body PubsubPushRequest pushRequest) {
+ var message = pushRequest.getMessage();
+ var chatEventJson = message.getData()
+ .toStringUtf8();
+ _debug().log("Received a new chat event:%n%s", chatEventJson);
+ ChatEvent chatEvent = Json.fromJson(chatEventJson, ChatEvent.class);
+ var actor = eventActor(chatEvent.getUser());
+ var chatEventReceived = ChatEventReceived
+ .newBuilder()
+ .setEvent(chatEvent)
+ .vBuild();
+ GOOGLE_CHAT_SERVER.emittedEvent(chatEventReceived, actor);
+ return "OK";
+ }
+
+ private static UserId eventActor(User user) {
+ return UserId
+ .newBuilder()
+ .setValue(user.getName())
+ .vBuild();
+ }
+
+ /**
+ * Cleans up resources of the {@link #GOOGLE_CHAT_SERVER context}.
+ */
+ @EventListener
+ void on(ShutdownEvent event) {
+ _info().log("Closing `%s` third-party context.", GOOGLE_CHAT_SERVER_CONTEXT_NAME);
+ try {
+ GOOGLE_CHAT_SERVER.close();
+ } catch (Exception e) {
+ _error().withCause(e)
+ .log("Unable to gracefully close `%s` context.",
+ GOOGLE_CHAT_SERVER_CONTEXT_NAME);
+ }
+ }
+}
diff --git a/google-chat-bot/src/main/java/io/spine/chatbot/RepositoriesController.java b/google-chat-bot/src/main/java/io/spine/chatbot/RepositoriesController.java
new file mode 100644
index 00000000..f092ed99
--- /dev/null
+++ b/google-chat-bot/src/main/java/io/spine/chatbot/RepositoriesController.java
@@ -0,0 +1,68 @@
+/*
+ * Copyright 2020, TeamDev. All rights reserved.
+ *
+ * Redistribution and use in source and/or binary forms, with or without
+ * modification, must retain the above copyright notice and the following
+ * disclaimer.
+ *
+ * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
+ * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
+ * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
+ * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
+ * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
+ * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
+ * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
+ * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
+ * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
+ * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+ * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+ */
+
+package io.spine.chatbot;
+
+import io.micronaut.http.annotation.Controller;
+import io.micronaut.http.annotation.Post;
+import io.spine.chatbot.client.Client;
+import io.spine.chatbot.github.RepositoryId;
+import io.spine.chatbot.github.organization.Organization;
+import io.spine.chatbot.github.repository.build.command.CheckRepositoryBuild;
+import io.spine.logging.Logging;
+
+/**
+ * A REST controller handling Repository commands.
+ */
+@Controller("/repositories")
+final class RepositoriesController implements Logging {
+
+ /**
+ * Sends {@link CheckRepositoryBuild} commands to all repositories registered in the system.
+ */
+ @Post("/builds/check")
+ String checkBuildStatuses() {
+ _debug().log("Checking repositories build statuses.");
+ try (var client = Client.newInstance()) {
+ var organizations = client.listOrganizations();
+ for (var org : organizations) {
+ var repos = client.listOrgRepos(org.getId());
+ repos.forEach(repo -> checkBuildStatus(client, repo, org));
+ }
+ return "success";
+ }
+ }
+
+ private void checkBuildStatus(Client client, RepositoryId repo, Organization org) {
+ _debug().log("Sending `%s` command for the repository `%s`.",
+ CheckRepositoryBuild.class.getSimpleName(), repo.getValue());
+ var checkRepositoryBuild = checkRepoBuildCommand(repo, org);
+ client.post(checkRepositoryBuild);
+ }
+
+ private static CheckRepositoryBuild checkRepoBuildCommand(RepositoryId repo, Organization org) {
+ return CheckRepositoryBuild
+ .newBuilder()
+ .setRepository(repo)
+ .setOrganization(org.getId())
+ .setSpace(org.space())
+ .vBuild();
+ }
+}
diff --git a/google-chat-bot/src/main/java/io/spine/chatbot/client/Client.java b/google-chat-bot/src/main/java/io/spine/chatbot/client/Client.java
new file mode 100644
index 00000000..8818a2f0
--- /dev/null
+++ b/google-chat-bot/src/main/java/io/spine/chatbot/client/Client.java
@@ -0,0 +1,149 @@
+/*
+ * Copyright 2020, TeamDev. All rights reserved.
+ *
+ * Redistribution and use in source and/or binary forms, with or without
+ * modification, must retain the above copyright notice and the following
+ * disclaimer.
+ *
+ * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
+ * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
+ * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
+ * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
+ * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
+ * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
+ * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
+ * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
+ * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
+ * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+ * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+ */
+
+package io.spine.chatbot.client;
+
+import com.google.common.collect.ImmutableList;
+import com.google.errorprone.annotations.CanIgnoreReturnValue;
+import io.spine.base.CommandMessage;
+import io.spine.base.EventMessage;
+import io.spine.chatbot.github.OrganizationId;
+import io.spine.chatbot.github.RepositoryId;
+import io.spine.chatbot.github.organization.Organization;
+import io.spine.chatbot.github.organization.OrganizationRepositories;
+import io.spine.chatbot.server.Server;
+import io.spine.client.CommandRequest;
+import io.spine.client.Subscription;
+
+import java.util.concurrent.CountDownLatch;
+
+import static com.google.common.base.Preconditions.checkNotNull;
+import static com.google.common.base.Preconditions.checkState;
+import static io.spine.util.Exceptions.newIllegalStateException;
+
+/**
+ * A ChatBot application's Spine client.
+ *
+ *
Abstracts working with Spine's {@link io.spine.client.Client client}.
+ */
+public final class Client implements AutoCloseable {
+
+ private final io.spine.client.Client client;
+
+ private Client(io.spine.client.Client client) {
+ this.client = client;
+ }
+
+ /**
+ * Creates a new in-process client linked to the {@link Server}.
+ */
+ public static Client newInstance() {
+ io.spine.client.Client client = io.spine.client.Client
+ .inProcess(Server.name())
+ .build();
+ return new Client(client);
+ }
+
+ /**
+ * Retrieves all registered organizations.
+ */
+ public ImmutableList listOrganizations() {
+ return client.asGuest()
+ .select(Organization.class)
+ .run();
+ }
+
+ /**
+ * Returns list of all registered repositories for the {@code organization}.
+ */
+ public ImmutableList listOrgRepos(OrganizationId org) {
+ checkNotNull(org);
+ var orgRepos = client.asGuest()
+ .select(OrganizationRepositories.class)
+ .byId(org)
+ .run();
+ checkState(orgRepos.size() == 1);
+ return ImmutableList.copyOf(orgRepos.get(0)
+ .getRepositoryList());
+ }
+
+ @Override
+ public void close() {
+ this.client.close();
+ }
+
+ /**
+ * Posts a command and waits synchronously till the expected outcome event is published.
+ */
+ public void post(CommandMessage command, Class expectedOutcome) {
+ checkNotNull(command);
+ checkNotNull(expectedOutcome);
+ post(command, expectedOutcome, 1);
+ }
+
+ /**
+ * Posts a command asynchronously.
+ *
+ * @see #post(CommandMessage, Class)
+ */
+ public void post(CommandMessage command) {
+ checkNotNull(command);
+ var subscriptions = client.asGuest()
+ .command(command)
+ .onStreamingError(Client::throwProcessingError)
+ .post();
+ subscriptions.forEach(this::cancelSubscription);
+ }
+
+ private void
+ post(CommandMessage command, Class expectedOutcome, int expectedEvents) {
+ var latch = new CountDownLatch(expectedEvents);
+ var subscriptions = client.asGuest()
+ .command(command)
+ .onStreamingError(Client::throwProcessingError)
+ .observe(expectedOutcome, event -> latch.countDown())
+ .post();
+ try {
+ latch.await();
+ } catch (InterruptedException e) {
+ newIllegalStateException(e, "Processing of command interrupted:%n%s.", command);
+ }
+ subscriptions.forEach(this::cancelSubscription);
+ }
+
+ /**
+ * Cancels the passed subscription.
+ *
+ * @see io.spine.client.Subscriptions#cancel(Subscription)
+ * @see CommandRequest#post()
+ */
+ @CanIgnoreReturnValue
+ private boolean cancelSubscription(Subscription subscription) {
+ checkNotNull(subscription);
+ return client.subscriptions()
+ .cancel(subscription);
+ }
+
+ private static void throwProcessingError(Throwable throwable) {
+ throw newIllegalStateException(
+ throwable, "An error while processing the command."
+ );
+ }
+}
diff --git a/google-chat-bot/src/main/java/io/spine/chatbot/client/package-info.java b/google-chat-bot/src/main/java/io/spine/chatbot/client/package-info.java
new file mode 100644
index 00000000..4915e9fc
--- /dev/null
+++ b/google-chat-bot/src/main/java/io/spine/chatbot/client/package-info.java
@@ -0,0 +1,30 @@
+/*
+ * Copyright 2020, TeamDev. All rights reserved.
+ *
+ * Redistribution and use in source and/or binary forms, with or without
+ * modification, must retain the above copyright notice and the following
+ * disclaimer.
+ *
+ * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
+ * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
+ * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
+ * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
+ * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
+ * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
+ * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
+ * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
+ * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
+ * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+ * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+ */
+
+/**
+ * This package contains the ChatBot client.
+ */
+@CheckReturnValue
+@ParametersAreNonnullByDefault
+package io.spine.chatbot.client;
+
+import com.google.errorprone.annotations.CheckReturnValue;
+
+import javax.annotation.ParametersAreNonnullByDefault;
diff --git a/google-chat-bot/src/main/java/io/spine/chatbot/delivery/DistributedDelivery.java b/google-chat-bot/src/main/java/io/spine/chatbot/delivery/DistributedDelivery.java
new file mode 100644
index 00000000..f33df478
--- /dev/null
+++ b/google-chat-bot/src/main/java/io/spine/chatbot/delivery/DistributedDelivery.java
@@ -0,0 +1,63 @@
+/*
+ * Copyright 2020, TeamDev. All rights reserved.
+ *
+ * Redistribution and use in source and/or binary forms, with or without
+ * modification, must retain the above copyright notice and the following
+ * disclaimer.
+ *
+ * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
+ * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
+ * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
+ * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
+ * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
+ * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
+ * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
+ * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
+ * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
+ * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+ * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+ */
+
+package io.spine.chatbot.delivery;
+
+import io.spine.server.delivery.Delivery;
+import io.spine.server.delivery.ShardedWorkRegistry;
+import io.spine.server.delivery.UniformAcrossAllShards;
+import io.spine.server.storage.datastore.DatastoreStorageFactory;
+import io.spine.server.storage.datastore.DsShardedWorkRegistry;
+
+/**
+ * Delivers messages using Datastore as the underlying storage.
+ *
+ * The delivery is based on the {@link DatastoreStorageFactory Datastore} and uses
+ * {@link DsShardedWorkRegistry} as the {@linkplain ShardedWorkRegistry work registry}.
+ */
+final class DistributedDelivery {
+
+ /** The number of shards used for the signal delivery. **/
+ private static final int NUMBER_OF_SHARDS = 50;
+
+ /**
+ * Prevents instantiation of this utility class.
+ */
+ private DistributedDelivery() {
+ }
+
+ /**
+ * Creates a new Datastore-based delivery using the supplied Datastore {@code storageFactory}.
+ *
+ *
Assigns the targets uniformly across shards. Configures the inbox storage
+ * to be single-tenant.
+ */
+ public static Delivery instance(DatastoreStorageFactory storageFactory) {
+ var workRegistry = new DsShardedWorkRegistry(storageFactory);
+ var inboxStorage = storageFactory.createInboxStorage(false);
+ var delivery = Delivery
+ .newBuilder()
+ .setStrategy(UniformAcrossAllShards.forNumber(NUMBER_OF_SHARDS))
+ .setWorkRegistry(workRegistry)
+ .setInboxStorage(inboxStorage)
+ .build();
+ return delivery;
+ }
+}
diff --git a/google-chat-bot/src/main/java/io/spine/chatbot/delivery/LocalDelivery.java b/google-chat-bot/src/main/java/io/spine/chatbot/delivery/LocalDelivery.java
new file mode 100644
index 00000000..0033eac6
--- /dev/null
+++ b/google-chat-bot/src/main/java/io/spine/chatbot/delivery/LocalDelivery.java
@@ -0,0 +1,71 @@
+/*
+ * Copyright 2020, TeamDev. All rights reserved.
+ *
+ * Redistribution and use in source and/or binary forms, with or without
+ * modification, must retain the above copyright notice and the following
+ * disclaimer.
+ *
+ * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
+ * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
+ * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
+ * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
+ * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
+ * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
+ * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
+ * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
+ * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
+ * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+ * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+ */
+
+package io.spine.chatbot.delivery;
+
+import com.google.protobuf.util.Durations;
+import io.spine.server.delivery.CatchUpStorage;
+import io.spine.server.delivery.Delivery;
+import io.spine.server.delivery.InboxStorage;
+import io.spine.server.delivery.UniformAcrossAllShards;
+import io.spine.server.delivery.memory.InMemoryShardedWorkRegistry;
+import io.spine.server.storage.memory.InMemoryCatchUpStorage;
+import io.spine.server.storage.memory.InMemoryInboxStorage;
+
+/**
+ * A {@link Delivery} factory that creates deliveries for local or test environments.
+ */
+public final class LocalDelivery {
+
+ /** A singleton instance of the local delivery. **/
+ public static final Delivery instance = delivery();
+
+ /**
+ * Prevents instantiation of this class.
+ */
+ private LocalDelivery() {
+ }
+
+ /**
+ * Creates a new instance of an in-memory local delivery.
+ */
+ private static Delivery delivery() {
+ var delivery = Delivery
+ .newBuilder()
+ .setInboxStorage(singleTenantInboxStorage())
+ .setCatchUpStorage(singleTenantCatchupStorage())
+ .setWorkRegistry(new InMemoryShardedWorkRegistry())
+ .setStrategy(UniformAcrossAllShards.singleShard())
+ .setDeduplicationWindow(Durations.fromSeconds(0))
+ .build();
+ delivery.subscribe(ShardDelivery::deliver);
+ return delivery;
+ }
+
+ @SuppressWarnings("TestOnlyProblems") // we do want the in-memory delivery in local-dev env
+ private static InboxStorage singleTenantInboxStorage() {
+ return new InMemoryInboxStorage(false);
+ }
+
+ @SuppressWarnings("TestOnlyProblems") // we do want the in-memory delivery in local-dev env
+ private static CatchUpStorage singleTenantCatchupStorage() {
+ return new InMemoryCatchUpStorage(false);
+ }
+}
diff --git a/google-chat-bot/src/main/java/io/spine/chatbot/delivery/ShardDelivery.java b/google-chat-bot/src/main/java/io/spine/chatbot/delivery/ShardDelivery.java
new file mode 100644
index 00000000..e8548cc0
--- /dev/null
+++ b/google-chat-bot/src/main/java/io/spine/chatbot/delivery/ShardDelivery.java
@@ -0,0 +1,82 @@
+/*
+ * Copyright 2020, TeamDev. All rights reserved.
+ *
+ * Redistribution and use in source and/or binary forms, with or without
+ * modification, must retain the above copyright notice and the following
+ * disclaimer.
+ *
+ * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
+ * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
+ * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
+ * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
+ * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
+ * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
+ * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
+ * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
+ * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
+ * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+ * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+ */
+
+package io.spine.chatbot.delivery;
+
+import io.spine.logging.Logging;
+import io.spine.server.ServerEnvironment;
+import io.spine.server.delivery.DeliveryStats;
+import io.spine.server.delivery.ShardIndex;
+import io.spine.server.delivery.ShardedRecord;
+
+import static com.google.common.base.Preconditions.checkNotNull;
+
+/**
+ * Delivers messages from a particular shard.
+ *
+ *
Wraps the {@link io.spine.server.delivery.Delivery#deliverMessagesFrom(ShardIndex)
+ * Delivery#deliverMessagesFrom} with server environment-specific logging and provides helpers
+ * that unifies the usage of the delivery.
+ */
+final class ShardDelivery implements Logging {
+
+ private final ShardIndex shard;
+
+ private ShardDelivery(ShardIndex shard) {
+ this.shard = shard;
+ }
+
+ /**
+ * Delivers the {@code message}.
+ */
+ static void deliver(ShardedRecord message) {
+ checkNotNull(message);
+ deliverFrom(message.shardIndex());
+ }
+
+ /**
+ * Delivers messages from the {@code shard}.
+ */
+ private static void deliverFrom(ShardIndex shard) {
+ checkNotNull(shard);
+ var delivery = new ShardDelivery(shard);
+ delivery.deliverNow();
+ }
+
+ private void deliverNow() {
+ var trace = _trace();
+ var server = ServerEnvironment.instance();
+ var nodeId = server.nodeId()
+ .getValue();
+ var indexValue = shard.getIndex();
+ trace.log("Delivering messages from the shard with index `%d`. NodeId=%s.",
+ indexValue, nodeId);
+ var stats = server.delivery()
+ .deliverMessagesFrom(shard);
+ if (stats.isPresent()) {
+ DeliveryStats deliveryStats = stats.get();
+ trace.log("`%d` messages delivered from the shard with index `%s`. NodeId=%s.",
+ deliveryStats.deliveredCount(), indexValue, nodeId);
+ } else {
+ trace.log("No messages delivered from the shard with index `%d`. NodeId=%s.",
+ indexValue, nodeId);
+ }
+ }
+}
diff --git a/google-chat-bot/src/main/java/io/spine/chatbot/delivery/package-info.java b/google-chat-bot/src/main/java/io/spine/chatbot/delivery/package-info.java
new file mode 100644
index 00000000..dc021563
--- /dev/null
+++ b/google-chat-bot/src/main/java/io/spine/chatbot/delivery/package-info.java
@@ -0,0 +1,30 @@
+/*
+ * Copyright 2020, TeamDev. All rights reserved.
+ *
+ * Redistribution and use in source and/or binary forms, with or without
+ * modification, must retain the above copyright notice and the following
+ * disclaimer.
+ *
+ * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
+ * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
+ * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
+ * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
+ * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
+ * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
+ * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
+ * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
+ * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
+ * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+ * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+ */
+
+/**
+ * This package is devoted to configuring the delivery mechanism for ChatBot signals.
+ */
+@CheckReturnValue
+@ParametersAreNonnullByDefault
+package io.spine.chatbot.delivery;
+
+import com.google.errorprone.annotations.CheckReturnValue;
+
+import javax.annotation.ParametersAreNonnullByDefault;
diff --git a/google-chat-bot/src/main/java/io/spine/chatbot/github/GitHubIdentifiers.java b/google-chat-bot/src/main/java/io/spine/chatbot/github/GitHubIdentifiers.java
new file mode 100644
index 00000000..9eeeb5f8
--- /dev/null
+++ b/google-chat-bot/src/main/java/io/spine/chatbot/github/GitHubIdentifiers.java
@@ -0,0 +1,57 @@
+/*
+ * Copyright 2020, TeamDev. All rights reserved.
+ *
+ * Redistribution and use in source and/or binary forms, with or without
+ * modification, must retain the above copyright notice and the following
+ * disclaimer.
+ *
+ * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
+ * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
+ * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
+ * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
+ * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
+ * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
+ * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
+ * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
+ * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
+ * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+ * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+ */
+
+package io.spine.chatbot.github;
+
+import static io.spine.util.Preconditions2.checkNotEmptyOrBlank;
+
+/**
+ * A utility for working with {@code GitHub} context identifiers.
+ */
+public final class GitHubIdentifiers {
+
+ /**
+ * Prevents instantiation of this utility class.
+ */
+ private GitHubIdentifiers() {
+ }
+
+ /**
+ * Creates a new {@code OrganizationId} out of the specified {@code name}.
+ */
+ public static OrganizationId organization(String name) {
+ checkNotEmptyOrBlank(name);
+ return OrganizationId
+ .newBuilder()
+ .setValue(name)
+ .vBuild();
+ }
+
+ /**
+ * Creates a new {@code RepositoryId} out of the specified {@code slug}.
+ */
+ public static RepositoryId repository(String slug) {
+ checkNotEmptyOrBlank(slug);
+ return RepositoryId
+ .newBuilder()
+ .setValue(slug)
+ .vBuild();
+ }
+}
diff --git a/google-chat-bot/src/main/java/io/spine/chatbot/github/SlugMixin.java b/google-chat-bot/src/main/java/io/spine/chatbot/github/SlugMixin.java
new file mode 100644
index 00000000..4c3ef629
--- /dev/null
+++ b/google-chat-bot/src/main/java/io/spine/chatbot/github/SlugMixin.java
@@ -0,0 +1,55 @@
+/*
+ * Copyright 2020, TeamDev. All rights reserved.
+ *
+ * Redistribution and use in source and/or binary forms, with or without
+ * modification, must retain the above copyright notice and the following
+ * disclaimer.
+ *
+ * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
+ * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
+ * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
+ * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
+ * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
+ * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
+ * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
+ * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
+ * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
+ * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+ * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+ */
+
+package io.spine.chatbot.github;
+
+import io.spine.annotation.GeneratedMixin;
+
+import java.net.URLEncoder;
+import java.nio.charset.StandardCharsets;
+
+/**
+ * Augments {@link Slug} with useful methods.
+ */
+@GeneratedMixin
+public interface SlugMixin extends SlugOrBuilder {
+
+ /**
+ * Returns the slug {@code value}.
+ */
+ default String value() {
+ return getValue();
+ }
+
+ /**
+ * Returns URL-encoded slug {@code value}.
+ */
+ default String encodedValue() {
+ return encode(getValue());
+ }
+
+ /**
+ * Encodes passed value using {@link URLEncoder} and
+ * {@link StandardCharsets#UTF_8 UTF_8} charset.
+ */
+ private static String encode(String value) {
+ return URLEncoder.encode(value, StandardCharsets.UTF_8);
+ }
+}
diff --git a/google-chat-bot/src/main/java/io/spine/chatbot/github/Slugs.java b/google-chat-bot/src/main/java/io/spine/chatbot/github/Slugs.java
new file mode 100644
index 00000000..8222cec1
--- /dev/null
+++ b/google-chat-bot/src/main/java/io/spine/chatbot/github/Slugs.java
@@ -0,0 +1,62 @@
+/*
+ * Copyright 2020, TeamDev. All rights reserved.
+ *
+ * Redistribution and use in source and/or binary forms, with or without
+ * modification, must retain the above copyright notice and the following
+ * disclaimer.
+ *
+ * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
+ * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
+ * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
+ * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
+ * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
+ * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
+ * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
+ * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
+ * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
+ * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+ * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+ */
+
+package io.spine.chatbot.github;
+
+import static com.google.common.base.Preconditions.checkNotNull;
+import static io.spine.util.Preconditions2.checkNotEmptyOrBlank;
+
+/**
+ * A utility for working with {@link Slug}s.
+ */
+public final class Slugs {
+
+ /**
+ * Prevents instantiation of this utility class.
+ */
+ private Slugs() {
+ }
+
+ /**
+ * Creates a new {@code Slug} for the {@code repository}.
+ */
+ public static Slug repoSlug(RepositoryId repo) {
+ checkNotNull(repo);
+ return newSlug(repo.getValue());
+ }
+
+ /**
+ * Creates a new {@code Slug} for the {@code organization}.
+ */
+ public static Slug orgSlug(OrganizationId org) {
+ checkNotNull(org);
+ return newSlug(org.getValue());
+ }
+
+ /**
+ * Creates a new {@code Slug} with the specified {@code value}.
+ */
+ public static Slug newSlug(String value) {
+ checkNotEmptyOrBlank(value);
+ return Slug.newBuilder()
+ .setValue(value)
+ .vBuild();
+ }
+}
diff --git a/google-chat-bot/src/main/java/io/spine/chatbot/github/organization/OrgHeaderAware.java b/google-chat-bot/src/main/java/io/spine/chatbot/github/organization/OrgHeaderAware.java
new file mode 100644
index 00000000..f737b9fe
--- /dev/null
+++ b/google-chat-bot/src/main/java/io/spine/chatbot/github/organization/OrgHeaderAware.java
@@ -0,0 +1,74 @@
+/*
+ * Copyright 2020, TeamDev. All rights reserved.
+ *
+ * Redistribution and use in source and/or binary forms, with or without
+ * modification, must retain the above copyright notice and the following
+ * disclaimer.
+ *
+ * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
+ * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
+ * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
+ * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
+ * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
+ * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
+ * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
+ * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
+ * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
+ * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+ * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+ */
+
+package io.spine.chatbot.github.organization;
+
+import io.spine.annotation.GeneratedMixin;
+import io.spine.chatbot.google.chat.SpaceId;
+import io.spine.net.Url;
+
+/**
+ * Common interface for messages aware of the {@link OrgHeader}.
+ */
+@GeneratedMixin
+public interface OrgHeaderAware {
+
+ /**
+ * Returns the organization {@code header}.
+ *
+ * @implNote This method is implemented in the deriving Protobuf messages.
+ */
+ OrgHeader getHeader();
+
+ /**
+ * Returns the organization {@code name}.
+ */
+ default String name() {
+ return getHeader().getName();
+ }
+
+ /**
+ * Returns the organization {@code website}.
+ */
+ default Url website() {
+ return getHeader().getWebsite();
+ }
+
+ /**
+ * Returns the organization {@code githubProfile}.
+ */
+ default Url githubProfile() {
+ return getHeader().getGithubProfile();
+ }
+
+ /**
+ * Returns the organization {@code travisProfile}.
+ */
+ default Url travisProfile() {
+ return getHeader().getTravisProfile();
+ }
+
+ /**
+ * Returns the {@code space} associated with the organization.
+ */
+ default SpaceId space() {
+ return getHeader().getSpace();
+ }
+}
diff --git a/google-chat-bot/src/main/java/io/spine/chatbot/github/organization/package-info.java b/google-chat-bot/src/main/java/io/spine/chatbot/github/organization/package-info.java
new file mode 100644
index 00000000..69b88571
--- /dev/null
+++ b/google-chat-bot/src/main/java/io/spine/chatbot/github/organization/package-info.java
@@ -0,0 +1,30 @@
+/*
+ * Copyright 2020, TeamDev. All rights reserved.
+ *
+ * Redistribution and use in source and/or binary forms, with or without
+ * modification, must retain the above copyright notice and the following
+ * disclaimer.
+ *
+ * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
+ * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
+ * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
+ * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
+ * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
+ * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
+ * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
+ * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
+ * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
+ * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+ * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+ */
+
+/**
+ * This package contains the GitHub organization-specific language.
+ */
+@CheckReturnValue
+@ParametersAreNonnullByDefault
+package io.spine.chatbot.github.organization;
+
+import com.google.errorprone.annotations.CheckReturnValue;
+
+import javax.annotation.ParametersAreNonnullByDefault;
diff --git a/google-chat-bot/src/main/java/io/spine/chatbot/github/package-info.java b/google-chat-bot/src/main/java/io/spine/chatbot/github/package-info.java
new file mode 100644
index 00000000..682d9d72
--- /dev/null
+++ b/google-chat-bot/src/main/java/io/spine/chatbot/github/package-info.java
@@ -0,0 +1,30 @@
+/*
+ * Copyright 2020, TeamDev. All rights reserved.
+ *
+ * Redistribution and use in source and/or binary forms, with or without
+ * modification, must retain the above copyright notice and the following
+ * disclaimer.
+ *
+ * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
+ * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
+ * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
+ * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
+ * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
+ * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
+ * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
+ * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
+ * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
+ * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+ * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+ */
+
+/**
+ * This package contains the GitHub-specific language.
+ */
+@CheckReturnValue
+@ParametersAreNonnullByDefault
+package io.spine.chatbot.github;
+
+import com.google.errorprone.annotations.CheckReturnValue;
+
+import javax.annotation.ParametersAreNonnullByDefault;
diff --git a/google-chat-bot/src/main/java/io/spine/chatbot/github/repository/RepoHeaderAware.java b/google-chat-bot/src/main/java/io/spine/chatbot/github/repository/RepoHeaderAware.java
new file mode 100644
index 00000000..91acc055
--- /dev/null
+++ b/google-chat-bot/src/main/java/io/spine/chatbot/github/repository/RepoHeaderAware.java
@@ -0,0 +1,67 @@
+/*
+ * Copyright 2020, TeamDev. All rights reserved.
+ *
+ * Redistribution and use in source and/or binary forms, with or without
+ * modification, must retain the above copyright notice and the following
+ * disclaimer.
+ *
+ * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
+ * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
+ * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
+ * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
+ * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
+ * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
+ * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
+ * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
+ * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
+ * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+ * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+ */
+
+package io.spine.chatbot.github.repository;
+
+import io.spine.annotation.GeneratedMixin;
+import io.spine.chatbot.github.OrganizationId;
+import io.spine.net.Url;
+
+/**
+ * Common interface for messages aware of the {@link RepoHeader}.
+ */
+@GeneratedMixin
+public interface RepoHeaderAware {
+
+ /**
+ * Returns the repository {@code header}.
+ *
+ * @implNote This method is implemented in the deriving Protobuf messages.
+ */
+ RepoHeader getHeader();
+
+ /**
+ * Returns the repository {@code name}.
+ */
+ default String name() {
+ return getHeader().getName();
+ }
+
+ /**
+ * Returns the repository {@code githubProfile}.
+ */
+ default Url githubProfile() {
+ return getHeader().getGithubProfile();
+ }
+
+ /**
+ * Returns the repository {@code travisProfile}.
+ */
+ default Url travisProfile() {
+ return getHeader().getTravisProfile();
+ }
+
+ /**
+ * Returns the organization associated with the repository.
+ */
+ default OrganizationId organization() {
+ return getHeader().getOrganization();
+ }
+}
diff --git a/google-chat-bot/src/main/java/io/spine/chatbot/github/repository/RepositoryAware.java b/google-chat-bot/src/main/java/io/spine/chatbot/github/repository/RepositoryAware.java
new file mode 100644
index 00000000..5febfc89
--- /dev/null
+++ b/google-chat-bot/src/main/java/io/spine/chatbot/github/repository/RepositoryAware.java
@@ -0,0 +1,45 @@
+/*
+ * Copyright 2020, TeamDev. All rights reserved.
+ *
+ * Redistribution and use in source and/or binary forms, with or without
+ * modification, must retain the above copyright notice and the following
+ * disclaimer.
+ *
+ * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
+ * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
+ * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
+ * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
+ * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
+ * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
+ * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
+ * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
+ * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
+ * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+ * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+ */
+
+package io.spine.chatbot.github.repository;
+
+import io.spine.annotation.GeneratedMixin;
+import io.spine.chatbot.github.RepositoryId;
+
+/**
+ * Common interface for messages aware of the {@link RepositoryId repository}.
+ */
+@GeneratedMixin
+public interface RepositoryAware {
+
+ /**
+ * Obtains the repository ID.
+ */
+ default RepositoryId repository() {
+ return getRepository();
+ }
+
+ /**
+ * Obtains the repository ID.
+ *
+ * @implNote This method is implemented in the deriving Protobuf messages.
+ */
+ RepositoryId getRepository();
+}
diff --git a/google-chat-bot/src/main/java/io/spine/chatbot/github/repository/RepositoryAwareEvent.java b/google-chat-bot/src/main/java/io/spine/chatbot/github/repository/RepositoryAwareEvent.java
new file mode 100644
index 00000000..f9d35bd7
--- /dev/null
+++ b/google-chat-bot/src/main/java/io/spine/chatbot/github/repository/RepositoryAwareEvent.java
@@ -0,0 +1,33 @@
+/*
+ * Copyright 2020, TeamDev. All rights reserved.
+ *
+ * Redistribution and use in source and/or binary forms, with or without
+ * modification, must retain the above copyright notice and the following
+ * disclaimer.
+ *
+ * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
+ * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
+ * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
+ * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
+ * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
+ * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
+ * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
+ * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
+ * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
+ * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+ * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+ */
+
+package io.spine.chatbot.github.repository;
+
+import com.google.errorprone.annotations.Immutable;
+import io.spine.annotation.GeneratedMixin;
+import io.spine.base.EventMessage;
+
+/**
+ * A repository-aware event message.
+ */
+@GeneratedMixin
+@Immutable
+public interface RepositoryAwareEvent extends RepositoryAware, EventMessage {
+}
diff --git a/google-chat-bot/src/main/java/io/spine/chatbot/github/repository/build/BuildStateMixin.java b/google-chat-bot/src/main/java/io/spine/chatbot/github/repository/build/BuildStateMixin.java
new file mode 100644
index 00000000..00cf8fad
--- /dev/null
+++ b/google-chat-bot/src/main/java/io/spine/chatbot/github/repository/build/BuildStateMixin.java
@@ -0,0 +1,127 @@
+/*
+ * Copyright 2020, TeamDev. All rights reserved.
+ *
+ * Redistribution and use in source and/or binary forms, with or without
+ * modification, must retain the above copyright notice and the following
+ * disclaimer.
+ *
+ * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
+ * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
+ * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
+ * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
+ * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
+ * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
+ * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
+ * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
+ * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
+ * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+ * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+ */
+
+package io.spine.chatbot.github.repository.build;
+
+import io.spine.annotation.GeneratedMixin;
+
+import java.util.EnumSet;
+
+import static io.spine.chatbot.github.repository.build.Build.State.BS_UNKNOWN;
+import static io.spine.chatbot.github.repository.build.Build.State.PASSED;
+import static io.spine.chatbot.github.repository.build.BuildStateChange.Type.FAILED;
+import static io.spine.chatbot.github.repository.build.BuildStateChange.Type.RECOVERED;
+import static io.spine.chatbot.github.repository.build.BuildStateChange.Type.STABLE;
+import static io.spine.util.Exceptions.newIllegalStateException;
+import static io.spine.util.Preconditions2.checkNotEmptyOrBlank;
+
+/**
+ * Augments {@link Build} with useful methods.
+ */
+@GeneratedMixin
+public interface BuildStateMixin extends BuildOrBuilder {
+
+ /**
+ * Determines whether the build is failed.
+ *
+ * @return {@code true} if the build is failed, {@code false} otherwise
+ * @see #failed(Build.State)
+ */
+ default boolean failed() {
+ return failed(getState());
+ }
+
+ /**
+ * Returns a capitalized label of the {@linkplain Build.State build state}.
+ */
+ default String stateLabel() {
+ var state = getState();
+ var name = state.name();
+ var result = name.charAt(0) + name.substring(1)
+ .toLowerCase();
+ return result;
+ }
+
+ /**
+ * Creates an instance of the {@linkplain Build.State build state} of out its
+ * string representation, ignoring the case.
+ */
+ static Build.State buildStateFrom(String state) {
+ checkNotEmptyOrBlank(state);
+ return Build.State.valueOf(state.toUpperCase());
+ }
+
+ /**
+ * Determines the {@linkplain BuildStateChange state change} of the build comparing to the
+ * {@code previousState}.
+ *
+ * @see #stateChange(BuildStateMixin, BuildStateMixin)
+ */
+ default BuildStateChange.Type stateChangeFrom(BuildStateMixin previousState) {
+ return stateChange(this, previousState);
+ }
+
+ /**
+ * Determines the {@linkplain BuildStateChange state change} between build states.
+ *
+ *
The status is considered:
+ *
+ *
+ * - {@code failed} if the new state is {@link #failed() failed};
+ *
- {@code recovered} if the new state is {@code passed} and the previous is
+ * {@link #failed() failed};
+ *
- {@code stable} if the new state is {@code passed} and the previous is either
+ * {@code unknown} meaning that there were no previous states or {@code passed} as well.
+ *
+ */
+ private static BuildStateChange.Type stateChange(BuildStateMixin newBuildState,
+ BuildStateMixin previousBuildState) {
+ var currentState = newBuildState.getState();
+ var previousState = previousBuildState.getState();
+ if (newBuildState.failed()) {
+ return FAILED;
+ }
+ if (currentState == PASSED && previousBuildState.failed()) {
+ return RECOVERED;
+ }
+ if (currentState == PASSED && (previousState == PASSED || previousState == BS_UNKNOWN)) {
+ return STABLE;
+ }
+ throw newIllegalStateException(
+ "Build is in an unpredictable state. Current state `%s`. Previous state `%s`.",
+ currentState.name(), previousState.name()
+ );
+ }
+
+ /**
+ * Determines whether the build state denotes a failed status.
+ *
+ * The {@code cancelled}, {@code failed} and {@code errored} statuses are considered
+ * failed statuses.
+ *
+ * @return {@code true} if the build status is failed, {@code false} otherwise
+ */
+ private static boolean failed(Build.State state) {
+ var failedStatuses = EnumSet.of(
+ Build.State.CANCELLED, Build.State.FAILED, Build.State.ERRORED
+ );
+ return failedStatuses.contains(state);
+ }
+}
diff --git a/google-chat-bot/src/main/java/io/spine/chatbot/github/repository/build/package-info.java b/google-chat-bot/src/main/java/io/spine/chatbot/github/repository/build/package-info.java
new file mode 100644
index 00000000..9c6fb54a
--- /dev/null
+++ b/google-chat-bot/src/main/java/io/spine/chatbot/github/repository/build/package-info.java
@@ -0,0 +1,30 @@
+/*
+ * Copyright 2020, TeamDev. All rights reserved.
+ *
+ * Redistribution and use in source and/or binary forms, with or without
+ * modification, must retain the above copyright notice and the following
+ * disclaimer.
+ *
+ * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
+ * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
+ * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
+ * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
+ * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
+ * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
+ * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
+ * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
+ * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
+ * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+ * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+ */
+
+/**
+ * This package contains the GitHub repository build-specific language.
+ */
+@CheckReturnValue
+@ParametersAreNonnullByDefault
+package io.spine.chatbot.github.repository.build;
+
+import com.google.errorprone.annotations.CheckReturnValue;
+
+import javax.annotation.ParametersAreNonnullByDefault;
diff --git a/google-chat-bot/src/main/java/io/spine/chatbot/github/repository/package-info.java b/google-chat-bot/src/main/java/io/spine/chatbot/github/repository/package-info.java
new file mode 100644
index 00000000..ada78377
--- /dev/null
+++ b/google-chat-bot/src/main/java/io/spine/chatbot/github/repository/package-info.java
@@ -0,0 +1,30 @@
+/*
+ * Copyright 2020, TeamDev. All rights reserved.
+ *
+ * Redistribution and use in source and/or binary forms, with or without
+ * modification, must retain the above copyright notice and the following
+ * disclaimer.
+ *
+ * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
+ * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
+ * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
+ * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
+ * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
+ * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
+ * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
+ * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
+ * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
+ * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+ * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+ */
+
+/**
+ * This package contains the GitHub repository-specific language.
+ */
+@CheckReturnValue
+@ParametersAreNonnullByDefault
+package io.spine.chatbot.github.repository;
+
+import com.google.errorprone.annotations.CheckReturnValue;
+
+import javax.annotation.ParametersAreNonnullByDefault;
diff --git a/google-chat-bot/src/main/java/io/spine/chatbot/google/chat/BuildStateUpdates.java b/google-chat-bot/src/main/java/io/spine/chatbot/google/chat/BuildStateUpdates.java
new file mode 100644
index 00000000..88a36ebb
--- /dev/null
+++ b/google-chat-bot/src/main/java/io/spine/chatbot/google/chat/BuildStateUpdates.java
@@ -0,0 +1,118 @@
+/*
+ * Copyright 2020, TeamDev. All rights reserved.
+ *
+ * Redistribution and use in source and/or binary forms, with or without
+ * modification, must retain the above copyright notice and the following
+ * disclaimer.
+ *
+ * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
+ * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
+ * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
+ * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
+ * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
+ * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
+ * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
+ * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
+ * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
+ * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+ * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+ */
+
+package io.spine.chatbot.google.chat;
+
+import com.google.api.services.chat.v1.model.CardHeader;
+import com.google.api.services.chat.v1.model.KeyValue;
+import com.google.api.services.chat.v1.model.Message;
+import com.google.api.services.chat.v1.model.Section;
+import com.google.api.services.chat.v1.model.Thread;
+import com.google.api.services.chat.v1.model.WidgetMarkup;
+import com.google.common.collect.ImmutableList;
+import io.spine.chatbot.github.repository.build.Build;
+import io.spine.chatbot.google.chat.thread.ThreadResource;
+
+import static com.google.common.base.Preconditions.checkNotNull;
+import static io.spine.chatbot.google.chat.ChatWidgets.cardWith;
+import static io.spine.chatbot.google.chat.ChatWidgets.linkButton;
+import static io.spine.chatbot.google.chat.ChatWidgets.sectionWithWidget;
+import static io.spine.chatbot.google.chat.ChatWidgets.textParagraph;
+import static io.spine.protobuf.Messages.isNotDefault;
+import static io.spine.validate.Validate.checkValid;
+
+/**
+ * A utility class that creates {@link Build} update messages.
+ */
+final class BuildStateUpdates {
+
+ private static final String FAILURE_ICON =
+ "https://storage.googleapis.com/spine-chat-bot.appspot.com/failure-icon.png";
+ private static final String SUCCESS_ICON =
+ "https://storage.googleapis.com/spine-chat-bot.appspot.com/success-icon.png";
+
+ /**
+ * Prevents instantiation of this utility class.
+ */
+ private BuildStateUpdates() {
+ }
+
+ /**
+ * Creates a new {@link Build} update message from the supplied {@code build}
+ * and {@code thread}.
+ *
+ *
If the thread has no name set, assumes that the update message should be
+ * sent to a new thread.
+ */
+ static Message buildStateMessage(Build build, ThreadResource thread) {
+ checkValid(build);
+ checkNotNull(thread);
+ var headerIcon = build.failed() ? FAILURE_ICON : SUCCESS_ICON;
+ var cardHeader = new CardHeader()
+ .setTitle(build.getRepository()
+ .value())
+ .setImageUrl(headerIcon);
+ var sections = ImmutableList.of(
+ buildStateSection(build),
+ commitSection(build),
+ actions(build)
+ );
+ var message = new Message().setCards(cardWith(cardHeader, sections));
+ if (isNotDefault(thread)) {
+ message.setThread(new Thread().setName(thread.getName()));
+ }
+ return message;
+ }
+
+ private static Section commitSection(Build build) {
+ var commit = build.getLastCommit();
+ var commitInfo = String.format(
+ "Authored by %s at %s.", commit.getAuthoredBy(), commit.getCommittedAt()
+ );
+ var section = new Section()
+ .setHeader("Commit " + commit.getSha())
+ .setWidgets(ImmutableList.of(
+ textParagraph(commit.getMessage()),
+ textParagraph(commitInfo)
+ ));
+ return section;
+ }
+
+ private static Section actions(Build build) {
+ var commit = build.getLastCommit();
+ var actionButtons = new WidgetMarkup().setButtons(ImmutableList.of(
+ linkButton("Build", build.getTravisCiUrl()),
+ linkButton("Changeset", commit.getCompareUrl())
+ ));
+ return sectionWithWidget(actionButtons);
+ }
+
+ private static Section buildStateSection(Build build) {
+ return sectionWithWidget(buildStateWidget(build));
+ }
+
+ private static WidgetMarkup buildStateWidget(Build build) {
+ var keyValue = new KeyValue()
+ .setTopLabel("Build No.")
+ .setContent(build.getNumber())
+ .setBottomLabel(build.stateLabel());
+ return new WidgetMarkup().setKeyValue(keyValue);
+ }
+}
diff --git a/google-chat-bot/src/main/java/io/spine/chatbot/google/chat/ChatWidgets.java b/google-chat-bot/src/main/java/io/spine/chatbot/google/chat/ChatWidgets.java
new file mode 100644
index 00000000..ce9620cc
--- /dev/null
+++ b/google-chat-bot/src/main/java/io/spine/chatbot/google/chat/ChatWidgets.java
@@ -0,0 +1,99 @@
+/*
+ * Copyright 2020, TeamDev. All rights reserved.
+ *
+ * Redistribution and use in source and/or binary forms, with or without
+ * modification, must retain the above copyright notice and the following
+ * disclaimer.
+ *
+ * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
+ * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
+ * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
+ * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
+ * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
+ * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
+ * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
+ * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
+ * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
+ * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+ * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+ */
+
+package io.spine.chatbot.google.chat;
+
+import com.google.api.services.chat.v1.model.Button;
+import com.google.api.services.chat.v1.model.Card;
+import com.google.api.services.chat.v1.model.CardHeader;
+import com.google.api.services.chat.v1.model.OnClick;
+import com.google.api.services.chat.v1.model.OpenLink;
+import com.google.api.services.chat.v1.model.Section;
+import com.google.api.services.chat.v1.model.TextButton;
+import com.google.api.services.chat.v1.model.TextParagraph;
+import com.google.api.services.chat.v1.model.WidgetMarkup;
+import com.google.common.collect.ImmutableList;
+import io.spine.net.Url;
+
+import java.util.List;
+
+import static com.google.common.base.Preconditions.checkNotNull;
+import static io.spine.util.Preconditions2.checkNotEmptyOrBlank;
+
+/**
+ * Provides building blocks to empower the rich messages sent to Google Chat.
+ *
+ * @see
+ * Google Chat Cards
+ */
+final class ChatWidgets {
+
+ /**
+ * Prevents instantiation of this utility class.
+ */
+ private ChatWidgets() {
+ }
+
+ /**
+ * Creates a new button with an on-click open link action with the specified {@code title}
+ * and {@code url} to open upon a click.
+ */
+ static Button linkButton(String title, Url url) {
+ checkNotEmptyOrBlank(title);
+ checkNotNull(url);
+ var button = new TextButton().setText(title)
+ .setOnClick(openLink(url));
+ return new Button().setTextButton(button);
+ }
+
+ private static OnClick openLink(Url url) {
+ return new OnClick().setOpenLink(new OpenLink().setUrl(url.getSpec()));
+ }
+
+ /**
+ * Creates a singleton card list with a new {@link Card} with a specified {@code header}
+ * and {@code sections}.
+ */
+ static ImmutableList cardWith(CardHeader header, List sections) {
+ checkNotNull(header);
+ checkNotNull(sections);
+ return ImmutableList.of(new Card().setHeader(header)
+ .setSections(sections));
+ }
+
+ /**
+ * Creates a new {@link Section} with a single {@code widget}.
+ */
+ static Section sectionWithWidget(WidgetMarkup widget) {
+ checkNotNull(widget);
+ return new Section().setWidgets(List.of(widget));
+ }
+
+ /**
+ * Creates a new {@link TextParagraph} widget with the supplied {@code formattedText}.
+ *
+ * @see
+ * Card text formatting
+ */
+ static WidgetMarkup textParagraph(String formattedText) {
+ checkNotEmptyOrBlank(formattedText);
+ return new WidgetMarkup().setTextParagraph(new TextParagraph().setText(formattedText));
+ }
+}
diff --git a/google-chat-bot/src/main/java/io/spine/chatbot/google/chat/GoogleChat.java b/google-chat-bot/src/main/java/io/spine/chatbot/google/chat/GoogleChat.java
new file mode 100644
index 00000000..64687b55
--- /dev/null
+++ b/google-chat-bot/src/main/java/io/spine/chatbot/google/chat/GoogleChat.java
@@ -0,0 +1,89 @@
+/*
+ * Copyright 2020, TeamDev. All rights reserved.
+ *
+ * Redistribution and use in source and/or binary forms, with or without
+ * modification, must retain the above copyright notice and the following
+ * disclaimer.
+ *
+ * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
+ * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
+ * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
+ * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
+ * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
+ * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
+ * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
+ * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
+ * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
+ * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+ * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+ */
+
+package io.spine.chatbot.google.chat;
+
+import com.google.api.services.chat.v1.HangoutsChat;
+import com.google.api.services.chat.v1.model.Message;
+import com.google.errorprone.annotations.CanIgnoreReturnValue;
+import io.spine.chatbot.github.repository.build.Build;
+import io.spine.chatbot.google.chat.thread.ThreadResource;
+import io.spine.logging.Logging;
+
+import java.io.IOException;
+
+import static com.google.api.client.util.Preconditions.checkNotNull;
+import static io.spine.chatbot.google.chat.BuildStateUpdates.buildStateMessage;
+import static io.spine.chatbot.google.chat.GoogleChatIdentifiers.message;
+import static io.spine.chatbot.google.chat.GoogleChatIdentifiers.thread;
+import static io.spine.chatbot.server.google.chat.ThreadResources.threadResource;
+import static io.spine.util.Exceptions.newIllegalStateException;
+
+/**
+ * Google Chat API client.
+ *
+ * @see Google Chat API
+ */
+final class GoogleChat implements GoogleChatClient, Logging {
+
+ private final HangoutsChat chat;
+
+ GoogleChat(HangoutsChat chat) {
+ this.chat = checkNotNull(chat);
+ }
+
+ @Override
+ public BuildStateUpdate sendBuildStateUpdate(Build build, ThreadResource thread) {
+ checkNotNull(build);
+ checkNotNull(thread);
+ var repo = build.getRepository();
+ var trace = _trace();
+ trace.log("Building state update message for the repository `%s`.", repo);
+ var message = buildStateMessage(build, thread);
+ trace.log("Sending state update message for the repository `%s`.", repo);
+ var sentMessage = sendMessage(build.getSpace(), message);
+ trace.log(
+ "Build state update message with ID `%s` for the repository `%s` sent to the thread `%s`.",
+ sentMessage.getName(), repo, sentMessage.getThread()
+ .getName()
+ );
+ return BuildStateUpdate
+ .newBuilder()
+ .setMessage(message(message.getName()))
+ .setResource(threadResource(message.getThread()
+ .getName()))
+ .setSpace(build.getSpace())
+ .setThread(thread(repo.value()))
+ .vBuild();
+ }
+
+ @CanIgnoreReturnValue
+ private Message sendMessage(SpaceId space, Message message) {
+ try {
+ return chat
+ .spaces()
+ .messages()
+ .create(space.getValue(), message)
+ .execute();
+ } catch (IOException e) {
+ throw newIllegalStateException(e, "Unable to send message to the space `%s`.", space);
+ }
+ }
+}
diff --git a/google-chat-bot/src/main/java/io/spine/chatbot/google/chat/GoogleChatClient.java b/google-chat-bot/src/main/java/io/spine/chatbot/google/chat/GoogleChatClient.java
new file mode 100644
index 00000000..469d38cf
--- /dev/null
+++ b/google-chat-bot/src/main/java/io/spine/chatbot/google/chat/GoogleChatClient.java
@@ -0,0 +1,52 @@
+/*
+ * Copyright 2020, TeamDev. All rights reserved.
+ *
+ * Redistribution and use in source and/or binary forms, with or without
+ * modification, must retain the above copyright notice and the following
+ * disclaimer.
+ *
+ * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
+ * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
+ * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
+ * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
+ * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
+ * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
+ * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
+ * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
+ * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
+ * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+ * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+ */
+
+package io.spine.chatbot.google.chat;
+
+import com.google.api.services.chat.v1.HangoutsChat;
+import io.spine.chatbot.github.repository.build.Build;
+import io.spine.chatbot.google.chat.thread.ThreadResource;
+
+/**
+ * A client to the Google Chat server.
+ *
+ * Abstracts out usage of the chat API by exposing only ready-to-use ChatBot-specific
+ * methods.
+ */
+public interface GoogleChatClient {
+
+ /**
+ * Sends {@link Build} state update message to the related space and thread.
+ *
+ *
If the {@code thread} has no name specified the message is sent to a new thread.
+ *
+ * @return a sent build state update message
+ */
+ BuildStateUpdate sendBuildStateUpdate(Build build, ThreadResource thread);
+
+ /**
+ * Creates a new Google Chat client.
+ *
+ *
The client is backed by {@link HangoutsChat} API.
+ */
+ static GoogleChatClient newInstance() {
+ return new GoogleChat(HangoutsChatFactory.newInstance());
+ }
+}
diff --git a/google-chat-bot/src/main/java/io/spine/chatbot/google/chat/GoogleChatIdentifiers.java b/google-chat-bot/src/main/java/io/spine/chatbot/google/chat/GoogleChatIdentifiers.java
new file mode 100644
index 00000000..11c9a7f0
--- /dev/null
+++ b/google-chat-bot/src/main/java/io/spine/chatbot/google/chat/GoogleChatIdentifiers.java
@@ -0,0 +1,68 @@
+/*
+ * Copyright 2020, TeamDev. All rights reserved.
+ *
+ * Redistribution and use in source and/or binary forms, with or without
+ * modification, must retain the above copyright notice and the following
+ * disclaimer.
+ *
+ * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
+ * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
+ * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
+ * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
+ * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
+ * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
+ * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
+ * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
+ * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
+ * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+ * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+ */
+
+package io.spine.chatbot.google.chat;
+
+import static io.spine.util.Preconditions2.checkNotEmptyOrBlank;
+
+/**
+ * A utility for working with {@code Google Chat} context identifiers.
+ */
+public final class GoogleChatIdentifiers {
+
+ /**
+ * Prevents instantiation of this utility class.
+ */
+ private GoogleChatIdentifiers() {
+ }
+
+ /**
+ * Creates a new {@code ThreadId} out of the specified {@code value}.
+ */
+ public static ThreadId thread(String value) {
+ checkNotEmptyOrBlank(value);
+ return ThreadId
+ .newBuilder()
+ .setValue(value)
+ .vBuild();
+ }
+
+ /**
+ * Creates a new {@code SpaceId} out of the specified {@code value}.
+ */
+ public static SpaceId space(String value) {
+ checkNotEmptyOrBlank(value);
+ return SpaceId
+ .newBuilder()
+ .setValue(value)
+ .vBuild();
+ }
+
+ /**
+ * Creates a new {@code MessageId} out of the specified {@code value}.
+ */
+ public static MessageId message(String value) {
+ checkNotEmptyOrBlank(value);
+ return MessageId
+ .newBuilder()
+ .setValue(value)
+ .vBuild();
+ }
+}
diff --git a/google-chat-bot/src/main/java/io/spine/chatbot/google/chat/GoogleChatKey.java b/google-chat-bot/src/main/java/io/spine/chatbot/google/chat/GoogleChatKey.java
new file mode 100644
index 00000000..4f7fadef
--- /dev/null
+++ b/google-chat-bot/src/main/java/io/spine/chatbot/google/chat/GoogleChatKey.java
@@ -0,0 +1,72 @@
+/*
+ * Copyright 2020, TeamDev. All rights reserved.
+ *
+ * Redistribution and use in source and/or binary forms, with or without
+ * modification, must retain the above copyright notice and the following
+ * disclaimer.
+ *
+ * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
+ * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
+ * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
+ * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
+ * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
+ * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
+ * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
+ * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
+ * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
+ * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+ * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+ */
+
+package io.spine.chatbot.google.chat;
+
+import com.google.auth.oauth2.GoogleCredentials;
+import io.spine.chatbot.google.secret.Secret;
+
+import java.io.ByteArrayInputStream;
+import java.io.IOException;
+import java.io.InputStream;
+import java.nio.charset.Charset;
+
+import static io.spine.util.Exceptions.newIllegalStateException;
+import static io.spine.util.Preconditions2.checkNotEmptyOrBlank;
+
+/**
+ * A service account key configured for Google Chat.
+ */
+final class GoogleChatKey extends Secret {
+
+ private static final String CHAT_BOT_SCOPE = "https://www.googleapis.com/auth/chat.bot";
+ private static final String CHAT_SERVICE_ACCOUNT = "ChatServiceAccount";
+
+ private final String value;
+
+ private GoogleChatKey(String value) {
+ this.value = value;
+ }
+
+ /**
+ * Creates a new Google Chat service account key.
+ */
+ static GoogleChatKey chatServiceAccountKey() {
+ var value = checkNotEmptyOrBlank(retrieveSecret(CHAT_SERVICE_ACCOUNT));
+ return new GoogleChatKey(value);
+ }
+
+ /**
+ * Converts the key to respective scoped {@code GoogleCredentials}.
+ */
+ GoogleCredentials toCredentials() {
+ try {
+ return GoogleCredentials
+ .fromStream(streamFrom(value))
+ .createScoped(CHAT_BOT_SCOPE);
+ } catch (IOException e) {
+ throw newIllegalStateException(e, "Unable to read `GoogleCredentials`.");
+ }
+ }
+
+ private static InputStream streamFrom(String data) {
+ return new ByteArrayInputStream(data.getBytes(Charset.defaultCharset()));
+ }
+}
diff --git a/google-chat-bot/src/main/java/io/spine/chatbot/google/chat/HangoutsChatFactory.java b/google-chat-bot/src/main/java/io/spine/chatbot/google/chat/HangoutsChatFactory.java
new file mode 100644
index 00000000..418aacd5
--- /dev/null
+++ b/google-chat-bot/src/main/java/io/spine/chatbot/google/chat/HangoutsChatFactory.java
@@ -0,0 +1,74 @@
+/*
+ * Copyright 2020, TeamDev. All rights reserved.
+ *
+ * Redistribution and use in source and/or binary forms, with or without
+ * modification, must retain the above copyright notice and the following
+ * disclaimer.
+ *
+ * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
+ * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
+ * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
+ * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
+ * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
+ * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
+ * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
+ * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
+ * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
+ * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+ * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+ */
+
+package io.spine.chatbot.google.chat;
+
+import com.google.api.client.googleapis.javanet.GoogleNetHttpTransport;
+import com.google.api.client.http.HttpTransport;
+import com.google.api.client.json.jackson2.JacksonFactory;
+import com.google.api.services.chat.v1.HangoutsChat;
+import com.google.auth.http.HttpCredentialsAdapter;
+
+import java.io.IOException;
+import java.security.GeneralSecurityException;
+
+import static io.spine.chatbot.google.chat.GoogleChatKey.chatServiceAccountKey;
+import static io.spine.util.Exceptions.newIllegalStateException;
+
+/**
+ * Provides fully-configured {@link HangoutsChat chat} client.
+ */
+final class HangoutsChatFactory {
+
+ private static final String BOT_NAME = "Spine ChatBot";
+
+ /**
+ * Prevents direct instantiation of the utility class.
+ */
+ private HangoutsChatFactory() {
+ }
+
+ /**
+ * Creates a new instance of the {@link HangoutsChat} client.
+ */
+ static HangoutsChat newInstance() {
+ var credentials = chatServiceAccountKey().toCredentials();
+ var credentialsAdapter = new HttpCredentialsAdapter(credentials);
+ var chat = chatWithCredentials(credentialsAdapter)
+ .setApplicationName(BOT_NAME)
+ .build();
+ return chat;
+ }
+
+ private static HangoutsChat.Builder
+ chatWithCredentials(HttpCredentialsAdapter credentialsAdapter) {
+ var transport = newTrustedTransport();
+ var jacksonFactory = JacksonFactory.getDefaultInstance();
+ return new HangoutsChat.Builder(transport, jacksonFactory, credentialsAdapter);
+ }
+
+ private static HttpTransport newTrustedTransport() {
+ try {
+ return GoogleNetHttpTransport.newTrustedTransport();
+ } catch (GeneralSecurityException | IOException e) {
+ throw newIllegalStateException(e, "Unable to instantiate trusted transport.");
+ }
+ }
+}
diff --git a/google-chat-bot/src/main/java/io/spine/chatbot/google/chat/SpaceHeaderAware.java b/google-chat-bot/src/main/java/io/spine/chatbot/google/chat/SpaceHeaderAware.java
new file mode 100644
index 00000000..c5a7a09c
--- /dev/null
+++ b/google-chat-bot/src/main/java/io/spine/chatbot/google/chat/SpaceHeaderAware.java
@@ -0,0 +1,58 @@
+/*
+ * Copyright 2020, TeamDev. All rights reserved.
+ *
+ * Redistribution and use in source and/or binary forms, with or without
+ * modification, must retain the above copyright notice and the following
+ * disclaimer.
+ *
+ * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
+ * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
+ * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
+ * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
+ * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
+ * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
+ * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
+ * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
+ * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
+ * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+ * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+ */
+
+package io.spine.chatbot.google.chat;
+
+import io.spine.annotation.GeneratedMixin;
+
+/**
+ * Common interface for messages aware of the {@link SpaceHeader}.
+ */
+@GeneratedMixin
+public interface SpaceHeaderAware {
+
+ /**
+ * Returns the space header.
+ *
+ * @implNote This method is implemented in the deriving Protobuf messages.
+ */
+ SpaceHeader getHeader();
+
+ /**
+ * Determines whether a space is a Direct Message (DM) between a bot and a human.
+ */
+ default boolean directMessage() {
+ return getHeader().getSingleUserBotDm();
+ }
+
+ /**
+ * Determines whether the messages are threaded in the space.
+ */
+ default boolean isThreaded() {
+ return getHeader().getThreaded();
+ }
+
+ /**
+ * Returns the display name of the space.
+ */
+ default String displayName() {
+ return getHeader().getDisplayName();
+ }
+}
diff --git a/google-chat-bot/src/main/java/io/spine/chatbot/google/chat/incoming/SpaceMixin.java b/google-chat-bot/src/main/java/io/spine/chatbot/google/chat/incoming/SpaceMixin.java
new file mode 100644
index 00000000..947cc78f
--- /dev/null
+++ b/google-chat-bot/src/main/java/io/spine/chatbot/google/chat/incoming/SpaceMixin.java
@@ -0,0 +1,49 @@
+/*
+ * Copyright 2020, TeamDev. All rights reserved.
+ *
+ * Redistribution and use in source and/or binary forms, with or without
+ * modification, must retain the above copyright notice and the following
+ * disclaimer.
+ *
+ * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
+ * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
+ * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
+ * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
+ * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
+ * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
+ * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
+ * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
+ * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
+ * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+ * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+ */
+
+package io.spine.chatbot.google.chat.incoming;
+
+import io.spine.annotation.GeneratedMixin;
+import io.spine.chatbot.google.chat.SpaceId;
+
+import static io.spine.chatbot.google.chat.GoogleChatIdentifiers.space;
+
+/**
+ * Provides utility helpers for the {@link io.spine.chatbot.google.chat.incoming.Space Space} type.
+ */
+@GeneratedMixin
+public interface SpaceMixin extends SpaceOrBuilder {
+
+ /**
+ * Determines whether the space is threaded.
+ *
+ * @return {@code true} if the space is threaded, {@code false} otherwise
+ */
+ default boolean isThreaded() {
+ return getType() == SpaceType.ROOM;
+ }
+
+ /**
+ * Returns the space ID.
+ */
+ default SpaceId id() {
+ return space(getName());
+ }
+}
diff --git a/google-chat-bot/src/main/java/io/spine/chatbot/google/chat/incoming/package-info.java b/google-chat-bot/src/main/java/io/spine/chatbot/google/chat/incoming/package-info.java
new file mode 100644
index 00000000..81c75e6a
--- /dev/null
+++ b/google-chat-bot/src/main/java/io/spine/chatbot/google/chat/incoming/package-info.java
@@ -0,0 +1,30 @@
+/*
+ * Copyright 2020, TeamDev. All rights reserved.
+ *
+ * Redistribution and use in source and/or binary forms, with or without
+ * modification, must retain the above copyright notice and the following
+ * disclaimer.
+ *
+ * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
+ * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
+ * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
+ * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
+ * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
+ * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
+ * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
+ * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
+ * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
+ * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+ * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+ */
+
+/**
+ * This package contains entities related to the Google Chat incoming events handling.
+ */
+@CheckReturnValue
+@ParametersAreNonnullByDefault
+package io.spine.chatbot.google.chat.incoming;
+
+import com.google.errorprone.annotations.CheckReturnValue;
+
+import javax.annotation.ParametersAreNonnullByDefault;
diff --git a/google-chat-bot/src/main/java/io/spine/chatbot/google/chat/package-info.java b/google-chat-bot/src/main/java/io/spine/chatbot/google/chat/package-info.java
new file mode 100644
index 00000000..a2382aea
--- /dev/null
+++ b/google-chat-bot/src/main/java/io/spine/chatbot/google/chat/package-info.java
@@ -0,0 +1,33 @@
+/*
+ * Copyright 2020, TeamDev. All rights reserved.
+ *
+ * Redistribution and use in source and/or binary forms, with or without
+ * modification, must retain the above copyright notice and the following
+ * disclaimer.
+ *
+ * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
+ * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
+ * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
+ * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
+ * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
+ * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
+ * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
+ * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
+ * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
+ * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+ * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+ */
+
+/**
+ * This package contains Google Chat API facade.
+ *
+ *
The usage of the Chat API itself it not straightforward. That's why it is recommended to
+ * use the {@linkplain io.spine.chatbot.google.chat.GoogleChatClient facade} instead.
+ */
+@CheckReturnValue
+@ParametersAreNonnullByDefault
+package io.spine.chatbot.google.chat;
+
+import com.google.errorprone.annotations.CheckReturnValue;
+
+import javax.annotation.ParametersAreNonnullByDefault;
diff --git a/google-chat-bot/src/main/java/io/spine/chatbot/google/secret/Secret.java b/google-chat-bot/src/main/java/io/spine/chatbot/google/secret/Secret.java
new file mode 100644
index 00000000..e7b0dda1
--- /dev/null
+++ b/google-chat-bot/src/main/java/io/spine/chatbot/google/secret/Secret.java
@@ -0,0 +1,61 @@
+/*
+ * Copyright 2020, TeamDev. All rights reserved.
+ *
+ * Redistribution and use in source and/or binary forms, with or without
+ * modification, must retain the above copyright notice and the following
+ * disclaimer.
+ *
+ * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
+ * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
+ * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
+ * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
+ * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
+ * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
+ * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
+ * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
+ * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
+ * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+ * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+ */
+
+package io.spine.chatbot.google.secret;
+
+import com.google.cloud.secretmanager.v1.SecretManagerServiceClient;
+import com.google.cloud.secretmanager.v1.SecretVersionName;
+
+import java.io.IOException;
+
+import static io.spine.util.Exceptions.newIllegalStateException;
+
+/**
+ * The abstract base for utilities that access application secrets stored in Google Secret Manager.
+ *
+ * @see Google Secret Manager
+ */
+public abstract class Secret {
+
+ @SuppressWarnings("CallToSystemGetenv")
+ private static final String PROJECT_ID = System.getenv("GCP_PROJECT_ID");
+
+ protected Secret() {
+ }
+
+ /**
+ * Retrieves the secret with the specified {@code name}.
+ *
+ *
The latest version of the secret available in the current {@link #PROJECT_ID project}
+ * is retrieved.
+ */
+ protected static String retrieveSecret(String name) {
+ try (SecretManagerServiceClient client = SecretManagerServiceClient.create()) {
+ var secretVersion = SecretVersionName.of(PROJECT_ID, name, "latest");
+ var secret = client.accessSecretVersion(secretVersion)
+ .getPayload()
+ .getData()
+ .toStringUtf8();
+ return secret;
+ } catch (IOException e) {
+ throw newIllegalStateException(e, "Unable to retrieve secret `%s`.", name);
+ }
+ }
+}
diff --git a/google-chat-bot/src/main/java/io/spine/chatbot/google/secret/package-info.java b/google-chat-bot/src/main/java/io/spine/chatbot/google/secret/package-info.java
new file mode 100644
index 00000000..92224ac0
--- /dev/null
+++ b/google-chat-bot/src/main/java/io/spine/chatbot/google/secret/package-info.java
@@ -0,0 +1,30 @@
+/*
+ * Copyright 2020, TeamDev. All rights reserved.
+ *
+ * Redistribution and use in source and/or binary forms, with or without
+ * modification, must retain the above copyright notice and the following
+ * disclaimer.
+ *
+ * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
+ * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
+ * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
+ * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
+ * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
+ * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
+ * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
+ * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
+ * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
+ * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+ * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+ */
+
+/**
+ * This package contains Google Secret Manager API facade.
+ */
+@CheckReturnValue
+@ParametersAreNonnullByDefault
+package io.spine.chatbot.google.secret;
+
+import com.google.errorprone.annotations.CheckReturnValue;
+
+import javax.annotation.ParametersAreNonnullByDefault;
diff --git a/google-chat-bot/src/main/java/io/spine/chatbot/net/MoreUrls.java b/google-chat-bot/src/main/java/io/spine/chatbot/net/MoreUrls.java
new file mode 100644
index 00000000..52fefc56
--- /dev/null
+++ b/google-chat-bot/src/main/java/io/spine/chatbot/net/MoreUrls.java
@@ -0,0 +1,74 @@
+/*
+ * Copyright 2020, TeamDev. All rights reserved.
+ *
+ * Redistribution and use in source and/or binary forms, with or without
+ * modification, must retain the above copyright notice and the following
+ * disclaimer.
+ *
+ * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
+ * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
+ * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
+ * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
+ * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
+ * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
+ * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
+ * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
+ * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
+ * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+ * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+ */
+
+package io.spine.chatbot.net;
+
+import io.spine.chatbot.github.Slug;
+import io.spine.net.Url;
+import io.spine.net.Urls;
+
+import static com.google.common.base.Preconditions.checkNotNull;
+import static io.spine.util.Preconditions2.checkPositive;
+import static java.lang.String.format;
+
+/**
+ * Static factories for creating project-specific {@link Url}s.
+ */
+public final class MoreUrls {
+
+ private static final String TRAVIS_GITHUB_ENDPOINT = "https://travis-ci.com/github";
+ private static final String GITHUB = "https://github.com";
+
+ /**
+ * Prevents instantiation of this utility class.
+ */
+ private MoreUrls() {
+ }
+
+ /**
+ * Creates a new Travis CI build URL for a build with the specified {@code buildId} of
+ * the repository with the specified {@code slug}.
+ */
+ public static Url travisBuildUrlFor(Slug slug, long buildId) {
+ checkNotNull(slug);
+ var spec = format(
+ "%s/%s/builds/%d", TRAVIS_GITHUB_ENDPOINT, slug.getValue(), checkPositive(buildId)
+ );
+ return Urls.create(spec);
+ }
+
+ /**
+ * Creates a new Travis CI URL.
+ */
+ public static Url travisUrlFor(Slug slug) {
+ checkNotNull(slug);
+ var spec = format("%s/%s", TRAVIS_GITHUB_ENDPOINT, slug.getValue());
+ return Urls.create(spec);
+ }
+
+ /**
+ * Creates a new GitHub URL.
+ */
+ public static Url githubUrlFor(Slug slug) {
+ checkNotNull(slug);
+ var spec = format("%s/%s", GITHUB, slug.getValue());
+ return Urls.create(spec);
+ }
+}
diff --git a/google-chat-bot/src/main/java/io/spine/chatbot/net/package-info.java b/google-chat-bot/src/main/java/io/spine/chatbot/net/package-info.java
new file mode 100644
index 00000000..f825e52b
--- /dev/null
+++ b/google-chat-bot/src/main/java/io/spine/chatbot/net/package-info.java
@@ -0,0 +1,30 @@
+/*
+ * Copyright 2020, TeamDev. All rights reserved.
+ *
+ * Redistribution and use in source and/or binary forms, with or without
+ * modification, must retain the above copyright notice and the following
+ * disclaimer.
+ *
+ * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
+ * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
+ * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
+ * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
+ * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
+ * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
+ * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
+ * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
+ * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
+ * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+ * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+ */
+
+/**
+ * This package contains utilities for working with {@link io.spine.net.Url URL}s.
+ */
+@CheckReturnValue
+@ParametersAreNonnullByDefault
+package io.spine.chatbot.net;
+
+import com.google.errorprone.annotations.CheckReturnValue;
+
+import javax.annotation.ParametersAreNonnullByDefault;
diff --git a/google-chat-bot/src/main/java/io/spine/chatbot/package-info.java b/google-chat-bot/src/main/java/io/spine/chatbot/package-info.java
new file mode 100644
index 00000000..f74c9fd4
--- /dev/null
+++ b/google-chat-bot/src/main/java/io/spine/chatbot/package-info.java
@@ -0,0 +1,30 @@
+/*
+ * Copyright 2020, TeamDev. All rights reserved.
+ *
+ * Redistribution and use in source and/or binary forms, with or without
+ * modification, must retain the above copyright notice and the following
+ * disclaimer.
+ *
+ * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
+ * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
+ * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
+ * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
+ * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
+ * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
+ * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
+ * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
+ * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
+ * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+ * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+ */
+
+/**
+ * This contains package the application {@linkplain io.spine.chatbot.Application entry point}.
+ */
+@CheckReturnValue
+@ParametersAreNonnullByDefault
+package io.spine.chatbot;
+
+import com.google.errorprone.annotations.CheckReturnValue;
+
+import javax.annotation.ParametersAreNonnullByDefault;
diff --git a/google-chat-bot/src/main/java/io/spine/chatbot/server/ContextBuilderAware.java b/google-chat-bot/src/main/java/io/spine/chatbot/server/ContextBuilderAware.java
new file mode 100644
index 00000000..d7737bca
--- /dev/null
+++ b/google-chat-bot/src/main/java/io/spine/chatbot/server/ContextBuilderAware.java
@@ -0,0 +1,35 @@
+/*
+ * Copyright 2020, TeamDev. All rights reserved.
+ *
+ * Redistribution and use in source and/or binary forms, with or without
+ * modification, must retain the above copyright notice and the following
+ * disclaimer.
+ *
+ * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
+ * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
+ * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
+ * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
+ * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
+ * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
+ * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
+ * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
+ * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
+ * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+ * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+ */
+
+package io.spine.chatbot.server;
+
+import io.spine.server.BoundedContextBuilder;
+
+/**
+ * Common interface for {@link io.spine.server.BoundedContext BoundedContext} providers
+ * aware of the {@link BoundedContextBuilder}.
+ */
+public interface ContextBuilderAware {
+
+ /**
+ * Returns the context builder associated with context.
+ */
+ BoundedContextBuilder builder();
+}
diff --git a/google-chat-bot/src/main/java/io/spine/chatbot/server/DiagnosticEventLogger.java b/google-chat-bot/src/main/java/io/spine/chatbot/server/DiagnosticEventLogger.java
new file mode 100644
index 00000000..f660e9d6
--- /dev/null
+++ b/google-chat-bot/src/main/java/io/spine/chatbot/server/DiagnosticEventLogger.java
@@ -0,0 +1,117 @@
+/*
+ * Copyright 2020, TeamDev. All rights reserved.
+ *
+ * Redistribution and use in source and/or binary forms, with or without
+ * modification, must retain the above copyright notice and the following
+ * disclaimer.
+ *
+ * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
+ * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
+ * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
+ * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
+ * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
+ * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
+ * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
+ * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
+ * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
+ * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+ * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+ */
+
+package io.spine.chatbot.server;
+
+import io.spine.base.Identifier;
+import io.spine.core.Subscribe;
+import io.spine.logging.Logging;
+import io.spine.server.event.AbstractEventSubscriber;
+import io.spine.system.server.CannotDispatchDuplicateCommand;
+import io.spine.system.server.CannotDispatchDuplicateEvent;
+import io.spine.system.server.ConstraintViolated;
+import io.spine.system.server.HandlerFailedUnexpectedly;
+import io.spine.system.server.RoutingFailed;
+import io.spine.validate.diags.ViolationText;
+
+import java.util.stream.Collectors;
+
+/**
+ * Logs internal diagnostic events to ease applications management.
+ */
+public final class DiagnosticEventLogger extends AbstractEventSubscriber implements Logging {
+
+ /**
+ * Logs entity constraint violation rejection.
+ */
+ @Subscribe
+ void on(ConstraintViolated e) {
+ var entity = e.getEntity();
+ var violations = e.getViolationList()
+ .stream()
+ .map(ViolationText::of)
+ .map(ViolationText::toString)
+ .collect(Collectors.joining());
+ _error().log(
+ "Entity `%s` with value `%s` validation constraints are violated. The violations are:%n%s",
+ entity.getTypeUrl(), Identifier.toString(entity.id()), violations
+ );
+ }
+
+ /**
+ * Logs duplicate command delivery rejection.
+ */
+ @Subscribe
+ void on(CannotDispatchDuplicateCommand e) {
+ var command = e.getDuplicateCommand();
+ var entity = e.getEntity();
+ _warn().log(
+ "Duplicate delivery of the command `%s` with ID `%s` to the entity `%s` with ID `%s` prevented.",
+ command.getTypeUrl(), Identifier.toString(command.getId()),
+ entity.getTypeUrl(), Identifier.toString(entity.id())
+ );
+ }
+
+ /**
+ * Logs duplicate event delivery rejection.
+ */
+ @Subscribe
+ void on(CannotDispatchDuplicateEvent e) {
+ var event = e.getDuplicateEvent();
+ var entity = e.getEntity();
+ _warn().log(
+ "Duplicate delivery of the event `%s` with ID `%s` to the entity `%s` with ID `%s` prevented.",
+ event.getTypeUrl(), Identifier.toString(entity.getId()),
+ entity.getTypeUrl(), Identifier.toString(entity.id())
+ );
+ }
+
+ /**
+ * Logs unexpected signal handler exception.
+ */
+ @Subscribe
+ void on(HandlerFailedUnexpectedly e) {
+ var signal = e.getHandledSignal();
+ var entity = e.getEntity();
+ var error = e.getError();
+ _error().log(
+ "Signal `%s` with ID `%s` handler of the entity `%s` with ID `%s` failed with error `%s`.%n%s",
+ signal.getTypeUrl(), Identifier.toString(signal.id()),
+ entity.getTypeUrl(), Identifier.toString(entity.id()),
+ error.getMessage(), error.getStacktrace()
+ );
+ }
+
+ /**
+ * Logs routing failures.
+ */
+ @Subscribe
+ void on(RoutingFailed e) {
+ var entityType = e.getEntityType()
+ .getJavaClassName();
+ var signal = e.getHandledSignal();
+ var error = e.getError();
+ _error().log(
+ "Signal `%s` with ID `%s` routing to the entity `%s` failed with the error `%s`.%n%s",
+ signal.getTypeUrl(), Identifier.toString(signal.id()),
+ entityType, error.getMessage(), error.getStacktrace()
+ );
+ }
+}
diff --git a/google-chat-bot/src/main/java/io/spine/chatbot/server/Server.java b/google-chat-bot/src/main/java/io/spine/chatbot/server/Server.java
new file mode 100644
index 00000000..0604d420
--- /dev/null
+++ b/google-chat-bot/src/main/java/io/spine/chatbot/server/Server.java
@@ -0,0 +1,130 @@
+/*
+ * Copyright 2020, TeamDev. All rights reserved.
+ *
+ * Redistribution and use in source and/or binary forms, with or without
+ * modification, must retain the above copyright notice and the following
+ * disclaimer.
+ *
+ * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
+ * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
+ * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
+ * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
+ * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
+ * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
+ * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
+ * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
+ * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
+ * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+ * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+ */
+
+package io.spine.chatbot.server;
+
+import com.google.common.collect.ImmutableSet;
+import com.google.errorprone.annotations.concurrent.LazyInit;
+import io.spine.logging.Logging;
+import org.checkerframework.checker.nullness.qual.MonotonicNonNull;
+
+import java.io.IOException;
+
+import static com.google.common.base.Preconditions.checkArgument;
+import static com.google.common.base.Preconditions.checkNotNull;
+import static io.spine.util.Exceptions.newIllegalStateException;
+
+/**
+ * A ChatBot application's Spine server.
+ *
+ *
Abstracts working with Spine's {@link io.spine.server.Server server}.
+ */
+public final class Server implements Logging {
+
+ /**
+ * The name of the GRPC {@link io.spine.server.Server Server}.
+ */
+ private static final String SERVER_NAME = "ChatBotServer";
+
+ private final ImmutableSet contexts;
+
+ @LazyInit
+ private io.spine.server.@MonotonicNonNull Server grpcServer;
+
+ private Server(ImmutableSet contexts) {
+ this.contexts = contexts;
+ }
+
+ /**
+ * Creates a new in-process GRPC server with the supplied {@code contexts}.
+ */
+ public static Server withContexts(ContextBuilderAware... contexts) {
+ checkNotNull(contexts);
+ checkArgument(contexts.length > 0, "At least a single Bounded Context is required.");
+ return new Server(ImmutableSet.copyOf(contexts));
+ }
+
+ /**
+ * Returns the name of the server.
+ */
+ public static String name() {
+ return SERVER_NAME;
+ }
+
+ /**
+ * Starts the server.
+ *
+ * Performs {@linkplain #init() initialization} of the server if it was not
+ * previously initialized.
+ */
+ public void start() {
+ if (grpcServer == null) {
+ init();
+ }
+ try {
+ _config().log("Starting GRPC server.");
+ grpcServer.start();
+ Runtime.getRuntime()
+ .addShutdownHook(ShutdownHook.newInstance(grpcServer));
+ } catch (IOException e) {
+ throw newIllegalStateException(
+ e, "Unable to start Spine GRPC server `%s`.", SERVER_NAME
+ );
+ }
+ }
+
+ /**
+ * Initializes the server and its {@link ServerEnvironment environment}.
+ */
+ public void init() {
+ _config().log("Initializing server environment.");
+ ServerEnvironment.init();
+ _config().log("Bootstrapping server.");
+ io.spine.server.Server.Builder serverBuilder = io.spine.server.Server.inProcess(
+ SERVER_NAME);
+ for (ContextBuilderAware contextAware : contexts) {
+ serverBuilder.add(contextAware.builder());
+ }
+ grpcServer = serverBuilder.build();
+ }
+
+ /**
+ * Gracefully stops the {@link #server}.
+ */
+ private static final class ShutdownHook implements Runnable, Logging {
+
+ private final io.spine.server.Server server;
+
+ private ShutdownHook(io.spine.server.Server server) {
+ this.server = server;
+ }
+
+ private static Thread newInstance(io.spine.server.Server server) {
+ checkNotNull(server);
+ return new Thread(new ShutdownHook(server));
+ }
+
+ @Override
+ public void run() {
+ _info().log("Shutting down the GRPC server.");
+ server.shutdownAndWait();
+ }
+ }
+}
diff --git a/google-chat-bot/src/main/java/io/spine/chatbot/server/ServerEnvironment.java b/google-chat-bot/src/main/java/io/spine/chatbot/server/ServerEnvironment.java
new file mode 100644
index 00000000..d4015503
--- /dev/null
+++ b/google-chat-bot/src/main/java/io/spine/chatbot/server/ServerEnvironment.java
@@ -0,0 +1,74 @@
+/*
+ * Copyright 2020, TeamDev. All rights reserved.
+ *
+ * Redistribution and use in source and/or binary forms, with or without
+ * modification, must retain the above copyright notice and the following
+ * disclaimer.
+ *
+ * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
+ * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
+ * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
+ * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
+ * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
+ * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
+ * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
+ * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
+ * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
+ * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+ * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+ */
+
+package io.spine.chatbot.server;
+
+import com.google.cloud.datastore.DatastoreOptions;
+import io.spine.base.Environment;
+import io.spine.base.Production;
+import io.spine.base.Tests;
+import io.spine.chatbot.delivery.LocalDelivery;
+import io.spine.server.storage.StorageFactory;
+import io.spine.server.storage.datastore.DatastoreStorageFactory;
+import io.spine.server.storage.memory.InMemoryStorageFactory;
+import io.spine.server.transport.memory.InMemoryTransportFactory;
+
+/**
+ * Initializes the {@link io.spine.server.ServerEnvironment ServerEnvironment}.
+ *
+ *
Configures the {@link StorageFactory} depending on the current {@link Environment}.
+ * Uses the Datastore storage factory for the production mode and in-memory storage for tests.
+ *
+ *
Configures the inbox delivery through the Datastore work registry while
+ * in Production environment, otherwise uses local synchronous delivery.
+ */
+final class ServerEnvironment {
+
+ /**
+ * Prevents instantiation of this utility class.
+ */
+ private ServerEnvironment() {
+ }
+
+ /**
+ * Initializes {@link io.spine.server.ServerEnvironment ServerEnvironment} for ChatBot.
+ */
+ static void init() {
+ //TODO:2020-06-21:yuri-sergiichuk: switch to io.spine.chatbot.delivery.DistributedDelivery
+ // for Production environment after implementing the delivery strategy.
+ // see https://github.com/SpineEventEngine/chat-bot/issues/5.
+ io.spine.server.ServerEnvironment
+ .instance()
+ .use(InMemoryTransportFactory.newInstance(), Production.class)
+ .use(LocalDelivery.instance, Production.class)
+ .use(LocalDelivery.instance, Tests.class)
+ .use(dsStorageFactory(), Production.class)
+ .use(InMemoryStorageFactory.newInstance(), Tests.class);
+ }
+
+ private static DatastoreStorageFactory dsStorageFactory() {
+ var datastore = DatastoreOptions.getDefaultInstance()
+ .getService();
+ return DatastoreStorageFactory
+ .newBuilder()
+ .setDatastore(datastore)
+ .build();
+ }
+}
diff --git a/google-chat-bot/src/main/java/io/spine/chatbot/server/github/GitHubContext.java b/google-chat-bot/src/main/java/io/spine/chatbot/server/github/GitHubContext.java
new file mode 100644
index 00000000..4dbbb951
--- /dev/null
+++ b/google-chat-bot/src/main/java/io/spine/chatbot/server/github/GitHubContext.java
@@ -0,0 +1,114 @@
+/*
+ * Copyright 2020, TeamDev. All rights reserved.
+ *
+ * Redistribution and use in source and/or binary forms, with or without
+ * modification, must retain the above copyright notice and the following
+ * disclaimer.
+ *
+ * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
+ * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
+ * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
+ * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
+ * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
+ * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
+ * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
+ * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
+ * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
+ * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+ * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+ */
+
+package io.spine.chatbot.server.github;
+
+import io.spine.chatbot.server.ContextBuilderAware;
+import io.spine.chatbot.server.DiagnosticEventLogger;
+import io.spine.chatbot.travis.TravisClient;
+import io.spine.server.BoundedContext;
+import io.spine.server.BoundedContextBuilder;
+
+import static com.google.common.base.Preconditions.checkNotNull;
+
+/**
+ * Provides {@link BoundedContextBuilder} for the GitHub context.
+ */
+public final class GitHubContext implements ContextBuilderAware {
+
+ /**
+ * The name of the GitHub Context.
+ */
+ static final String GIT_HUB_CONTEXT_NAME = "GitHub";
+
+ private final BoundedContextBuilder builder;
+
+ private GitHubContext(TravisClient client) {
+ this.builder = configureBuilder(client);
+ }
+
+ /**
+ * Returns the context builder associated with the GitHub context.
+ */
+ @Override
+ public BoundedContextBuilder builder() {
+ return this.builder;
+ }
+
+ private static BoundedContextBuilder configureBuilder(TravisClient client) {
+ return BoundedContext
+ .singleTenant(GIT_HUB_CONTEXT_NAME)
+ .add(OrganizationAggregate.class)
+ .add(RepositoryAggregate.class)
+ .add(new OrgReposRepository())
+ .add(new SpineOrgInitRepository(client))
+ .add(new RepoBuildRepository(client))
+ .addEventDispatcher(new DiagnosticEventLogger());
+ }
+
+ /**
+ * Creates a new builder of the GitHub context.
+ */
+ public static Builder newBuilder() {
+ return new Builder();
+ }
+
+ /**
+ * Creates a new GitHub context.
+ */
+ public static GitHubContext newInstance() {
+ return newBuilder().build();
+ }
+
+ /**
+ * A Builder for configuring GitHub context.
+ */
+ public static final class Builder {
+
+ private TravisClient client;
+
+ /**
+ * Prevents direct instantiation.
+ */
+ private Builder() {
+ }
+
+ /**
+ * Sets Travis CI client to be used within the context.
+ */
+ public Builder setTravis(TravisClient client) {
+ this.client = checkNotNull(client);
+ return this;
+ }
+
+ /**
+ * Finishes configuration of the context and builds a new instance.
+ *
+ *
If the {@link #client} was not explicitly configured, uses the
+ * {@link TravisClient#newInstance() default} client.
+ */
+ public GitHubContext build() {
+ if (client == null) {
+ client = TravisClient.newInstance();
+ }
+ return new GitHubContext(client);
+ }
+ }
+}
diff --git a/google-chat-bot/src/main/java/io/spine/chatbot/server/github/OrgReposProjection.java b/google-chat-bot/src/main/java/io/spine/chatbot/server/github/OrgReposProjection.java
new file mode 100644
index 00000000..435fe6b8
--- /dev/null
+++ b/google-chat-bot/src/main/java/io/spine/chatbot/server/github/OrgReposProjection.java
@@ -0,0 +1,65 @@
+/*
+ * Copyright 2020, TeamDev. All rights reserved.
+ *
+ * Redistribution and use in source and/or binary forms, with or without
+ * modification, must retain the above copyright notice and the following
+ * disclaimer.
+ *
+ * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
+ * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
+ * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
+ * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
+ * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
+ * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
+ * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
+ * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
+ * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
+ * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+ * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+ */
+
+package io.spine.chatbot.server.github;
+
+import io.spine.chatbot.github.OrganizationId;
+import io.spine.chatbot.github.RepositoryId;
+import io.spine.chatbot.github.organization.OrganizationRepositories;
+import io.spine.chatbot.github.organization.event.OrganizationRegistered;
+import io.spine.chatbot.github.repository.event.RepositoryRegistered;
+import io.spine.core.Subscribe;
+import io.spine.server.projection.Projection;
+
+/**
+ * Organization repositories projection.
+ *
+ *
Repositories are only referenced by their identifiers.
+ * See {@link io.spine.chatbot.github.repository.Repository Repository} for the details
+ * on each repository.
+ */
+final class OrgReposProjection
+ extends Projection {
+
+ /**
+ * Registers the organization to watch the repositories for.
+ */
+ @Subscribe
+ void on(OrganizationRegistered e) {
+ builder().setOrganization(e.getOrganization());
+ }
+
+ /**
+ * Registers the organization repository.
+ */
+ @Subscribe
+ void on(RepositoryRegistered e) {
+ var newRepo = e.getRepository();
+ if (!hasRepository(newRepo)) {
+ builder().addRepository(newRepo);
+ }
+ }
+
+ private boolean hasRepository(RepositoryId repo) {
+ return builder()
+ .getRepositoryList()
+ .contains(repo);
+ }
+}
diff --git a/google-chat-bot/src/main/java/io/spine/chatbot/server/github/OrgReposRepository.java b/google-chat-bot/src/main/java/io/spine/chatbot/server/github/OrgReposRepository.java
new file mode 100644
index 00000000..61302ed6
--- /dev/null
+++ b/google-chat-bot/src/main/java/io/spine/chatbot/server/github/OrgReposRepository.java
@@ -0,0 +1,49 @@
+/*
+ * Copyright 2020, TeamDev. All rights reserved.
+ *
+ * Redistribution and use in source and/or binary forms, with or without
+ * modification, must retain the above copyright notice and the following
+ * disclaimer.
+ *
+ * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
+ * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
+ * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
+ * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
+ * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
+ * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
+ * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
+ * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
+ * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
+ * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+ * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+ */
+
+package io.spine.chatbot.server.github;
+
+import com.google.errorprone.annotations.OverridingMethodsMustInvokeSuper;
+import io.spine.chatbot.github.OrganizationId;
+import io.spine.chatbot.github.organization.OrganizationRepositories;
+import io.spine.chatbot.github.repository.event.RepositoryRegistered;
+import io.spine.server.projection.ProjectionRepository;
+import io.spine.server.route.EventRouting;
+
+import static io.spine.protobuf.Messages.isNotDefault;
+import static io.spine.server.route.EventRoute.noTargets;
+import static io.spine.server.route.EventRoute.withId;
+
+/**
+ * The repository for {@link OrganizationRepositories}.
+ */
+final class OrgReposRepository
+ extends ProjectionRepository {
+
+ @OverridingMethodsMustInvokeSuper
+ @Override
+ protected void setupEventRouting(EventRouting routing) {
+ super.setupEventRouting(routing);
+ routing.route(RepositoryRegistered.class, (event, context) ->
+ isNotDefault(event.organization())
+ ? withId(event.organization())
+ : noTargets());
+ }
+}
diff --git a/google-chat-bot/src/main/java/io/spine/chatbot/server/github/OrganizationAggregate.java b/google-chat-bot/src/main/java/io/spine/chatbot/server/github/OrganizationAggregate.java
new file mode 100644
index 00000000..62df58d2
--- /dev/null
+++ b/google-chat-bot/src/main/java/io/spine/chatbot/server/github/OrganizationAggregate.java
@@ -0,0 +1,59 @@
+/*
+ * Copyright 2020, TeamDev. All rights reserved.
+ *
+ * Redistribution and use in source and/or binary forms, with or without
+ * modification, must retain the above copyright notice and the following
+ * disclaimer.
+ *
+ * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
+ * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
+ * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
+ * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
+ * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
+ * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
+ * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
+ * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
+ * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
+ * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+ * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+ */
+
+package io.spine.chatbot.server.github;
+
+import io.spine.chatbot.github.OrganizationId;
+import io.spine.chatbot.github.organization.Organization;
+import io.spine.chatbot.github.organization.command.RegisterOrganization;
+import io.spine.chatbot.github.organization.event.OrganizationRegistered;
+import io.spine.logging.Logging;
+import io.spine.server.aggregate.Aggregate;
+import io.spine.server.aggregate.Apply;
+import io.spine.server.command.Assign;
+
+/**
+ * A GitHub organization.
+ *
+ * The ChatBot watches organization resources and the organization is the root entity
+ * the resources are organized around.
+ */
+final class OrganizationAggregate
+ extends Aggregate
+ implements Logging {
+
+ /**
+ * Registers a new organization.
+ */
+ @Assign
+ OrganizationRegistered handle(RegisterOrganization c) {
+ _info().log("Registering organization `%s`.", idAsString());
+ return OrganizationRegistered
+ .newBuilder()
+ .setOrganization(c.getId())
+ .setHeader(c.getHeader())
+ .vBuild();
+ }
+
+ @Apply
+ private void on(OrganizationRegistered e) {
+ builder().setHeader(e.getHeader());
+ }
+}
diff --git a/google-chat-bot/src/main/java/io/spine/chatbot/server/github/RepoBuildProcess.java b/google-chat-bot/src/main/java/io/spine/chatbot/server/github/RepoBuildProcess.java
new file mode 100644
index 00000000..8645ecff
--- /dev/null
+++ b/google-chat-bot/src/main/java/io/spine/chatbot/server/github/RepoBuildProcess.java
@@ -0,0 +1,207 @@
+/*
+ * Copyright 2020, TeamDev. All rights reserved.
+ *
+ * Redistribution and use in source and/or binary forms, with or without
+ * modification, must retain the above copyright notice and the following
+ * disclaimer.
+ *
+ * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
+ * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
+ * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
+ * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
+ * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
+ * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
+ * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
+ * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
+ * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
+ * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+ * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+ */
+
+package io.spine.chatbot.server.github;
+
+import com.google.common.annotations.VisibleForTesting;
+import com.google.errorprone.annotations.concurrent.LazyInit;
+import io.spine.base.Time;
+import io.spine.chatbot.github.RepositoryId;
+import io.spine.chatbot.github.repository.build.Build;
+import io.spine.chatbot.github.repository.build.BuildStateChange;
+import io.spine.chatbot.github.repository.build.BuildStateMixin;
+import io.spine.chatbot.github.repository.build.Commit;
+import io.spine.chatbot.github.repository.build.RepositoryBuild;
+import io.spine.chatbot.github.repository.build.command.CheckRepositoryBuild;
+import io.spine.chatbot.github.repository.build.event.BuildFailed;
+import io.spine.chatbot.github.repository.build.event.BuildRecovered;
+import io.spine.chatbot.github.repository.build.event.BuildSucceededAgain;
+import io.spine.chatbot.github.repository.build.rejection.NoBuildsFound;
+import io.spine.chatbot.google.chat.SpaceId;
+import io.spine.chatbot.travis.BuildsQuery;
+import io.spine.chatbot.travis.RepoBranchBuildResponse;
+import io.spine.chatbot.travis.TravisClient;
+import io.spine.logging.Logging;
+import io.spine.net.Urls;
+import io.spine.server.command.Assign;
+import io.spine.server.procman.ProcessManager;
+import io.spine.server.tuple.EitherOf3;
+import org.checkerframework.checker.nullness.qual.MonotonicNonNull;
+
+import static io.spine.chatbot.github.Slugs.newSlug;
+import static io.spine.chatbot.github.Slugs.repoSlug;
+import static io.spine.chatbot.net.MoreUrls.travisBuildUrlFor;
+import static io.spine.protobuf.Messages.isDefault;
+import static io.spine.util.Exceptions.newIllegalStateException;
+
+/**
+ * Verifies a status of a build of a repository.
+ *
+ * Performs repository build checks and acknowledges the state of the repository builds.
+ * As a result, emits build status events such as:
+ *
+ *
+ * - {@link BuildFailed} — whenever the build is failed;
+ *
- {@link BuildRecovered} — whenever the build state changes from {@code failed}
+ * to {@code passing};
+ *
- {@link BuildSucceededAgain} — whenever the build state is {@code passing} and was
+ * {@code passing} previously.
+ *
+ *
+ * Or, if the repository builds cannot be retrieved, throws {@link NoBuildsFound} rejection.
+ */
+final class RepoBuildProcess
+ extends ProcessManager
+ implements Logging {
+
+ @LazyInit
+ private @MonotonicNonNull TravisClient client;
+
+ /**
+ * Checks the repository build state and propagates the respective events.
+ *
+ * If the repository build state cannot be retrieved, throws {@link NoBuildsFound} rejection.
+ */
+ @Assign
+ EitherOf3 handle(CheckRepositoryBuild c)
+ throws NoBuildsFound {
+ var repo = c.getRepository();
+ _info().log("Checking build status for the repository `%s`.", repo.getValue());
+ var branchBuild = client.execute(BuildsQuery.forRepo(repoSlug(repo)));
+ if (isDefault(branchBuild.getLastBuild())) {
+ _warn().log("No builds found for the repository `%s`.", repo.getValue());
+ throw NoBuildsFound
+ .newBuilder()
+ .setRepository(repo)
+ .build();
+ }
+ var build = buildFrom(branchBuild, c.getSpace());
+ builder().setWhenLastChecked(Time.currentTime())
+ .setBuild(build)
+ .setCurrentState(build.getState());
+ var stateChange = BuildStateChange
+ .newBuilder()
+ .setPreviousValue(state().getBuild())
+ .setNewValue(build)
+ .vBuild();
+ var result = determineOutcome(repo, stateChange);
+ return result;
+ }
+
+ private EitherOf3
+ determineOutcome(RepositoryId repo, BuildStateChange stateChange) {
+ var newBuildState = stateChange.getNewValue();
+ var previousBuildState = stateChange.getPreviousValue();
+ var stateStatusChange = newBuildState.stateChangeFrom(previousBuildState);
+ switch (stateStatusChange) {
+ case FAILED:
+ return onFailed(repo, stateChange);
+ case RECOVERED:
+ return onRecovered(repo, stateChange);
+ case STABLE:
+ return onStable(repo, stateChange);
+ case BSCT_UNKNOWN:
+ case UNRECOGNIZED:
+ default:
+ throw newIllegalStateException(
+ "Unexpected state status change `%s` for the repository `%s`.",
+ stateStatusChange, repo.getValue()
+ );
+ }
+ }
+
+ private EitherOf3
+ onStable(RepositoryId repo, BuildStateChange stateChange) {
+ _info().log("The build for the repository `%s` is stable.", repo.getValue());
+ var buildSucceededAgain = BuildSucceededAgain
+ .newBuilder()
+ .setRepository(repo)
+ .setChange(stateChange)
+ .vBuild();
+ return EitherOf3.withC(buildSucceededAgain);
+ }
+
+ private EitherOf3
+ onRecovered(RepositoryId repo, BuildStateChange stateChange) {
+ _info().log("The build for the repository `%s` is recovered.", repo.getValue());
+ var buildRecovered = BuildRecovered
+ .newBuilder()
+ .setRepository(repo)
+ .setChange(stateChange)
+ .vBuild();
+ return EitherOf3.withB(buildRecovered);
+ }
+
+ private EitherOf3
+ onFailed(RepositoryId repo, BuildStateChange stateChange) {
+ var newBuildState = stateChange.getNewValue();
+ _info().log("A build for the repository `%s` failed with the status `%s`.",
+ repo.getValue(), newBuildState.getState());
+ var buildFailed = BuildFailed
+ .newBuilder()
+ .setRepository(repo)
+ .setChange(stateChange)
+ .vBuild();
+ return EitherOf3.withA(buildFailed);
+ }
+
+ @VisibleForTesting
+ static Build buildFrom(RepoBranchBuildResponse branchBuild, SpaceId space) {
+ var branchBuildName = branchBuild.getName();
+ var slug = newSlug(branchBuild.getRepository()
+ .getSlug());
+ var build = branchBuild.getLastBuild();
+ return Build
+ .newBuilder()
+ .setNumber(build.getNumber())
+ .setSpace(space)
+ .setState(BuildStateMixin.buildStateFrom(build.getState()))
+ .setPreviousState(BuildStateMixin.buildStateFrom(build.getPreviousState()))
+ .setBranch(branchBuildName)
+ .setLastCommit(from(build.getCommit()))
+ .setCreatedBy(build.getCreatedBy()
+ .getLogin())
+ .setRepository(slug)
+ .setTravisCiUrl(travisBuildUrlFor(slug, build.getId()))
+ .vBuild();
+ }
+
+ private static Commit from(io.spine.chatbot.travis.Commit commit) {
+ return Commit
+ .newBuilder()
+ .setSha(commit.getSha())
+ .setMessage(commit.getMessage())
+ .setCommittedAt(commit.getCommittedAt())
+ .setAuthoredBy(commit.getAuthor()
+ .getName())
+ .setCompareUrl(Urls.create(commit.getCompareUrl()))
+ .vBuild();
+ }
+
+ /**
+ * Sets {@link #client} to be used during handling of signals.
+ *
+ * @implNote the method is intended to be used as part of the entity configuration
+ * done through the repository
+ */
+ void setClient(TravisClient client) {
+ this.client = client;
+ }
+}
diff --git a/google-chat-bot/src/main/java/io/spine/chatbot/server/github/RepoBuildRepository.java b/google-chat-bot/src/main/java/io/spine/chatbot/server/github/RepoBuildRepository.java
new file mode 100644
index 00000000..2d658b4a
--- /dev/null
+++ b/google-chat-bot/src/main/java/io/spine/chatbot/server/github/RepoBuildRepository.java
@@ -0,0 +1,45 @@
+/*
+ * Copyright 2020, TeamDev. All rights reserved.
+ *
+ * Redistribution and use in source and/or binary forms, with or without
+ * modification, must retain the above copyright notice and the following
+ * disclaimer.
+ *
+ * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
+ * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
+ * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
+ * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
+ * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
+ * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
+ * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
+ * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
+ * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
+ * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+ * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+ */
+
+package io.spine.chatbot.server.github;
+
+import io.spine.chatbot.github.RepositoryId;
+import io.spine.chatbot.github.repository.build.RepositoryBuild;
+import io.spine.chatbot.travis.TravisClient;
+import io.spine.server.procman.ProcessManagerRepository;
+
+/**
+ * The repository for {@link RepoBuildProcess}es.
+ */
+final class RepoBuildRepository
+ extends ProcessManagerRepository {
+
+ private final TravisClient client;
+
+ RepoBuildRepository(TravisClient client) {
+ this.client = client;
+ }
+
+ @Override
+ protected void configure(RepoBuildProcess processManager) {
+ super.configure(processManager);
+ processManager.setClient(client);
+ }
+}
diff --git a/google-chat-bot/src/main/java/io/spine/chatbot/server/github/RepositoryAggregate.java b/google-chat-bot/src/main/java/io/spine/chatbot/server/github/RepositoryAggregate.java
new file mode 100644
index 00000000..15cf5639
--- /dev/null
+++ b/google-chat-bot/src/main/java/io/spine/chatbot/server/github/RepositoryAggregate.java
@@ -0,0 +1,59 @@
+/*
+ * Copyright 2020, TeamDev. All rights reserved.
+ *
+ * Redistribution and use in source and/or binary forms, with or without
+ * modification, must retain the above copyright notice and the following
+ * disclaimer.
+ *
+ * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
+ * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
+ * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
+ * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
+ * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
+ * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
+ * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
+ * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
+ * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
+ * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+ * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+ */
+
+package io.spine.chatbot.server.github;
+
+import io.spine.chatbot.github.RepositoryId;
+import io.spine.chatbot.github.repository.Repository;
+import io.spine.chatbot.github.repository.command.RegisterRepository;
+import io.spine.chatbot.github.repository.event.RepositoryRegistered;
+import io.spine.logging.Logging;
+import io.spine.server.aggregate.Aggregate;
+import io.spine.server.aggregate.Apply;
+import io.spine.server.command.Assign;
+
+/**
+ * A GitHub repository.
+ *
+ * The ChatBot watches for the repository build status.
+ */
+final class RepositoryAggregate extends Aggregate
+ implements Logging {
+
+ /**
+ * Registers the repository.
+ */
+ @Assign
+ RepositoryRegistered handle(RegisterRepository c) {
+ var repository = c.getId();
+ _info().log("Registering repository `%s`.", repository.getValue());
+ var result = RepositoryRegistered
+ .newBuilder()
+ .setRepository(repository)
+ .setHeader(c.getHeader())
+ .vBuild();
+ return result;
+ }
+
+ @Apply
+ private void on(RepositoryRegistered e) {
+ builder().setHeader(e.getHeader());
+ }
+}
diff --git a/google-chat-bot/src/main/java/io/spine/chatbot/server/github/SpineOrgInitProcess.java b/google-chat-bot/src/main/java/io/spine/chatbot/server/github/SpineOrgInitProcess.java
new file mode 100644
index 00000000..0466824b
--- /dev/null
+++ b/google-chat-bot/src/main/java/io/spine/chatbot/server/github/SpineOrgInitProcess.java
@@ -0,0 +1,146 @@
+/*
+ * Copyright 2020, TeamDev. All rights reserved.
+ *
+ * Redistribution and use in source and/or binary forms, with or without
+ * modification, must retain the above copyright notice and the following
+ * disclaimer.
+ *
+ * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
+ * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
+ * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
+ * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
+ * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
+ * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
+ * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
+ * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
+ * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
+ * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+ * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+ */
+
+package io.spine.chatbot.server.github;
+
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableSet;
+import com.google.errorprone.annotations.concurrent.LazyInit;
+import io.spine.base.CommandMessage;
+import io.spine.chatbot.github.OrganizationId;
+import io.spine.chatbot.github.organization.OrgHeader;
+import io.spine.chatbot.github.organization.command.RegisterOrganization;
+import io.spine.chatbot.github.organization.init.OrganizationInit;
+import io.spine.chatbot.github.repository.RepoHeader;
+import io.spine.chatbot.github.repository.command.RegisterRepository;
+import io.spine.chatbot.google.chat.SpaceId;
+import io.spine.chatbot.google.chat.event.SpaceRegistered;
+import io.spine.chatbot.travis.ReposQuery;
+import io.spine.chatbot.travis.Repository;
+import io.spine.chatbot.travis.TravisClient;
+import io.spine.core.External;
+import io.spine.logging.Logging;
+import io.spine.net.Urls;
+import io.spine.server.command.Command;
+import io.spine.server.procman.ProcessManager;
+import org.checkerframework.checker.nullness.qual.MonotonicNonNull;
+
+import static io.spine.chatbot.github.GitHubIdentifiers.organization;
+import static io.spine.chatbot.github.GitHubIdentifiers.repository;
+import static io.spine.chatbot.github.Slugs.newSlug;
+import static io.spine.chatbot.github.Slugs.orgSlug;
+import static io.spine.chatbot.net.MoreUrls.githubUrlFor;
+import static io.spine.chatbot.net.MoreUrls.travisUrlFor;
+
+/**
+ * Spine organization init process.
+ *
+ * Registers Spine organization and the watched {@link #WATCHED_REPOS repositories} upon adding
+ * the ChatBot to the space.
+ */
+final class SpineOrgInitProcess
+ extends ProcessManager
+ implements Logging {
+
+ private static final ImmutableList WATCHED_REPOS = ImmutableList.of(
+ "base", "time", "core-java", "web", "gcloud-java", "bootstrap", "money", "jdbc-storage"
+ );
+
+ /**
+ * The initialization process ID.
+ */
+ static final OrganizationId ORGANIZATION = organization("SpineEventEngine");
+
+ @LazyInit
+ private @MonotonicNonNull TravisClient client;
+
+ /**
+ * Registers {@link #ORGANIZATION Spine} organization and watched resources that are
+ * currently available in the Travis CI.
+ *
+ * If a particular repository is not available in Travis, it is then skipped
+ * and not registered.
+ */
+ @Command
+ Iterable on(@External SpaceRegistered e) {
+ if (state().getInitialized()) {
+ _info().log("Spine organization is already initialized. Skipping the process.");
+ return ImmutableSet.of();
+ }
+ var space = e.getSpace();
+ _info().log("Starting Spine organization initialization process in the space `%s`.", space);
+ var commands = ImmutableSet.builder();
+ commands.add(registerOrgCommand(ORGANIZATION, space));
+ client.execute(ReposQuery.forOwner(orgSlug(ORGANIZATION)))
+ .getRepositoriesList()
+ .stream()
+ .filter(repository -> WATCHED_REPOS.contains(repository.getName()))
+ .map(repository -> registerRepoCommand(repository, ORGANIZATION))
+ .forEach(commands::add);
+ builder().setSpace(space)
+ .setInitialized(true);
+ return commands.build();
+ }
+
+ private RegisterRepository registerRepoCommand(Repository repo, OrganizationId org) {
+ var slug = newSlug(repo.getSlug());
+ _info().log("Registering `%s` repository.", slug.getValue());
+ var header = RepoHeader
+ .newBuilder()
+ .setOrganization(org)
+ .setGithubProfile(githubUrlFor(slug))
+ .setName(repo.getName())
+ .setTravisProfile(travisUrlFor(slug))
+ .vBuild();
+ return RegisterRepository
+ .newBuilder()
+ .setId(repository(slug.getValue()))
+ .setHeader(header)
+ .vBuild();
+ }
+
+ private RegisterOrganization registerOrgCommand(OrganizationId spineOrg, SpaceId space) {
+ var slug = orgSlug(spineOrg);
+ _info().log("Registering `%s` organization.", spineOrg.getValue());
+ var header = OrgHeader
+ .newBuilder()
+ .setName("Spine Event Engine")
+ .setWebsite(Urls.create("https://spine.io/"))
+ .setTravisProfile(travisUrlFor(slug))
+ .setGithubProfile(githubUrlFor(slug))
+ .setSpace(space)
+ .vBuild();
+ return RegisterOrganization
+ .newBuilder()
+ .setId(spineOrg)
+ .setHeader(header)
+ .vBuild();
+ }
+
+ /**
+ * Sets {@link #client} to be used during handling of signals.
+ *
+ * @implNote the method is intended to be used as part of the entity configuration
+ * done through the repository
+ */
+ void setClient(TravisClient client) {
+ this.client = client;
+ }
+}
diff --git a/google-chat-bot/src/main/java/io/spine/chatbot/server/github/SpineOrgInitRepository.java b/google-chat-bot/src/main/java/io/spine/chatbot/server/github/SpineOrgInitRepository.java
new file mode 100644
index 00000000..4685c839
--- /dev/null
+++ b/google-chat-bot/src/main/java/io/spine/chatbot/server/github/SpineOrgInitRepository.java
@@ -0,0 +1,58 @@
+/*
+ * Copyright 2020, TeamDev. All rights reserved.
+ *
+ * Redistribution and use in source and/or binary forms, with or without
+ * modification, must retain the above copyright notice and the following
+ * disclaimer.
+ *
+ * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
+ * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
+ * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
+ * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
+ * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
+ * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
+ * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
+ * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
+ * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
+ * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+ * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+ */
+
+package io.spine.chatbot.server.github;
+
+import com.google.errorprone.annotations.OverridingMethodsMustInvokeSuper;
+import io.spine.chatbot.github.OrganizationId;
+import io.spine.chatbot.github.organization.init.OrganizationInit;
+import io.spine.chatbot.google.chat.event.SpaceRegistered;
+import io.spine.chatbot.travis.TravisClient;
+import io.spine.server.procman.ProcessManagerRepository;
+import io.spine.server.route.EventRouting;
+
+import static io.spine.chatbot.server.github.SpineOrgInitProcess.ORGANIZATION;
+import static io.spine.server.route.EventRoute.withId;
+
+/**
+ * The repository for {@link SpineOrgInitProcess}.
+ */
+final class SpineOrgInitRepository
+ extends ProcessManagerRepository {
+
+ private final TravisClient client;
+
+ SpineOrgInitRepository(TravisClient client) {
+ this.client = client;
+ }
+
+ @OverridingMethodsMustInvokeSuper
+ @Override
+ protected void setupEventRouting(EventRouting routing) {
+ super.setupEventRouting(routing);
+ routing.route(SpaceRegistered.class, (event, context) -> withId(ORGANIZATION));
+ }
+
+ @Override
+ protected void configure(SpineOrgInitProcess processManager) {
+ super.configure(processManager);
+ processManager.setClient(client);
+ }
+}
diff --git a/google-chat-bot/src/main/java/io/spine/chatbot/server/github/package-info.java b/google-chat-bot/src/main/java/io/spine/chatbot/server/github/package-info.java
new file mode 100644
index 00000000..91fd9c98
--- /dev/null
+++ b/google-chat-bot/src/main/java/io/spine/chatbot/server/github/package-info.java
@@ -0,0 +1,32 @@
+/*
+ * Copyright 2020, TeamDev. All rights reserved.
+ *
+ * Redistribution and use in source and/or binary forms, with or without
+ * modification, must retain the above copyright notice and the following
+ * disclaimer.
+ *
+ * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
+ * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
+ * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
+ * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
+ * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
+ * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
+ * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
+ * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
+ * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
+ * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+ * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+ */
+
+/**
+ * This package contains server-side implementation of the GitHub Context.
+ */
+@BoundedContext(GitHubContext.GIT_HUB_CONTEXT_NAME)
+@CheckReturnValue
+@ParametersAreNonnullByDefault
+package io.spine.chatbot.server.github;
+
+import com.google.errorprone.annotations.CheckReturnValue;
+import io.spine.core.BoundedContext;
+
+import javax.annotation.ParametersAreNonnullByDefault;
diff --git a/google-chat-bot/src/main/java/io/spine/chatbot/server/google/chat/ChatEvents.java b/google-chat-bot/src/main/java/io/spine/chatbot/server/google/chat/ChatEvents.java
new file mode 100644
index 00000000..023b684e
--- /dev/null
+++ b/google-chat-bot/src/main/java/io/spine/chatbot/server/google/chat/ChatEvents.java
@@ -0,0 +1,80 @@
+/*
+ * Copyright 2020, TeamDev. All rights reserved.
+ *
+ * Redistribution and use in source and/or binary forms, with or without
+ * modification, must retain the above copyright notice and the following
+ * disclaimer.
+ *
+ * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
+ * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
+ * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
+ * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
+ * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
+ * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
+ * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
+ * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
+ * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
+ * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+ * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+ */
+
+package io.spine.chatbot.server.google.chat;
+
+import io.spine.chatbot.google.chat.incoming.ChatEvent;
+import io.spine.chatbot.google.chat.incoming.event.BotAddedToSpace;
+import io.spine.chatbot.google.chat.incoming.event.BotRemovedFromSpace;
+import io.spine.chatbot.google.chat.incoming.event.MessageReceived;
+
+import static com.google.common.base.Preconditions.checkNotNull;
+import static io.spine.chatbot.google.chat.GoogleChatIdentifiers.message;
+
+/**
+ * A utility for working with {@link ChatEvent}s.
+ */
+final class ChatEvents {
+
+ /**
+ * Prevents instantiation of this utility class.
+ */
+ private ChatEvents() {
+ }
+
+ /**
+ * Creates a new {@code BotRemovedFromSpace} message out of the supplied {@code event}.
+ */
+ static BotRemovedFromSpace toBotRemovedFromSpace(ChatEvent event) {
+ checkNotNull(event);
+ var space = event.getSpace();
+ return BotRemovedFromSpace
+ .newBuilder()
+ .setEvent(event)
+ .setSpace(space.id())
+ .vBuild();
+ }
+
+ /**
+ * Creates a new {@code BotAddedToSpace} message out of the supplied {@code event}.
+ */
+ static BotAddedToSpace toBotAddedToSpace(ChatEvent event) {
+ checkNotNull(event);
+ var space = event.getSpace();
+ return BotAddedToSpace
+ .newBuilder()
+ .setEvent(event)
+ .setSpace(space.id())
+ .vBuild();
+ }
+
+ /**
+ * Creates a new {@code MessageReceived} message out of the supplied {@code event}.
+ */
+ static MessageReceived toMessageReceived(ChatEvent event) {
+ checkNotNull(event);
+ var message = event.getMessage();
+ return MessageReceived
+ .newBuilder()
+ .setEvent(event)
+ .setMessage(message(message.getName()))
+ .vBuild();
+ }
+}
diff --git a/google-chat-bot/src/main/java/io/spine/chatbot/server/google/chat/GoogleChatContext.java b/google-chat-bot/src/main/java/io/spine/chatbot/server/google/chat/GoogleChatContext.java
new file mode 100644
index 00000000..af96266b
--- /dev/null
+++ b/google-chat-bot/src/main/java/io/spine/chatbot/server/google/chat/GoogleChatContext.java
@@ -0,0 +1,114 @@
+/*
+ * Copyright 2020, TeamDev. All rights reserved.
+ *
+ * Redistribution and use in source and/or binary forms, with or without
+ * modification, must retain the above copyright notice and the following
+ * disclaimer.
+ *
+ * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
+ * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
+ * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
+ * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
+ * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
+ * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
+ * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
+ * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
+ * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
+ * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+ * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+ */
+
+package io.spine.chatbot.server.google.chat;
+
+import io.spine.chatbot.google.chat.GoogleChatClient;
+import io.spine.chatbot.server.ContextBuilderAware;
+import io.spine.chatbot.server.DiagnosticEventLogger;
+import io.spine.server.BoundedContext;
+import io.spine.server.BoundedContextBuilder;
+
+import static com.google.common.base.Preconditions.checkNotNull;
+
+/**
+ * Provides {@link BoundedContextBuilder} for the Google Chat context.
+ */
+public final class GoogleChatContext implements ContextBuilderAware {
+
+ /**
+ * The name of the Google Chat Context.
+ */
+ static final String GOOGLE_CHAT_CONTEXT_NAME = "GoogleChat";
+
+ private final BoundedContextBuilder builder;
+
+ private GoogleChatContext(GoogleChatClient client) {
+ this.builder = configureBuilder(client);
+ }
+
+ /**
+ * Returns the context builder associated with the Google Chat context.
+ */
+ @Override
+ public BoundedContextBuilder builder() {
+ return this.builder;
+ }
+
+ /**
+ * Creates a new instance of the Google Chat context builder.
+ */
+ private static BoundedContextBuilder
+ configureBuilder(GoogleChatClient client) {
+ return BoundedContext
+ .singleTenant(GOOGLE_CHAT_CONTEXT_NAME)
+ .add(new SpaceRepository())
+ .add(new ThreadRepository())
+ .add(new ThreadChatRepository(client))
+ .addEventDispatcher(new IncomingEventsHandler())
+ .addEventDispatcher(new DiagnosticEventLogger());
+ }
+
+ /**
+ * Creates a new builder of the Google Chat context.
+ */
+ public static Builder newBuilder() {
+ return new Builder();
+ }
+
+ /**
+ * Creates a new Google Chat context.
+ */
+ public static GoogleChatContext newInstance() {
+ return newBuilder().build();
+ }
+
+ /**
+ * A Builder for configuring Google Chat context.
+ */
+ public static final class Builder {
+
+ private GoogleChatClient client;
+
+ /**
+ * Prevents direct instantiation.
+ */
+ private Builder() {
+ }
+
+ /**
+ * Sets Google Chat client to be used within the context.
+ */
+ public Builder setClient(GoogleChatClient client) {
+ this.client = checkNotNull(client);
+ return this;
+ }
+
+ /**
+ * Finishes configuration of the context and builds a new instance.
+ */
+ public GoogleChatContext build() {
+ if (client == null) {
+ client = GoogleChatClient.newInstance();
+ }
+ return new GoogleChatContext(client);
+ }
+ }
+}
diff --git a/google-chat-bot/src/main/java/io/spine/chatbot/server/google/chat/IncomingEventsHandler.java b/google-chat-bot/src/main/java/io/spine/chatbot/server/google/chat/IncomingEventsHandler.java
new file mode 100644
index 00000000..d5471d56
--- /dev/null
+++ b/google-chat-bot/src/main/java/io/spine/chatbot/server/google/chat/IncomingEventsHandler.java
@@ -0,0 +1,87 @@
+/*
+ * Copyright 2020, TeamDev. All rights reserved.
+ *
+ * Redistribution and use in source and/or binary forms, with or without
+ * modification, must retain the above copyright notice and the following
+ * disclaimer.
+ *
+ * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
+ * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
+ * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
+ * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
+ * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
+ * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
+ * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
+ * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
+ * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
+ * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+ * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+ */
+
+package io.spine.chatbot.server.google.chat;
+
+import io.spine.chatbot.google.chat.incoming.ChatEvent;
+import io.spine.chatbot.google.chat.incoming.event.BotAddedToSpace;
+import io.spine.chatbot.google.chat.incoming.event.BotRemovedFromSpace;
+import io.spine.chatbot.google.chat.incoming.event.ChatEventReceived;
+import io.spine.chatbot.google.chat.incoming.event.MessageReceived;
+import io.spine.core.External;
+import io.spine.logging.Logging;
+import io.spine.server.event.AbstractEventReactor;
+import io.spine.server.event.React;
+import io.spine.server.model.Nothing;
+import io.spine.server.tuple.EitherOf4;
+
+import static io.spine.chatbot.server.google.chat.ChatEvents.toBotAddedToSpace;
+import static io.spine.chatbot.server.google.chat.ChatEvents.toBotRemovedFromSpace;
+import static io.spine.chatbot.server.google.chat.ChatEvents.toMessageReceived;
+
+/**
+ * Processes incoming {@link ChatEvent} messages and emits one of the following domain events:
+ *
+ *
+ * - {@link BotAddedToSpace} — the ChatBot is added to a space;
+ *
- {@link BotRemovedFromSpace} — the ChatBot is removed from the space;
+ *
- {@link MessageReceived} — the ChatBot received a new incoming message from a user
+ * within a space.
+ *
+ *
+ * If the bot receives a chat event with a not supported currently event type,
+ * {@link Nothing} is emitted.
+ */
+final class IncomingEventsHandler extends AbstractEventReactor implements Logging {
+
+ /**
+ * Processes an incoming external {@link ChatEvent}.
+ *
+ *
If the event type is not supported, returns {@link #nothing() nothing}.
+ */
+ @React
+ EitherOf4
+ on(@External ChatEventReceived e) {
+ var chatEvent = e.getEvent();
+ var space = chatEvent.getSpace();
+ switch (chatEvent.getType()) {
+ case MESSAGE:
+ _debug().log("A new user message received in the space `%s` (%s).",
+ space.getDisplayName(), space.getName());
+ return EitherOf4.withC(toMessageReceived(chatEvent));
+ case ADDED_TO_SPACE:
+ _info().log("ChatBot added to the space `%s` (%s).",
+ space.getDisplayName(), space.getName());
+ return EitherOf4.withA(toBotAddedToSpace(chatEvent));
+ case REMOVED_FROM_SPACE:
+ _info().log("ChatBot removed from the space `%s` (%s).",
+ space.getDisplayName(), space.getName());
+ return EitherOf4.withB(toBotRemovedFromSpace(chatEvent));
+ case CARD_CLICKED:
+ _debug().log("Skipping card clicks.");
+ return EitherOf4.withD(nothing());
+ case UNRECOGNIZED:
+ case ET_UNKNOWN:
+ default:
+ _error().log("Unsupported chat event type received: `%s`.", chatEvent.getType());
+ return EitherOf4.withD(nothing());
+ }
+ }
+}
diff --git a/google-chat-bot/src/main/java/io/spine/chatbot/server/google/chat/SpaceAggregate.java b/google-chat-bot/src/main/java/io/spine/chatbot/server/google/chat/SpaceAggregate.java
new file mode 100644
index 00000000..41b0d8fa
--- /dev/null
+++ b/google-chat-bot/src/main/java/io/spine/chatbot/server/google/chat/SpaceAggregate.java
@@ -0,0 +1,86 @@
+/*
+ * Copyright 2020, TeamDev. All rights reserved.
+ *
+ * Redistribution and use in source and/or binary forms, with or without
+ * modification, must retain the above copyright notice and the following
+ * disclaimer.
+ *
+ * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
+ * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
+ * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
+ * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
+ * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
+ * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
+ * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
+ * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
+ * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
+ * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+ * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+ */
+
+package io.spine.chatbot.server.google.chat;
+
+import io.spine.chatbot.google.chat.Space;
+import io.spine.chatbot.google.chat.SpaceHeader;
+import io.spine.chatbot.google.chat.SpaceId;
+import io.spine.chatbot.google.chat.command.RegisterSpace;
+import io.spine.chatbot.google.chat.event.SpaceRegistered;
+import io.spine.chatbot.google.chat.incoming.event.BotAddedToSpace;
+import io.spine.logging.Logging;
+import io.spine.server.aggregate.Aggregate;
+import io.spine.server.aggregate.Apply;
+import io.spine.server.command.Assign;
+import io.spine.server.event.React;
+
+/**
+ * A room or direct message chat in the Google Chat.
+ *
+ * Whenever the ChatBot is added to the space, the space is registered in the context.
+ */
+final class SpaceAggregate extends Aggregate implements Logging {
+
+ /**
+ * Registers a new space when the ChatBot is added to the space.
+ */
+ @React
+ SpaceRegistered on(BotAddedToSpace e) {
+ var space = e.getEvent()
+ .getSpace();
+ var displayName = space.getDisplayName();
+ var spaceId = e.getSpace();
+ _info().log("Registering the space `%s` (`%s`) because the bot is added the space.",
+ displayName, spaceId.getValue());
+ var header = SpaceHeader
+ .newBuilder()
+ .setDisplayName(displayName)
+ .setThreaded(space.isThreaded())
+ .vBuild();
+ return SpaceRegistered
+ .newBuilder()
+ .setSpace(spaceId)
+ .setHeader(header)
+ .vBuild();
+ }
+
+ /**
+ * Registers the space in the context.
+ */
+ @Assign
+ SpaceRegistered handle(RegisterSpace c) {
+ var header = c.getHeader();
+ var space = c.getId();
+ _info().log("Registering the space `%s` (`%s`) on direct registration request.",
+ header.getDisplayName(), space.getValue());
+ var result = SpaceRegistered
+ .newBuilder()
+ .setSpace(space)
+ .setHeader(header)
+ .vBuild();
+ return result;
+ }
+
+ @Apply
+ private void on(SpaceRegistered e) {
+ builder().setHeader(e.getHeader());
+ }
+}
diff --git a/google-chat-bot/src/main/java/io/spine/chatbot/server/google/chat/SpaceRepository.java b/google-chat-bot/src/main/java/io/spine/chatbot/server/google/chat/SpaceRepository.java
new file mode 100644
index 00000000..53422983
--- /dev/null
+++ b/google-chat-bot/src/main/java/io/spine/chatbot/server/google/chat/SpaceRepository.java
@@ -0,0 +1,40 @@
+/*
+ * Copyright 2020, TeamDev. All rights reserved.
+ *
+ * Redistribution and use in source and/or binary forms, with or without
+ * modification, must retain the above copyright notice and the following
+ * disclaimer.
+ *
+ * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
+ * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
+ * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
+ * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
+ * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
+ * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
+ * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
+ * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
+ * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
+ * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+ * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+ */
+
+package io.spine.chatbot.server.google.chat;
+
+import io.spine.chatbot.google.chat.SpaceId;
+import io.spine.chatbot.google.chat.incoming.event.BotAddedToSpace;
+import io.spine.server.aggregate.AggregateRepository;
+import io.spine.server.route.EventRouting;
+
+import static io.spine.server.route.EventRoute.withId;
+
+/**
+ * The repository for {@link SpaceAggregate}s.
+ */
+final class SpaceRepository extends AggregateRepository {
+
+ @Override
+ protected void setupEventRouting(EventRouting routing) {
+ super.setupEventRouting(routing);
+ routing.route(BotAddedToSpace.class, (event, context) -> withId(event.getSpace()));
+ }
+}
diff --git a/google-chat-bot/src/main/java/io/spine/chatbot/server/google/chat/ThreadAggregate.java b/google-chat-bot/src/main/java/io/spine/chatbot/server/google/chat/ThreadAggregate.java
new file mode 100644
index 00000000..91f22811
--- /dev/null
+++ b/google-chat-bot/src/main/java/io/spine/chatbot/server/google/chat/ThreadAggregate.java
@@ -0,0 +1,82 @@
+/*
+ * Copyright 2020, TeamDev. All rights reserved.
+ *
+ * Redistribution and use in source and/or binary forms, with or without
+ * modification, must retain the above copyright notice and the following
+ * disclaimer.
+ *
+ * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
+ * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
+ * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
+ * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
+ * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
+ * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
+ * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
+ * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
+ * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
+ * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+ * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+ */
+
+package io.spine.chatbot.server.google.chat;
+
+import io.spine.chatbot.google.chat.ThreadId;
+import io.spine.chatbot.google.chat.event.MessageCreated;
+import io.spine.chatbot.google.chat.event.ThreadCreated;
+import io.spine.chatbot.google.chat.thread.Thread;
+import io.spine.chatbot.google.chat.thread.event.MessageAdded;
+import io.spine.chatbot.google.chat.thread.event.ThreadInitialized;
+import io.spine.logging.Logging;
+import io.spine.server.aggregate.Aggregate;
+import io.spine.server.aggregate.Apply;
+import io.spine.server.event.React;
+
+/**
+ * A thread in a chat room.
+ *
+ * A new thread is initialized as early as a new conversation is started in the room.
+ * It happens once the first message is posted to the conversation.
+ */
+final class ThreadAggregate extends Aggregate implements Logging {
+
+ /**
+ * Initializes the thread information upon the creation of the thread.
+ */
+ @React
+ ThreadInitialized on(ThreadCreated e) {
+ _info().log("A new thread `%s` created.", idAsString());
+ return ThreadInitialized
+ .newBuilder()
+ .setThread(e.getThread())
+ .setResource(e.getResource())
+ .setSpace(e.getSpace())
+ .vBuild();
+ }
+
+ @Apply
+ private void on(ThreadInitialized e) {
+ builder().setResource(e.getResource())
+ .setSpace(e.getSpace());
+ }
+
+ /**
+ * Acknowledges creation of a new thread message.
+ */
+ @React
+ MessageAdded on(MessageCreated e) {
+ var message = e.getMessage();
+ var thread = e.getThread();
+ _info().log("A new message `%s` added to the thread `%s`.",
+ message.getValue(), thread.getValue());
+ return MessageAdded
+ .newBuilder()
+ .setMessage(message)
+ .setThread(thread)
+ .vBuild();
+ }
+
+ @Apply
+ private void on(MessageAdded e) {
+ builder().addMessage(e.getMessage());
+ }
+}
diff --git a/google-chat-bot/src/main/java/io/spine/chatbot/server/google/chat/ThreadChatProcess.java b/google-chat-bot/src/main/java/io/spine/chatbot/server/google/chat/ThreadChatProcess.java
new file mode 100644
index 00000000..94e07727
--- /dev/null
+++ b/google-chat-bot/src/main/java/io/spine/chatbot/server/google/chat/ThreadChatProcess.java
@@ -0,0 +1,120 @@
+/*
+ * Copyright 2020, TeamDev. All rights reserved.
+ *
+ * Redistribution and use in source and/or binary forms, with or without
+ * modification, must retain the above copyright notice and the following
+ * disclaimer.
+ *
+ * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
+ * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
+ * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
+ * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
+ * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
+ * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
+ * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
+ * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
+ * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
+ * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+ * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+ */
+
+package io.spine.chatbot.server.google.chat;
+
+import com.google.errorprone.annotations.concurrent.LazyInit;
+import io.spine.chatbot.github.RepositoryId;
+import io.spine.chatbot.github.repository.build.Build;
+import io.spine.chatbot.github.repository.build.event.BuildFailed;
+import io.spine.chatbot.github.repository.build.event.BuildRecovered;
+import io.spine.chatbot.google.chat.GoogleChatClient;
+import io.spine.chatbot.google.chat.ThreadId;
+import io.spine.chatbot.google.chat.event.MessageCreated;
+import io.spine.chatbot.google.chat.event.ThreadCreated;
+import io.spine.chatbot.google.chat.thread.ThreadChat;
+import io.spine.core.External;
+import io.spine.logging.Logging;
+import io.spine.protobuf.Messages;
+import io.spine.server.event.React;
+import io.spine.server.procman.ProcessManager;
+import io.spine.server.tuple.Pair;
+import org.checkerframework.checker.nullness.qual.MonotonicNonNull;
+
+import java.util.Optional;
+
+/**
+ * A process of notifying thread members about the changes in the watched resouces.
+ */
+final class ThreadChatProcess extends ProcessManager
+ implements Logging {
+
+ @LazyInit
+ private @MonotonicNonNull GoogleChatClient client;
+
+ /**
+ * Notifies thread members about a failed CI build.
+ */
+ @React
+ Pair> on(@External BuildFailed e) {
+ var change = e.getChange();
+ var build = change.getNewValue();
+ var repo = e.getRepository();
+ _info().log("A build for the repository `%s` failed.", repo.getValue());
+ return processBuildStateUpdate(build, repo);
+ }
+
+ /**
+ * Notifies thread members about a recovered CI build.
+ *
+ * The build is considered a recovered when it changes its state from
+ * {@code failed} to {@code passing}.
+ */
+ @React
+ Pair> on(@External BuildRecovered e) {
+ var change = e.getChange();
+ var build = change.getNewValue();
+ var repo = e.getRepository();
+ _info().log("A build for the repository `%s` recovered.", repo.getValue());
+ return processBuildStateUpdate(build, repo);
+ }
+
+ private Pair>
+ processBuildStateUpdate(Build build, RepositoryId repo) {
+ var sentUpdate = client.sendBuildStateUpdate(build, state().getResource());
+ var space = sentUpdate.getSpace();
+ var thread = sentUpdate.getThread();
+ var messageCreated = MessageCreated
+ .newBuilder()
+ .setMessage(sentUpdate.getMessage())
+ .setSpace(space)
+ .setThread(thread)
+ .vBuild();
+ if (shouldCreateThread()) {
+ var resource = sentUpdate.getResource();
+ _debug().log("A new thread `%s` created for the repository `%s`.",
+ resource.getName(), repo.getValue());
+ builder().setResource(resource)
+ .setSpace(space);
+ var threadCreated = ThreadCreated
+ .newBuilder()
+ .setThread(thread)
+ .setResource(resource)
+ .setSpace(space)
+ .vBuild();
+ return Pair.withNullable(messageCreated, threadCreated);
+ }
+ return Pair.withNullable(messageCreated, null);
+ }
+
+ private boolean shouldCreateThread() {
+ return Messages.isDefault(state().getResource());
+ }
+
+ /**
+ * Sets {@link #client} to be used during handling of signals.
+ *
+ * @implNote the method is intended to be used as part of the entity configuration
+ * done through the repository
+ */
+ void setClient(GoogleChatClient client) {
+ this.client = client;
+ }
+}
diff --git a/google-chat-bot/src/main/java/io/spine/chatbot/server/google/chat/ThreadChatRepository.java b/google-chat-bot/src/main/java/io/spine/chatbot/server/google/chat/ThreadChatRepository.java
new file mode 100644
index 00000000..92bac486
--- /dev/null
+++ b/google-chat-bot/src/main/java/io/spine/chatbot/server/google/chat/ThreadChatRepository.java
@@ -0,0 +1,76 @@
+/*
+ * Copyright 2020, TeamDev. All rights reserved.
+ *
+ * Redistribution and use in source and/or binary forms, with or without
+ * modification, must retain the above copyright notice and the following
+ * disclaimer.
+ *
+ * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
+ * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
+ * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
+ * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
+ * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
+ * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
+ * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
+ * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
+ * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
+ * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+ * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+ */
+
+package io.spine.chatbot.server.google.chat;
+
+import com.google.errorprone.annotations.OverridingMethodsMustInvokeSuper;
+import io.spine.chatbot.github.repository.RepositoryAwareEvent;
+import io.spine.chatbot.github.repository.build.event.BuildFailed;
+import io.spine.chatbot.github.repository.build.event.BuildRecovered;
+import io.spine.chatbot.google.chat.GoogleChatClient;
+import io.spine.chatbot.google.chat.ThreadId;
+import io.spine.chatbot.google.chat.thread.ThreadChat;
+import io.spine.core.EventContext;
+import io.spine.server.procman.ProcessManagerRepository;
+import io.spine.server.route.EventRoute;
+import io.spine.server.route.EventRouting;
+
+import java.util.Set;
+
+import static io.spine.chatbot.google.chat.GoogleChatIdentifiers.thread;
+import static io.spine.server.route.EventRoute.withId;
+
+/**
+ * The repository for {@link ThreadChatProcess}es.
+ */
+final class ThreadChatRepository
+ extends ProcessManagerRepository {
+
+ private final GoogleChatClient client;
+
+ ThreadChatRepository(GoogleChatClient client) {
+ this.client = client;
+ }
+
+ @OverridingMethodsMustInvokeSuper
+ @Override
+ protected void setupEventRouting(EventRouting routing) {
+ super.setupEventRouting(routing);
+ routing.route(BuildFailed.class, new RepositoryEventRoute<>())
+ .route(BuildRecovered.class, new RepositoryEventRoute<>());
+ }
+
+ @Override
+ protected void configure(ThreadChatProcess processManager) {
+ processManager.setClient(client);
+ }
+
+ private static class RepositoryEventRoute
+ implements EventRoute {
+
+ private static final long serialVersionUID = 0L;
+
+ @Override
+ public Set apply(M event, EventContext context) {
+ var repository = event.repository();
+ return withId(thread(repository.getValue()));
+ }
+ }
+}
diff --git a/google-chat-bot/src/main/java/io/spine/chatbot/server/google/chat/ThreadRepository.java b/google-chat-bot/src/main/java/io/spine/chatbot/server/google/chat/ThreadRepository.java
new file mode 100644
index 00000000..50665445
--- /dev/null
+++ b/google-chat-bot/src/main/java/io/spine/chatbot/server/google/chat/ThreadRepository.java
@@ -0,0 +1,42 @@
+/*
+ * Copyright 2020, TeamDev. All rights reserved.
+ *
+ * Redistribution and use in source and/or binary forms, with or without
+ * modification, must retain the above copyright notice and the following
+ * disclaimer.
+ *
+ * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
+ * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
+ * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
+ * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
+ * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
+ * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
+ * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
+ * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
+ * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
+ * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+ * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+ */
+
+package io.spine.chatbot.server.google.chat;
+
+import io.spine.chatbot.google.chat.ThreadId;
+import io.spine.chatbot.google.chat.event.MessageCreated;
+import io.spine.chatbot.google.chat.event.ThreadCreated;
+import io.spine.server.aggregate.AggregateRepository;
+import io.spine.server.route.EventRouting;
+
+import static io.spine.server.route.EventRoute.withId;
+
+/**
+ * The repository for {@link ThreadAggregate}s.
+ */
+final class ThreadRepository extends AggregateRepository {
+
+ @Override
+ protected void setupEventRouting(EventRouting routing) {
+ super.setupEventRouting(routing);
+ routing.route(ThreadCreated.class, (event, context) -> withId(event.getThread()))
+ .route(MessageCreated.class, (event, context) -> withId(event.getThread()));
+ }
+}
diff --git a/google-chat-bot/src/main/java/io/spine/chatbot/server/google/chat/ThreadResources.java b/google-chat-bot/src/main/java/io/spine/chatbot/server/google/chat/ThreadResources.java
new file mode 100644
index 00000000..e3754057
--- /dev/null
+++ b/google-chat-bot/src/main/java/io/spine/chatbot/server/google/chat/ThreadResources.java
@@ -0,0 +1,48 @@
+/*
+ * Copyright 2020, TeamDev. All rights reserved.
+ *
+ * Redistribution and use in source and/or binary forms, with or without
+ * modification, must retain the above copyright notice and the following
+ * disclaimer.
+ *
+ * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
+ * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
+ * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
+ * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
+ * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
+ * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
+ * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
+ * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
+ * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
+ * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+ * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+ */
+
+package io.spine.chatbot.server.google.chat;
+
+import io.spine.chatbot.google.chat.thread.ThreadResource;
+
+import static io.spine.util.Preconditions2.checkNotEmptyOrBlank;
+
+/**
+ * A utility for working with {@link ThreadResource}s.
+ */
+public final class ThreadResources {
+
+ /**
+ * Prevents instantiation of this utility class.
+ */
+ private ThreadResources() {
+ }
+
+ /**
+ * Creates a new {@code ThreadResource} with the specified {@code name}.
+ */
+ public static ThreadResource threadResource(String name) {
+ checkNotEmptyOrBlank(name);
+ return ThreadResource
+ .newBuilder()
+ .setName(name)
+ .vBuild();
+ }
+}
diff --git a/google-chat-bot/src/main/java/io/spine/chatbot/server/google/chat/package-info.java b/google-chat-bot/src/main/java/io/spine/chatbot/server/google/chat/package-info.java
new file mode 100644
index 00000000..ea2b8728
--- /dev/null
+++ b/google-chat-bot/src/main/java/io/spine/chatbot/server/google/chat/package-info.java
@@ -0,0 +1,32 @@
+/*
+ * Copyright 2020, TeamDev. All rights reserved.
+ *
+ * Redistribution and use in source and/or binary forms, with or without
+ * modification, must retain the above copyright notice and the following
+ * disclaimer.
+ *
+ * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
+ * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
+ * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
+ * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
+ * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
+ * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
+ * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
+ * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
+ * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
+ * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+ * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+ */
+
+/**
+ * This package contains server-side implementation of the Google Chat Context.
+ */
+@BoundedContext(GoogleChatContext.GOOGLE_CHAT_CONTEXT_NAME)
+@CheckReturnValue
+@ParametersAreNonnullByDefault
+package io.spine.chatbot.server.google.chat;
+
+import com.google.errorprone.annotations.CheckReturnValue;
+import io.spine.core.BoundedContext;
+
+import javax.annotation.ParametersAreNonnullByDefault;
diff --git a/google-chat-bot/src/main/java/io/spine/chatbot/server/package-info.java b/google-chat-bot/src/main/java/io/spine/chatbot/server/package-info.java
new file mode 100644
index 00000000..bf59b25b
--- /dev/null
+++ b/google-chat-bot/src/main/java/io/spine/chatbot/server/package-info.java
@@ -0,0 +1,30 @@
+/*
+ * Copyright 2020, TeamDev. All rights reserved.
+ *
+ * Redistribution and use in source and/or binary forms, with or without
+ * modification, must retain the above copyright notice and the following
+ * disclaimer.
+ *
+ * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
+ * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
+ * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
+ * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
+ * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
+ * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
+ * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
+ * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
+ * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
+ * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+ * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+ */
+
+/**
+ * This package contains the ChatBot server.
+ */
+@CheckReturnValue
+@ParametersAreNonnullByDefault
+package io.spine.chatbot.server;
+
+import com.google.errorprone.annotations.CheckReturnValue;
+
+import javax.annotation.ParametersAreNonnullByDefault;
diff --git a/google-chat-bot/src/main/java/io/spine/chatbot/travis/BuildsQuery.java b/google-chat-bot/src/main/java/io/spine/chatbot/travis/BuildsQuery.java
new file mode 100644
index 00000000..4ffeeb7e
--- /dev/null
+++ b/google-chat-bot/src/main/java/io/spine/chatbot/travis/BuildsQuery.java
@@ -0,0 +1,48 @@
+/*
+ * Copyright 2020, TeamDev. All rights reserved.
+ *
+ * Redistribution and use in source and/or binary forms, with or without
+ * modification, must retain the above copyright notice and the following
+ * disclaimer.
+ *
+ * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
+ * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
+ * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
+ * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
+ * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
+ * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
+ * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
+ * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
+ * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
+ * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+ * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+ */
+
+package io.spine.chatbot.travis;
+
+import io.spine.chatbot.github.Slug;
+
+/**
+ * A branch builds query to the Travis CI API.
+ *
+ * @see Finding the branch build
+ */
+public final class BuildsQuery extends Query {
+
+ private BuildsQuery(String request) {
+ super(request, RepoBranchBuildResponse.class);
+ }
+
+ /**
+ * Creates a query for the {@code repository}.
+ *
+ * Requests the latest build from the {@code master} branch.
+ */
+ public static BuildsQuery forRepo(Slug repo) {
+ var encodedSlug = repo.encodedValue();
+ var request = "/repo/"
+ + encodedSlug
+ + "/branch/master?&include=build.commit,build.created_by";
+ return new BuildsQuery(request);
+ }
+}
diff --git a/google-chat-bot/src/main/java/io/spine/chatbot/travis/JsonProtoBodyHandler.java b/google-chat-bot/src/main/java/io/spine/chatbot/travis/JsonProtoBodyHandler.java
new file mode 100644
index 00000000..9ae2667e
--- /dev/null
+++ b/google-chat-bot/src/main/java/io/spine/chatbot/travis/JsonProtoBodyHandler.java
@@ -0,0 +1,65 @@
+/*
+ * Copyright 2020, TeamDev. All rights reserved.
+ *
+ * Redistribution and use in source and/or binary forms, with or without
+ * modification, must retain the above copyright notice and the following
+ * disclaimer.
+ *
+ * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
+ * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
+ * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
+ * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
+ * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
+ * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
+ * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
+ * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
+ * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
+ * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+ * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+ */
+
+package io.spine.chatbot.travis;
+
+import com.google.protobuf.Message;
+import io.spine.json.Json;
+
+import java.net.http.HttpResponse;
+import java.net.http.HttpResponse.BodySubscribers;
+import java.net.http.HttpResponse.ResponseInfo;
+import java.nio.charset.StandardCharsets;
+
+import static com.google.common.base.Preconditions.checkNotNull;
+
+/**
+ * Converts the incoming JSON strings into Protobuf messages relying on the Spine
+ * {@linkplain Json conversion functionality}.
+ *
+ * @param
+ * the Protobuf message supplied in the response body
+ */
+final class JsonProtoBodyHandler implements HttpResponse.BodyHandler {
+
+ private final Class type;
+
+ private JsonProtoBodyHandler(Class type) {
+ this.type = type;
+ }
+
+ /**
+ * Creates a body handler for a specified Protobuf message.
+ */
+ static JsonProtoBodyHandler jsonBodyHandler(Class type) {
+ checkNotNull(type);
+ return new JsonProtoBodyHandler<>(type);
+ }
+
+ @Override
+ public HttpResponse.BodySubscriber apply(ResponseInfo response) {
+ return BodySubscribers.mapping(BodySubscribers.ofString(StandardCharsets.UTF_8),
+ this::parseJson);
+ }
+
+ private T parseJson(String json) {
+ return Json.fromJson(json, type);
+ }
+}
diff --git a/google-chat-bot/src/main/java/io/spine/chatbot/travis/Query.java b/google-chat-bot/src/main/java/io/spine/chatbot/travis/Query.java
new file mode 100644
index 00000000..d197acce
--- /dev/null
+++ b/google-chat-bot/src/main/java/io/spine/chatbot/travis/Query.java
@@ -0,0 +1,87 @@
+/*
+ * Copyright 2020, TeamDev. All rights reserved.
+ *
+ * Redistribution and use in source and/or binary forms, with or without
+ * modification, must retain the above copyright notice and the following
+ * disclaimer.
+ *
+ * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
+ * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
+ * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
+ * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
+ * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
+ * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
+ * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
+ * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
+ * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
+ * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+ * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+ */
+
+package io.spine.chatbot.travis;
+
+import com.google.common.base.MoreObjects;
+import com.google.common.base.Objects;
+
+import static com.google.common.base.Preconditions.checkNotNull;
+
+/**
+ * A query to the Travis CI API.
+ *
+ * @param
+ * type of the expected query execution response
+ */
+abstract class Query {
+
+ private final Class responseType;
+ private final String request;
+
+ /**
+ * Creates a new API query with the specified {@code request}.
+ */
+ Query(String request, Class responseType) {
+ this.request = checkNotNull(request);
+ this.responseType = checkNotNull(responseType);
+ }
+
+ /**
+ * Returns the request URL to the REST endpoint.
+ */
+ final String request() {
+ return request;
+ }
+
+ /**
+ * Returns query response type.
+ */
+ final Class responseType() {
+ return responseType;
+ }
+
+ @Override
+ public boolean equals(Object o) {
+ if (this == o) {
+ return true;
+ }
+ if (!(o instanceof Query)) {
+ return false;
+ }
+ Query> query = (Query>) o;
+ return Objects.equal(responseType, query.responseType) &&
+ Objects.equal(request, query.request);
+ }
+
+ @Override
+ public int hashCode() {
+ return Objects.hashCode(responseType, request);
+ }
+
+ @Override
+ public String toString() {
+ return MoreObjects
+ .toStringHelper(this)
+ .add("request", request)
+ .add("responseType", responseType)
+ .toString();
+ }
+}
diff --git a/google-chat-bot/src/main/java/io/spine/chatbot/travis/ReposQuery.java b/google-chat-bot/src/main/java/io/spine/chatbot/travis/ReposQuery.java
new file mode 100644
index 00000000..8cf5513d
--- /dev/null
+++ b/google-chat-bot/src/main/java/io/spine/chatbot/travis/ReposQuery.java
@@ -0,0 +1,46 @@
+/*
+ * Copyright 2020, TeamDev. All rights reserved.
+ *
+ * Redistribution and use in source and/or binary forms, with or without
+ * modification, must retain the above copyright notice and the following
+ * disclaimer.
+ *
+ * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
+ * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
+ * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
+ * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
+ * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
+ * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
+ * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
+ * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
+ * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
+ * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+ * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+ */
+
+package io.spine.chatbot.travis;
+
+import io.spine.chatbot.github.Slug;
+
+/**
+ * A repositories query to the Travis CI API.
+ *
+ * @see
+ * Repos for owner
+ */
+public final class ReposQuery extends Query {
+
+ private ReposQuery(String request) {
+ super(request, RepositoriesResponse.class);
+ }
+
+ /**
+ * Creates a repository query for repositories of the specified {@code owner}
+ * (either a user or an organization).
+ */
+ public static ReposQuery forOwner(Slug owner) {
+ var encodedOwner = owner.encodedValue();
+ var request = "/owner/" + encodedOwner + "/repos";
+ return new ReposQuery(request);
+ }
+}
diff --git a/google-chat-bot/src/main/java/io/spine/chatbot/travis/Token.java b/google-chat-bot/src/main/java/io/spine/chatbot/travis/Token.java
new file mode 100644
index 00000000..236b0df8
--- /dev/null
+++ b/google-chat-bot/src/main/java/io/spine/chatbot/travis/Token.java
@@ -0,0 +1,54 @@
+/*
+ * Copyright 2020, TeamDev. All rights reserved.
+ *
+ * Redistribution and use in source and/or binary forms, with or without
+ * modification, must retain the above copyright notice and the following
+ * disclaimer.
+ *
+ * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
+ * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
+ * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
+ * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
+ * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
+ * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
+ * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
+ * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
+ * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
+ * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+ * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+ */
+
+package io.spine.chatbot.travis;
+
+import io.spine.chatbot.google.secret.Secret;
+
+import static io.spine.util.Preconditions2.checkNotEmptyOrBlank;
+
+/**
+ * A Travis CI API access token.
+ */
+final class Token extends Secret {
+
+ private static final String TRAVIS_API_TOKEN = "TravisApiToken";
+
+ private final String value;
+
+ private Token(String value) {
+ this.value = value;
+ }
+
+ /**
+ * Returns the token value.
+ */
+ String value() {
+ return value;
+ }
+
+ /**
+ * Creates the Travis CI API access token.
+ */
+ static Token privateToken() {
+ var value = checkNotEmptyOrBlank(retrieveSecret(TRAVIS_API_TOKEN));
+ return new Token(value);
+ }
+}
diff --git a/google-chat-bot/src/main/java/io/spine/chatbot/travis/Travis.java b/google-chat-bot/src/main/java/io/spine/chatbot/travis/Travis.java
new file mode 100644
index 00000000..4fe36729
--- /dev/null
+++ b/google-chat-bot/src/main/java/io/spine/chatbot/travis/Travis.java
@@ -0,0 +1,90 @@
+/*
+ * Copyright 2020, TeamDev. All rights reserved.
+ *
+ * Redistribution and use in source and/or binary forms, with or without
+ * modification, must retain the above copyright notice and the following
+ * disclaimer.
+ *
+ * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
+ * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
+ * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
+ * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
+ * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
+ * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
+ * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
+ * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
+ * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
+ * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+ * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+ */
+
+package io.spine.chatbot.travis;
+
+import io.spine.logging.Logging;
+
+import java.io.IOException;
+import java.net.URI;
+import java.net.http.HttpClient;
+import java.net.http.HttpRequest;
+
+import static com.google.api.client.util.Preconditions.checkNotNull;
+import static io.spine.chatbot.travis.JsonProtoBodyHandler.jsonBodyHandler;
+import static io.spine.util.Exceptions.newIllegalStateException;
+
+/**
+ * A client to the Travis CI REST API.
+ *
+ * @see Travis CI API
+ */
+final class Travis implements TravisClient, Logging {
+
+ private static final HttpClient CLIENT = HttpClient.newHttpClient();
+ private static final String BASE_URL = "https://api.travis-ci.com";
+ private static final String API_HEADER = "Travis-API-Version";
+ private static final String API_VERSION = "3";
+ private static final String AUTH_HEADER = "Authorization";
+
+ private final Token apiToken;
+
+ /**
+ * Creates a new Travis client with the specified API token.
+ */
+ Travis(Token apiToken) {
+ this.apiToken = checkNotNull(apiToken);
+ }
+
+ @Override
+ public T execute(Query query) {
+ var result = execute(query.request(), query.responseType());
+ return result;
+ }
+
+ private T execute(String request, Class responseType) {
+ var apiRequest = apiRequest(request, apiToken);
+ try {
+ _trace().log("Executing Travis API request `%s` for response `%s`.",
+ request, responseType.getSimpleName());
+ var result = CLIENT.send(apiRequest, jsonBodyHandler(responseType));
+ return result.body();
+ } catch (IOException | InterruptedException e) {
+ throw newIllegalStateException(
+ e, "Unable to query data for response of type '%s' using request '%s'.",
+ responseType, request
+ );
+ }
+ }
+
+ private static HttpRequest apiRequest(String request, Token token) {
+ return authorizedApiRequest(token)
+ .uri(URI.create(BASE_URL + request))
+ .build();
+ }
+
+ private static HttpRequest.Builder authorizedApiRequest(Token token) {
+ return HttpRequest
+ .newBuilder()
+ .GET()
+ .header(API_HEADER, API_VERSION)
+ .header(AUTH_HEADER, "token " + token.value());
+ }
+}
diff --git a/google-chat-bot/src/main/java/io/spine/chatbot/travis/TravisClient.java b/google-chat-bot/src/main/java/io/spine/chatbot/travis/TravisClient.java
new file mode 100644
index 00000000..dcd181be
--- /dev/null
+++ b/google-chat-bot/src/main/java/io/spine/chatbot/travis/TravisClient.java
@@ -0,0 +1,49 @@
+/*
+ * Copyright 2020, TeamDev. All rights reserved.
+ *
+ * Redistribution and use in source and/or binary forms, with or without
+ * modification, must retain the above copyright notice and the following
+ * disclaimer.
+ *
+ * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
+ * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
+ * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
+ * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
+ * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
+ * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
+ * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
+ * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
+ * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
+ * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+ * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+ */
+
+package io.spine.chatbot.travis;
+
+import static io.spine.chatbot.travis.Token.privateToken;
+
+/**
+ * A Travis CI API client.
+ *
+ * @see Travis CI API
+ */
+public interface TravisClient {
+
+ /**
+ * Executes the supplied {@code query} and returns the response of type {@code T}.
+ *
+ * @param query
+ * query to execute
+ * @param
+ * type of the query response
+ * @return query execution result
+ */
+ T execute(Query query);
+
+ /**
+ * Creates a new Travis client with the default Travis token.
+ */
+ static TravisClient newInstance() {
+ return new Travis(privateToken());
+ }
+}
diff --git a/google-chat-bot/src/main/java/io/spine/chatbot/travis/TravisResponse.java b/google-chat-bot/src/main/java/io/spine/chatbot/travis/TravisResponse.java
new file mode 100644
index 00000000..e4a1e665
--- /dev/null
+++ b/google-chat-bot/src/main/java/io/spine/chatbot/travis/TravisResponse.java
@@ -0,0 +1,29 @@
+/*
+ * Copyright 2020, TeamDev. All rights reserved.
+ *
+ * Redistribution and use in source and/or binary forms, with or without
+ * modification, must retain the above copyright notice and the following
+ * disclaimer.
+ *
+ * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
+ * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
+ * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
+ * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
+ * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
+ * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
+ * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
+ * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
+ * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
+ * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+ * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+ */
+
+package io.spine.chatbot.travis;
+
+import com.google.protobuf.Message;
+
+/**
+ * Travis CI API response marker.
+ */
+public interface TravisResponse extends Message {
+}
diff --git a/google-chat-bot/src/main/java/io/spine/chatbot/travis/package-info.java b/google-chat-bot/src/main/java/io/spine/chatbot/travis/package-info.java
new file mode 100644
index 00000000..b6a0bb63
--- /dev/null
+++ b/google-chat-bot/src/main/java/io/spine/chatbot/travis/package-info.java
@@ -0,0 +1,35 @@
+/*
+ * Copyright 2020, TeamDev. All rights reserved.
+ *
+ * Redistribution and use in source and/or binary forms, with or without
+ * modification, must retain the above copyright notice and the following
+ * disclaimer.
+ *
+ * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
+ * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
+ * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
+ * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
+ * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
+ * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
+ * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
+ * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
+ * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
+ * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+ * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+ */
+
+/**
+ * This package contains Travis CI v3 API client.
+ *
+ * The travis itself does not provide an idiomatic Java client, so the API contains only
+ * specific required functionality.
+ *
+ * @see Travis CI API
+ */
+@CheckReturnValue
+@ParametersAreNonnullByDefault
+package io.spine.chatbot.travis;
+
+import com.google.errorprone.annotations.CheckReturnValue;
+
+import javax.annotation.ParametersAreNonnullByDefault;
diff --git a/google-chat-bot/src/main/proto/spine/chatbot/github/identifiers.proto b/google-chat-bot/src/main/proto/spine/chatbot/github/identifiers.proto
new file mode 100644
index 00000000..9b28ac42
--- /dev/null
+++ b/google-chat-bot/src/main/proto/spine/chatbot/github/identifiers.proto
@@ -0,0 +1,48 @@
+/*
+ * Copyright 2020, TeamDev. All rights reserved.
+ *
+ * Redistribution and use in source and/or binary forms, with or without
+ * modification, must retain the above copyright notice and the following
+ * disclaimer.
+ *
+ * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
+ * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
+ * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
+ * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
+ * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
+ * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
+ * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
+ * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
+ * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
+ * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+ * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+ */
+
+syntax = "proto3";
+
+package spine.chatbot.github;
+
+import "spine/options.proto";
+
+option (type_url_prefix) = "type.spine.io.chatbot";
+option java_package = "io.spine.chatbot.github";
+option java_outer_classname = "IdentifiersProto";
+option java_multiple_files = true;
+option java_generate_equals_and_hash = true;
+
+// GitHub organization ID.
+message OrganizationId {
+
+ // The name of the GitHub organization.
+ string value = 1 [(required) = true];
+}
+
+// GitHub repository ID.
+message RepositoryId {
+
+ // The repository slug.
+ //
+ // E.g. `SpineEventEngine/base`. The slug is used as a human-friendly unique identifier.
+ //
+ string value = 1 [(required) = true];
+}
diff --git a/google-chat-bot/src/main/proto/spine/chatbot/github/organization.proto b/google-chat-bot/src/main/proto/spine/chatbot/github/organization.proto
new file mode 100644
index 00000000..26877789
--- /dev/null
+++ b/google-chat-bot/src/main/proto/spine/chatbot/github/organization.proto
@@ -0,0 +1,66 @@
+/*
+ * Copyright 2020, TeamDev. All rights reserved.
+ *
+ * Redistribution and use in source and/or binary forms, with or without
+ * modification, must retain the above copyright notice and the following
+ * disclaimer.
+ *
+ * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
+ * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
+ * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
+ * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
+ * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
+ * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
+ * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
+ * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
+ * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
+ * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+ * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+ */
+
+syntax = "proto3";
+
+package spine.chatbot.github;
+
+import "spine/options.proto";
+
+option (type_url_prefix) = "type.spine.io.chatbot";
+option java_package = "io.spine.chatbot.github.organization";
+option java_outer_classname = "OrganizationProto";
+option java_multiple_files = true;
+option java_generate_equals_and_hash = true;
+
+import "spine/net/url.proto";
+
+import "spine/chatbot/github/identifiers.proto";
+import "spine/chatbot/google/chat/identifiers.proto";
+
+// A GitHub organization.
+message Organization {
+ option (entity) = {kind: AGGREGATE visibility: QUERY};
+ option (is).java_type = "OrgHeaderAware";
+
+ OrganizationId id = 1;
+
+ // The organization header.
+ OrgHeader header = 2;
+}
+
+// The GitHub organization header.
+message OrgHeader {
+
+ // The name of the GitHub organization.
+ string name = 1 [(required) = true];
+
+ // The URL of the official organization-related website.
+ spine.net.Url website = 2;
+
+ // The URL of the organization GitHub profile.
+ spine.net.Url github_profile = 3 [(required) = true];
+
+ // The URL of the organization Travis CI profile.
+ spine.net.Url travis_profile = 4 [(required) = true];
+
+ // The Google Chat space associated with the organization.
+ google.chat.SpaceId space = 5 [(required) = true, (validate) = true];
+}
diff --git a/google-chat-bot/src/main/proto/spine/chatbot/github/organization_commands.proto b/google-chat-bot/src/main/proto/spine/chatbot/github/organization_commands.proto
new file mode 100644
index 00000000..85ce7644
--- /dev/null
+++ b/google-chat-bot/src/main/proto/spine/chatbot/github/organization_commands.proto
@@ -0,0 +1,44 @@
+/*
+ * Copyright 2020, TeamDev. All rights reserved.
+ *
+ * Redistribution and use in source and/or binary forms, with or without
+ * modification, must retain the above copyright notice and the following
+ * disclaimer.
+ *
+ * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
+ * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
+ * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
+ * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
+ * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
+ * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
+ * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
+ * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
+ * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
+ * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+ * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+ */
+
+syntax = "proto3";
+
+package spine.chatbot.github;
+
+import "spine/options.proto";
+
+option (type_url_prefix) = "type.spine.io.chatbot";
+option java_package = "io.spine.chatbot.github.organization.command";
+option java_outer_classname = "OrganizationCommandsProto";
+option java_multiple_files = true;
+option java_generate_equals_and_hash = true;
+
+import "spine/chatbot/github/identifiers.proto";
+import "spine/chatbot/github/organization.proto";
+
+// A request to register an organization.
+message RegisterOrganization {
+ option (is).java_type = "io.spine.chatbot.github.organization.OrgHeaderAware";
+
+ OrganizationId id = 1 [(required) = true];
+
+ // The organization header.
+ OrgHeader header = 2 [(required) = true, (validate) = true];
+}
diff --git a/google-chat-bot/src/main/proto/spine/chatbot/github/organization_events.proto b/google-chat-bot/src/main/proto/spine/chatbot/github/organization_events.proto
new file mode 100644
index 00000000..4a62e9d5
--- /dev/null
+++ b/google-chat-bot/src/main/proto/spine/chatbot/github/organization_events.proto
@@ -0,0 +1,44 @@
+/*
+ * Copyright 2020, TeamDev. All rights reserved.
+ *
+ * Redistribution and use in source and/or binary forms, with or without
+ * modification, must retain the above copyright notice and the following
+ * disclaimer.
+ *
+ * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
+ * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
+ * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
+ * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
+ * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
+ * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
+ * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
+ * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
+ * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
+ * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+ * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+ */
+
+syntax = "proto3";
+
+package spine.chatbot.github;
+
+import "spine/options.proto";
+
+option (type_url_prefix) = "type.spine.io.chatbot";
+option java_package = "io.spine.chatbot.github.organization.event";
+option java_outer_classname = "OrganizationEventsProto";
+option java_multiple_files = true;
+option java_generate_equals_and_hash = true;
+
+import "spine/chatbot/github/identifiers.proto";
+import "spine/chatbot/github/organization.proto";
+
+// An organization is registered.
+message OrganizationRegistered {
+ option (is).java_type = "io.spine.chatbot.github.organization.OrgHeaderAware";
+
+ OrganizationId organization = 1 [(required) = true];
+
+ // The organization header.
+ OrgHeader header = 2 [(required) = true, (validate) = true];
+}
diff --git a/google-chat-bot/src/main/proto/spine/chatbot/github/organization_init.proto b/google-chat-bot/src/main/proto/spine/chatbot/github/organization_init.proto
new file mode 100644
index 00000000..ca5d4f2b
--- /dev/null
+++ b/google-chat-bot/src/main/proto/spine/chatbot/github/organization_init.proto
@@ -0,0 +1,52 @@
+/*
+ * Copyright 2020, TeamDev. All rights reserved.
+ *
+ * Redistribution and use in source and/or binary forms, with or without
+ * modification, must retain the above copyright notice and the following
+ * disclaimer.
+ *
+ * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
+ * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
+ * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
+ * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
+ * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
+ * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
+ * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
+ * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
+ * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
+ * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+ * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+ */
+
+syntax = "proto3";
+
+package spine.chatbot.github;
+
+import "spine/options.proto";
+
+option (type_url_prefix) = "type.spine.io.chatbot";
+option java_package = "io.spine.chatbot.github.organization.init";
+option java_outer_classname = "OrganizationInitProto";
+option java_multiple_files = true;
+option java_generate_equals_and_hash = true;
+
+import "spine/chatbot/github/identifiers.proto";
+import "spine/chatbot/google/chat/identifiers.proto";
+
+// The initialization process of the default watched organization resources.
+//
+// Ensures that watched resources organization and its repositories are initialized
+// for a particular Chat space.
+//
+message OrganizationInit {
+ option (entity) = {kind: PROCESS_MANAGER visibility: NONE};
+
+ // The organization for which the initialization is performed.
+ OrganizationId organization = 1;
+
+ // The Google Chat space associated with the organization.
+ google.chat.SpaceId space = 2;
+
+ // Determines whether the organization resources are already initialized.
+ bool initialized = 3;
+}
diff --git a/google-chat-bot/src/main/proto/spine/chatbot/github/organization_init_commands.proto b/google-chat-bot/src/main/proto/spine/chatbot/github/organization_init_commands.proto
new file mode 100644
index 00000000..c9406565
--- /dev/null
+++ b/google-chat-bot/src/main/proto/spine/chatbot/github/organization_init_commands.proto
@@ -0,0 +1,47 @@
+/*
+ * Copyright 2020, TeamDev. All rights reserved.
+ *
+ * Redistribution and use in source and/or binary forms, with or without
+ * modification, must retain the above copyright notice and the following
+ * disclaimer.
+ *
+ * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
+ * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
+ * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
+ * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
+ * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
+ * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
+ * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
+ * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
+ * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
+ * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+ * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+ */
+
+syntax = "proto3";
+
+package spine.chatbot.github;
+
+import "spine/options.proto";
+
+option (type_url_prefix) = "type.spine.io.chatbot";
+option java_package = "io.spine.chatbot.github.organization.init.command";
+option java_outer_classname = "OrganizationInitCommandsProto";
+option java_multiple_files = true;
+option java_generate_equals_and_hash = true;
+
+import "spine/chatbot/github/identifiers.proto";
+import "spine/chatbot/google/chat/identifiers.proto";
+
+// A request to initialize watched organization resources.
+//
+// Registers the organization itself and the related repositories to be watched by the ChatBot.
+//
+message InitializeOrganization {
+
+ // The organization to perform initialization for.
+ OrganizationId organization = 1 [(required) = true];
+
+ // The Google Chat space associated with the organization.
+ google.chat.SpaceId space = 2 [(required) = true, (validate) = true];
+}
diff --git a/google-chat-bot/src/main/proto/spine/chatbot/github/organization_repositories.proto b/google-chat-bot/src/main/proto/spine/chatbot/github/organization_repositories.proto
new file mode 100644
index 00000000..0f366192
--- /dev/null
+++ b/google-chat-bot/src/main/proto/spine/chatbot/github/organization_repositories.proto
@@ -0,0 +1,43 @@
+/*
+ * Copyright 2020, TeamDev. All rights reserved.
+ *
+ * Redistribution and use in source and/or binary forms, with or without
+ * modification, must retain the above copyright notice and the following
+ * disclaimer.
+ *
+ * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
+ * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
+ * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
+ * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
+ * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
+ * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
+ * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
+ * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
+ * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
+ * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+ * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+ */
+
+syntax = "proto3";
+
+package spine.chatbot.github;
+
+import "spine/options.proto";
+
+option (type_url_prefix) = "type.spine.io.chatbot";
+option java_package = "io.spine.chatbot.github.organization";
+option java_outer_classname = "OrganizationRepositoriesProto";
+option java_multiple_files = true;
+option java_generate_equals_and_hash = true;
+
+import "spine/chatbot/github/identifiers.proto";
+
+// Lists repositories of an organization.
+message OrganizationRepositories {
+ option (entity) = {kind: PROJECTION visibility: FULL};
+
+ OrganizationId organization = 1;
+
+ // Linked organization repositories.
+ repeated RepositoryId repository = 2;
+}
diff --git a/google-chat-bot/src/main/proto/spine/chatbot/github/repository.proto b/google-chat-bot/src/main/proto/spine/chatbot/github/repository.proto
new file mode 100644
index 00000000..119e5015
--- /dev/null
+++ b/google-chat-bot/src/main/proto/spine/chatbot/github/repository.proto
@@ -0,0 +1,61 @@
+/*
+ * Copyright 2020, TeamDev. All rights reserved.
+ *
+ * Redistribution and use in source and/or binary forms, with or without
+ * modification, must retain the above copyright notice and the following
+ * disclaimer.
+ *
+ * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
+ * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
+ * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
+ * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
+ * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
+ * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
+ * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
+ * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
+ * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
+ * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+ * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+ */
+
+syntax = "proto3";
+
+package spine.chatbot.github;
+
+import "spine/options.proto";
+
+option (type_url_prefix) = "type.spine.io.chatbot";
+option java_package = "io.spine.chatbot.github.repository";
+option java_outer_classname = "RepositoryProto";
+option java_multiple_files = true;
+option java_generate_equals_and_hash = true;
+
+import "spine/net/url.proto";
+import "spine/chatbot/github/identifiers.proto";
+
+// A GitHub repository.
+message Repository {
+ option (entity) = {kind: AGGREGATE visibility: NONE};
+ option (is).java_type = "RepoHeaderAware";
+
+ RepositoryId id = 1;
+
+ // The repository header.
+ RepoHeader header = 2;
+}
+
+// The GitHub repository header.
+message RepoHeader {
+
+ // The name of the repository.
+ string name = 1 [(required) = true];
+
+ // The URL of the repository GitHub profile.
+ spine.net.Url github_profile = 2 [(required) = true];
+
+ // The URL of the Travis CI profile.
+ spine.net.Url travis_profile = 3 [(required) = true];
+
+ // The organization repository is related to, if any.
+ OrganizationId organization = 4;
+}
diff --git a/google-chat-bot/src/main/proto/spine/chatbot/github/repository_build.proto b/google-chat-bot/src/main/proto/spine/chatbot/github/repository_build.proto
new file mode 100644
index 00000000..c69ffbb6
--- /dev/null
+++ b/google-chat-bot/src/main/proto/spine/chatbot/github/repository_build.proto
@@ -0,0 +1,166 @@
+/*
+ * Copyright 2020, TeamDev. All rights reserved.
+ *
+ * Redistribution and use in source and/or binary forms, with or without
+ * modification, must retain the above copyright notice and the following
+ * disclaimer.
+ *
+ * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
+ * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
+ * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
+ * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
+ * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
+ * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
+ * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
+ * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
+ * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
+ * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+ * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+ */
+
+syntax = "proto3";
+
+package spine.chatbot.github;
+
+import "spine/options.proto";
+
+option (type_url_prefix) = "type.spine.io.chatbot";
+option java_package = "io.spine.chatbot.github.repository.build";
+option java_outer_classname = "RepositoryBuildProto";
+option java_multiple_files = true;
+option java_generate_equals_and_hash = true;
+
+
+import "google/protobuf/timestamp.proto";
+
+import "spine/net/url.proto";
+import "spine/chatbot/github/identifiers.proto";
+import "spine/chatbot/github/slug.proto";
+import "spine/chatbot/google/chat/identifiers.proto";
+
+// A build process of a GitHub repository.
+message RepositoryBuild {
+ option (entity) = {kind: PROCESS_MANAGER visibility: NONE};
+
+ RepositoryId repository = 1;
+
+ // The time of the last build status check.
+ .google.protobuf.Timestamp when_last_checked = 2;
+
+ // The current build.
+ Build build = 3;
+
+ // The current repository state.
+ Build.State current_state = 4 [(column) = true];
+}
+
+// A repository branch build.
+message Build {
+ option (is).java_type = "BuildStateMixin";
+
+ // Incremental number for a repository's builds.
+ string number = 1;
+
+ // Travis CI URL of the build.
+ spine.net.Url travis_ci_url = 2;
+
+ // Current state of the build.
+ State state = 3;
+
+ // State of the previous build.
+ State previous_state = 4;
+
+ // The build state.
+ enum State {
+ BS_UNKNOWN = 0;
+
+ // The build is created.
+ CREATED = 1;
+
+ // The build is received by the CI.
+ RECEIVED = 2;
+
+ // The build is in progress.
+ STARTED = 3;
+
+ // The build has passed successfully.
+ PASSED = 4;
+
+ // The build has failed.
+ //
+ // Denotes that the developer code could not be successfully built.
+ //
+ FAILED = 5;
+
+ // The build configuration has failed.
+ //
+ // Denotes that the build configuration or pre-build steps failed.
+ //
+ ERRORED = 6;
+
+ // The build is cancelled.
+ CANCELLED = 7;
+ }
+
+ // The branch the build is associated with.
+ string branch = 5;
+
+ // The commit the build is associated with.
+ Commit last_commit = 6;
+
+ // The User or Organization that created the build.
+ string created_by = 7;
+
+ // The repository slug the build is associated with.
+ Slug repository = 8;
+
+ // The Google Chat space associated with the organization.
+ google.chat.SpaceId space = 9 [(required) = true, (validate) = true];
+}
+
+// A git commit.
+message Commit {
+
+ // Checksum the commit has in git and is identified by.
+ string sha = 1;
+
+ // Commit message.
+ string message = 2;
+
+ // URL to the commit's diff on GitHub.
+ spine.net.Url compare_url = 3;
+
+ // Commit date from git.
+ string committed_at = 4;
+
+ // Commit author from git.
+ string authored_by = 5;
+}
+
+// Definition of a change in a `build` field.
+message BuildStateChange {
+
+ // The value of the field that's changing.
+ Build previous_value = 1;
+
+ // The new value of the field.
+ Build new_value = 2 [(required) = true];
+
+ // The type of the build state change.
+ enum Type {
+
+ BSCT_UNKNOWN = 0;
+
+ // The build has failed.
+ //
+ // It could be either a build configuration failure or the actual code build issue.
+ //
+ FAILED = 1;
+
+ // The build has recovered from the failed state.
+ RECOVERED = 2;
+
+ // The build is stable.
+ STABLE = 3;
+ }
+}
diff --git a/google-chat-bot/src/main/proto/spine/chatbot/github/repository_build_commands.proto b/google-chat-bot/src/main/proto/spine/chatbot/github/repository_build_commands.proto
new file mode 100644
index 00000000..a2658b3b
--- /dev/null
+++ b/google-chat-bot/src/main/proto/spine/chatbot/github/repository_build_commands.proto
@@ -0,0 +1,47 @@
+/*
+ * Copyright 2020, TeamDev. All rights reserved.
+ *
+ * Redistribution and use in source and/or binary forms, with or without
+ * modification, must retain the above copyright notice and the following
+ * disclaimer.
+ *
+ * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
+ * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
+ * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
+ * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
+ * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
+ * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
+ * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
+ * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
+ * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
+ * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+ * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+ */
+
+syntax = "proto3";
+
+package spine.chatbot.github;
+
+import "spine/options.proto";
+
+option (type_url_prefix) = "type.spine.io.chatbot";
+option java_package = "io.spine.chatbot.github.repository.build.command";
+option java_outer_classname = "RepositoryBuildCommandsProto";
+option java_multiple_files = true;
+option java_generate_equals_and_hash = true;
+
+import "spine/chatbot/github/identifiers.proto";
+import "spine/chatbot/google/chat/identifiers.proto";
+
+// Check repository CI build state command.
+message CheckRepositoryBuild {
+
+ // The repository to perform a check for.
+ RepositoryId repository = 1 [(required) = true];
+
+ // The organization the repository belongs to.
+ OrganizationId organization = 2 [(required) = true];
+
+ // The Google Chat space associated with the organization.
+ google.chat.SpaceId space = 3 [(required) = true, (validate) = true];
+}
diff --git a/google-chat-bot/src/main/proto/spine/chatbot/github/repository_build_events.proto b/google-chat-bot/src/main/proto/spine/chatbot/github/repository_build_events.proto
new file mode 100644
index 00000000..9a904d54
--- /dev/null
+++ b/google-chat-bot/src/main/proto/spine/chatbot/github/repository_build_events.proto
@@ -0,0 +1,69 @@
+/*
+ * Copyright 2020, TeamDev. All rights reserved.
+ *
+ * Redistribution and use in source and/or binary forms, with or without
+ * modification, must retain the above copyright notice and the following
+ * disclaimer.
+ *
+ * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
+ * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
+ * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
+ * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
+ * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
+ * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
+ * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
+ * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
+ * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
+ * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+ * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+ */
+
+syntax = "proto3";
+
+package spine.chatbot.github;
+
+import "spine/options.proto";
+
+option (type_url_prefix) = "type.spine.io.chatbot";
+option java_package = "io.spine.chatbot.github.repository.build.event";
+option java_outer_classname = "RepositoryBuildEventsProto";
+option java_multiple_files = true;
+option java_generate_equals_and_hash = true;
+
+option (every_is).java_type = "io.spine.chatbot.github.repository.RepositoryAwareEvent";
+
+import "spine/chatbot/github/identifiers.proto";
+import "spine/chatbot/github/repository_build.proto";
+
+// The build has failed for a repository.
+message BuildFailed {
+
+ RepositoryId repository = 1 [(required) = true];
+
+ // The change of the build state.
+ BuildStateChange change = 2 [(required) = true];
+}
+
+// The build has recovered from the failed state.
+message BuildRecovered {
+
+ RepositoryId repository = 1 [(required) = true];
+
+ // The change of the build state.
+ BuildStateChange change = 2 [(required) = true];
+}
+
+// The build is stable and passing.
+message BuildSucceededAgain {
+
+ RepositoryId repository = 1 [(required) = true];
+
+ // The change of the build state.
+ BuildStateChange change = 2 [(required) = true];
+}
+
+// The build is stable and passing.
+message BuildInProgress {
+
+ RepositoryId repository = 1 [(required) = true];
+}
diff --git a/google-chat-bot/src/main/proto/spine/chatbot/github/repository_build_rejections.proto b/google-chat-bot/src/main/proto/spine/chatbot/github/repository_build_rejections.proto
new file mode 100644
index 00000000..77a20878
--- /dev/null
+++ b/google-chat-bot/src/main/proto/spine/chatbot/github/repository_build_rejections.proto
@@ -0,0 +1,40 @@
+/*
+ * Copyright 2020, TeamDev. All rights reserved.
+ *
+ * Redistribution and use in source and/or binary forms, with or without
+ * modification, must retain the above copyright notice and the following
+ * disclaimer.
+ *
+ * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
+ * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
+ * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
+ * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
+ * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
+ * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
+ * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
+ * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
+ * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
+ * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+ * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+ */
+
+syntax = "proto3";
+
+package spine.chatbot.github;
+
+import "spine/options.proto";
+
+option (type_url_prefix) = "type.spine.io.chatbot";
+option java_package = "io.spine.chatbot.github.repository.build.rejection";
+option java_multiple_files = false;
+option java_generate_equals_and_hash = true;
+
+option (every_is).java_type = "io.spine.chatbot.github.repository.RepositoryAwareEvent";
+
+import "spine/chatbot/github/identifiers.proto";
+
+// No CI builds found for the repository.
+message NoBuildsFound {
+
+ RepositoryId repository = 1 [(required) = true];
+}
diff --git a/google-chat-bot/src/main/proto/spine/chatbot/github/repository_commands.proto b/google-chat-bot/src/main/proto/spine/chatbot/github/repository_commands.proto
new file mode 100644
index 00000000..7ee15b4e
--- /dev/null
+++ b/google-chat-bot/src/main/proto/spine/chatbot/github/repository_commands.proto
@@ -0,0 +1,44 @@
+/*
+ * Copyright 2020, TeamDev. All rights reserved.
+ *
+ * Redistribution and use in source and/or binary forms, with or without
+ * modification, must retain the above copyright notice and the following
+ * disclaimer.
+ *
+ * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
+ * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
+ * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
+ * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
+ * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
+ * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
+ * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
+ * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
+ * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
+ * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+ * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+ */
+
+syntax = "proto3";
+
+package spine.chatbot.github;
+
+import "spine/options.proto";
+
+option (type_url_prefix) = "type.spine.io.chatbot";
+option java_package = "io.spine.chatbot.github.repository.command";
+option java_outer_classname = "RepositoryCommandsProto";
+option java_multiple_files = true;
+option java_generate_equals_and_hash = true;
+
+import "spine/chatbot/github/identifiers.proto";
+import "spine/chatbot/github/repository.proto";
+
+// A request to register a repository.
+message RegisterRepository {
+ option (is).java_type = "io.spine.chatbot.github.repository.RepoHeaderAware";
+
+ RepositoryId id = 1 [(required) = true];
+
+ // The repository header.
+ RepoHeader header = 2 [(required) = true, (validate) = true];
+}
diff --git a/google-chat-bot/src/main/proto/spine/chatbot/github/repository_events.proto b/google-chat-bot/src/main/proto/spine/chatbot/github/repository_events.proto
new file mode 100644
index 00000000..d0a012c7
--- /dev/null
+++ b/google-chat-bot/src/main/proto/spine/chatbot/github/repository_events.proto
@@ -0,0 +1,44 @@
+/*
+ * Copyright 2020, TeamDev. All rights reserved.
+ *
+ * Redistribution and use in source and/or binary forms, with or without
+ * modification, must retain the above copyright notice and the following
+ * disclaimer.
+ *
+ * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
+ * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
+ * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
+ * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
+ * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
+ * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
+ * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
+ * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
+ * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
+ * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+ * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+ */
+
+syntax = "proto3";
+
+package spine.chatbot.github;
+
+import "spine/options.proto";
+
+option (type_url_prefix) = "type.spine.io.chatbot";
+option java_package = "io.spine.chatbot.github.repository.event";
+option java_outer_classname = "RepositoryEventsProto";
+option java_multiple_files = true;
+option java_generate_equals_and_hash = true;
+
+import "spine/chatbot/github/identifiers.proto";
+import "spine/chatbot/github/repository.proto";
+
+// A repository is registered.
+message RepositoryRegistered {
+ option (is).java_type = "io.spine.chatbot.github.repository.RepoHeaderAware";
+
+ RepositoryId repository = 1 [(required) = true];
+
+ // The repository header.
+ RepoHeader header = 2 [(required) = true, (validate) = true];
+}
diff --git a/google-chat-bot/src/main/proto/spine/chatbot/github/slug.proto b/google-chat-bot/src/main/proto/spine/chatbot/github/slug.proto
new file mode 100644
index 00000000..9a2b2e1e
--- /dev/null
+++ b/google-chat-bot/src/main/proto/spine/chatbot/github/slug.proto
@@ -0,0 +1,22 @@
+syntax = "proto3";
+
+package spine.chatbot.github;
+
+import "spine/options.proto";
+
+option (type_url_prefix) = "type.spine.io.chatbot";
+option java_package = "io.spine.chatbot.github";
+option java_outer_classname = "SlugProto";
+option java_multiple_files = true;
+option java_generate_equals_and_hash = true;
+
+// A unique human-readable identifier.
+message Slug {
+ option (is).java_type = "SlugMixin";
+
+ // The slug value.
+ //
+ // E.g. it could be a GitHub repository slug in the format `SpineEventEngine/base`.
+ //
+ string value = 1 [(required) = true];
+}
diff --git a/google-chat-bot/src/main/proto/spine/chatbot/google/chat/chat.proto b/google-chat-bot/src/main/proto/spine/chatbot/google/chat/chat.proto
new file mode 100644
index 00000000..be42b776
--- /dev/null
+++ b/google-chat-bot/src/main/proto/spine/chatbot/google/chat/chat.proto
@@ -0,0 +1,50 @@
+/*
+ * Copyright 2020, TeamDev. All rights reserved.
+ *
+ * Redistribution and use in source and/or binary forms, with or without
+ * modification, must retain the above copyright notice and the following
+ * disclaimer.
+ *
+ * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
+ * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
+ * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
+ * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
+ * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
+ * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
+ * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
+ * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
+ * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
+ * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+ * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+ */
+
+syntax = "proto3";
+
+package spine.chatbot.google.chat;
+
+import "spine/options.proto";
+
+option (type_url_prefix) = "type.spine.io.chatbot";
+option java_package = "io.spine.chatbot.google.chat";
+option java_outer_classname = "ChatProto";
+option java_multiple_files = true;
+option java_generate_equals_and_hash = true;
+
+import "spine/chatbot/google/chat/identifiers.proto";
+import "spine/chatbot/google/chat/thread.proto";
+
+// A build state update message that was sent to the Chat.
+message BuildStateUpdate {
+
+ // The message that was sent to the chat.
+ MessageId message = 1 [(required) = true];
+
+ // The space to which the message was sent.
+ SpaceId space = 2 [(required) = true];
+
+ // The thread to which the message was sent.
+ ThreadId thread = 3 [(required) = true];
+
+ // The resource name of the thread.
+ ThreadResource resource = 4 [(required) = true];
+}
diff --git a/google-chat-bot/src/main/proto/spine/chatbot/google/chat/chat_events.proto b/google-chat-bot/src/main/proto/spine/chatbot/google/chat/chat_events.proto
new file mode 100644
index 00000000..3874d5f9
--- /dev/null
+++ b/google-chat-bot/src/main/proto/spine/chatbot/google/chat/chat_events.proto
@@ -0,0 +1,58 @@
+/*
+ * Copyright 2020, TeamDev. All rights reserved.
+ *
+ * Redistribution and use in source and/or binary forms, with or without
+ * modification, must retain the above copyright notice and the following
+ * disclaimer.
+ *
+ * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
+ * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
+ * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
+ * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
+ * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
+ * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
+ * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
+ * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
+ * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
+ * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+ * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+ */
+
+syntax = "proto3";
+
+package spine.chatbot.google.chat;
+
+import "spine/options.proto";
+
+option (type_url_prefix) = "type.spine.io.chatbot";
+option java_package = "io.spine.chatbot.google.chat.event";
+option java_outer_classname = "ChatEventsProto";
+option java_multiple_files = true;
+option java_generate_equals_and_hash = true;
+
+import "spine/chatbot/google/chat/identifiers.proto";
+import "spine/chatbot/google/chat/thread.proto";
+
+// A message created in the space.
+message MessageCreated {
+
+ MessageId message = 1 [(required) = true];
+
+ // The space within which the message is created.
+ SpaceId space = 2 [(required) = true];
+
+ // The thread within which the message is created, if created in a threaded space.
+ ThreadId thread = 3;
+}
+
+// A recently created message created a new thread.
+message ThreadCreated {
+
+ ThreadId thread = 1 [(required) = true];
+
+ // Chat thread.
+ ThreadResource resource = 2 [(required) = true];
+
+ // The space within with the thread is available.
+ SpaceId space = 3 [(required) = true];
+}
diff --git a/google-chat-bot/src/main/proto/spine/chatbot/google/chat/identifiers.proto b/google-chat-bot/src/main/proto/spine/chatbot/google/chat/identifiers.proto
new file mode 100644
index 00000000..94d79846
--- /dev/null
+++ b/google-chat-bot/src/main/proto/spine/chatbot/google/chat/identifiers.proto
@@ -0,0 +1,56 @@
+/*
+ * Copyright 2020, TeamDev. All rights reserved.
+ *
+ * Redistribution and use in source and/or binary forms, with or without
+ * modification, must retain the above copyright notice and the following
+ * disclaimer.
+ *
+ * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
+ * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
+ * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
+ * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
+ * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
+ * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
+ * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
+ * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
+ * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
+ * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+ * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+ */
+
+syntax = "proto3";
+
+package spine.chatbot.google.chat;
+
+import "spine/options.proto";
+
+option (type_url_prefix) = "type.spine.io.chatbot";
+option java_package = "io.spine.chatbot.google.chat";
+option java_outer_classname = "IdentifiersProto";
+option java_multiple_files = true;
+option java_generate_equals_and_hash = true;
+
+// Chat Space identifier.
+message SpaceId {
+
+ // Resource name of the space, in the form `spaces/`.
+ string value = 1 [(required) = true, (pattern).regex = "spaces/.+"];
+}
+
+// Chat Room Thread identifier.
+message ThreadId {
+
+ // The thread identifier is linked to the topic discussed in the thread.
+ //
+ // E.g. if the thread is denoted to the build status of a GitHub repository,
+ // the GitHub repository slug could be used as the thread ID.
+ //
+ string value = 1 [(required) = true];
+}
+
+// Chat Message identifier.
+message MessageId {
+
+ // Resource name of the message, in the form `spaces//messages/`.
+ string value = 1 [(required) = true, (pattern).regex = "spaces/.+/messages/.+"];
+}
diff --git a/google-chat-bot/src/main/proto/spine/chatbot/google/chat/incoming/incoming_chat_events.proto b/google-chat-bot/src/main/proto/spine/chatbot/google/chat/incoming/incoming_chat_events.proto
new file mode 100644
index 00000000..1ce41363
--- /dev/null
+++ b/google-chat-bot/src/main/proto/spine/chatbot/google/chat/incoming/incoming_chat_events.proto
@@ -0,0 +1,51 @@
+syntax = "proto3";
+
+package spine.chatbot.google.chat.incoming;
+
+import "spine/options.proto";
+
+option (type_url_prefix) = "type.spine.io.chatbot";
+option java_package = "io.spine.chatbot.google.chat.incoming.event";
+option java_outer_classname = "IncomingChatEventsProto";
+option java_multiple_files = true;
+option java_generate_equals_and_hash = true;
+
+import "spine/chatbot/google/chat/identifiers.proto";
+import "spine/chatbot/google/chat/incoming/incoming_chat_messages.proto";
+
+// A new event received from the Chat.
+message ChatEventReceived {
+
+ // The received event.
+ ChatEvent event = 1 [(required) = true];
+}
+
+// The ChatBot is added to a Chat space.
+message BotAddedToSpace {
+
+ // The ID of the Space to which the ChatBot is added.
+ SpaceId space = 1 [(required) = true];
+
+ // The actual chat event message.
+ ChatEvent event = 2 [(required) = true];
+}
+
+// The ChatBot is removed from the Chat space.
+message BotRemovedFromSpace {
+
+ // The ID of the Space from which the ChatBot is removed.
+ SpaceId space = 1 [(required) = true];
+
+ // The actual chat event message.
+ ChatEvent event = 2 [(required) = true];
+}
+
+// The ChatBot received a new incoming Chat message.
+message MessageReceived {
+
+ // The ID of the new message.
+ MessageId message = 1 [(required) = true];
+
+ // The actual chat event message.
+ ChatEvent event = 2 [(required) = true];
+}
diff --git a/google-chat-bot/src/main/proto/spine/chatbot/google/chat/incoming/incoming_chat_messages.proto b/google-chat-bot/src/main/proto/spine/chatbot/google/chat/incoming/incoming_chat_messages.proto
new file mode 100644
index 00000000..150721ff
--- /dev/null
+++ b/google-chat-bot/src/main/proto/spine/chatbot/google/chat/incoming/incoming_chat_messages.proto
@@ -0,0 +1,212 @@
+/*
+ * Copyright 2020, TeamDev. All rights reserved.
+ *
+ * Redistribution and use in source and/or binary forms, with or without
+ * modification, must retain the above copyright notice and the following
+ * disclaimer.
+ *
+ * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
+ * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
+ * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
+ * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
+ * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
+ * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
+ * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
+ * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
+ * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
+ * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+ * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+ */
+
+syntax = "proto3";
+
+// This file contains the proto definitions that conform to the
+//
+// event formats of the Hangouts Chat.
+//
+// The layout of types and fields make them compatible with the Travis JSON output.
+// This way we don't have to create custom conversion.
+//
+
+package spine.chatbot.google.chat.incoming;
+
+import "spine/options.proto";
+
+option (type_url_prefix) = "type.spine.io.chatbot";
+option java_package = "io.spine.chatbot.google.chat.incoming";
+option java_outer_classname = "IncomingChatMessagesProto";
+option java_multiple_files = true;
+option java_generate_equals_and_hash = true;
+
+// An incoming Chat event.
+//
+// The definition conforms to the JSON representation defined in the
+// .
+//
+message ChatEvent {
+
+ // The type of the event
+ EventType type = 1 [(required) = true];
+
+ // The timestamp indicating when the event was dispatched.
+ string event_time = 2 [(required) = true];
+
+ // A secret value that bots can use to verify if a request is from Google.
+ //
+ // The token is randomly generated by Google, remains static, and can be obtained from
+ // the Chat API configuration page in the Cloud Console. Developers can revoke/regenerate
+ // it if needed from the same page.
+ //
+ string token = 3;
+
+ // The bot-defined key for the thread related to the event.
+ string thread_key = 4;
+
+ // The room or DM in which the event occurred.
+ Space space = 5;
+
+ // The message that triggered the event, if applicable.
+ Message message = 6;
+
+ // The user that triggered the event.
+ User user = 7 [(required) = true];
+}
+
+// A message in Chat.
+//
+// See
+// reference declaration for more details.
+//
+message Message {
+
+ // The resource name, in the form "spaces/*/messages/*".
+ string name = 1 [(required) = true, (pattern).regex = "spaces/.+/messages/.+"];
+
+ // The user who created the message.
+ User sender = 2;
+
+ // The time at which the message was created in Hangouts Chat server.
+ string create_time = 3;
+
+ // The plain-text body of the message.
+ string text = 4;
+
+ // The plain-text body of the message with all bot mentions stripped out.
+ string argument_text = 5;
+
+ // The thread the message belongs to.
+ Thread thread = 6;
+
+ // Annotations associated with the text in this message.
+ repeated Annotation annotations = 7;
+}
+
+// Annotation metadata of the message.
+message Annotation {
+
+ // The length of the substring in the plain-text message body this annotation corresponds to.
+ uint32 length = 1;
+
+ // The start index (0-based, inclusive) in the plain-text message body this annotation
+ // corresponds to.
+ //
+ uint32 start_index = 2;
+
+ // The metadata of user mention.
+ UserMention user_mention = 3;
+
+ // The type of the annotation.
+ string type = 4;
+}
+
+// Annotation metadata for user mentions (@).
+message UserMention {
+
+ // The type of user mention.
+ string type = 1;
+
+ // The user mentioned.
+ User user = 2;
+}
+
+// A thread in Chat.
+message Thread {
+
+ // Resource name, in the form "spaces/*/threads/*".
+ string name = 1 [(required) = true, (pattern).regex = "spaces/.+/threads/.+"];
+}
+
+// A room or DM in Chat.
+//
+// See
+// reference declaration for more details.
+//
+message Space {
+ option (is).java_type = "SpaceMixin";
+
+ // The resource name of the space, in the form "spaces/*".
+ string name = 1 [(required) = true, (pattern).regex = "spaces/.+"];
+
+ // The display name (only if the space is a room).
+ string display_name = 2;
+
+ // The type of a space
+ SpaceType type = 3;
+}
+
+// A user in Chat.
+//
+// See reference
+// declaration for more details.
+//
+message User {
+
+ // The resource name, in the format "users/*".
+ string name = 1 [(required) = true, (pattern).regex = "users/.+"];
+
+ // The user's display name.
+ string displayName = 2;
+
+ // The user's avatar URL.
+ string avatarUrl = 3;
+
+ // The user's email.
+ string email = 4;
+
+ // The user's type.
+ string type = 5;
+}
+
+// The type of a space.
+enum SpaceType {
+
+ ST_UNKNOWN = 0;
+
+ // Multi-user spaces such as rooms and DMs between humans.
+ ROOM = 1;
+
+ // 1:1 Direct Message between a human and a bot, where all messages are flat.
+ DM = 2;
+}
+
+// The type of the event the bot is receiving.
+//
+// See reference
+// declaration for more details.
+//
+enum EventType {
+
+ ET_UNKNOWN = 0;
+
+ // A message was sent in a room or direct message.
+ MESSAGE = 1;
+
+ // The bot was added to a room or DM.
+ ADDED_TO_SPACE = 2;
+
+ // The bot was removed from a room or DM.
+ REMOVED_FROM_SPACE = 3;
+
+ // The bot's interactive card was clicked.
+ CARD_CLICKED = 4;
+}
diff --git a/google-chat-bot/src/main/proto/spine/chatbot/google/chat/space.proto b/google-chat-bot/src/main/proto/spine/chatbot/google/chat/space.proto
new file mode 100644
index 00000000..2f8d080b
--- /dev/null
+++ b/google-chat-bot/src/main/proto/spine/chatbot/google/chat/space.proto
@@ -0,0 +1,57 @@
+/*
+ * Copyright 2020, TeamDev. All rights reserved.
+ *
+ * Redistribution and use in source and/or binary forms, with or without
+ * modification, must retain the above copyright notice and the following
+ * disclaimer.
+ *
+ * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
+ * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
+ * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
+ * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
+ * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
+ * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
+ * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
+ * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
+ * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
+ * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+ * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+ */
+
+syntax = "proto3";
+
+package spine.chatbot.google.chat;
+
+import "spine/options.proto";
+
+option (type_url_prefix) = "type.spine.io.chatbot";
+option java_package = "io.spine.chatbot.google.chat";
+option java_outer_classname = "SpaceProto";
+option java_multiple_files = true;
+option java_generate_equals_and_hash = true;
+
+import "spine/chatbot/google/chat/identifiers.proto";
+
+// A room or DM in Chat.
+message Space {
+ option (entity) = {kind: AGGREGATE visibility: NONE};
+ option (is).java_type = "SpaceHeaderAware";
+
+ SpaceId id = 1;
+
+ // The space header.
+ SpaceHeader header = 2;
+}
+
+// The Chat space header.
+message SpaceHeader {
+
+ // Whether the space is a DM between a bot and a single human.
+ bool single_user_bot_dm = 1;
+
+ // Whether the messages are threaded in this space.
+ bool threaded = 2;
+
+ // The display name (only if the space is a room).
+ string display_name = 3;
+}
diff --git a/google-chat-bot/src/main/proto/spine/chatbot/google/chat/space_commands.proto b/google-chat-bot/src/main/proto/spine/chatbot/google/chat/space_commands.proto
new file mode 100644
index 00000000..c56b9224
--- /dev/null
+++ b/google-chat-bot/src/main/proto/spine/chatbot/google/chat/space_commands.proto
@@ -0,0 +1,44 @@
+/*
+ * Copyright 2020, TeamDev. All rights reserved.
+ *
+ * Redistribution and use in source and/or binary forms, with or without
+ * modification, must retain the above copyright notice and the following
+ * disclaimer.
+ *
+ * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
+ * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
+ * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
+ * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
+ * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
+ * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
+ * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
+ * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
+ * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
+ * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+ * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+ */
+
+syntax = "proto3";
+
+package spine.chatbot.google.chat;
+
+import "spine/options.proto";
+
+option (type_url_prefix) = "type.spine.io.chatbot";
+option java_package = "io.spine.chatbot.google.chat.command";
+option java_outer_classname = "SpaceCommandsProto";
+option java_multiple_files = true;
+option java_generate_equals_and_hash = true;
+
+import "spine/chatbot/google/chat/identifiers.proto";
+import "spine/chatbot/google/chat/space.proto";
+
+// A request to register a Chat space.
+message RegisterSpace {
+ option (is).java_type = "io.spine.chatbot.google.chat.SpaceHeaderAware";
+
+ SpaceId id = 1 [(required) = true];
+
+ // The space header.
+ SpaceHeader header = 2 [(required) = true];
+}
diff --git a/google-chat-bot/src/main/proto/spine/chatbot/google/chat/space_events.proto b/google-chat-bot/src/main/proto/spine/chatbot/google/chat/space_events.proto
new file mode 100644
index 00000000..e2d6421e
--- /dev/null
+++ b/google-chat-bot/src/main/proto/spine/chatbot/google/chat/space_events.proto
@@ -0,0 +1,44 @@
+/*
+ * Copyright 2020, TeamDev. All rights reserved.
+ *
+ * Redistribution and use in source and/or binary forms, with or without
+ * modification, must retain the above copyright notice and the following
+ * disclaimer.
+ *
+ * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
+ * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
+ * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
+ * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
+ * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
+ * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
+ * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
+ * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
+ * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
+ * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+ * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+ */
+
+syntax = "proto3";
+
+package spine.chatbot.google.chat;
+
+import "spine/options.proto";
+
+option (type_url_prefix) = "type.spine.io.chatbot";
+option java_package = "io.spine.chatbot.google.chat.event";
+option java_outer_classname = "SpaceEventsProto";
+option java_multiple_files = true;
+option java_generate_equals_and_hash = true;
+
+import "spine/chatbot/google/chat/identifiers.proto";
+import "spine/chatbot/google/chat/space.proto";
+
+// A new Chat space registered.
+message SpaceRegistered {
+ option (is).java_type = "io.spine.chatbot.google.chat.SpaceHeaderAware";
+
+ SpaceId space = 1 [(required) = true];
+
+ // The space header.
+ SpaceHeader header = 2 [(required) = true];
+}
diff --git a/google-chat-bot/src/main/proto/spine/chatbot/google/chat/thread.proto b/google-chat-bot/src/main/proto/spine/chatbot/google/chat/thread.proto
new file mode 100644
index 00000000..e0ad8ad4
--- /dev/null
+++ b/google-chat-bot/src/main/proto/spine/chatbot/google/chat/thread.proto
@@ -0,0 +1,56 @@
+/*
+ * Copyright 2020, TeamDev. All rights reserved.
+ *
+ * Redistribution and use in source and/or binary forms, with or without
+ * modification, must retain the above copyright notice and the following
+ * disclaimer.
+ *
+ * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
+ * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
+ * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
+ * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
+ * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
+ * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
+ * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
+ * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
+ * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
+ * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+ * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+ */
+
+syntax = "proto3";
+
+package spine.chatbot.google.chat;
+
+import "spine/options.proto";
+
+option (type_url_prefix) = "type.spine.io.chatbot";
+option java_package = "io.spine.chatbot.google.chat.thread";
+option java_outer_classname = "ThreadProto";
+option java_multiple_files = true;
+option java_generate_equals_and_hash = true;
+
+import "spine/chatbot/google/chat/identifiers.proto";
+
+// A thread in a room.
+message Thread {
+ option (entity) = {kind: AGGREGATE visibility: NONE};
+
+ ThreadId id = 1;
+
+ // The resource name of the thread.
+ ThreadResource resource = 2;
+
+ // The space within with the thread is available.
+ SpaceId space = 3;
+
+ // Messages posted by the bot to the thread.
+ repeated MessageId message = 4;
+}
+
+// Chat Thread resource.
+message ThreadResource {
+
+ // The resource name of the thread, in the form `spaces//threads/`.
+ string name = 1 [(required) = true, (pattern).regex = "spaces/.+/threads/.+"];
+}
diff --git a/google-chat-bot/src/main/proto/spine/chatbot/google/chat/thread_chat.proto b/google-chat-bot/src/main/proto/spine/chatbot/google/chat/thread_chat.proto
new file mode 100644
index 00000000..c5fa0890
--- /dev/null
+++ b/google-chat-bot/src/main/proto/spine/chatbot/google/chat/thread_chat.proto
@@ -0,0 +1,50 @@
+/*
+ * Copyright 2020, TeamDev. All rights reserved.
+ *
+ * Redistribution and use in source and/or binary forms, with or without
+ * modification, must retain the above copyright notice and the following
+ * disclaimer.
+ *
+ * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
+ * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
+ * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
+ * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
+ * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
+ * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
+ * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
+ * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
+ * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
+ * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+ * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+ */
+
+syntax = "proto3";
+
+package spine.chatbot.google.chat;
+
+import "spine/options.proto";
+
+option (type_url_prefix) = "type.spine.io.chatbot";
+option java_package = "io.spine.chatbot.google.chat.thread";
+option java_outer_classname = "ThreadChatProto";
+option java_multiple_files = true;
+option java_generate_equals_and_hash = true;
+
+import "spine/chatbot/google/chat/identifiers.proto";
+import "spine/chatbot/google/chat/thread.proto";
+
+// A thread chatting process.
+//
+// Acknowledges incoming events and publishes messages to a respective thread if needed.
+//
+message ThreadChat {
+ option (entity) = {kind: PROCESS_MANAGER visibility: FULL};
+
+ ThreadId thread = 1;
+
+ // Chat thread.
+ ThreadResource resource = 2;
+
+ // Space within with the thread is available.
+ SpaceId space = 3;
+}
diff --git a/google-chat-bot/src/main/proto/spine/chatbot/google/chat/thread_events.proto b/google-chat-bot/src/main/proto/spine/chatbot/google/chat/thread_events.proto
new file mode 100644
index 00000000..ac0e2686
--- /dev/null
+++ b/google-chat-bot/src/main/proto/spine/chatbot/google/chat/thread_events.proto
@@ -0,0 +1,55 @@
+/*
+ * Copyright 2020, TeamDev. All rights reserved.
+ *
+ * Redistribution and use in source and/or binary forms, with or without
+ * modification, must retain the above copyright notice and the following
+ * disclaimer.
+ *
+ * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
+ * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
+ * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
+ * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
+ * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
+ * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
+ * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
+ * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
+ * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
+ * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+ * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+ */
+
+syntax = "proto3";
+
+package spine.chatbot.google.chat;
+
+import "spine/options.proto";
+
+option (type_url_prefix) = "type.spine.io.chatbot";
+option java_package = "io.spine.chatbot.google.chat.thread.event";
+option java_outer_classname = "ThreadEventsProto";
+option java_multiple_files = true;
+option java_generate_equals_and_hash = true;
+
+import "spine/chatbot/google/chat/identifiers.proto";
+import "spine/chatbot/google/chat/thread.proto";
+
+// A Chat thread initialized.
+message ThreadInitialized {
+
+ ThreadId thread = 1 [(required) = true];
+
+ // Chat thread.
+ ThreadResource resource = 2 [(required) = true];
+
+ // Space within which the thread is available.
+ SpaceId space = 3 [(required) = true];
+}
+
+// A message added to the thead.
+message MessageAdded {
+
+ MessageId message = 1 [(required) = true];
+
+ // Thread to which the message is added.
+ ThreadId thread = 2 [(required) = true];
+}
diff --git a/google-chat-bot/src/main/proto/spine/chatbot/travis/travis.proto b/google-chat-bot/src/main/proto/spine/chatbot/travis/travis.proto
new file mode 100644
index 00000000..dc4af61e
--- /dev/null
+++ b/google-chat-bot/src/main/proto/spine/chatbot/travis/travis.proto
@@ -0,0 +1,199 @@
+/*
+ * Copyright 2020, TeamDev. All rights reserved.
+ *
+ * Redistribution and use in source and/or binary forms, with or without
+ * modification, must retain the above copyright notice and the following
+ * disclaimer.
+ *
+ * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
+ * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
+ * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
+ * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
+ * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
+ * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
+ * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
+ * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
+ * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
+ * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+ * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+ */
+
+syntax = "proto3";
+
+// This file contains the proto definitions that conform to the
+// Travis CI API v3 data types.
+//
+// The layout of types and fields make them compatible with the Travis JSON output.
+// This way we don't have to create custom conversion.
+//
+
+package spine.chatbot.travis;
+
+import "spine/options.proto";
+
+option (type_url_prefix) = "type.spine.io.chatbot";
+option java_package = "io.spine.chatbot.travis";
+option java_outer_classname = "TravisCiApiProto";
+option java_multiple_files = true;
+option java_generate_equals_and_hash = true;
+
+// The owner of a resource.
+//
+// This will be either a user or an organization.
+// See reference declaration
+// for more details.
+//
+message Owner {
+
+ // Value uniquely identifying the owner.
+ uint64 id = 1;
+
+ // User or organization login set on GitHub.
+ string login = 2;
+}
+
+// An individual repository.
+//
+// See reference
+// declaration for more details.
+//
+message Repository {
+
+ // Value uniquely identifying the repository.
+ uint64 id = 1;
+
+ // The repository's name on GitHub.
+ string name = 2;
+
+ // The repository's slug.
+ //
+ // Same as {repository.owner.name}/{repository.name}.
+ //
+ string slug = 3;
+}
+
+// The branch of a repository.
+//
+// See reference declaration
+// for more details.
+//
+message Branch {
+
+ // Name of the git branch.
+ string name = 1;
+}
+
+// Commit information is obtained by requesting a build.
+//
+// See reference declaration
+// for more details.
+//
+message Commit {
+
+ // Value uniquely identifying the commit.
+ uint64 id = 1;
+
+ // Checksum the commit has in git and is identified by.
+ string sha = 2;
+
+ // Named reference the commit has in git.
+ string ref = 3;
+
+ // Commit message.
+ string message = 4;
+
+ // URL to the commit's diff on GitHub.
+ string compare_url = 5;
+
+ // Commit date from git.
+ string committed_at = 6;
+
+ // Commit author.
+ Author author = 7;
+}
+
+// Git commit author information.
+message Author {
+
+ // Git name of the commit author.
+ string name = 1;
+}
+
+// Minimal Travis CI build representation.
+//
+// See reference declaration
+// for more details.
+//
+message Build {
+
+ // Value uniquely identifying the build.
+ uint64 id = 1;
+
+ // Incremental number for a repository's builds.
+ string number = 2;
+
+ // Current state of the build.
+ string state = 3;
+
+ // Wall clock time in seconds.
+ uint64 duration = 4;
+
+ // Event that triggered the build.
+ string event_type = 5;
+
+ // State of the previous build.
+ string previous_state = 6;
+
+ // The repository the build is associated with.
+ Repository repository = 7;
+
+ // The branch the build is associated with.
+ Branch branch = 8;
+
+ // The build's tag.
+ string tag = 9;
+
+ // The commit the build is associated with.
+ Commit commit = 10;
+
+ // The User or Organization that created the build.
+ Owner created_by = 11;
+}
+
+// A Travis `branch` API endpoint response.
+//
+// See API reference for more
+// details.
+//
+message RepoBranchBuildResponse {
+
+ option (is).java_type = "TravisResponse";
+
+ // Name of the git branch.
+ string name = 1;
+
+ // The repository.
+ Repository repository = 2;
+
+ // Whether or not this is the repository's default branch.
+ bool default_branch = 3;
+
+ // Whether or not the branch still exists on GitHub.
+ bool exists_on_github = 4;
+
+ // Last build on the branch.
+ Build last_build = 5;
+}
+
+// A Travis `repos` API endpoint response.
+//
+// See API reference
+// for more details.
+//
+message RepositoriesResponse {
+
+ option (is).java_type = "TravisResponse";
+
+ // Repositories fetched by the API call.
+ repeated Repository repositories = 1;
+}
diff --git a/google-chat-bot/src/main/resources/application.yml b/google-chat-bot/src/main/resources/application.yml
new file mode 100644
index 00000000..b554a48e
--- /dev/null
+++ b/google-chat-bot/src/main/resources/application.yml
@@ -0,0 +1,23 @@
+#
+# Copyright 2020, TeamDev. All rights reserved.
+#
+# Redistribution and use in source and/or binary forms, with or without
+# modification, must retain the above copyright notice and the following
+# disclaimer.
+#
+# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
+# "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
+# LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
+# A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
+# OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
+# SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
+# LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
+# DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
+# THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
+# (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+#
+
+micronaut:
+ application:
+ name: ChatBot
diff --git a/google-chat-bot/src/main/resources/index.yaml b/google-chat-bot/src/main/resources/index.yaml
new file mode 100644
index 00000000..ffa8850d
--- /dev/null
+++ b/google-chat-bot/src/main/resources/index.yaml
@@ -0,0 +1,94 @@
+# This file is the configuration of the Cloud Datastore DB indexes.
+# To update the index, modify the file and run:
+#
+# $ gcloud datastore indexes create google-chat-bot/src/main/resources/index.yaml --project
+#
+
+indexes:
+
+ # Common index for all the applications.
+ - kind: spine.core.Event
+ ancestor: no
+ properties:
+ - name: type
+ - name: created
+
+ # Index required for `DsInboxStorage.readAll` query.
+
+ - kind: spine.server.delivery.InboxMessage
+ ancestor: yes
+ properties:
+ - name: inbox_shard
+ - name: of_total_inbox_shards
+ - name: received_at
+ - name: version
+
+ - kind: spine.server.delivery.InboxMessage
+ properties:
+ - name: inbox_shard
+ - name: of_total_inbox_shards
+ - name: received_at
+ - name: version
+
+ # Index required for `DsInboxStorage.newestMessageToDeliver` query.
+
+ - kind: spine.server.delivery.InboxMessage
+ ancestor: yes
+ properties:
+ - name: inbox_shard
+ - name: of_total_inbox_shards
+ - name: status
+
+ - kind: spine.system.server.CommandLifecycle
+ properties:
+ - name: aggregate_id
+ - name: version
+ direction: desc
+ - name: created
+ direction: desc
+ - name: snapshot
+
+ - kind: spine.system.server.EntityHistory
+ properties:
+ - name: aggregate_id
+ - name: version
+ direction: desc
+ - name: created
+ direction: desc
+ - name: snapshot
+
+ - kind: spine.chatbot.github.Organization
+ properties:
+ - name: aggregate_id
+ - name: version
+ direction: desc
+ - name: created
+ direction: desc
+ - name: snapshot
+
+ - kind: spine.chatbot.github.Repository
+ properties:
+ - name: aggregate_id
+ - name: version
+ direction: desc
+ - name: created
+ direction: desc
+ - name: snapshot
+
+ - kind: spine.chatbot.google.chat.Space
+ properties:
+ - name: aggregate_id
+ - name: version
+ direction: desc
+ - name: created
+ direction: desc
+ - name: snapshot
+
+ - kind: spine.chatbot.google.chat.Thread
+ properties:
+ - name: aggregate_id
+ - name: version
+ direction: desc
+ - name: created
+ direction: desc
+ - name: snapshot
diff --git a/google-chat-bot/src/main/resources/log4j2.xml b/google-chat-bot/src/main/resources/log4j2.xml
new file mode 100644
index 00000000..9e22026e
--- /dev/null
+++ b/google-chat-bot/src/main/resources/log4j2.xml
@@ -0,0 +1,35 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/google-chat-bot/src/test/java/io/spine/chatbot/CanFailFast.java b/google-chat-bot/src/test/java/io/spine/chatbot/CanFailFast.java
new file mode 100644
index 00000000..f2be68d2
--- /dev/null
+++ b/google-chat-bot/src/test/java/io/spine/chatbot/CanFailFast.java
@@ -0,0 +1,80 @@
+/*
+ * Copyright 2020, TeamDev. All rights reserved.
+ *
+ * Redistribution and use in source and/or binary forms, with or without
+ * modification, must retain the above copyright notice and the following
+ * disclaimer.
+ *
+ * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
+ * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
+ * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
+ * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
+ * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
+ * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
+ * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
+ * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
+ * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
+ * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+ * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+ */
+
+package io.spine.chatbot;
+
+import io.spine.logging.Logging;
+import org.checkerframework.checker.nullness.qual.NonNull;
+import org.checkerframework.checker.nullness.qual.Nullable;
+
+import static io.spine.util.Exceptions.newIllegalStateException;
+
+/**
+ * An abstract API that exposes the {@code fail-fast} concept.
+ */
+public abstract class CanFailFast implements Logging {
+
+ /**
+ * Determines whether the client should fail if a particular response is not preconfigured.
+ */
+ private final boolean failFast;
+
+ /**
+ * Creates a new client with the specified {@code failFast} behavior.
+ */
+ protected CanFailFast(boolean failFast) {
+ this.failFast = failFast;
+ }
+
+ /**
+ * Applies the fail-fast approach to the supplied value if the client is configured so,
+ * otherwise returns the {@code defaultValue} if the supplied {@code value} is {@code null}.
+ *
+ * @param value
+ * the value under check
+ * @param key
+ * the key using which the {@code value} was obtained
+ * @param defaultValue
+ * the default value to be used if the client is not using the fail-fast approach and
+ * the value is {@code null}
+ * @param
+ * the type of the key the API client was called with
+ * @param
+ * the type of the value the API client is expected to return
+ * @throws IllegalStateException
+ * if the client is configured to use the fail-fast approach and the supplied
+ * {@code value} is {@code null}
+ */
+ protected @NonNull V failOrDefault(@Nullable V value,
+ @NonNull K key,
+ @NonNull V defaultValue) {
+ if (failFast && value == null) {
+ throw newIllegalStateException(
+ "Response of type `%s` is not configured for the key `%s`.",
+ defaultValue.getClass()
+ .getSimpleName(), String.valueOf(key)
+ );
+ }
+ if (!failFast && value == null) {
+ return defaultValue;
+ }
+ return value;
+ }
+}
diff --git a/google-chat-bot/src/test/java/io/spine/chatbot/IncomingEventsControllerTest.java b/google-chat-bot/src/test/java/io/spine/chatbot/IncomingEventsControllerTest.java
new file mode 100644
index 00000000..88bff3cd
--- /dev/null
+++ b/google-chat-bot/src/test/java/io/spine/chatbot/IncomingEventsControllerTest.java
@@ -0,0 +1,99 @@
+/*
+ * Copyright 2020, TeamDev. All rights reserved.
+ *
+ * Redistribution and use in source and/or binary forms, with or without
+ * modification, must retain the above copyright notice and the following
+ * disclaimer.
+ *
+ * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
+ * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
+ * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
+ * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
+ * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
+ * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
+ * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
+ * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
+ * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
+ * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+ * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+ */
+
+package io.spine.chatbot;
+
+import com.google.common.io.Resources;
+import com.google.protobuf.ByteString;
+import com.google.pubsub.v1.PubsubMessage;
+import io.micronaut.http.MediaType;
+import io.micronaut.http.client.HttpClient;
+import io.micronaut.http.client.annotation.Client;
+import io.micronaut.test.annotation.MicronautTest;
+import io.spine.chatbot.google.chat.InMemoryGoogleChatClient;
+import io.spine.chatbot.server.Server;
+import io.spine.chatbot.server.github.GitHubContext;
+import io.spine.chatbot.server.google.chat.GoogleChatContext;
+import io.spine.chatbot.travis.InMemoryTravisClient;
+import io.spine.json.Json;
+import io.spine.pubsub.PubsubPushRequest;
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+
+import javax.inject.Inject;
+import java.io.IOException;
+import java.nio.charset.StandardCharsets;
+
+import static io.micronaut.http.HttpRequest.POST;
+import static io.spine.util.Exceptions.newIllegalStateException;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+
+@MicronautTest
+@DisplayName("`IncomingEventsController` should")
+final class IncomingEventsControllerTest {
+
+ @Inject
+ @Client("/")
+ private HttpClient client;
+
+ @BeforeAll
+ static void setupServer() {
+ var chatContext = GoogleChatContext
+ .newBuilder()
+ .setClient(InMemoryGoogleChatClient.lenientClient())
+ .build();
+ var gitHubContext = GitHubContext
+ .newBuilder()
+ .setTravis(InMemoryTravisClient.lenientClient())
+ .build();
+ Server.withContexts(chatContext, gitHubContext)
+ .start();
+ }
+
+ @Test
+ @DisplayName("receive and decode a Pub/Sub message with Google Chat event")
+ void receiveAndDecode() {
+ var pubsubMessage = PubsubMessage
+ .newBuilder()
+ .setMessageId("129y418y4houfhiuehwr")
+ .setData(ByteString.copyFromUtf8(chatEventJson()))
+ .build();
+ var pushRequest = PubsubPushRequest
+ .newBuilder()
+ .setMessage(pubsubMessage)
+ .setSubscription("projects/test-project/subscriptions/test-subscription")
+ .vBuild();
+ var request = POST("/chat/incoming/event", Json.toJson(pushRequest))
+ .contentType(MediaType.APPLICATION_JSON);
+ String actual = client.toBlocking()
+ .retrieve(request);
+ assertEquals("OK", actual);
+ }
+
+ private static String chatEventJson() {
+ try {
+ var resource = Resources.getResource("chat_event.json");
+ return Resources.toString(resource, StandardCharsets.UTF_8);
+ } catch (IOException e) {
+ throw newIllegalStateException(e, "Unable to load ChatEvent message JSON definition.");
+ }
+ }
+}
diff --git a/google-chat-bot/src/test/java/io/spine/chatbot/PubsubPushRequestDeserializerTest.java b/google-chat-bot/src/test/java/io/spine/chatbot/PubsubPushRequestDeserializerTest.java
new file mode 100644
index 00000000..96753796
--- /dev/null
+++ b/google-chat-bot/src/test/java/io/spine/chatbot/PubsubPushRequestDeserializerTest.java
@@ -0,0 +1,82 @@
+/*
+ * Copyright 2020, TeamDev. All rights reserved.
+ *
+ * Redistribution and use in source and/or binary forms, with or without
+ * modification, must retain the above copyright notice and the following
+ * disclaimer.
+ *
+ * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
+ * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
+ * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
+ * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
+ * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
+ * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
+ * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
+ * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
+ * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
+ * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+ * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+ */
+
+package io.spine.chatbot;
+
+import com.fasterxml.jackson.core.JsonProcessingException;
+import com.google.common.io.Resources;
+import com.google.common.truth.extensions.proto.ProtoTruth;
+import com.google.protobuf.ByteString;
+import com.google.protobuf.util.Timestamps;
+import com.google.pubsub.v1.PubsubMessage;
+import io.micronaut.jackson.ObjectMapperFactory;
+import io.micronaut.test.annotation.MicronautTest;
+import io.spine.pubsub.PubsubPushRequest;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+
+import javax.inject.Inject;
+import java.io.IOException;
+import java.nio.charset.StandardCharsets;
+import java.text.ParseException;
+
+import static io.spine.testing.Tests.nullRef;
+import static io.spine.util.Exceptions.newIllegalStateException;
+
+@MicronautTest
+@DisplayName("`PubsubPushRequestDeserializer` should")
+final class PubsubPushRequestDeserializerTest {
+
+ @Inject
+ private ObjectMapperFactory mapperFactory;
+
+ @Test
+ @DisplayName("deserialize Pub/Sub message")
+ void deserializePubsubMessage() throws JsonProcessingException, ParseException {
+ var pubsubMessage = PubsubMessage
+ .newBuilder()
+ .setMessageId("450292511223766")
+ .setPublishTime(Timestamps.parse("2020-06-21T20:48:25.908Z"))
+ .setData(ByteString.copyFromUtf8("{\"key\":\"value\"}"))
+ .buildPartial();
+ var expectedResult = PubsubPushRequest
+ .newBuilder()
+ .setSubscription("projects/test-project/subscriptions/test-subscription")
+ .setMessage(pubsubMessage)
+ .vBuild();
+
+ var mapper = mapperFactory.objectMapper(nullRef(), nullRef());
+
+ var pushRequest = mapper.readValue(pushRequestJson(), PubsubPushRequest.class);
+ ProtoTruth.assertThat(pushRequest)
+ .isEqualTo(expectedResult);
+ }
+
+ private static String pushRequestJson() {
+ try {
+ var resource = Resources.getResource("pubsub_push_request.json");
+ return Resources.toString(resource, StandardCharsets.UTF_8);
+ } catch (IOException e) {
+ throw newIllegalStateException(
+ e, "Unable to load PubsubPushRequest message JSON definition."
+ );
+ }
+ }
+}
diff --git a/google-chat-bot/src/test/java/io/spine/chatbot/github/SlugMixinTest.java b/google-chat-bot/src/test/java/io/spine/chatbot/github/SlugMixinTest.java
new file mode 100644
index 00000000..f5140060
--- /dev/null
+++ b/google-chat-bot/src/test/java/io/spine/chatbot/github/SlugMixinTest.java
@@ -0,0 +1,37 @@
+/*
+ * Copyright 2020, TeamDev. All rights reserved.
+ *
+ * Redistribution and use in source and/or binary forms, with or without
+ * modification, must retain the above copyright notice and the following
+ * disclaimer.
+ *
+ * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
+ * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
+ * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
+ * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
+ * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
+ * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
+ * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
+ * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
+ * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
+ * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+ * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+ */
+
+package io.spine.chatbot.github;
+
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+
+@DisplayName("`SlugMixin` should")
+final class SlugMixinTest {
+
+ @Test
+ @DisplayName("encode slug value")
+ void encodeSlugValue() {
+ var slug = Slugs.newSlug("TestOrganization/test-repository");
+ assertEquals("TestOrganization%2Ftest-repository", slug.encodedValue());
+ }
+}
diff --git a/google-chat-bot/src/test/java/io/spine/chatbot/github/SlugsTest.java b/google-chat-bot/src/test/java/io/spine/chatbot/github/SlugsTest.java
new file mode 100644
index 00000000..172ebf3d
--- /dev/null
+++ b/google-chat-bot/src/test/java/io/spine/chatbot/github/SlugsTest.java
@@ -0,0 +1,32 @@
+/*
+ * Copyright 2020, TeamDev. All rights reserved.
+ *
+ * Redistribution and use in source and/or binary forms, with or without
+ * modification, must retain the above copyright notice and the following
+ * disclaimer.
+ *
+ * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
+ * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
+ * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
+ * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
+ * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
+ * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
+ * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
+ * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
+ * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
+ * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+ * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+ */
+
+package io.spine.chatbot.github;
+
+import io.spine.testing.UtilityClassTest;
+import org.junit.jupiter.api.DisplayName;
+
+@DisplayName("`Slugs` should")
+final class SlugsTest extends UtilityClassTest {
+
+ SlugsTest() {
+ super(Slugs.class);
+ }
+}
diff --git a/google-chat-bot/src/test/java/io/spine/chatbot/github/repository/build/BuildStateMixinTest.java b/google-chat-bot/src/test/java/io/spine/chatbot/github/repository/build/BuildStateMixinTest.java
new file mode 100644
index 00000000..7aa6f35a
--- /dev/null
+++ b/google-chat-bot/src/test/java/io/spine/chatbot/github/repository/build/BuildStateMixinTest.java
@@ -0,0 +1,57 @@
+/*
+ * Copyright 2020, TeamDev. All rights reserved.
+ *
+ * Redistribution and use in source and/or binary forms, with or without
+ * modification, must retain the above copyright notice and the following
+ * disclaimer.
+ *
+ * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
+ * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
+ * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
+ * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
+ * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
+ * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
+ * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
+ * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
+ * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
+ * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+ * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+ */
+
+package io.spine.chatbot.github.repository.build;
+
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.EnumSource;
+
+import static io.spine.chatbot.github.repository.build.BuildStateMixin.buildStateFrom;
+import static io.spine.testing.Tests.nullRef;
+import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.params.provider.EnumSource.Mode.EXCLUDE;
+
+@DisplayName("`BuildStateMixin` should")
+final class BuildStateMixinTest {
+
+ @Test
+ @DisplayName("not accept `null` build states")
+ void notAcceptNull() {
+ assertThrows(NullPointerException.class, () -> buildStateFrom(nullRef()));
+ }
+
+ @Test
+ @DisplayName("not accept unknown build states")
+ void notAcceptUnknownStates() {
+ assertThrows(IllegalArgumentException.class, () -> buildStateFrom("unknown"));
+ }
+
+ @DisplayName("accept valid `Build.State` value")
+ @ParameterizedTest
+ @EnumSource(mode = EXCLUDE, value = Build.State.class, names = {"BS_UNKNOWN", "UNRECOGNIZED"})
+ void processValidValues(Build.State state) {
+ assertDoesNotThrow(() -> {
+ buildStateFrom(state.name());
+ });
+ }
+}
diff --git a/google-chat-bot/src/test/java/io/spine/chatbot/google/chat/BuildStateUpdatesTest.java b/google-chat-bot/src/test/java/io/spine/chatbot/google/chat/BuildStateUpdatesTest.java
new file mode 100644
index 00000000..259621ba
--- /dev/null
+++ b/google-chat-bot/src/test/java/io/spine/chatbot/google/chat/BuildStateUpdatesTest.java
@@ -0,0 +1,32 @@
+/*
+ * Copyright 2020, TeamDev. All rights reserved.
+ *
+ * Redistribution and use in source and/or binary forms, with or without
+ * modification, must retain the above copyright notice and the following
+ * disclaimer.
+ *
+ * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
+ * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
+ * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
+ * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
+ * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
+ * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
+ * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
+ * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
+ * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
+ * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+ * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+ */
+
+package io.spine.chatbot.google.chat;
+
+import io.spine.testing.UtilityClassTest;
+import org.junit.jupiter.api.DisplayName;
+
+@DisplayName("`BuildStateUpdates` should")
+final class BuildStateUpdatesTest extends UtilityClassTest {
+
+ BuildStateUpdatesTest() {
+ super(BuildStateUpdates.class);
+ }
+}
diff --git a/google-chat-bot/src/test/java/io/spine/chatbot/google/chat/ChatWidgetsTest.java b/google-chat-bot/src/test/java/io/spine/chatbot/google/chat/ChatWidgetsTest.java
new file mode 100644
index 00000000..4dcd900c
--- /dev/null
+++ b/google-chat-bot/src/test/java/io/spine/chatbot/google/chat/ChatWidgetsTest.java
@@ -0,0 +1,32 @@
+/*
+ * Copyright 2020, TeamDev. All rights reserved.
+ *
+ * Redistribution and use in source and/or binary forms, with or without
+ * modification, must retain the above copyright notice and the following
+ * disclaimer.
+ *
+ * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
+ * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
+ * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
+ * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
+ * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
+ * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
+ * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
+ * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
+ * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
+ * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+ * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+ */
+
+package io.spine.chatbot.google.chat;
+
+import io.spine.testing.UtilityClassTest;
+import org.junit.jupiter.api.DisplayName;
+
+@DisplayName("`ChatWidgets` should")
+final class ChatWidgetsTest extends UtilityClassTest {
+
+ ChatWidgetsTest() {
+ super(ChatWidgets.class);
+ }
+}
diff --git a/google-chat-bot/src/test/java/io/spine/chatbot/google/chat/InMemoryGoogleChatClient.java b/google-chat-bot/src/test/java/io/spine/chatbot/google/chat/InMemoryGoogleChatClient.java
new file mode 100644
index 00000000..0ae0ab98
--- /dev/null
+++ b/google-chat-bot/src/test/java/io/spine/chatbot/google/chat/InMemoryGoogleChatClient.java
@@ -0,0 +1,82 @@
+/*
+ * Copyright 2020, TeamDev. All rights reserved.
+ *
+ * Redistribution and use in source and/or binary forms, with or without
+ * modification, must retain the above copyright notice and the following
+ * disclaimer.
+ *
+ * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
+ * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
+ * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
+ * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
+ * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
+ * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
+ * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
+ * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
+ * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
+ * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+ * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+ */
+
+package io.spine.chatbot.google.chat;
+
+import io.spine.chatbot.CanFailFast;
+import io.spine.chatbot.github.repository.build.Build;
+import io.spine.chatbot.google.chat.thread.ThreadResource;
+
+import java.util.HashMap;
+import java.util.Map;
+
+import static com.google.common.base.Preconditions.checkNotNull;
+import static java.util.Collections.synchronizedMap;
+
+/**
+ * An in-memory test-only implementation of the Google Chat client.
+ */
+public final class InMemoryGoogleChatClient extends CanFailFast implements GoogleChatClient {
+
+ private final Map sentMessages = synchronizedMap(new HashMap<>());
+
+ private InMemoryGoogleChatClient(boolean failFast) {
+ super(failFast);
+ }
+
+ /**
+ * Creates a {@link #CanFailFast#failFast failFast} in-memory Google Chat client.
+ */
+ public static InMemoryGoogleChatClient strictClient() {
+ return new InMemoryGoogleChatClient(true);
+ }
+
+ /**
+ * Creates a lenient in-memory Google Chat client.
+ */
+ public static InMemoryGoogleChatClient lenientClient() {
+ return new InMemoryGoogleChatClient(false);
+ }
+
+ @Override
+ public BuildStateUpdate sendBuildStateUpdate(Build build, ThreadResource thread) {
+ var stubbedValue = sentMessages.get(build.getNumber());
+ var result = failOrDefault(stubbedValue,
+ build.getNumber(),
+ BuildStateUpdate.getDefaultInstance());
+ return result;
+ }
+
+ /**
+ * Sets up a stub {@code update} for a build state with the specified {@code buildNumber}.
+ */
+ public void setBuildStateUpdate(String buildNumber, BuildStateUpdate update) {
+ checkNotNull(buildNumber);
+ checkNotNull(update);
+ sentMessages.put(buildNumber, update);
+ }
+
+ /**
+ * Resets state of the configured responses.
+ */
+ public void reset() {
+ sentMessages.clear();
+ }
+}
diff --git a/google-chat-bot/src/test/java/io/spine/chatbot/net/MoreUrlsTest.java b/google-chat-bot/src/test/java/io/spine/chatbot/net/MoreUrlsTest.java
new file mode 100644
index 00000000..0b615e3e
--- /dev/null
+++ b/google-chat-bot/src/test/java/io/spine/chatbot/net/MoreUrlsTest.java
@@ -0,0 +1,74 @@
+/*
+ * Copyright 2020, TeamDev. All rights reserved.
+ *
+ * Redistribution and use in source and/or binary forms, with or without
+ * modification, must retain the above copyright notice and the following
+ * disclaimer.
+ *
+ * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
+ * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
+ * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
+ * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
+ * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
+ * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
+ * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
+ * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
+ * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
+ * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+ * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+ */
+
+package io.spine.chatbot.net;
+
+import io.spine.chatbot.github.Slug;
+import io.spine.chatbot.github.Slugs;
+import io.spine.net.Urls;
+import io.spine.testing.UtilityClassTest;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Nested;
+import org.junit.jupiter.api.Test;
+
+import static com.google.common.truth.extensions.proto.ProtoTruth.assertThat;
+import static io.spine.chatbot.net.MoreUrls.githubUrlFor;
+import static io.spine.chatbot.net.MoreUrls.travisBuildUrlFor;
+import static io.spine.chatbot.net.MoreUrls.travisUrlFor;
+
+@DisplayName("`MoreUrls` should")
+final class MoreUrlsTest extends UtilityClassTest {
+
+ private static final Slug REPO_SLUG = Slugs.newSlug("SpineEventEngine/chat-bot");
+
+ MoreUrlsTest() {
+ super(MoreUrls.class);
+ }
+
+ @DisplayName("compose URL for")
+ @Nested
+ @SuppressWarnings("ClassCanBeStatic") // jUnit Jupiter cannot work with static classes
+ final class Compose {
+
+ @DisplayName("Travis CI repository page")
+ @Test
+ void travisRepo() {
+ assertThat(travisUrlFor(REPO_SLUG)).isEqualTo(Urls.create(
+ "https://travis-ci.com/github/SpineEventEngine/chat-bot"
+ ));
+ }
+
+ @DisplayName("Travis CI repository build page")
+ @Test
+ void travisBuild() {
+ assertThat(travisBuildUrlFor(REPO_SLUG, 331)).isEqualTo(Urls.create(
+ "https://travis-ci.com/github/SpineEventEngine/chat-bot/builds/331"
+ ));
+ }
+
+ @DisplayName("GitHub repository page")
+ @Test
+ void githubRepo() {
+ assertThat(githubUrlFor(REPO_SLUG)).isEqualTo(Urls.create(
+ "https://github.com/SpineEventEngine/chat-bot"
+ ));
+ }
+ }
+}
diff --git a/google-chat-bot/src/test/java/io/spine/chatbot/server/github/GitHubContextAwareTest.java b/google-chat-bot/src/test/java/io/spine/chatbot/server/github/GitHubContextAwareTest.java
new file mode 100644
index 00000000..9e79b271
--- /dev/null
+++ b/google-chat-bot/src/test/java/io/spine/chatbot/server/github/GitHubContextAwareTest.java
@@ -0,0 +1,59 @@
+/*
+ * Copyright 2020, TeamDev. All rights reserved.
+ *
+ * Redistribution and use in source and/or binary forms, with or without
+ * modification, must retain the above copyright notice and the following
+ * disclaimer.
+ *
+ * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
+ * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
+ * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
+ * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
+ * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
+ * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
+ * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
+ * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
+ * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
+ * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+ * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+ */
+
+package io.spine.chatbot.server.github;
+
+import com.google.errorprone.annotations.OverridingMethodsMustInvokeSuper;
+import io.spine.chatbot.travis.InMemoryTravisClient;
+import io.spine.server.BoundedContextBuilder;
+import io.spine.testing.server.blackbox.ContextAwareTest;
+import org.junit.jupiter.api.AfterEach;
+
+/**
+ * An abstract test-base for GitHub context-based tests.
+ */
+abstract class GitHubContextAwareTest extends ContextAwareTest {
+
+ private final InMemoryTravisClient client = InMemoryTravisClient.strictClient();
+
+ @Override
+ protected final BoundedContextBuilder contextBuilder() {
+ return GitHubContext
+ .newBuilder()
+ .setTravis(client)
+ .build()
+ .builder();
+ }
+
+ @AfterEach
+ @OverridingMethodsMustInvokeSuper
+ @Override
+ protected final void closeContext() {
+ super.closeContext();
+ client.reset();
+ }
+
+ /**
+ * Returns configured for the {@link #context() context} Travis client.
+ */
+ final InMemoryTravisClient travisClient() {
+ return client;
+ }
+}
diff --git a/google-chat-bot/src/test/java/io/spine/chatbot/server/github/GitHubContextTest.java b/google-chat-bot/src/test/java/io/spine/chatbot/server/github/GitHubContextTest.java
new file mode 100644
index 00000000..18337961
--- /dev/null
+++ b/google-chat-bot/src/test/java/io/spine/chatbot/server/github/GitHubContextTest.java
@@ -0,0 +1,53 @@
+/*
+ * Copyright 2020, TeamDev. All rights reserved.
+ *
+ * Redistribution and use in source and/or binary forms, with or without
+ * modification, must retain the above copyright notice and the following
+ * disclaimer.
+ *
+ * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
+ * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
+ * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
+ * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
+ * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
+ * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
+ * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
+ * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
+ * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
+ * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+ * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+ */
+
+package io.spine.chatbot.server.github;
+
+import io.spine.chatbot.travis.InMemoryTravisClient;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+
+import static io.spine.testing.Tests.nullRef;
+import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+
+@DisplayName("`GitHubContext` should")
+final class GitHubContextTest {
+
+ @Test
+ @DisplayName("allow configuring Travis CI client")
+ void allowConfiguringTravisClient() {
+ assertDoesNotThrow(
+ () -> GitHubContext.newBuilder()
+ .setTravis(InMemoryTravisClient.lenientClient())
+ .build()
+ );
+ }
+
+ @SuppressWarnings("ResultOfMethodCallIgnored")
+ @Test
+ @DisplayName("not allow passing `null` value as Travis CI client")
+ void notAllowNullTravisClients() {
+ assertThrows(
+ NullPointerException.class, () -> GitHubContext.newBuilder()
+ .setTravis(nullRef())
+ );
+ }
+}
diff --git a/google-chat-bot/src/test/java/io/spine/chatbot/server/github/GitHubIdentifiersTest.java b/google-chat-bot/src/test/java/io/spine/chatbot/server/github/GitHubIdentifiersTest.java
new file mode 100644
index 00000000..4c279195
--- /dev/null
+++ b/google-chat-bot/src/test/java/io/spine/chatbot/server/github/GitHubIdentifiersTest.java
@@ -0,0 +1,33 @@
+/*
+ * Copyright 2020, TeamDev. All rights reserved.
+ *
+ * Redistribution and use in source and/or binary forms, with or without
+ * modification, must retain the above copyright notice and the following
+ * disclaimer.
+ *
+ * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
+ * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
+ * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
+ * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
+ * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
+ * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
+ * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
+ * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
+ * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
+ * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+ * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+ */
+
+package io.spine.chatbot.server.github;
+
+import io.spine.chatbot.google.chat.GoogleChatIdentifiers;
+import io.spine.testing.UtilityClassTest;
+import org.junit.jupiter.api.DisplayName;
+
+@DisplayName("`GitHubIdentifiers` should")
+final class GitHubIdentifiersTest extends UtilityClassTest {
+
+ GitHubIdentifiersTest() {
+ super(GoogleChatIdentifiers.class);
+ }
+}
diff --git a/google-chat-bot/src/test/java/io/spine/chatbot/server/github/OrgReposProjectionTest.java b/google-chat-bot/src/test/java/io/spine/chatbot/server/github/OrgReposProjectionTest.java
new file mode 100644
index 00000000..4b5dc47a
--- /dev/null
+++ b/google-chat-bot/src/test/java/io/spine/chatbot/server/github/OrgReposProjectionTest.java
@@ -0,0 +1,188 @@
+/*
+ * Copyright 2020, TeamDev. All rights reserved.
+ *
+ * Redistribution and use in source and/or binary forms, with or without
+ * modification, must retain the above copyright notice and the following
+ * disclaimer.
+ *
+ * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
+ * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
+ * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
+ * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
+ * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
+ * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
+ * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
+ * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
+ * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
+ * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+ * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+ */
+
+package io.spine.chatbot.server.github;
+
+import io.spine.chatbot.github.OrganizationId;
+import io.spine.chatbot.github.RepositoryId;
+import io.spine.chatbot.github.organization.OrgHeader;
+import io.spine.chatbot.github.organization.OrganizationRepositories;
+import io.spine.chatbot.github.organization.command.RegisterOrganization;
+import io.spine.chatbot.github.repository.RepoHeader;
+import io.spine.chatbot.github.repository.command.RegisterRepository;
+import io.spine.chatbot.google.chat.SpaceId;
+import io.spine.net.Url;
+import io.spine.net.Urls;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Nested;
+import org.junit.jupiter.api.Test;
+
+import static io.spine.chatbot.github.GitHubIdentifiers.organization;
+import static io.spine.chatbot.github.GitHubIdentifiers.repository;
+import static io.spine.chatbot.github.Slugs.orgSlug;
+import static io.spine.chatbot.google.chat.GoogleChatIdentifiers.space;
+import static io.spine.chatbot.net.MoreUrls.githubUrlFor;
+import static io.spine.chatbot.net.MoreUrls.travisUrlFor;
+
+@DisplayName("`OrgReposProjection` should")
+final class OrgReposProjectionTest extends GitHubContextAwareTest {
+
+ @Nested
+ @DisplayName("register an organization")
+ final class RegisterOrg {
+
+ private static final String orgName = "Our Org";
+
+ private final SpaceId googleChatSpace = space("spaces/qwdp123tt1");
+ private final OrganizationId organization = organization("OurOrg");
+
+ private final Url githubUrl = githubUrlFor(orgSlug(organization));
+ private final Url travisCiUrl = travisUrlFor(orgSlug(organization));
+ private final Url websiteUrl = Urls.create("https://our-organization.com");
+
+ private final OrgHeader header = OrgHeader
+ .newBuilder()
+ .setGithubProfile(githubUrl)
+ .setTravisProfile(travisCiUrl)
+ .setWebsite(websiteUrl)
+ .setName(orgName)
+ .setSpace(googleChatSpace)
+ .vBuild();
+
+ @BeforeEach
+ void registerOrg() {
+ var registerOrganization = RegisterOrganization
+ .newBuilder()
+ .setId(organization)
+ .setHeader(header)
+ .vBuild();
+ context().receivesCommand(registerOrganization);
+ }
+
+ @Test
+ @DisplayName("setting organization to the state")
+ void settingState() {
+ var expectedState = OrganizationRepositories
+ .newBuilder()
+ .setOrganization(organization)
+ .vBuild();
+ context().assertState(organization, OrganizationRepositories.class)
+ .isEqualTo(expectedState);
+ }
+ }
+
+ @Nested
+ @DisplayName("register repositories")
+ final class RegisterRepo {
+
+ private static final String orgName = "Multi Repo Org";
+
+ private final SpaceId space = space("spaces/qqwp123ttQ");
+ private final OrganizationId org = organization("MultiRepoOrg");
+ private final RepositoryId repo = repository("main-repo");
+
+ private final Url githubUrl = githubUrlFor(orgSlug(org));
+ private final Url travisCiUrl = travisUrlFor(orgSlug(org));
+ private final Url websiteUrl = Urls.create("https://multi-repo-organization.com");
+
+ private final OrgHeader orgHeader = OrgHeader
+ .newBuilder()
+ .setGithubProfile(githubUrl)
+ .setTravisProfile(travisCiUrl)
+ .setWebsite(websiteUrl)
+ .setName(orgName)
+ .setSpace(space)
+ .vBuild();
+
+ private final RepoHeader repoHeader = RepoHeader
+ .newBuilder()
+ .setGithubProfile(githubUrl)
+ .setTravisProfile(travisCiUrl)
+ .setName("Main Repo")
+ .setOrganization(org)
+ .vBuild();
+
+ @BeforeEach
+ void registerOrg() {
+ var registerOrganization = RegisterOrganization
+ .newBuilder()
+ .setId(org)
+ .setHeader(orgHeader)
+ .vBuild();
+ context().receivesCommand(registerOrganization);
+ }
+
+ @Test
+ @DisplayName("setting repository to the state")
+ void settingState() {
+ var registerRepository = RegisterRepository
+ .newBuilder()
+ .setId(repo)
+ .setHeader(repoHeader)
+ .vBuild();
+ context().receivesCommand(registerRepository);
+ var expectedState = OrganizationRepositories
+ .newBuilder()
+ .setOrganization(org)
+ .addRepository(repo)
+ .vBuild();
+ context().assertState(org, OrganizationRepositories.class)
+ .isEqualTo(expectedState);
+ }
+
+ @Test
+ @DisplayName("handling duplicate repos gracefully")
+ void handleDuplicateRepos() {
+ var registerRepository = RegisterRepository
+ .newBuilder()
+ .setId(repo)
+ .setHeader(repoHeader)
+ .vBuild();
+ context().receivesCommand(registerRepository);
+ context().receivesCommand(registerRepository);
+ var expectedState = OrganizationRepositories
+ .newBuilder()
+ .setOrganization(org)
+ .addRepository(repo)
+ .vBuild();
+ context().assertState(org, OrganizationRepositories.class)
+ .isEqualTo(expectedState);
+ }
+
+ @Test
+ @DisplayName("ignoring repos without organization")
+ void ignoreRepoWithoutOrg() {
+ var registerRepository = RegisterRepository
+ .newBuilder()
+ .setId(repo)
+ .setHeader(repoHeader.toBuilder()
+ .clearOrganization())
+ .vBuild();
+ context().receivesCommand(registerRepository);
+ var expectedState = OrganizationRepositories
+ .newBuilder()
+ .setOrganization(org)
+ .vBuild();
+ context().assertState(org, OrganizationRepositories.class)
+ .isEqualTo(expectedState);
+ }
+ }
+}
diff --git a/google-chat-bot/src/test/java/io/spine/chatbot/server/github/OrganizationAggregateTest.java b/google-chat-bot/src/test/java/io/spine/chatbot/server/github/OrganizationAggregateTest.java
new file mode 100644
index 00000000..9910c7cc
--- /dev/null
+++ b/google-chat-bot/src/test/java/io/spine/chatbot/server/github/OrganizationAggregateTest.java
@@ -0,0 +1,100 @@
+/*
+ * Copyright 2020, TeamDev. All rights reserved.
+ *
+ * Redistribution and use in source and/or binary forms, with or without
+ * modification, must retain the above copyright notice and the following
+ * disclaimer.
+ *
+ * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
+ * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
+ * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
+ * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
+ * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
+ * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
+ * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
+ * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
+ * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
+ * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+ * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+ */
+
+package io.spine.chatbot.server.github;
+
+import io.spine.chatbot.github.OrganizationId;
+import io.spine.chatbot.github.organization.OrgHeader;
+import io.spine.chatbot.github.organization.Organization;
+import io.spine.chatbot.github.organization.command.RegisterOrganization;
+import io.spine.chatbot.github.organization.event.OrganizationRegistered;
+import io.spine.chatbot.google.chat.SpaceId;
+import io.spine.net.Url;
+import io.spine.net.Urls;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Nested;
+import org.junit.jupiter.api.Test;
+
+import static io.spine.chatbot.github.GitHubIdentifiers.organization;
+import static io.spine.chatbot.github.Slugs.orgSlug;
+import static io.spine.chatbot.google.chat.GoogleChatIdentifiers.space;
+import static io.spine.chatbot.net.MoreUrls.githubUrlFor;
+import static io.spine.chatbot.net.MoreUrls.travisUrlFor;
+
+@DisplayName("`OrganizationAggregate` should")
+final class OrganizationAggregateTest extends GitHubContextAwareTest {
+
+ @Nested
+ @DisplayName("register an organization")
+ final class Register {
+
+ private static final String orgName = "Test Organization";
+
+ private final SpaceId googleChatSpace = space("spaces/qwdp123ttQ");
+ private final OrganizationId organization = organization("TestOrganization");
+
+ private final Url githubUrl = githubUrlFor(orgSlug(organization));
+ private final Url travisCiUrl = travisUrlFor(orgSlug(organization));
+ private final Url websiteUrl = Urls.create("https://test-organization.com");
+
+ private final OrgHeader header = OrgHeader
+ .newBuilder()
+ .setGithubProfile(githubUrl)
+ .setTravisProfile(travisCiUrl)
+ .setWebsite(websiteUrl)
+ .setName(orgName)
+ .setSpace(googleChatSpace)
+ .vBuild();
+
+ @BeforeEach
+ void registerOrganization() {
+ var registerOrganization = RegisterOrganization
+ .newBuilder()
+ .setId(organization)
+ .setHeader(header)
+ .vBuild();
+ context().receivesCommand(registerOrganization);
+ }
+
+ @Test
+ @DisplayName("producing `OrganizationRegistered` event")
+ void producingEvent() {
+ var organizationRegistered = OrganizationRegistered
+ .newBuilder()
+ .setOrganization(organization)
+ .setHeader(header)
+ .vBuild();
+ context().assertEvent(organizationRegistered);
+ }
+
+ @Test
+ @DisplayName("setting organization state")
+ void settingState() {
+ var expectedState = Organization
+ .newBuilder()
+ .setId(organization)
+ .setHeader(header)
+ .vBuild();
+ context().assertState(organization, Organization.class)
+ .isEqualTo(expectedState);
+ }
+ }
+}
diff --git a/google-chat-bot/src/test/java/io/spine/chatbot/server/github/RepoBuildProcessTest.java b/google-chat-bot/src/test/java/io/spine/chatbot/server/github/RepoBuildProcessTest.java
new file mode 100644
index 00000000..f8cffe66
--- /dev/null
+++ b/google-chat-bot/src/test/java/io/spine/chatbot/server/github/RepoBuildProcessTest.java
@@ -0,0 +1,369 @@
+/*
+ * Copyright 2020, TeamDev. All rights reserved.
+ *
+ * Redistribution and use in source and/or binary forms, with or without
+ * modification, must retain the above copyright notice and the following
+ * disclaimer.
+ *
+ * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
+ * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
+ * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
+ * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
+ * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
+ * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
+ * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
+ * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
+ * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
+ * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+ * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+ */
+
+package io.spine.chatbot.server.github;
+
+import io.spine.chatbot.github.OrganizationId;
+import io.spine.chatbot.github.RepositoryId;
+import io.spine.chatbot.github.repository.build.Build;
+import io.spine.chatbot.github.repository.build.BuildStateChange;
+import io.spine.chatbot.github.repository.build.RepositoryBuild;
+import io.spine.chatbot.github.repository.build.command.CheckRepositoryBuild;
+import io.spine.chatbot.github.repository.build.event.BuildFailed;
+import io.spine.chatbot.github.repository.build.event.BuildRecovered;
+import io.spine.chatbot.github.repository.build.event.BuildSucceededAgain;
+import io.spine.chatbot.github.repository.build.rejection.RepositoryBuildRejections;
+import io.spine.chatbot.google.chat.SpaceId;
+import io.spine.chatbot.travis.Author;
+import io.spine.chatbot.travis.Commit;
+import io.spine.chatbot.travis.RepoBranchBuildResponse;
+import io.spine.chatbot.travis.Repository;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Nested;
+import org.junit.jupiter.api.Test;
+
+import static io.spine.chatbot.github.GitHubIdentifiers.organization;
+import static io.spine.chatbot.github.GitHubIdentifiers.repository;
+import static io.spine.chatbot.github.Slugs.repoSlug;
+import static io.spine.chatbot.google.chat.GoogleChatIdentifiers.space;
+import static io.spine.chatbot.server.github.RepoBuildProcess.buildFrom;
+
+@DisplayName("`RepoBuildProcess` should")
+final class RepoBuildProcessTest extends GitHubContextAwareTest {
+
+ private static final OrganizationId org = organization("SpineEventEngine");
+ private static final RepositoryId repo = repository("SpineEventEngine/web");
+ private static final SpaceId space = space("spaces/1245wrq");
+
+ @Test
+ @DisplayName("throw `NoBuildsFound` rejection when Travis API cannot return builds for a repo")
+ void throwNoBuildsFoundRejection() {
+ travisClient().setBuildsFor(repoSlug(repo), RepoBranchBuildResponse.getDefaultInstance());
+ var checkRepoBuild = CheckRepositoryBuild
+ .newBuilder()
+ .setRepository(repo)
+ .setOrganization(org)
+ .setSpace(space)
+ .vBuild();
+ context().receivesCommand(checkRepoBuild);
+
+ var noBuildsFound = RepositoryBuildRejections.NoBuildsFound
+ .newBuilder()
+ .setRepository(repo)
+ .vBuild();
+ context().assertEvent(noBuildsFound);
+ }
+
+ @SuppressWarnings("ClassCanBeStatic") // nested tests do not work with static classes
+ @Nested
+ @DisplayName("handle build failure")
+ final class FailedBuild {
+
+ private final io.spine.chatbot.travis.Build build = failedBuild();
+ private final RepoBranchBuildResponse branchBuild = branchBuildOf(build);
+ private final Build buildState = buildFrom(branchBuild, space);
+
+ @BeforeEach
+ void sendCheckCommand() {
+ travisClient().setBuildsFor(repoSlug(repo), branchBuild);
+ var checkRepoBuild = CheckRepositoryBuild
+ .newBuilder()
+ .setRepository(repo)
+ .setSpace(space)
+ .setOrganization(org)
+ .vBuild();
+ context().receivesCommand(checkRepoBuild);
+ }
+
+ @Test
+ @DisplayName("producing `BuildFailed` event")
+ void producingEvent() {
+ var stateChange = BuildStateChange
+ .newBuilder()
+ .setNewValue(buildState)
+ .vBuild();
+ var buildFailed = BuildFailed
+ .newBuilder()
+ .setRepository(repo)
+ .setChange(stateChange)
+ .vBuild();
+ context().assertEvent(buildFailed);
+ }
+
+ @Test
+ @DisplayName("setting process state")
+ void settingState() {
+ var expectedState = RepositoryBuild
+ .newBuilder()
+ .setRepository(repo)
+ .setBuild(buildState)
+ .setCurrentState(buildState.getState())
+ .vBuild();
+ context().assertState(repo, RepositoryBuild.class)
+ .isEqualTo(expectedState);
+ }
+ }
+
+ @SuppressWarnings("ClassCanBeStatic") // nested tests do not work with static classes
+ @Nested
+ @DisplayName("handle build recovery")
+ final class RecoveredBuild {
+
+ private final io.spine.chatbot.travis.Build previousBuild = failedBuild();
+ private final RepoBranchBuildResponse previousBranchBuild = branchBuildOf(previousBuild);
+ private final Build previousBuildState = buildFrom(previousBranchBuild,
+ space);
+
+ private final io.spine.chatbot.travis.Build newBuild = passingBuild();
+ private final RepoBranchBuildResponse newBranchBuild = branchBuildOf(newBuild);
+ private final Build newBuildState = buildFrom(newBranchBuild, space);
+
+ @BeforeEach
+ void sendCheckCommands() {
+ travisClient().setBuildsFor(repoSlug(repo), previousBranchBuild);
+ var checkRepoFailure = CheckRepositoryBuild
+ .newBuilder()
+ .setRepository(repo)
+ .setSpace(space)
+ .setOrganization(org)
+ .vBuild();
+ context().receivesCommand(checkRepoFailure);
+ travisClient().setBuildsFor(repoSlug(repo), newBranchBuild);
+ var checkRepoRecovery = CheckRepositoryBuild
+ .newBuilder()
+ .setRepository(repo)
+ .setSpace(space)
+ .setOrganization(org)
+ .vBuild();
+ context().receivesCommand(checkRepoRecovery);
+ }
+
+ @Test
+ @DisplayName("producing `BuildRecovered` event")
+ void producingEvent() {
+ var stateChange = BuildStateChange
+ .newBuilder()
+ .setPreviousValue(previousBuildState)
+ .setNewValue(newBuildState)
+ .vBuild();
+ var buildFailed = BuildRecovered
+ .newBuilder()
+ .setRepository(repo)
+ .setChange(stateChange)
+ .vBuild();
+ context().assertEvent(buildFailed);
+ }
+
+ @Test
+ @DisplayName("setting process state")
+ void settingState() {
+ var expectedState = RepositoryBuild
+ .newBuilder()
+ .setRepository(repo)
+ .setBuild(newBuildState)
+ .setCurrentState(newBuildState.getState())
+ .vBuild();
+ context().assertState(repo, RepositoryBuild.class)
+ .isEqualTo(expectedState);
+ }
+ }
+
+ @SuppressWarnings("ClassCanBeStatic") // nested tests do not work with static classes
+ @Nested
+ @DisplayName("handle stable builds")
+ final class StableBuild {
+
+ private final io.spine.chatbot.travis.Build initialFailedBuild = failedBuild();
+
+ private final io.spine.chatbot.travis.Build previousBuild = passingBuild();
+ private final RepoBranchBuildResponse previousBranchBuild = branchBuildOf(previousBuild);
+ private final Build previousBuildState = buildFrom(previousBranchBuild,
+ space);
+
+ private final io.spine.chatbot.travis.Build newBuild = nextPassingBuild();
+ private final RepoBranchBuildResponse newBranchBuild = branchBuildOf(newBuild);
+ private final Build newBuildState = buildFrom(newBranchBuild, space);
+
+ @BeforeEach
+ void sendCheckCommands() {
+ travisClient().setBuildsFor(repoSlug(repo), branchBuildOf(initialFailedBuild));
+ var checkRepoFailure = CheckRepositoryBuild
+ .newBuilder()
+ .setRepository(repo)
+ .setSpace(space)
+ .setOrganization(org)
+ .vBuild();
+ context().receivesCommand(checkRepoFailure);
+ travisClient().setBuildsFor(repoSlug(repo), previousBranchBuild);
+ var checkRepoRecovery = CheckRepositoryBuild
+ .newBuilder()
+ .setRepository(repo)
+ .setSpace(space)
+ .setOrganization(org)
+ .vBuild();
+ context().receivesCommand(checkRepoRecovery);
+ travisClient().setBuildsFor(repoSlug(repo), newBranchBuild);
+ var checkRepoStable = CheckRepositoryBuild
+ .newBuilder()
+ .setRepository(repo)
+ .setSpace(space)
+ .setOrganization(org)
+ .vBuild();
+ context().receivesCommand(checkRepoStable);
+ }
+
+ @Test
+ @DisplayName("producing `BuildSucceededAgain` event")
+ void producingEvent() {
+ var stateChange = BuildStateChange
+ .newBuilder()
+ .setPreviousValue(previousBuildState)
+ .setNewValue(newBuildState)
+ .vBuild();
+ var buildSucceededAgain = BuildSucceededAgain
+ .newBuilder()
+ .setRepository(repo)
+ .setChange(stateChange)
+ .vBuild();
+ context().assertEvent(buildSucceededAgain);
+ }
+
+ @Test
+ @DisplayName("setting process state")
+ void settingState() {
+ var expectedState = RepositoryBuild
+ .newBuilder()
+ .setRepository(repo)
+ .setBuild(newBuildState)
+ .setCurrentState(newBuildState.getState())
+ .vBuild();
+ context().assertState(repo, RepositoryBuild.class)
+ .isEqualTo(expectedState);
+ }
+ }
+
+ private static RepoBranchBuildResponse branchBuildOf(io.spine.chatbot.travis.Build build) {
+ return RepoBranchBuildResponse
+ .newBuilder()
+ .setLastBuild(build)
+ .setName("master")
+ .setRepository(Repository.newBuilder()
+ .setSlug(repo.getValue()))
+ .buildPartial();
+ }
+
+ private static io.spine.chatbot.travis.Build passingBuild() {
+ return io.spine.chatbot.travis.Build
+ .newBuilder()
+ .setId(123153L)
+ .setNumber("42")
+ .setState("passed")
+ .setPreviousState("failed")
+ .setRepository(webRepository())
+ .setCommit(luckyCommit())
+ .buildPartial();
+ }
+
+ private static io.spine.chatbot.travis.Build nextPassingBuild() {
+ return io.spine.chatbot.travis.Build
+ .newBuilder()
+ .setId(123154L)
+ .setNumber("43")
+ .setState("passed")
+ .setPreviousState("passed")
+ .setRepository(webRepository())
+ .setCommit(stableCommit())
+ .buildPartial();
+ }
+
+ private static io.spine.chatbot.travis.Build failedBuild() {
+ return io.spine.chatbot.travis.Build
+ .newBuilder()
+ .setId(123152L)
+ .setNumber("41")
+ .setState("failed")
+ .setPreviousState("failed")
+ .setRepository(webRepository())
+ .setCommit(fatefulCommit())
+ .buildPartial();
+ }
+
+ private static Commit stableCommit() {
+ var compareUrl = "https://github.com/SpineEventEngine/web/compare/04694f26f24a...afc1b76bf93c";
+ var author = Author
+ .newBuilder()
+ .setName("God")
+ .buildPartial();
+ return Commit
+ .newBuilder()
+ .setId(668)
+ .setCompareUrl(compareUrl)
+ .setSha("afc1b76bf93c4dadf86075280da623f947e1434b")
+ .setAuthor(author)
+ .setCommittedAt("2020-06-06T18:30:00Z")
+ .setMessage("May the heaven be on earth.")
+ .buildPartial();
+ }
+
+ private static Commit luckyCommit() {
+ var compareUrl = "https://github.com/SpineEventEngine/web/compare/6b4d32cadd9c...6b0a31d033a2";
+ var author = Author
+ .newBuilder()
+ .setName("God")
+ .buildPartial();
+ return Commit
+ .newBuilder()
+ .setId(667)
+ .setCompareUrl(compareUrl)
+ .setSha("6b0a31d033a2fc8d29d49baad600bc31789d9615")
+ .setAuthor(author)
+ .setCommittedAt("2020-06-06T18:00:00Z")
+ .setMessage("No you're not. Fixing the world.")
+ .buildPartial();
+ }
+
+ private static Commit fatefulCommit() {
+ var compareUrl = "https://github.com/SpineEventEngine/web/compare/5cbfa7423708...8fcf5d98e50f";
+ var author = Author
+ .newBuilder()
+ .setName("Lucifer")
+ .buildPartial();
+ return Commit
+ .newBuilder()
+ .setId(666)
+ .setCompareUrl(compareUrl)
+ .setSha("8fcf5d98e50f8ffa6daa8c81746181c72bd09a50")
+ .setAuthor(author)
+ .setCommittedAt("2020-06-06T06:06:66Z")
+ .setMessage("I am going to conquer the world!")
+ .buildPartial();
+
+ }
+
+ private static Repository webRepository() {
+ return Repository
+ .newBuilder()
+ .setId(1112)
+ .setName("web")
+ .setSlug(repo.getValue())
+ .buildPartial();
+ }
+}
diff --git a/google-chat-bot/src/test/java/io/spine/chatbot/server/github/RepositoryAggregateTest.java b/google-chat-bot/src/test/java/io/spine/chatbot/server/github/RepositoryAggregateTest.java
new file mode 100644
index 00000000..6b0b7764
--- /dev/null
+++ b/google-chat-bot/src/test/java/io/spine/chatbot/server/github/RepositoryAggregateTest.java
@@ -0,0 +1,99 @@
+/*
+ * Copyright 2020, TeamDev. All rights reserved.
+ *
+ * Redistribution and use in source and/or binary forms, with or without
+ * modification, must retain the above copyright notice and the following
+ * disclaimer.
+ *
+ * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
+ * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
+ * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
+ * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
+ * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
+ * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
+ * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
+ * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
+ * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
+ * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+ * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+ */
+
+package io.spine.chatbot.server.github;
+
+import io.spine.chatbot.github.OrganizationId;
+import io.spine.chatbot.github.RepositoryId;
+import io.spine.chatbot.github.repository.RepoHeader;
+import io.spine.chatbot.github.repository.Repository;
+import io.spine.chatbot.github.repository.command.RegisterRepository;
+import io.spine.chatbot.github.repository.event.RepositoryRegistered;
+import io.spine.net.Url;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Nested;
+import org.junit.jupiter.api.Test;
+
+import static io.spine.chatbot.github.GitHubIdentifiers.organization;
+import static io.spine.chatbot.github.GitHubIdentifiers.repository;
+import static io.spine.chatbot.github.Slugs.orgSlug;
+import static io.spine.chatbot.github.Slugs.repoSlug;
+import static io.spine.chatbot.net.MoreUrls.githubUrlFor;
+import static io.spine.chatbot.net.MoreUrls.travisUrlFor;
+
+@DisplayName("`RepositoryAggregate` should")
+final class RepositoryAggregateTest extends GitHubContextAwareTest {
+
+ @Nested
+ @DisplayName("register a repository")
+ final class Register {
+
+ private static final String ORG_SLUG = "SpineEventEngine";
+ private static final String REPO_SLUG = ORG_SLUG + "/base";
+ private static final String REPO_NAME = "Spine Base";
+
+ private final RepositoryId repo = repository(REPO_SLUG);
+ private final OrganizationId org = organization(ORG_SLUG);
+
+ private final Url githubProfile = githubUrlFor(orgSlug(org));
+ private final Url travisProfile = travisUrlFor(repoSlug(repo));
+ private final RepoHeader repoHeader = RepoHeader
+ .newBuilder()
+ .setGithubProfile(githubProfile)
+ .setTravisProfile(travisProfile)
+ .setName(REPO_NAME)
+ .setOrganization(org)
+ .vBuild();
+
+ @BeforeEach
+ void registerRepository() {
+ var registerRepository = RegisterRepository
+ .newBuilder()
+ .setId(repo)
+ .setHeader(repoHeader)
+ .vBuild();
+ context().receivesCommand(registerRepository);
+ }
+
+ @Test
+ @DisplayName("producing `RepositoryRegistered` event")
+ void producingEvent() {
+ var repositoryRegistered = RepositoryRegistered
+ .newBuilder()
+ .setRepository(repo)
+ .setHeader(repoHeader)
+ .vBuild();
+ context().assertEvent(repositoryRegistered);
+ }
+
+ @Test
+ @DisplayName("setting repository state")
+ void settingState() {
+ var expectedState = Repository
+ .newBuilder()
+ .setId(repo)
+ .setHeader(repoHeader)
+ .vBuild();
+ context().assertState(repo, Repository.class)
+ .isEqualTo(expectedState);
+ }
+ }
+}
diff --git a/google-chat-bot/src/test/java/io/spine/chatbot/server/github/SpineOrgInitProcessTest.java b/google-chat-bot/src/test/java/io/spine/chatbot/server/github/SpineOrgInitProcessTest.java
new file mode 100644
index 00000000..d5b26584
--- /dev/null
+++ b/google-chat-bot/src/test/java/io/spine/chatbot/server/github/SpineOrgInitProcessTest.java
@@ -0,0 +1,93 @@
+/*
+ * Copyright 2020, TeamDev. All rights reserved.
+ *
+ * Redistribution and use in source and/or binary forms, with or without
+ * modification, must retain the above copyright notice and the following
+ * disclaimer.
+ *
+ * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
+ * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
+ * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
+ * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
+ * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
+ * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
+ * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
+ * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
+ * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
+ * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+ * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+ */
+
+package io.spine.chatbot.server.github;
+
+import io.spine.chatbot.github.organization.init.OrganizationInit;
+import io.spine.chatbot.google.chat.SpaceHeader;
+import io.spine.chatbot.google.chat.SpaceId;
+import io.spine.chatbot.google.chat.event.SpaceRegistered;
+import io.spine.chatbot.travis.RepositoriesResponse;
+import io.spine.chatbot.travis.Repository;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Nested;
+import org.junit.jupiter.api.Test;
+
+import static io.spine.chatbot.github.Slugs.orgSlug;
+import static io.spine.chatbot.google.chat.GoogleChatIdentifiers.space;
+import static io.spine.chatbot.server.github.SpineOrgInitProcess.ORGANIZATION;
+
+@DisplayName("`SpineOrgInitProcess` should")
+final class SpineOrgInitProcessTest extends GitHubContextAwareTest {
+
+ @Nested
+ @DisplayName("perform initialization of watched Spine resources")
+ final class Init {
+
+ private final SpaceId space = space("spaces/qjwrp1441");
+ private final Repository repo = Repository
+ .newBuilder()
+ .setId(123312L)
+ .setName("time")
+ .setSlug("SpineEventEngine/time")
+ .vBuild();
+ private final SpaceHeader spaceHeader = SpaceHeader
+ .newBuilder()
+ .setThreaded(true)
+ .setDisplayName("Test Space")
+ .vBuild();
+
+ @BeforeEach
+ void registerSpace() {
+ var repositoriesResponse = RepositoriesResponse
+ .newBuilder()
+ .addRepositories(repo)
+ .vBuild();
+ travisClient().setRepositoriesFor(orgSlug(ORGANIZATION), repositoriesResponse);
+ var spaceRegistered = SpaceRegistered
+ .newBuilder()
+ .setSpace(space)
+ .setHeader(spaceHeader)
+ .vBuild();
+ context().receivesExternalEvent(spaceRegistered);
+ }
+
+ @Test
+ @DisplayName("setting process state")
+ void settingState() {
+ var expectedState = OrganizationInit
+ .newBuilder()
+ .setSpace(space)
+ .setInitialized(true)
+ .setOrganization(ORGANIZATION)
+ .vBuild();
+ context().assertState(ORGANIZATION, OrganizationInit.class)
+ .isEqualTo(expectedState);
+ }
+
+ @Test
+ @DisplayName("producing commands to register organization and repositories")
+ void producingCommands() {
+ context().assertCommands()
+ .hasSize(2);
+ }
+ }
+}
diff --git a/google-chat-bot/src/test/java/io/spine/chatbot/server/google/chat/ChatEventsTest.java b/google-chat-bot/src/test/java/io/spine/chatbot/server/google/chat/ChatEventsTest.java
new file mode 100644
index 00000000..849111a4
--- /dev/null
+++ b/google-chat-bot/src/test/java/io/spine/chatbot/server/google/chat/ChatEventsTest.java
@@ -0,0 +1,32 @@
+/*
+ * Copyright 2020, TeamDev. All rights reserved.
+ *
+ * Redistribution and use in source and/or binary forms, with or without
+ * modification, must retain the above copyright notice and the following
+ * disclaimer.
+ *
+ * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
+ * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
+ * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
+ * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
+ * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
+ * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
+ * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
+ * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
+ * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
+ * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+ * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+ */
+
+package io.spine.chatbot.server.google.chat;
+
+import io.spine.testing.UtilityClassTest;
+import org.junit.jupiter.api.DisplayName;
+
+@DisplayName("`ChatEvents` should")
+final class ChatEventsTest extends UtilityClassTest {
+
+ ChatEventsTest() {
+ super(ChatEvents.class);
+ }
+}
diff --git a/google-chat-bot/src/test/java/io/spine/chatbot/server/google/chat/GoogleChatContextAwareTest.java b/google-chat-bot/src/test/java/io/spine/chatbot/server/google/chat/GoogleChatContextAwareTest.java
new file mode 100644
index 00000000..a9881d4f
--- /dev/null
+++ b/google-chat-bot/src/test/java/io/spine/chatbot/server/google/chat/GoogleChatContextAwareTest.java
@@ -0,0 +1,59 @@
+/*
+ * Copyright 2020, TeamDev. All rights reserved.
+ *
+ * Redistribution and use in source and/or binary forms, with or without
+ * modification, must retain the above copyright notice and the following
+ * disclaimer.
+ *
+ * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
+ * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
+ * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
+ * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
+ * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
+ * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
+ * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
+ * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
+ * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
+ * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+ * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+ */
+
+package io.spine.chatbot.server.google.chat;
+
+import com.google.errorprone.annotations.OverridingMethodsMustInvokeSuper;
+import io.spine.chatbot.google.chat.InMemoryGoogleChatClient;
+import io.spine.server.BoundedContextBuilder;
+import io.spine.testing.server.blackbox.ContextAwareTest;
+import org.junit.jupiter.api.AfterEach;
+
+/**
+ * An abstract test-base for Google Chat context-based tests.
+ */
+abstract class GoogleChatContextAwareTest extends ContextAwareTest {
+
+ private final InMemoryGoogleChatClient client = InMemoryGoogleChatClient.strictClient();
+
+ @Override
+ protected final BoundedContextBuilder contextBuilder() {
+ return GoogleChatContext
+ .newBuilder()
+ .setClient(client)
+ .build()
+ .builder();
+ }
+
+ @AfterEach
+ @OverridingMethodsMustInvokeSuper
+ @Override
+ protected final void closeContext() {
+ super.closeContext();
+ client.reset();
+ }
+
+ /**
+ * Returns configured for the {@link #context() context} Google Chat client.
+ */
+ final InMemoryGoogleChatClient googleChatClient() {
+ return client;
+ }
+}
diff --git a/google-chat-bot/src/test/java/io/spine/chatbot/server/google/chat/GoogleChatContextTest.java b/google-chat-bot/src/test/java/io/spine/chatbot/server/google/chat/GoogleChatContextTest.java
new file mode 100644
index 00000000..89d8ccf9
--- /dev/null
+++ b/google-chat-bot/src/test/java/io/spine/chatbot/server/google/chat/GoogleChatContextTest.java
@@ -0,0 +1,54 @@
+/*
+ * Copyright 2020, TeamDev. All rights reserved.
+ *
+ * Redistribution and use in source and/or binary forms, with or without
+ * modification, must retain the above copyright notice and the following
+ * disclaimer.
+ *
+ * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
+ * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
+ * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
+ * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
+ * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
+ * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
+ * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
+ * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
+ * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
+ * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+ * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+ */
+
+package io.spine.chatbot.server.google.chat;
+
+import io.spine.chatbot.google.chat.InMemoryGoogleChatClient;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+
+import static io.spine.testing.Tests.nullRef;
+import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+
+@DisplayName("`GoogleChatContext` should")
+final class GoogleChatContextTest {
+
+ @Test
+ @DisplayName("allow configuring Google Chat client")
+ void allowConfiguringTravisClient() {
+ assertDoesNotThrow(
+ () -> GoogleChatContext
+ .newBuilder()
+ .setClient(InMemoryGoogleChatClient.lenientClient())
+ .build()
+ );
+ }
+
+ @SuppressWarnings("ResultOfMethodCallIgnored")
+ @Test
+ @DisplayName("not allow passing `null` value as Google Chat client")
+ void notAllowNullTravisClients() {
+ assertThrows(
+ NullPointerException.class, () -> GoogleChatContext.newBuilder()
+ .setClient(nullRef())
+ );
+ }
+}
diff --git a/google-chat-bot/src/test/java/io/spine/chatbot/server/google/chat/GoogleChatIdentifiersTest.java b/google-chat-bot/src/test/java/io/spine/chatbot/server/google/chat/GoogleChatIdentifiersTest.java
new file mode 100644
index 00000000..f7cebf95
--- /dev/null
+++ b/google-chat-bot/src/test/java/io/spine/chatbot/server/google/chat/GoogleChatIdentifiersTest.java
@@ -0,0 +1,105 @@
+/*
+ * Copyright 2020, TeamDev. All rights reserved.
+ *
+ * Redistribution and use in source and/or binary forms, with or without
+ * modification, must retain the above copyright notice and the following
+ * disclaimer.
+ *
+ * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
+ * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
+ * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
+ * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
+ * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
+ * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
+ * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
+ * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
+ * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
+ * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+ * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+ */
+
+package io.spine.chatbot.server.google.chat;
+
+import io.spine.chatbot.google.chat.GoogleChatIdentifiers;
+import io.spine.chatbot.google.chat.MessageId;
+import io.spine.chatbot.google.chat.SpaceId;
+import io.spine.testing.UtilityClassTest;
+import io.spine.validate.ValidationException;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Nested;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.TestInstance;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.MethodSource;
+
+import java.util.stream.Stream;
+
+import static com.google.common.truth.extensions.proto.ProtoTruth.assertThat;
+import static io.spine.chatbot.google.chat.GoogleChatIdentifiers.message;
+import static io.spine.chatbot.google.chat.GoogleChatIdentifiers.space;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.TestInstance.Lifecycle.PER_CLASS;
+
+@DisplayName("`GoogleChatIdentifiers` should")
+final class GoogleChatIdentifiersTest extends UtilityClassTest {
+
+ GoogleChatIdentifiersTest() {
+ super(GoogleChatIdentifiers.class);
+ }
+
+ // nested tests do not work with static classes
+ @SuppressWarnings({"ClassCanBeStatic", "ResultOfMethodCallIgnored"})
+ @TestInstance(PER_CLASS)
+ @Nested
+ @DisplayName("not accept invalid")
+ final class NotAcceptInvalid {
+
+ @ParameterizedTest
+ @MethodSource("spaceIdsSource")
+ @DisplayName("space IDs")
+ void spaceIds(String value) {
+ assertThrows(ValidationException.class, () -> space(value));
+ }
+
+ @ParameterizedTest
+ @MethodSource("messageIdsSource")
+ @DisplayName("space IDs")
+ void messageIds(String value) {
+ assertThrows(ValidationException.class, () -> message(value));
+ }
+
+ @SuppressWarnings("unused") // method is used as parameterized test source
+ private Stream spaceIdsSource() {
+ return Stream.of("spaces/", "spacs/12415");
+ }
+
+ @SuppressWarnings("unused") // method is used as parameterized test source
+ private Stream messageIdsSource() {
+ return Stream.of("spaces/", "spaces/qwe124", "spaces/eqwt23/messages/");
+ }
+ }
+
+ @Test
+ @DisplayName("create space ID")
+ void createSpaceId() {
+ var spaceId = "spaces/qew21466";
+ var expectedSpace = SpaceId
+ .newBuilder()
+ .setValue(spaceId)
+ .buildPartial();
+ assertThat(space(spaceId))
+ .isEqualTo(expectedSpace);
+ }
+
+ @Test
+ @DisplayName("create message ID")
+ void createMessageId() {
+ var messageId = "spaces/qew21466/messages/123112111";
+ var expectedMessage = MessageId
+ .newBuilder()
+ .setValue(messageId)
+ .buildPartial();
+ assertThat(message(messageId))
+ .isEqualTo(expectedMessage);
+ }
+}
diff --git a/google-chat-bot/src/test/java/io/spine/chatbot/server/google/chat/IncomingEventsHandlerTest.java b/google-chat-bot/src/test/java/io/spine/chatbot/server/google/chat/IncomingEventsHandlerTest.java
new file mode 100644
index 00000000..b4f18fee
--- /dev/null
+++ b/google-chat-bot/src/test/java/io/spine/chatbot/server/google/chat/IncomingEventsHandlerTest.java
@@ -0,0 +1,138 @@
+/*
+ * Copyright 2020, TeamDev. All rights reserved.
+ *
+ * Redistribution and use in source and/or binary forms, with or without
+ * modification, must retain the above copyright notice and the following
+ * disclaimer.
+ *
+ * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
+ * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
+ * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
+ * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
+ * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
+ * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
+ * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
+ * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
+ * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
+ * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+ * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+ */
+
+package io.spine.chatbot.server.google.chat;
+
+import io.spine.chatbot.google.chat.MessageId;
+import io.spine.chatbot.google.chat.SpaceId;
+import io.spine.chatbot.google.chat.incoming.ChatEvent;
+import io.spine.chatbot.google.chat.incoming.EventType;
+import io.spine.chatbot.google.chat.incoming.Message;
+import io.spine.chatbot.google.chat.incoming.User;
+import io.spine.chatbot.google.chat.incoming.event.BotAddedToSpace;
+import io.spine.chatbot.google.chat.incoming.event.BotRemovedFromSpace;
+import io.spine.chatbot.google.chat.incoming.event.ChatEventReceived;
+import io.spine.chatbot.google.chat.incoming.event.MessageReceived;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+
+import static io.spine.chatbot.google.chat.GoogleChatIdentifiers.message;
+import static io.spine.chatbot.google.chat.GoogleChatIdentifiers.space;
+import static io.spine.chatbot.google.chat.incoming.EventType.ADDED_TO_SPACE;
+import static io.spine.chatbot.google.chat.incoming.EventType.MESSAGE;
+import static io.spine.chatbot.google.chat.incoming.EventType.REMOVED_FROM_SPACE;
+import static io.spine.chatbot.google.chat.incoming.SpaceType.ROOM;
+
+@DisplayName("`IncomingEventsHandler` should")
+final class IncomingEventsHandlerTest extends GoogleChatContextAwareTest {
+
+ private static final SpaceId space = space("spaces/fqeq325661a");
+ private static final MessageId message = message("spaces/fqeq325661a/messages/422");
+
+ @Test
+ @DisplayName("add bot to a space")
+ void addBot() {
+ // given
+ var chatEventReceived = chatEventReceived(ADDED_TO_SPACE);
+ var botAddedToSpace = BotAddedToSpace
+ .newBuilder()
+ .setEvent(chatEventReceived.getEvent())
+ .setSpace(space)
+ .vBuild();
+ // when
+ context().receivesExternalEvent(chatEventReceived);
+ // then
+ context().assertEvent(botAddedToSpace);
+ }
+
+ @Test
+ @DisplayName("remove bot from the space")
+ void removeBot() {
+ // given
+ var chatEventReceived = chatEventReceived(REMOVED_FROM_SPACE);
+ var botRemovedFromSpace = BotRemovedFromSpace
+ .newBuilder()
+ .setEvent(chatEventReceived.getEvent())
+ .setSpace(space)
+ .vBuild();
+ // when
+ context().receivesExternalEvent(chatEventReceived);
+ // then
+ context().assertEvent(botRemovedFromSpace);
+ }
+
+ @Test
+ @DisplayName("receive incoming message")
+ void receiveIncomingMessage() {
+ // given
+ var chatEventReceived = chatEventReceived(MESSAGE);
+ var messageReceived = MessageReceived
+ .newBuilder()
+ .setEvent(chatEventReceived.getEvent())
+ .setMessage(message)
+ .vBuild();
+ // when
+ context().receivesExternalEvent(chatEventReceived);
+ // then
+ context().assertEvent(messageReceived);
+ }
+
+ private static ChatEventReceived chatEventReceived(EventType type) {
+ return ChatEventReceived
+ .newBuilder()
+ .setEvent(chatEventOfType(type))
+ .vBuild();
+ }
+
+ private static ChatEvent chatEventOfType(EventType type) {
+ return ChatEvent
+ .newBuilder()
+ .setSpace(chatSpace())
+ .setType(type)
+ .setUser(sender())
+ .setEventTime("2020-06-20T15:42:02Z")
+ .setMessage(chatMessage())
+ .vBuild();
+ }
+
+ private static Message chatMessage() {
+ return Message
+ .newBuilder()
+ .setName(message.getValue())
+ .setSender(sender())
+ .setText("To be, or not to be, that is the question.")
+ .vBuild();
+ }
+
+ private static User sender() {
+ return User
+ .newBuilder()
+ .setName("users/00qwe123")
+ .vBuild();
+ }
+
+ private static io.spine.chatbot.google.chat.incoming.Space chatSpace() {
+ return io.spine.chatbot.google.chat.incoming.Space
+ .newBuilder()
+ .setName(space.getValue())
+ .setType(ROOM)
+ .vBuild();
+ }
+}
diff --git a/google-chat-bot/src/test/java/io/spine/chatbot/server/google/chat/SpaceAggregateTest.java b/google-chat-bot/src/test/java/io/spine/chatbot/server/google/chat/SpaceAggregateTest.java
new file mode 100644
index 00000000..f19ba354
--- /dev/null
+++ b/google-chat-bot/src/test/java/io/spine/chatbot/server/google/chat/SpaceAggregateTest.java
@@ -0,0 +1,142 @@
+/*
+ * Copyright 2020, TeamDev. All rights reserved.
+ *
+ * Redistribution and use in source and/or binary forms, with or without
+ * modification, must retain the above copyright notice and the following
+ * disclaimer.
+ *
+ * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
+ * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
+ * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
+ * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
+ * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
+ * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
+ * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
+ * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
+ * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
+ * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+ * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+ */
+
+package io.spine.chatbot.server.google.chat;
+
+import io.spine.chatbot.google.chat.Space;
+import io.spine.chatbot.google.chat.SpaceHeader;
+import io.spine.chatbot.google.chat.SpaceId;
+import io.spine.chatbot.google.chat.command.RegisterSpace;
+import io.spine.chatbot.google.chat.event.SpaceRegistered;
+import io.spine.chatbot.google.chat.incoming.ChatEvent;
+import io.spine.chatbot.google.chat.incoming.User;
+import io.spine.chatbot.google.chat.incoming.event.BotAddedToSpace;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Nested;
+import org.junit.jupiter.api.Test;
+
+import static io.spine.chatbot.google.chat.GoogleChatIdentifiers.space;
+import static io.spine.chatbot.google.chat.incoming.EventType.ADDED_TO_SPACE;
+import static io.spine.chatbot.google.chat.incoming.SpaceType.ROOM;
+
+@DisplayName("`SpaceAggregate` should")
+final class SpaceAggregateTest extends GoogleChatContextAwareTest {
+
+ private static final SpaceId SPACE = space("spaces/poqwdpQ21");
+ private static final SpaceHeader HEADER = SpaceHeader
+ .newBuilder()
+ .setThreaded(true)
+ .setDisplayName("Spine Developers")
+ .vBuild();
+
+ @Nested
+ @DisplayName("register a space")
+ final class Register {
+
+ @BeforeEach
+ void registerSpace() {
+ var registerSpace = RegisterSpace
+ .newBuilder()
+ .setId(SPACE)
+ .setHeader(HEADER)
+ .vBuild();
+ context().receivesCommand(registerSpace);
+ }
+
+ @Test
+ @DisplayName("producing `SpaceRegistered` event")
+ void producingEvent() {
+ var spaceRegistered = SpaceRegistered
+ .newBuilder()
+ .setSpace(SPACE)
+ .setHeader(HEADER)
+ .vBuild();
+ context().assertEvent(spaceRegistered);
+ }
+
+ @Test
+ @DisplayName("setting space state")
+ void settingState() {
+ var expectedState = Space
+ .newBuilder()
+ .setId(SPACE)
+ .setHeader(HEADER)
+ .vBuild();
+ context().assertState(SPACE, Space.class)
+ .isEqualTo(expectedState);
+ }
+ }
+
+ @Nested
+ @DisplayName("register a space when a bot is added to the space")
+ final class RegisterOnAddedBot {
+
+ @BeforeEach
+ void addBotToSpace() {
+ var chatEvent = ChatEvent
+ .newBuilder()
+ .setSpace(chatSpace())
+ .setUser(User.newBuilder()
+ .setName("users/12e1ojep1"))
+ .setType(ADDED_TO_SPACE)
+ .setEventTime("2020-06-19T15:39:01Z")
+ .vBuild();
+ var botAddedToSpace = BotAddedToSpace
+ .newBuilder()
+ .setSpace(SPACE)
+ .setEvent(chatEvent)
+ .vBuild();
+ context().receivesEvent(botAddedToSpace);
+ }
+
+ @Test
+ @DisplayName("producing `SpaceRegistered` event")
+ void producingEvent() {
+ var spaceRegistered = SpaceRegistered
+ .newBuilder()
+ .setSpace(SPACE)
+ .setHeader(HEADER)
+ .vBuild();
+ context().assertEvent(spaceRegistered);
+ }
+
+ @Test
+ @DisplayName("setting space state")
+ void settingState() {
+ var expectedState = Space
+ .newBuilder()
+ .setId(SPACE)
+ .setHeader(HEADER)
+ .vBuild();
+ context().assertState(SPACE, Space.class)
+ .isEqualTo(expectedState);
+ }
+
+ private io.spine.chatbot.google.chat.incoming.Space chatSpace() {
+ return io.spine.chatbot.google.chat.incoming.Space
+ .newBuilder()
+ .setName(SPACE.getValue())
+ .setDisplayName(HEADER.getDisplayName())
+ .setType(ROOM)
+ .vBuild();
+ }
+ }
+}
diff --git a/google-chat-bot/src/test/java/io/spine/chatbot/server/google/chat/ThreadAggregateTest.java b/google-chat-bot/src/test/java/io/spine/chatbot/server/google/chat/ThreadAggregateTest.java
new file mode 100644
index 00000000..37a2359c
--- /dev/null
+++ b/google-chat-bot/src/test/java/io/spine/chatbot/server/google/chat/ThreadAggregateTest.java
@@ -0,0 +1,144 @@
+/*
+ * Copyright 2020, TeamDev. All rights reserved.
+ *
+ * Redistribution and use in source and/or binary forms, with or without
+ * modification, must retain the above copyright notice and the following
+ * disclaimer.
+ *
+ * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
+ * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
+ * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
+ * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
+ * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
+ * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
+ * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
+ * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
+ * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
+ * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+ * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+ */
+
+package io.spine.chatbot.server.google.chat;
+
+import io.spine.chatbot.google.chat.MessageId;
+import io.spine.chatbot.google.chat.SpaceId;
+import io.spine.chatbot.google.chat.ThreadId;
+import io.spine.chatbot.google.chat.event.MessageCreated;
+import io.spine.chatbot.google.chat.event.ThreadCreated;
+import io.spine.chatbot.google.chat.thread.Thread;
+import io.spine.chatbot.google.chat.thread.ThreadResource;
+import io.spine.chatbot.google.chat.thread.event.MessageAdded;
+import io.spine.chatbot.google.chat.thread.event.ThreadInitialized;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Nested;
+import org.junit.jupiter.api.Test;
+
+import static io.spine.chatbot.google.chat.GoogleChatIdentifiers.message;
+import static io.spine.chatbot.google.chat.GoogleChatIdentifiers.space;
+import static io.spine.chatbot.google.chat.GoogleChatIdentifiers.thread;
+import static io.spine.chatbot.server.google.chat.ThreadResources.threadResource;
+
+@DisplayName("`ThreadAggregate` should")
+final class ThreadAggregateTest extends GoogleChatContextAwareTest {
+
+ @Nested
+ @DisplayName("initialize a thread")
+ final class InitThread {
+
+ private final ThreadId thread = thread("SpineEventEngine/base");
+ private final SpaceId space = space("spaces/qpojdwpiq1241");
+ private final ThreadResource resource =
+ threadResource("spaces/qpojdwpiq1241/threads/qwdojp12");
+
+ @BeforeEach
+ void createThread() {
+ var threadCreated = ThreadCreated
+ .newBuilder()
+ .setThread(thread)
+ .setSpace(space)
+ .setResource(resource)
+ .vBuild();
+ context().receivesEvent(threadCreated);
+ }
+
+ @Test
+ @DisplayName("producing ThreadInitialized event")
+ void producingEvent() {
+ var threadInitialized = ThreadInitialized
+ .newBuilder()
+ .setThread(thread)
+ .setSpace(space)
+ .setResource(resource)
+ .vBuild();
+ context().assertEvent(threadInitialized);
+ }
+
+ @Test
+ @DisplayName("setting aggregate state")
+ void settingState() {
+ var state = Thread
+ .newBuilder()
+ .setId(thread)
+ .setSpace(space)
+ .setResource(resource)
+ .vBuild();
+ context().assertState(thread, Thread.class)
+ .isEqualTo(state);
+ }
+ }
+
+ @Nested
+ @DisplayName("add created message")
+ final class AddMessage {
+
+ private final ThreadId thread = thread("SpineEventEngine/base");
+ private final SpaceId space = space("spaces/qpojdwpiq1241");
+ private final MessageId message = message("spaces/qpojdwpiq1241/messages/dqpwjpop12");
+ private final ThreadResource threadResource =
+ threadResource("spaces/qpojdwpiq1241/threads/qwdojp12");
+
+ @BeforeEach
+ void createThreadAndMessage() {
+ var threadCreated = ThreadCreated
+ .newBuilder()
+ .setThread(thread)
+ .setSpace(space)
+ .setResource(threadResource)
+ .vBuild();
+ var messageCreated = MessageCreated
+ .newBuilder()
+ .setMessage(message)
+ .setSpace(space)
+ .setThread(thread)
+ .vBuild();
+ context().receivesEvent(threadCreated)
+ .receivesEvent(messageCreated);
+ }
+
+ @Test
+ @DisplayName("producing `MessageAdded` event")
+ void producingEvent() {
+ var messageAdded = MessageAdded
+ .newBuilder()
+ .setMessage(message)
+ .setThread(thread)
+ .vBuild();
+ context().assertEvent(messageAdded);
+ }
+
+ @Test
+ @DisplayName("setting aggregate state")
+ void settingState() {
+ var state = Thread
+ .newBuilder()
+ .setId(thread)
+ .setSpace(space)
+ .setResource(threadResource)
+ .addMessage(message)
+ .vBuild();
+ context().assertState(thread, Thread.class)
+ .isEqualTo(state);
+ }
+ }
+}
diff --git a/google-chat-bot/src/test/java/io/spine/chatbot/server/google/chat/ThreadChatProcessTest.java b/google-chat-bot/src/test/java/io/spine/chatbot/server/google/chat/ThreadChatProcessTest.java
new file mode 100644
index 00000000..ae10c71f
--- /dev/null
+++ b/google-chat-bot/src/test/java/io/spine/chatbot/server/google/chat/ThreadChatProcessTest.java
@@ -0,0 +1,146 @@
+/*
+ * Copyright 2020, TeamDev. All rights reserved.
+ *
+ * Redistribution and use in source and/or binary forms, with or without
+ * modification, must retain the above copyright notice and the following
+ * disclaimer.
+ *
+ * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
+ * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
+ * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
+ * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
+ * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
+ * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
+ * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
+ * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
+ * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
+ * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+ * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+ */
+
+package io.spine.chatbot.server.google.chat;
+
+import io.spine.base.EventMessage;
+import io.spine.chatbot.github.RepositoryId;
+import io.spine.chatbot.github.repository.build.Build;
+import io.spine.chatbot.github.repository.build.BuildStateChange;
+import io.spine.chatbot.github.repository.build.event.BuildFailed;
+import io.spine.chatbot.github.repository.build.event.BuildRecovered;
+import io.spine.chatbot.google.chat.BuildStateUpdate;
+import io.spine.chatbot.google.chat.SpaceId;
+import io.spine.chatbot.google.chat.ThreadId;
+import io.spine.chatbot.google.chat.event.MessageCreated;
+import io.spine.chatbot.google.chat.event.ThreadCreated;
+import io.spine.chatbot.google.chat.thread.ThreadChat;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Nested;
+import org.junit.jupiter.api.Test;
+
+import static io.spine.chatbot.github.GitHubIdentifiers.repository;
+import static io.spine.chatbot.google.chat.GoogleChatIdentifiers.message;
+import static io.spine.chatbot.google.chat.GoogleChatIdentifiers.space;
+import static io.spine.chatbot.google.chat.GoogleChatIdentifiers.thread;
+import static io.spine.chatbot.server.google.chat.ThreadResources.threadResource;
+
+@DisplayName("`ThreadChatProcess` should")
+final class ThreadChatProcessTest {
+
+ @SuppressWarnings("ClassCanBeStatic") // nested tests do not work with static classes
+ @Nested
+ @DisplayName("sent a message to the Google Chat room when build failed")
+ final class BuildIsFailed extends BuildStateChanged {
+
+ @Override
+ EventMessage buildStateChangeEvent(RepositoryId repo, BuildStateChange stateChange) {
+ return BuildFailed
+ .newBuilder()
+ .setRepository(repo)
+ .setChange(stateChange)
+ .vBuild();
+ }
+ }
+
+ @SuppressWarnings("ClassCanBeStatic") // nested tests do not work with static classes
+ @Nested
+ @DisplayName("sent a message to the Google Chat room when build recovered from failure")
+ final class BuildIsRecovered extends BuildStateChanged {
+
+ @Override
+ EventMessage buildStateChangeEvent(RepositoryId repo, BuildStateChange stateChange) {
+ return BuildRecovered
+ .newBuilder()
+ .setRepository(repo)
+ .setChange(stateChange)
+ .vBuild();
+ }
+ }
+
+ private abstract static class BuildStateChanged extends GoogleChatContextAwareTest {
+
+ private static final String buildNumber = "551";
+
+ private final RepositoryId repo = repository("SpineEventEngine/money");
+ private final ThreadId thread = thread(repo.getValue());
+ private final SpaceId space = space("spaces/1241pjwqe");
+
+ private final BuildStateUpdate stateUpdate = BuildStateUpdate
+ .newBuilder()
+ .setSpace(space)
+ .setMessage(message("spaces/1241pjwqe/messages/12154363643624"))
+ .setThread(thread)
+ .setResource(threadResource("spaces/1241pjwqe/threads/k12d1o2r1"))
+ .vBuild();
+
+ @BeforeEach
+ void receiveBuildStateChange() {
+ googleChatClient().setBuildStateUpdate(buildNumber, stateUpdate);
+ var newBuildState = Build
+ .newBuilder()
+ .setSpace(space)
+ .setNumber(buildNumber)
+ .vBuild();
+ var buildStateChange = BuildStateChange
+ .newBuilder()
+ .setNewValue(newBuildState)
+ .vBuild();
+ var buildFailed = buildStateChangeEvent(repo, buildStateChange);
+ context().receivesExternalEvent(buildFailed);
+ }
+
+ abstract EventMessage buildStateChangeEvent(RepositoryId repo,
+ BuildStateChange stateChange);
+
+ @Test
+ @DisplayName("producing `MessageCreated` and `ThreadCreated` events")
+ void producingEvents() {
+ var messageCreated = MessageCreated
+ .newBuilder()
+ .setMessage(stateUpdate.getMessage())
+ .setThread(thread)
+ .setSpace(space)
+ .vBuild();
+ var threadCreated = ThreadCreated
+ .newBuilder()
+ .setThread(thread)
+ .setResource(stateUpdate.getResource())
+ .setSpace(space)
+ .vBuild();
+ context().assertEvent(messageCreated);
+ context().assertEvent(threadCreated);
+ }
+
+ @Test
+ @DisplayName("setting process state")
+ void settingState() {
+ var expectedState = ThreadChat
+ .newBuilder()
+ .setThread(thread)
+ .setSpace(space)
+ .setResource(stateUpdate.getResource())
+ .vBuild();
+ context().assertState(thread, ThreadChat.class)
+ .isEqualTo(expectedState);
+ }
+ }
+}
diff --git a/google-chat-bot/src/test/java/io/spine/chatbot/server/google/chat/ThreadResourcesTest.java b/google-chat-bot/src/test/java/io/spine/chatbot/server/google/chat/ThreadResourcesTest.java
new file mode 100644
index 00000000..7c549ad7
--- /dev/null
+++ b/google-chat-bot/src/test/java/io/spine/chatbot/server/google/chat/ThreadResourcesTest.java
@@ -0,0 +1,32 @@
+/*
+ * Copyright 2020, TeamDev. All rights reserved.
+ *
+ * Redistribution and use in source and/or binary forms, with or without
+ * modification, must retain the above copyright notice and the following
+ * disclaimer.
+ *
+ * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
+ * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
+ * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
+ * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
+ * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
+ * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
+ * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
+ * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
+ * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
+ * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+ * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+ */
+
+package io.spine.chatbot.server.google.chat;
+
+import io.spine.testing.UtilityClassTest;
+import org.junit.jupiter.api.DisplayName;
+
+@DisplayName("`ThreadResources` should")
+final class ThreadResourcesTest extends UtilityClassTest {
+
+ ThreadResourcesTest() {
+ super(ThreadResources.class);
+ }
+}
diff --git a/google-chat-bot/src/test/java/io/spine/chatbot/travis/InMemoryTravisClient.java b/google-chat-bot/src/test/java/io/spine/chatbot/travis/InMemoryTravisClient.java
new file mode 100644
index 00000000..b12d3573
--- /dev/null
+++ b/google-chat-bot/src/test/java/io/spine/chatbot/travis/InMemoryTravisClient.java
@@ -0,0 +1,91 @@
+/*
+ * Copyright 2020, TeamDev. All rights reserved.
+ *
+ * Redistribution and use in source and/or binary forms, with or without
+ * modification, must retain the above copyright notice and the following
+ * disclaimer.
+ *
+ * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
+ * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
+ * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
+ * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
+ * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
+ * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
+ * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
+ * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
+ * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
+ * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+ * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+ */
+
+package io.spine.chatbot.travis;
+
+import io.spine.chatbot.CanFailFast;
+import io.spine.chatbot.github.Slug;
+
+import java.util.HashMap;
+import java.util.Map;
+
+import static com.google.common.base.Preconditions.checkNotNull;
+import static io.spine.protobuf.Messages.defaultInstance;
+import static java.util.Collections.synchronizedMap;
+
+/**
+ * An in-memory test-only implementation of the Travis CI API client.
+ */
+public final class InMemoryTravisClient extends CanFailFast implements TravisClient {
+
+ private final Map, TravisResponse> responses = synchronizedMap(new HashMap<>());
+
+ private InMemoryTravisClient(boolean failFast) {
+ super(failFast);
+ }
+
+ /**
+ * Creates a {@link #CanFailFast#failFast failFast} in-memory Travis CI client.
+ */
+ public static InMemoryTravisClient strictClient() {
+ return new InMemoryTravisClient(true);
+ }
+
+ /**
+ * Creates a lenient in-memory Travis CI client.
+ */
+ public static InMemoryTravisClient lenientClient() {
+ return new InMemoryTravisClient(false);
+ }
+
+ @Override
+ public T execute(Query query) {
+ checkNotNull(query);
+ var stubbedValue = responses.get(query);
+ var responseType = query.responseType();
+ var result = failOrDefault(stubbedValue, query, defaultInstance(responseType));
+ return responseType.cast(result);
+ }
+
+ /**
+ * Sets up a stub {@code branchBuild} response for a specified {@code repository}.
+ */
+ public void setBuildsFor(Slug repository, RepoBranchBuildResponse branchBuild) {
+ checkNotNull(repository);
+ checkNotNull(branchBuild);
+ responses.put(BuildsQuery.forRepo(repository), branchBuild);
+ }
+
+ /**
+ * Sets up a stub {@code repositories} response for a specified {@code owner}.
+ */
+ public void setRepositoriesFor(Slug owner, RepositoriesResponse repos) {
+ checkNotNull(owner);
+ checkNotNull(repos);
+ responses.put(ReposQuery.forOwner(owner), repos);
+ }
+
+ /**
+ * Resets state of the configured responses.
+ */
+ public void reset() {
+ responses.clear();
+ }
+}
diff --git a/google-chat-bot/src/test/resources/chat_event.json b/google-chat-bot/src/test/resources/chat_event.json
new file mode 100644
index 00000000..4f0cb697
--- /dev/null
+++ b/google-chat-bot/src/test/resources/chat_event.json
@@ -0,0 +1,46 @@
+{
+ "type": "MESSAGE",
+ "eventTime": "2017-03-02T19:02:59.910959Z",
+ "space": {
+ "name": "spaces/AAAAAAAAAAA",
+ "displayName": "Chuck Norris Discussion Room",
+ "type": "ROOM"
+ },
+ "message": {
+ "name": "spaces/AAAAAAAAAAA/messages/CCCCCCCCCCC",
+ "sender": {
+ "name": "users/12345678901234567890",
+ "displayName": "Chuck Norris",
+ "avatarUrl": "https://lh3.googleusercontent.com/.../photo.jpg",
+ "email": "chuck@example.com"
+ },
+ "createTime": "2017-03-02T19:02:59.910959Z",
+ "text": "@TestBot Violence is my last option.",
+ "argumentText": " Violence is my last option.",
+ "thread": {
+ "name": "spaces/AAAAAAAAAAA/threads/BBBBBBBBBBB"
+ },
+ "annotations": [
+ {
+ "length": 8,
+ "startIndex": 0,
+ "userMention": {
+ "type": "MENTION",
+ "user": {
+ "avatarUrl": "https://.../avatar.png",
+ "displayName": "TestBot",
+ "name": "users/1234567890987654321",
+ "type": "BOT"
+ }
+ },
+ "type": "USER_MENTION"
+ }
+ ]
+ },
+ "user": {
+ "name": "users/12345678901234567890",
+ "displayName": "Chuck Norris",
+ "avatarUrl": "https://lh3.googleusercontent.com/.../photo.jpg",
+ "email": "chuck@example.com"
+ }
+}
diff --git a/google-chat-bot/src/test/resources/pubsub_push_request.json b/google-chat-bot/src/test/resources/pubsub_push_request.json
new file mode 100644
index 00000000..af5a0293
--- /dev/null
+++ b/google-chat-bot/src/test/resources/pubsub_push_request.json
@@ -0,0 +1,10 @@
+{
+ "message": {
+ "data": "eyJrZXkiOiJ2YWx1ZSJ9",
+ "messageId": "450292511223766",
+ "message_id": "450292511223766",
+ "publishTime": "2020-06-21T20:48:25.908Z",
+ "publish_time": "2020-06-21T20:48:25.908Z"
+ },
+ "subscription": "projects/test-project/subscriptions/test-subscription"
+}
diff --git a/gradle.properties b/gradle.properties
index 988b791e..084555fc 100644
--- a/gradle.properties
+++ b/gradle.properties
@@ -1 +1,20 @@
-micronautVersion=2.0.0.M3
+#
+# Copyright 2020, TeamDev. All rights reserved.
+#
+# Redistribution and use in source and/or binary forms, with or without
+# modification, must retain the above copyright notice and the following
+# disclaimer.
+#
+# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
+# "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
+# LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
+# A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
+# OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
+# SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
+# LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
+# DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
+# THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
+# (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+#
+gcpProject=''
diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties
index 9ecfb344..7c0e0d60 100644
--- a/gradle/wrapper/gradle-wrapper.properties
+++ b/gradle/wrapper/gradle-wrapper.properties
@@ -1,4 +1,4 @@
-#Mon May 25 21:30:17 EEST 2020
+#Mon Jun 15 15:39:45 EEST 2020
distributionUrl=https\://services.gradle.org/distributions/gradle-6.4.1-all.zip
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
diff --git a/gradlew b/gradlew
old mode 100644
new mode 100755
diff --git a/http-client.env.json b/http-client.env.json
new file mode 100644
index 00000000..68c063e6
--- /dev/null
+++ b/http-client.env.json
@@ -0,0 +1,6 @@
+{
+ "travis": {
+ "host": "api.travis-ci.com",
+ "token": ""
+ }
+}
diff --git a/settings.gradle b/settings.gradle
deleted file mode 100644
index 61ffebc0..00000000
--- a/settings.gradle
+++ /dev/null
@@ -1 +0,0 @@
-rootProject.name="chat-bot"
diff --git a/settings.gradle.kts b/settings.gradle.kts
new file mode 100644
index 00000000..e1f0c05a
--- /dev/null
+++ b/settings.gradle.kts
@@ -0,0 +1,22 @@
+/*
+ * Copyright 2020, TeamDev. All rights reserved.
+ *
+ * Redistribution and use in source and/or binary forms, with or without
+ * modification, must retain the above copyright notice and the following
+ * disclaimer.
+ *
+ * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
+ * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
+ * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
+ * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
+ * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
+ * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
+ * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
+ * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
+ * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
+ * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+ * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+ */
+
+rootProject.name = "ChatBot"
+include("google-chat-bot")
diff --git a/src/main/java/io/spine/chatbot/Application.java b/src/main/java/io/spine/chatbot/Application.java
deleted file mode 100644
index 45281e3b..00000000
--- a/src/main/java/io/spine/chatbot/Application.java
+++ /dev/null
@@ -1,10 +0,0 @@
-package io.spine.chatbot;
-
-import io.micronaut.runtime.Micronaut;
-
-public class Application {
-
- public static void main(String[] args) {
- Micronaut.run(Application.class, args);
- }
-}
diff --git a/src/main/java/io/spine/chatbot/HelloController.java b/src/main/java/io/spine/chatbot/HelloController.java
deleted file mode 100644
index e41de7bf..00000000
--- a/src/main/java/io/spine/chatbot/HelloController.java
+++ /dev/null
@@ -1,12 +0,0 @@
-package io.spine.chatbot;
-
-import io.micronaut.http.annotation.*;
-
-@Controller("/hello")
-public class HelloController {
-
- @Get(uri="/", produces="text/plain")
- public String index() {
- return "Example Response";
- }
-}
\ No newline at end of file
diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml
deleted file mode 100644
index 8a4def16..00000000
--- a/src/main/resources/application.yml
+++ /dev/null
@@ -1,3 +0,0 @@
-micronaut:
- application:
- name: chatBot
diff --git a/src/main/resources/log4j2.xml b/src/main/resources/log4j2.xml
deleted file mode 100644
index 42f90d8e..00000000
--- a/src/main/resources/log4j2.xml
+++ /dev/null
@@ -1,16 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
\ No newline at end of file
diff --git a/src/test/java/io.spine/.gitkeep b/src/test/java/io.spine/.gitkeep
deleted file mode 100644
index e69de29b..00000000
diff --git a/src/test/java/io/spine/chatbot/HelloFunctionTest.java b/src/test/java/io/spine/chatbot/HelloFunctionTest.java
deleted file mode 100644
index 39a16676..00000000
--- a/src/test/java/io/spine/chatbot/HelloFunctionTest.java
+++ /dev/null
@@ -1,43 +0,0 @@
-package io.spine.chatbot;
-
-import io.micronaut.context.ApplicationContext;
-import io.micronaut.http.HttpRequest;
-import io.micronaut.http.client.HttpClient;
-import io.micronaut.runtime.server.EmbeddedServer;
-import io.micronaut.test.annotation.MicronautTest;
-import org.junit.jupiter.api.AfterAll;
-import org.junit.jupiter.api.BeforeAll;
-import org.junit.jupiter.api.Test;
-
-import static org.junit.jupiter.api.Assertions.assertEquals;
-
-@MicronautTest
-final class HelloFunctionTest {
-
- private static EmbeddedServer server;
- private static HttpClient client;
-
- @BeforeAll
- public static void setupServer() {
- server = ApplicationContext.run(EmbeddedServer.class);
- client = server
- .getApplicationContext()
- .createBean(HttpClient.class, server.getURL());
- }
-
- @AfterAll
- public static void stopServer() {
- if (server != null) {
- server.stop();
- }
- if (client != null) {
- client.stop();
- }
- }
-
- @Test
- public void testFunction() {
- String actual = client.toBlocking().retrieve(HttpRequest.GET("/hello"));
- assertEquals("Example Response", actual);
- }
-}
diff --git a/travis-ci.http b/travis-ci.http
new file mode 100644
index 00000000..7b02c0a3
--- /dev/null
+++ b/travis-ci.http
@@ -0,0 +1,20 @@
+### List Travis CI builds for a repository
+
+GET https://{{host}}/repo/SpineEventEngine%2Fcore-java/builds?limit=3&branch.name=master&include=build.commit
+Accept: application/json
+Authorization: token {{token}}
+Travis-API-Version: 3
+
+### List Travis CI repositories for a user
+
+GET https://{{host}}/owner/SpineEventEngine/repos
+Accept: application/json
+Authorization: token {{token}}
+Travis-API-Version: 3
+
+### List Travis CI repository branch builds
+
+GET https://{{host}}/repo/SpineEventEngine%2Fcore-java/branch/master?include=build.commit,build.created_by
+Accept: application/json
+Authorization: token {{token}}
+Travis-API-Version: 3
diff --git a/version.gradle.kts b/version.gradle.kts
new file mode 100644
index 00000000..79c72ebb
--- /dev/null
+++ b/version.gradle.kts
@@ -0,0 +1,25 @@
+/*
+ * Copyright 2020, TeamDev. All rights reserved.
+ *
+ * Redistribution and use in source and/or binary forms, with or without
+ * modification, must retain the above copyright notice and the following
+ * disclaimer.
+ *
+ * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
+ * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
+ * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
+ * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
+ * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
+ * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
+ * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
+ * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
+ * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
+ * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+ * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+ */
+
+/**
+ * The version of the application.
+ */
+
+val botVersion: String by extra("1.0.0")