~jan0sch/smederee

Showing details for patch 7f9f7add4fcf198c3af8fe04f03f905a1fd57da7.
2023-03-01 (Wed), 7:24 PM - Jens Grassel - 7f9f7add4fcf198c3af8fe04f03f905a1fd57da7

BREAKING CHANGES: Move ticket code to separate module and use DB schemas.

- move ticket code out of the hub module
- rewrite database code and migrations to use "hub" schema for the hub

This breaks existing installations! Because we're still in the alpha phase
this is tolerable although not great but I found no way to migrate an
existing installation automatically.

An existing installation MUST either configure a different database and
migrate the content from the old one into the new and into the hub schema
tables OR dump the old tables, clear the database and import the content
into the hub schema tables.
Summary of changes
25 files modified with 180 lines added and 258 lines removed
  • modules/hub/src/it/scala/de/smederee/hub/AuthenticationMiddlewareTest.scala with 0 added and 18 removed lines
  • modules/hub/src/it/scala/de/smederee/hub/BaseSpec.scala with 26 added and 8 removed lines
  • modules/hub/src/it/scala/de/smederee/hub/DatabaseMigratorTest.scala with 0 added and 6 removed lines
  • modules/hub/src/it/scala/de/smederee/hub/DoobieAccountManagementRepositoryTest.scala with 0 added and 17 removed lines
  • modules/hub/src/it/scala/de/smederee/hub/DoobieAuthenticationRepositoryTest.scala with 2 added and 2 removed lines
  • modules/hub/src/it/scala/de/smederee/hub/DoobieSignupRepositoryTest.scala with 0 added and 17 removed lines
  • modules/hub/src/it/scala/de/smederee/hub/DoobieVcsMetadataRepositoryTest.scala with 1 added and 18 removed lines
  • modules/hub/src/main/resources/assets/css/main.css with 4 added and 0 removed lines
  • modules/hub/src/main/resources/db/migration/V1__base_tables.sql with 37 added and 35 removed lines
  • modules/hub/src/main/resources/db/migration/V2__repository_tables.sql with 12 added and 12 removed lines
  • modules/hub/src/main/resources/db/migration/V3__fork_tables.sql with 6 added and 6 removed lines
  • modules/hub/src/main/resources/messages_en.properties with 2 added and 2 removed lines
  • modules/hub/src/main/resources/reference.conf with 1 added and 1 removed lines
  • modules/hub/src/main/scala/de/smederee/hub/Account.scala with 0 added and 15 removed lines
  • modules/hub/src/main/scala/de/smederee/hub/DatabaseMigrator.scala with 21 added and 2 removed lines
  • modules/hub/src/main/scala/de/smederee/hub/DoobieAccountManagementRepository.scala with 8 added and 8 removed lines
  • modules/hub/src/main/scala/de/smederee/hub/DoobieAuthenticationRepository.scala with 9 added and 9 removed lines
  • modules/hub/src/main/scala/de/smederee/hub/DoobieSignupRepository.scala with 3 added and 3 removed lines
  • modules/hub/src/main/scala/de/smederee/hub/DoobieVcsMetadataRepository.scala with 18 added and 8 removed lines
  • modules/hub/src/main/scala/de/smederee/hub/HubServer.scala with 5 added and 17 removed lines
  • modules/hub/src/main/scala/de/smederee/hub/LandingPageRoutes.scala with 5 added and 4 removed lines
  • modules/hub/src/main/scala/de/smederee/hub/SignupRoutes.scala with 6 added and 10 removed lines
  • modules/hub/src/main/scala/de/smederee/hub/config/SmedereeHubConfig.scala with 11 added and 31 removed lines
  • modules/hub/src/main/scala/de/smederee/ssh/DoobieSshAuthenticationRepository.scala with 3 added and 3 removed lines
  • modules/hub/src/main/twirl/de/smederee/hub/views/showRepositoryMenu.scala.html with 0 added and 6 removed lines
35 files removed
  • modules/hub/src/test/scala/de/smederee/tickets/ColourCodeTest.scala
  • modules/hub/src/test/scala/de/smederee/tickets/Generators.scala
  • modules/hub/src/test/scala/de/smederee/tickets/LabelDescriptionTest.scala
  • modules/hub/src/test/scala/de/smederee/tickets/LabelNameTest.scala
  • modules/hub/src/test/scala/de/smederee/tickets/LabelTest.scala
  • modules/hub/src/test/scala/de/smederee/tickets/MilestoneDescriptionTest.scala
  • modules/hub/src/test/scala/de/smederee/tickets/MilestoneTest.scala
  • modules/hub/src/test/scala/de/smederee/tickets/MilestoneTitleTest.scala
  • modules/hub/src/test/scala/de/smederee/tickets/TicketContentTest.scala
  • modules/hub/src/test/scala/de/smederee/tickets/TicketNumberTest.scala
  • modules/hub/src/test/scala/de/smederee/tickets/TicketTitleTest.scala
  • modules/hub/src/main/twirl/de/smederee/hub/views/tickets/editLabel.scala.html
  • modules/hub/src/main/twirl/de/smederee/hub/views/tickets/editLabels.scala.html
  • modules/hub/src/main/twirl/de/smederee/hub/views/tickets/editMilestone.scala.html
  • modules/hub/src/main/twirl/de/smederee/hub/views/tickets/editMilestones.scala.html
  • modules/hub/src/main/scala/de/smederee/tickets/Assignee.scala
  • modules/hub/src/main/scala/de/smederee/tickets/DoobieLabelRepository.scala
  • modules/hub/src/main/scala/de/smederee/tickets/DoobieMilestoneRepository.scala
  • modules/hub/src/main/scala/de/smederee/tickets/Label.scala
  • modules/hub/src/main/scala/de/smederee/tickets/LabelForm.scala
  • modules/hub/src/main/scala/de/smederee/tickets/LabelRepository.scala
  • modules/hub/src/main/scala/de/smederee/tickets/LabelRoutes.scala
  • modules/hub/src/main/scala/de/smederee/tickets/Milestone.scala
  • modules/hub/src/main/scala/de/smederee/tickets/MilestoneForm.scala
  • modules/hub/src/main/scala/de/smederee/tickets/MilestoneRepository.scala
  • modules/hub/src/main/scala/de/smederee/tickets/MilestoneRoutes.scala
  • modules/hub/src/main/scala/de/smederee/tickets/Submitter.scala
  • modules/hub/src/main/scala/de/smederee/tickets/Ticket.scala
  • modules/hub/src/main/scala/de/smederee/tickets/TicketRepository.scala
  • modules/hub/src/main/scala/de/smederee/html/LinkTools.scala
  • modules/hub/src/main/scala/de/smederee/html/MetaTags.scala
  • modules/hub/src/it/scala/de/smederee/tickets/DoobieLabelRepositoryTest.scala
  • modules/hub/src/it/scala/de/smederee/tickets/DoobieMilestoneRepositoryTest.scala
  • modules/hub/src/it/scala/de/smederee/tickets/Generators.scala
  • modules/hub/src/main/resources/db/migration/V4__ticket_tables.sql
diff -rN -u old-smederee/modules/hub/src/it/scala/de/smederee/hub/AuthenticationMiddlewareTest.scala new-smederee/modules/hub/src/it/scala/de/smederee/hub/AuthenticationMiddlewareTest.scala
--- old-smederee/modules/hub/src/it/scala/de/smederee/hub/AuthenticationMiddlewareTest.scala	2025-01-31 13:42:16.240771651 +0000
+++ new-smederee/modules/hub/src/it/scala/de/smederee/hub/AuthenticationMiddlewareTest.scala	2025-01-31 13:42:16.248771665 +0000
@@ -32,24 +32,6 @@
 import munit._
 
 final class AuthenticationMiddlewareTest extends BaseSpec with AuthenticationMiddleware {
-
-  override def beforeEach(context: BeforeEach): Unit = {
-    val dbConfig = configuration.database
-    val flyway: Flyway =
-      Flyway.configure().cleanDisabled(false).dataSource(dbConfig.url, dbConfig.user, dbConfig.pass).load()
-    val _ = flyway.migrate()
-    val _ = flyway.clean()
-    val _ = flyway.migrate()
-  }
-
-  override def afterEach(context: AfterEach): Unit = {
-    val dbConfig = configuration.database
-    val flyway: Flyway =
-      Flyway.configure().cleanDisabled(false).dataSource(dbConfig.url, dbConfig.user, dbConfig.pass).load()
-    val _ = flyway.migrate()
-    val _ = flyway.clean()
-  }
-
   test("extractSessionId must return the session id") {
     (genSignAndValidate.sample, genSessionId.sample) match {
       case (Some(signAndValidate), Some(sessionId)) =>
diff -rN -u old-smederee/modules/hub/src/it/scala/de/smederee/hub/BaseSpec.scala new-smederee/modules/hub/src/it/scala/de/smederee/hub/BaseSpec.scala
--- old-smederee/modules/hub/src/it/scala/de/smederee/hub/BaseSpec.scala	2025-01-31 13:42:16.240771651 +0000
+++ new-smederee/modules/hub/src/it/scala/de/smederee/hub/BaseSpec.scala	2025-01-31 13:42:16.248771665 +0000
@@ -27,6 +27,7 @@
 import com.comcast.ip4s._
 import com.typesafe.config.ConfigFactory
 import de.smederee.hub.config._
+import org.flywaydb.core.Flyway
 import pureconfig._
 
 import munit._
@@ -44,6 +45,12 @@
   protected final val configuration: SmedereeHubConfig =
     ConfigSource.fromConfig(ConfigFactory.load(getClass.getClassLoader)).loadOrThrow[SmedereeHubConfig]
 
+  protected final val flyway: Flyway =
+    DatabaseMigrator
+      .configureFlyway(configuration.database.url, configuration.database.user, configuration.database.pass)
+      .cleanDisabled(false)
+      .load()
+
   /** Connect to the DBMS using the generic "template1" database which should always be present.
     *
     * @param dbConfig
@@ -84,6 +91,17 @@
     }.unsafeRunSync()
   }
 
+  override def beforeEach(context: BeforeEach): Unit = {
+    val _ = flyway.migrate()
+    val _ = flyway.clean()
+    val _ = flyway.migrate()
+  }
+
+  override def afterEach(context: AfterEach): Unit = {
+    val _ = flyway.migrate()
+    val _ = flyway.clean()
+  }
+
   /** Find and return a free port on the local machine by starting a server socket and closing it. The port number used
     * by the socket is marked to allow reuse, considered free and returned.
     *
@@ -139,19 +157,19 @@
           (unlockToken, validationToken) match {
             case (None, None) =>
               con.prepareStatement(
-                """INSERT INTO "accounts" (uid, name, email, password, failed_attempts, created_at, updated_at, validated_email) VALUES(?, ?, ?, ?, ?, NOW(), NOW(), ?)"""
+                """INSERT INTO "hub"."accounts" (uid, name, email, password, failed_attempts, created_at, updated_at, validated_email) VALUES(?, ?, ?, ?, ?, NOW(), NOW(), ?)"""
               )
             case (Some(_), None) =>
               con.prepareStatement(
-                """INSERT INTO "accounts" (uid, name, email, password, failed_attempts, validated_email, locked_at, unlock_token, created_at, updated_at) VALUES(?, ?, ?, ?, ?, ?, NOW(), ?, NOW(), NOW())"""
+                """INSERT INTO "hub"."accounts" (uid, name, email, password, failed_attempts, validated_email, locked_at, unlock_token, created_at, updated_at) VALUES(?, ?, ?, ?, ?, ?, NOW(), ?, NOW(), NOW())"""
               )
             case (None, Some(_)) =>
               con.prepareStatement(
-                """INSERT INTO "accounts" (uid, name, email, password, failed_attempts, validated_email, locked_at, validation_token, created_at, updated_at) VALUES(?, ?, ?, ?, ?, ?, NOW(), ?, NOW(), NOW())"""
+                """INSERT INTO "hub"."accounts" (uid, name, email, password, failed_attempts, validated_email, locked_at, validation_token, created_at, updated_at) VALUES(?, ?, ?, ?, ?, ?, NOW(), ?, NOW(), NOW())"""
               )
             case (Some(_), Some(_)) =>
               con.prepareStatement(
-                """INSERT INTO "accounts" (uid, name, email, password, failed_attempts, validated_email, locked_at, unlock_token, validation_token, created_at, updated_at) VALUES(?, ?, ?, ?, ?, ?, NOW(), ?, NOW(), NOW())"""
+                """INSERT INTO "hub"."accounts" (uid, name, email, password, failed_attempts, validated_email, locked_at, unlock_token, validation_token, created_at, updated_at) VALUES(?, ?, ?, ?, ?, ?, NOW(), ?, NOW(), NOW())"""
               )
           }
         }
@@ -194,7 +212,7 @@
       for {
         statement <- IO.delay(
           con.prepareStatement(
-            """INSERT INTO "sessions" (id, uid, created_at, updated_at) VALUES(?, ?, ?, ?)"""
+            """INSERT INTO "hub"."sessions" (id, uid, created_at, updated_at) VALUES(?, ?, ?, ?)"""
           )
         )
         _ <- IO.delay(statement.setString(1, session.id.toString))
@@ -223,7 +241,7 @@
       for {
         statement <- IO.delay(
           con.prepareStatement(
-            """SELECT uid, name, email, password, created_at, updated_at, validated_email FROM "accounts" WHERE uid = ? LIMIT 1"""
+            """SELECT uid, name, email, password, created_at, updated_at, validated_email FROM "hub"."accounts" WHERE uid = ? LIMIT 1"""
           )
         )
         _      <- IO.delay(statement.setObject(1, uid))
@@ -259,7 +277,7 @@
       for {
         statement <- IO.delay(
           con.prepareStatement(
-            """SELECT validated_email, validation_token FROM "accounts" WHERE uid = ? LIMIT 1"""
+            """SELECT validated_email, validation_token FROM "hub"."accounts" WHERE uid = ? LIMIT 1"""
           )
         )
         _      <- IO.delay(statement.setObject(1, uid))
@@ -296,7 +314,7 @@
       for {
         statement <- IO.delay(
           con.prepareStatement(
-            """SELECT id FROM "repositories" WHERE owner = ? AND name = ? LIMIT 1"""
+            """SELECT id FROM "hub"."repositories" WHERE owner = ? AND name = ? LIMIT 1"""
           )
         )
         _      <- IO.delay(statement.setObject(1, owner))
diff -rN -u old-smederee/modules/hub/src/it/scala/de/smederee/hub/DatabaseMigratorTest.scala new-smederee/modules/hub/src/it/scala/de/smederee/hub/DatabaseMigratorTest.scala
--- old-smederee/modules/hub/src/it/scala/de/smederee/hub/DatabaseMigratorTest.scala	2025-01-31 13:42:16.240771651 +0000
+++ new-smederee/modules/hub/src/it/scala/de/smederee/hub/DatabaseMigratorTest.scala	2025-01-31 13:42:16.248771665 +0000
@@ -25,17 +25,11 @@
 
 final class DatabaseMigratorTest extends BaseSpec {
   override def beforeEach(context: BeforeEach): Unit = {
-    val dbConfig = configuration.database
-    val flyway: Flyway =
-      Flyway.configure().cleanDisabled(false).dataSource(dbConfig.url, dbConfig.user, dbConfig.pass).load()
     val _ = flyway.migrate()
     val _ = flyway.clean()
   }
 
   override def afterEach(context: AfterEach): Unit = {
-    val dbConfig = configuration.database
-    val flyway: Flyway =
-      Flyway.configure().cleanDisabled(false).dataSource(dbConfig.url, dbConfig.user, dbConfig.pass).load()
     val _ = flyway.migrate()
     val _ = flyway.clean()
   }
diff -rN -u old-smederee/modules/hub/src/it/scala/de/smederee/hub/DoobieAccountManagementRepositoryTest.scala new-smederee/modules/hub/src/it/scala/de/smederee/hub/DoobieAccountManagementRepositoryTest.scala
--- old-smederee/modules/hub/src/it/scala/de/smederee/hub/DoobieAccountManagementRepositoryTest.scala	2025-01-31 13:42:16.240771651 +0000
+++ new-smederee/modules/hub/src/it/scala/de/smederee/hub/DoobieAccountManagementRepositoryTest.scala	2025-01-31 13:42:16.248771665 +0000
@@ -31,23 +31,6 @@
 import munit._
 
 final class DoobieAccountManagementRepositoryTest extends BaseSpec {
-  override def beforeEach(context: BeforeEach): Unit = {
-    val dbConfig = configuration.database
-    val flyway: Flyway =
-      Flyway.configure().cleanDisabled(false).dataSource(dbConfig.url, dbConfig.user, dbConfig.pass).load()
-    val _ = flyway.migrate()
-    val _ = flyway.clean()
-    val _ = flyway.migrate()
-  }
-
-  override def afterEach(context: AfterEach): Unit = {
-    val dbConfig = configuration.database
-    val flyway: Flyway =
-      Flyway.configure().cleanDisabled(false).dataSource(dbConfig.url, dbConfig.user, dbConfig.pass).load()
-    val _ = flyway.migrate()
-    val _ = flyway.clean()
-  }
-
   val sshKeyWithComment = ResourceSuiteLocalFixture(
     "ssh-key-with-comment",
     Resource.make(IO {
diff -rN -u old-smederee/modules/hub/src/it/scala/de/smederee/hub/DoobieAuthenticationRepositoryTest.scala new-smederee/modules/hub/src/it/scala/de/smederee/hub/DoobieAuthenticationRepositoryTest.scala
--- old-smederee/modules/hub/src/it/scala/de/smederee/hub/DoobieAuthenticationRepositoryTest.scala	2025-01-31 13:42:16.240771651 +0000
+++ new-smederee/modules/hub/src/it/scala/de/smederee/hub/DoobieAuthenticationRepositoryTest.scala	2025-01-31 13:42:16.248771665 +0000
@@ -32,7 +32,7 @@
   override def beforeEach(context: BeforeEach): Unit = {
     val dbConfig = configuration.database
     val flyway: Flyway =
-      Flyway.configure().cleanDisabled(false).dataSource(dbConfig.url, dbConfig.user, dbConfig.pass).load()
+      DatabaseMigrator.configureFlyway(dbConfig.url, dbConfig.user, dbConfig.pass).cleanDisabled(false).load()
     val _ = flyway.migrate()
     val _ = flyway.clean()
     val _ = flyway.migrate()
@@ -41,7 +41,7 @@
   override def afterEach(context: AfterEach): Unit = {
     val dbConfig = configuration.database
     val flyway: Flyway =
-      Flyway.configure().cleanDisabled(false).dataSource(dbConfig.url, dbConfig.user, dbConfig.pass).load()
+      DatabaseMigrator.configureFlyway(dbConfig.url, dbConfig.user, dbConfig.pass).cleanDisabled(false).load()
     val _ = flyway.migrate()
     val _ = flyway.clean()
   }
diff -rN -u old-smederee/modules/hub/src/it/scala/de/smederee/hub/DoobieSignupRepositoryTest.scala new-smederee/modules/hub/src/it/scala/de/smederee/hub/DoobieSignupRepositoryTest.scala
--- old-smederee/modules/hub/src/it/scala/de/smederee/hub/DoobieSignupRepositoryTest.scala	2025-01-31 13:42:16.240771651 +0000
+++ new-smederee/modules/hub/src/it/scala/de/smederee/hub/DoobieSignupRepositoryTest.scala	2025-01-31 13:42:16.248771665 +0000
@@ -28,23 +28,6 @@
 import munit._
 
 final class DoobieSignupRepositoryTest extends BaseSpec {
-  override def beforeEach(context: BeforeEach): Unit = {
-    val dbConfig = configuration.database
-    val flyway: Flyway =
-      Flyway.configure().cleanDisabled(false).dataSource(dbConfig.url, dbConfig.user, dbConfig.pass).load()
-    val _ = flyway.migrate()
-    val _ = flyway.clean()
-    val _ = flyway.migrate()
-  }
-
-  override def afterEach(context: AfterEach): Unit = {
-    val dbConfig = configuration.database
-    val flyway: Flyway =
-      Flyway.configure().cleanDisabled(false).dataSource(dbConfig.url, dbConfig.user, dbConfig.pass).load()
-    val _ = flyway.migrate()
-    val _ = flyway.clean()
-  }
-
   test("createAccount must create a new account") {
     genValidAccount.sample match {
       case None => fail("Could not generate data samples!")
diff -rN -u old-smederee/modules/hub/src/it/scala/de/smederee/hub/DoobieVcsMetadataRepositoryTest.scala new-smederee/modules/hub/src/it/scala/de/smederee/hub/DoobieVcsMetadataRepositoryTest.scala
--- old-smederee/modules/hub/src/it/scala/de/smederee/hub/DoobieVcsMetadataRepositoryTest.scala	2025-01-31 13:42:16.240771651 +0000
+++ new-smederee/modules/hub/src/it/scala/de/smederee/hub/DoobieVcsMetadataRepositoryTest.scala	2025-01-31 13:42:16.248771665 +0000
@@ -44,7 +44,7 @@
     connectToDb(configuration).use { con =>
       for {
         statement <- IO.delay(
-          con.prepareStatement("""SELECT original_repo, forked_repo FROM "forks" WHERE original_repo = ?""")
+          con.prepareStatement("""SELECT original_repo, forked_repo FROM "hub"."forks" WHERE original_repo = ?""")
         )
         _      <- IO.delay(statement.setLong(1, originalRepoId))
         result <- IO.delay(statement.executeQuery)
@@ -58,23 +58,6 @@
       } yield forks.toList
     }
 
-  override def beforeEach(context: BeforeEach): Unit = {
-    val dbConfig = configuration.database
-    val flyway: Flyway =
-      Flyway.configure().cleanDisabled(false).dataSource(dbConfig.url, dbConfig.user, dbConfig.pass).load()
-    val _ = flyway.migrate()
-    val _ = flyway.clean()
-    val _ = flyway.migrate()
-  }
-
-  override def afterEach(context: AfterEach): Unit = {
-    val dbConfig = configuration.database
-    val flyway: Flyway =
-      Flyway.configure().cleanDisabled(false).dataSource(dbConfig.url, dbConfig.user, dbConfig.pass).load()
-    val _ = flyway.migrate()
-    val _ = flyway.clean()
-  }
-
   test("createFork must work correctly") {
     (genValidAccounts.suchThat(_.size > 4).sample, genValidVcsRepositories.suchThat(_.size > 4).sample) match {
       case (Some(accounts), Some(repositories)) =>
diff -rN -u old-smederee/modules/hub/src/it/scala/de/smederee/tickets/DoobieLabelRepositoryTest.scala new-smederee/modules/hub/src/it/scala/de/smederee/tickets/DoobieLabelRepositoryTest.scala
--- old-smederee/modules/hub/src/it/scala/de/smederee/tickets/DoobieLabelRepositoryTest.scala	2025-01-31 13:42:16.240771651 +0000
+++ new-smederee/modules/hub/src/it/scala/de/smederee/tickets/DoobieLabelRepositoryTest.scala	1970-01-01 00:00:00.000000000 +0000
@@ -1,296 +0,0 @@
-/*
- * Copyright (C) 2022  Contributors as noted in the AUTHORS.md file
- *
- * This program is free software: you can redistribute it and/or modify
- * it under the terms of the GNU Affero General Public License as
- * published by the Free Software Foundation, either version 3 of the
- * License, or (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
- * GNU Affero General Public License for more details.
- *
- * You should have received a copy of the GNU Affero General Public License
- * along with this program.  If not, see <http://www.gnu.org/licenses/>.
- */
-
-package de.smederee.tickets
-
-import cats.effect._
-import cats.syntax.all._
-import de.smederee.hub.Generators._
-import de.smederee.hub._
-import de.smederee.hub.config.SmedereeHubConfig
-import de.smederee.tickets.Generators._
-import doobie._
-import org.flywaydb.core.Flyway
-import org.http4s.implicits._
-
-import munit._
-
-import scala.collection.immutable.Queue
-
-final class DoobieLabelRepositoryTest extends BaseSpec {
-
-  /** Find the label ID for the given repository and label name.
-    *
-    * @param owner
-    *   The unique ID of the user account that owns the repository.
-    * @param vcsRepoName
-    *   The repository name which must be unique in regard to the owner.
-    * @param labelName
-    *   The label name which must be unique in the repository context.
-    * @return
-    *   An option to the internal database ID.
-    */
-  @throws[java.sql.SQLException]("Errors from the underlying SQL procedures will throw exceptions!")
-  protected def findLabelId(owner: UserId, vcsRepoName: VcsRepositoryName, labelName: LabelName): IO[Option[Long]] =
-    connectToDb(configuration).use { con =>
-      for {
-        statement <- IO.delay(
-          con.prepareStatement(
-            """SELECT "labels".id FROM "labels" AS "labels" JOIN "repositories" AS "repositories" ON "labels".repository = "repositories".id WHERE "repositories".owner = ? AND "repositories".name = ? AND "labels".name = ?"""
-          )
-        )
-        _      <- IO.delay(statement.setObject(1, owner))
-        _      <- IO.delay(statement.setString(2, vcsRepoName.toString))
-        _      <- IO.delay(statement.setString(3, labelName.toString))
-        result <- IO.delay(statement.executeQuery)
-        account <- IO.delay {
-          if (result.next()) {
-            Option(result.getLong("id"))
-          } else {
-            None
-          }
-        }
-        _ <- IO(statement.close())
-      } yield account
-    }
-
-  override def beforeEach(context: BeforeEach): Unit = {
-    val dbConfig = configuration.database
-    val flyway: Flyway =
-      Flyway.configure().cleanDisabled(false).dataSource(dbConfig.url, dbConfig.user, dbConfig.pass).load()
-    val _ = flyway.migrate()
-    val _ = flyway.clean()
-    val _ = flyway.migrate()
-  }
-
-  override def afterEach(context: AfterEach): Unit = {
-    val dbConfig = configuration.database
-    val flyway: Flyway =
-      Flyway.configure().cleanDisabled(false).dataSource(dbConfig.url, dbConfig.user, dbConfig.pass).load()
-    val _ = flyway.migrate()
-    val _ = flyway.clean()
-  }
-
-  test("allLabels must return all labels") {
-    (genValidAccount.sample, genValidVcsRepository.sample, genLabels.sample) match {
-      case (Some(account), Some(repository), Some(labels)) =>
-        val vcsRepository = repository.copy(owner = account.toVcsRepositoryOwner)
-        val dbConfig      = configuration.database
-        val expectedLabel = labels(scala.util.Random.nextInt(labels.size))
-        val tx        = Transactor.fromDriverManager[IO](dbConfig.driver, dbConfig.url, dbConfig.user, dbConfig.pass)
-        val labelRepo = new DoobieLabelRepository[IO](tx)
-        val vcsRepo   = new DoobieVcsMetadataRepository[IO](tx)
-        val test = for {
-          _            <- createAccount(account, PasswordHash("I am not a password hash!"), None, None)
-          createdRepos <- vcsRepo.createVcsRepository(vcsRepository)
-          repoId       <- loadVcsRepositoryId(account.uid, vcsRepository.name)
-          createdLabels <- repoId match {
-            case None         => IO.pure(List.empty)
-            case Some(repoId) => labels.traverse(label => labelRepo.createLabel(repoId)(label))
-          }
-          foundLabels <- repoId match {
-            case None         => IO.pure(List.empty)
-            case Some(repoId) => labelRepo.allLabels(repoId).compile.toList
-          }
-        } yield foundLabels
-        test.map { foundLabels =>
-          assert(foundLabels.size === labels.size, "Different number of labels!")
-          foundLabels.sortBy(_.name).zip(labels.sortBy(_.name)).map { (found, expected) =>
-            assertEquals(found.copy(id = expected.id), expected)
-          }
-        }
-      case _ => fail("Could not generate data samples!")
-    }
-  }
-
-  test("createLabel must create the label") {
-    (genValidAccount.sample, genValidVcsRepository.sample, genLabel.sample) match {
-      case (Some(account), Some(repository), Some(label)) =>
-        val vcsRepository = repository.copy(owner = account.toVcsRepositoryOwner)
-        val dbConfig      = configuration.database
-        val tx        = Transactor.fromDriverManager[IO](dbConfig.driver, dbConfig.url, dbConfig.user, dbConfig.pass)
-        val labelRepo = new DoobieLabelRepository[IO](tx)
-        val vcsRepo   = new DoobieVcsMetadataRepository[IO](tx)
-        val test = for {
-          _             <- createAccount(account, PasswordHash("I am not a password hash!"), None, None)
-          createdRepos  <- vcsRepo.createVcsRepository(vcsRepository)
-          repoId        <- loadVcsRepositoryId(account.uid, vcsRepository.name)
-          createdLabels <- repoId.traverse(id => labelRepo.createLabel(id)(label))
-          foundLabel    <- repoId.traverse(id => labelRepo.findLabel(id)(label.name))
-        } yield (createdRepos, repoId, createdLabels, foundLabel)
-        test.map { tuple =>
-          val (createdRepos, repoId, createdLabels, foundLabel) = tuple
-          assert(createdRepos === 1, "Test vcs repository was not created!")
-          assert(repoId.nonEmpty, "No vcs repository id found!")
-          assert(createdLabels.exists(_ === 1), "Test label was not created!")
-          foundLabel.getOrElse(None) match {
-            case None => fail("Created label not found!")
-            case Some(foundLabel) =>
-              assertEquals(foundLabel.name, label.name)
-              assertEquals(foundLabel.description, label.description)
-              assertEquals(foundLabel.colour, label.colour)
-          }
-        }
-      case _ => fail("Could not generate data samples!")
-    }
-  }
-
-  test("createLabel must fail if the label name already exists") {
-    (genValidAccount.sample, genValidVcsRepository.sample, genLabel.sample) match {
-      case (Some(account), Some(repository), Some(label)) =>
-        val vcsRepository = repository.copy(owner = account.toVcsRepositoryOwner)
-        val dbConfig      = configuration.database
-        val tx        = Transactor.fromDriverManager[IO](dbConfig.driver, dbConfig.url, dbConfig.user, dbConfig.pass)
-        val labelRepo = new DoobieLabelRepository[IO](tx)
-        val vcsRepo   = new DoobieVcsMetadataRepository[IO](tx)
-        val test = for {
-          _             <- createAccount(account, PasswordHash("I am not a password hash!"), None, None)
-          createdRepos  <- vcsRepo.createVcsRepository(vcsRepository)
-          repoId        <- loadVcsRepositoryId(account.uid, vcsRepository.name)
-          createdLabels <- repoId.traverse(id => labelRepo.createLabel(id)(label))
-          _             <- repoId.traverse(id => labelRepo.createLabel(id)(label))
-        } yield (createdRepos, repoId, createdLabels)
-        test.attempt.map(r => assert(r.isLeft, "Creating a label with a duplicate name must fail!"))
-      case _ => fail("Could not generate data samples!")
-    }
-  }
-
-  test("deleteLabel must delete an existing label") {
-    (genValidAccount.sample, genValidVcsRepository.sample, genLabel.sample) match {
-      case (Some(account), Some(repository), Some(label)) =>
-        val vcsRepository = repository.copy(owner = account.toVcsRepositoryOwner)
-        val dbConfig      = configuration.database
-        val tx        = Transactor.fromDriverManager[IO](dbConfig.driver, dbConfig.url, dbConfig.user, dbConfig.pass)
-        val labelRepo = new DoobieLabelRepository[IO](tx)
-        val vcsRepo   = new DoobieVcsMetadataRepository[IO](tx)
-        val test = for {
-          _             <- createAccount(account, PasswordHash("I am not a password hash!"), None, None)
-          createdRepos  <- vcsRepo.createVcsRepository(vcsRepository)
-          repoId        <- loadVcsRepositoryId(account.uid, vcsRepository.name)
-          createdLabels <- repoId.traverse(id => labelRepo.createLabel(id)(label))
-          labelId       <- findLabelId(account.uid, vcsRepository.name, label.name)
-          deletedLabels <- labelRepo.deleteLabel(label.copy(id = labelId.flatMap(LabelId.from)))
-          foundLabel    <- repoId.traverse(id => labelRepo.findLabel(id)(label.name))
-        } yield (createdRepos, repoId, createdLabels, deletedLabels, foundLabel)
-        test.map { tuple =>
-          val (createdRepos, repoId, createdLabels, deletedLabels, foundLabel) = tuple
-          assert(createdRepos === 1, "Test vcs repository was not created!")
-          assert(repoId.nonEmpty, "No vcs repository id found!")
-          assert(createdLabels.exists(_ === 1), "Test label was not created!")
-          assert(deletedLabels === 1, "Test label was not deleted!")
-          assert(foundLabel.getOrElse(None).isEmpty, "Test label was not deleted!")
-        }
-      case _ => fail("Could not generate data samples!")
-    }
-  }
-
-  test("findLabel must find existing labels") {
-    (genValidAccount.sample, genValidVcsRepository.sample, genLabels.sample) match {
-      case (Some(account), Some(repository), Some(labels)) =>
-        val vcsRepository = repository.copy(owner = account.toVcsRepositoryOwner)
-        val dbConfig      = configuration.database
-        val expectedLabel = labels(scala.util.Random.nextInt(labels.size))
-        val tx        = Transactor.fromDriverManager[IO](dbConfig.driver, dbConfig.url, dbConfig.user, dbConfig.pass)
-        val labelRepo = new DoobieLabelRepository[IO](tx)
-        val vcsRepo   = new DoobieVcsMetadataRepository[IO](tx)
-        val test = for {
-          _            <- createAccount(account, PasswordHash("I am not a password hash!"), None, None)
-          createdRepos <- vcsRepo.createVcsRepository(vcsRepository)
-          repoId       <- loadVcsRepositoryId(account.uid, vcsRepository.name)
-          createdLabels <- repoId match {
-            case None         => IO.pure(List.empty)
-            case Some(repoId) => labels.traverse(label => labelRepo.createLabel(repoId)(label))
-          }
-          foundLabel <- repoId.traverse(id => labelRepo.findLabel(id)(expectedLabel.name))
-        } yield foundLabel.flatten
-        test.map { foundLabel =>
-          assertEquals(foundLabel.map(_.copy(id = expectedLabel.id)), Option(expectedLabel))
-        }
-      case _ => fail("Could not generate data samples!")
-    }
-  }
-
-  test("updateLabel must update an existing label") {
-    (genValidAccount.sample, genValidVcsRepository.sample, genLabel.sample) match {
-      case (Some(account), Some(repository), Some(label)) =>
-        val updatedLabel = label.copy(
-          name = LabelName("updated label"),
-          description = Option(LabelDescription("I am an updated label description...")),
-          colour = ColourCode("#abcdef")
-        )
-        val vcsRepository = repository.copy(owner = account.toVcsRepositoryOwner)
-        val dbConfig      = configuration.database
-        val tx        = Transactor.fromDriverManager[IO](dbConfig.driver, dbConfig.url, dbConfig.user, dbConfig.pass)
-        val labelRepo = new DoobieLabelRepository[IO](tx)
-        val vcsRepo   = new DoobieVcsMetadataRepository[IO](tx)
-        val test = for {
-          _             <- createAccount(account, PasswordHash("I am not a password hash!"), None, None)
-          createdRepos  <- vcsRepo.createVcsRepository(vcsRepository)
-          repoId        <- loadVcsRepositoryId(account.uid, vcsRepository.name)
-          createdLabels <- repoId.traverse(id => labelRepo.createLabel(id)(label))
-          labelId       <- findLabelId(account.uid, vcsRepository.name, label.name)
-          updatedLabels <- labelRepo.updateLabel(updatedLabel.copy(id = labelId.map(LabelId.apply)))
-          foundLabel    <- repoId.traverse(id => labelRepo.findLabel(id)(updatedLabel.name))
-        } yield (createdRepos, repoId, createdLabels, updatedLabels, foundLabel.flatten)
-        test.map { tuple =>
-          val (createdRepos, repoId, createdLabels, updatedLabels, foundLabel) = tuple
-          assert(createdRepos === 1, "Test vcs repository was not created!")
-          assert(repoId.nonEmpty, "No vcs repository id found!")
-          assert(createdLabels.exists(_ === 1), "Test label was not created!")
-          assert(updatedLabels === 1, "Test label was not updated!")
-          assert(foundLabel.nonEmpty, "Updated label not found!")
-          foundLabel.map { label =>
-            assertEquals(label, updatedLabel.copy(id = label.id))
-          }
-        }
-      case _ => fail("Could not generate data samples!")
-    }
-  }
-
-  test("updateLabel must do nothing if id attribute is empty") {
-    (genValidAccount.sample, genValidVcsRepository.sample, genLabel.sample) match {
-      case (Some(account), Some(repository), Some(label)) =>
-        val updatedLabel = label.copy(
-          id = None,
-          name = LabelName("updated label"),
-          description = Option(LabelDescription("I am an updated label description...")),
-          colour = ColourCode("#abcdef")
-        )
-        val vcsRepository = repository.copy(owner = account.toVcsRepositoryOwner)
-        val dbConfig      = configuration.database
-        val tx        = Transactor.fromDriverManager[IO](dbConfig.driver, dbConfig.url, dbConfig.user, dbConfig.pass)
-        val labelRepo = new DoobieLabelRepository[IO](tx)
-        val vcsRepo   = new DoobieVcsMetadataRepository[IO](tx)
-        val test = for {
-          _             <- createAccount(account, PasswordHash("I am not a password hash!"), None, None)
-          createdRepos  <- vcsRepo.createVcsRepository(vcsRepository)
-          repoId        <- loadVcsRepositoryId(account.uid, vcsRepository.name)
-          createdLabels <- repoId.traverse(id => labelRepo.createLabel(id)(label))
-          labelId       <- findLabelId(account.uid, vcsRepository.name, label.name)
-          updatedLabels <- labelRepo.updateLabel(updatedLabel)
-        } yield (createdRepos, repoId, createdLabels, updatedLabels)
-        test.map { tuple =>
-          val (createdRepos, repoId, createdLabels, updatedLabels) = tuple
-          assert(createdRepos === 1, "Test vcs repository was not created!")
-          assert(repoId.nonEmpty, "No vcs repository id found!")
-          assert(createdLabels.exists(_ === 1), "Test label was not created!")
-          assert(updatedLabels === 0, "Label with empty id must not be updated!")
-        }
-      case _ => fail("Could not generate data samples!")
-    }
-  }
-}
diff -rN -u old-smederee/modules/hub/src/it/scala/de/smederee/tickets/DoobieMilestoneRepositoryTest.scala new-smederee/modules/hub/src/it/scala/de/smederee/tickets/DoobieMilestoneRepositoryTest.scala
--- old-smederee/modules/hub/src/it/scala/de/smederee/tickets/DoobieMilestoneRepositoryTest.scala	2025-01-31 13:42:16.240771651 +0000
+++ new-smederee/modules/hub/src/it/scala/de/smederee/tickets/DoobieMilestoneRepositoryTest.scala	1970-01-01 00:00:00.000000000 +0000
@@ -1,299 +0,0 @@
-/*
- * Copyright (C) 2022  Contributors as noted in the AUTHORS.md file
- *
- * This program is free software: you can redistribute it and/or modify
- * it under the terms of the GNU Affero General Public License as
- * published by the Free Software Foundation, either version 3 of the
- * License, or (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
- * GNU Affero General Public License for more details.
- *
- * You should have received a copy of the GNU Affero General Public License
- * along with this program.  If not, see <http://www.gnu.org/licenses/>.
- */
-
-package de.smederee.tickets
-
-import java.time._
-
-import cats.effect._
-import cats.syntax.all._
-import de.smederee.hub.Generators._
-import de.smederee.hub._
-import de.smederee.hub.config.SmedereeHubConfig
-import de.smederee.tickets.Generators._
-import doobie._
-import org.flywaydb.core.Flyway
-import org.http4s.implicits._
-
-import munit._
-
-final class DoobieMilestoneRepositoryTest extends BaseSpec {
-
-  /** Find the milestone ID for the given repository and milestone title.
-    *
-    * @param owner
-    *   The unique ID of the user account that owns the repository.
-    * @param vcsRepoName
-    *   The repository name which must be unique in regard to the owner.
-    * @param title
-    *   The milestone title which must be unique in the repository context.
-    * @return
-    *   An option to the internal database ID.
-    */
-  @throws[java.sql.SQLException]("Errors from the underlying SQL procedures will throw exceptions!")
-  protected def findMilestoneId(
-      owner: UserId,
-      vcsRepoName: VcsRepositoryName,
-      title: MilestoneTitle
-  ): IO[Option[Long]] =
-    connectToDb(configuration).use { con =>
-      for {
-        statement <- IO.delay(
-          con.prepareStatement(
-            """SELECT "milestones".id FROM "milestones" AS "milestones" JOIN "repositories" AS "repositories" ON "milestones".repository = "repositories".id WHERE "repositories".owner = ? AND "repositories".name = ? AND "milestones".title = ?"""
-          )
-        )
-        _      <- IO.delay(statement.setObject(1, owner))
-        _      <- IO.delay(statement.setString(2, vcsRepoName.toString))
-        _      <- IO.delay(statement.setString(3, title.toString))
-        result <- IO.delay(statement.executeQuery)
-        account <- IO.delay {
-          if (result.next()) {
-            Option(result.getLong("id"))
-          } else {
-            None
-          }
-        }
-        _ <- IO(statement.close())
-      } yield account
-    }
-
-  override def beforeEach(context: BeforeEach): Unit = {
-    val dbConfig = configuration.database
-    val flyway: Flyway =
-      Flyway.configure().cleanDisabled(false).dataSource(dbConfig.url, dbConfig.user, dbConfig.pass).load()
-    val _ = flyway.migrate()
-    val _ = flyway.clean()
-    val _ = flyway.migrate()
-  }
-
-  override def afterEach(context: AfterEach): Unit = {
-    val dbConfig = configuration.database
-    val flyway: Flyway =
-      Flyway.configure().cleanDisabled(false).dataSource(dbConfig.url, dbConfig.user, dbConfig.pass).load()
-    val _ = flyway.migrate()
-    val _ = flyway.clean()
-  }
-
-  test("allMilestones must return all milestones") {
-    (genValidAccount.sample, genValidVcsRepository.sample, genMilestones.sample) match {
-      case (Some(account), Some(repository), Some(milestones)) =>
-        val vcsRepository     = repository.copy(owner = account.toVcsRepositoryOwner)
-        val dbConfig          = configuration.database
-        val expectedMilestone = milestones(scala.util.Random.nextInt(milestones.size))
-        val tx = Transactor.fromDriverManager[IO](dbConfig.driver, dbConfig.url, dbConfig.user, dbConfig.pass)
-        val milestoneRepo = new DoobieMilestoneRepository[IO](tx)
-        val vcsRepo       = new DoobieVcsMetadataRepository[IO](tx)
-        val test = for {
-          _            <- createAccount(account, PasswordHash("I am not a password hash!"), None, None)
-          createdRepos <- vcsRepo.createVcsRepository(vcsRepository)
-          repoId       <- loadVcsRepositoryId(account.uid, vcsRepository.name)
-          createdMilestones <- repoId match {
-            case None         => IO.pure(List.empty)
-            case Some(repoId) => milestones.traverse(milestone => milestoneRepo.createMilestone(repoId)(milestone))
-          }
-          foundMilestones <- repoId match {
-            case None         => IO.pure(List.empty)
-            case Some(repoId) => milestoneRepo.allMilestones(repoId).compile.toList
-          }
-        } yield foundMilestones
-        test.map { foundMilestones =>
-          assert(foundMilestones.size === milestones.size, "Different number of milestones!")
-          foundMilestones.sortBy(_.title).zip(milestones.sortBy(_.title)).map { (found, expected) =>
-            assertEquals(found.copy(id = expected.id), expected)
-          }
-        }
-      case _ => fail("Could not generate data samples!")
-    }
-  }
-
-  test("createMilestone must create the milestone") {
-    (genValidAccount.sample, genValidVcsRepository.sample, genMilestone.sample) match {
-      case (Some(account), Some(repository), Some(milestone)) =>
-        val vcsRepository = repository.copy(owner = account.toVcsRepositoryOwner)
-        val dbConfig      = configuration.database
-        val tx = Transactor.fromDriverManager[IO](dbConfig.driver, dbConfig.url, dbConfig.user, dbConfig.pass)
-        val milestoneRepo = new DoobieMilestoneRepository[IO](tx)
-        val vcsRepo       = new DoobieVcsMetadataRepository[IO](tx)
-        val test = for {
-          _                 <- createAccount(account, PasswordHash("I am not a password hash!"), None, None)
-          createdRepos      <- vcsRepo.createVcsRepository(vcsRepository)
-          repoId            <- loadVcsRepositoryId(account.uid, vcsRepository.name)
-          createdMilestones <- repoId.traverse(id => milestoneRepo.createMilestone(id)(milestone))
-          foundMilestone    <- repoId.traverse(id => milestoneRepo.findMilestone(id)(milestone.title))
-        } yield (createdRepos, repoId, createdMilestones, foundMilestone)
-        test.map { tuple =>
-          val (createdRepos, repoId, createdMilestones, foundMilestone) = tuple
-          assert(createdRepos === 1, "Test vcs repository was not created!")
-          assert(repoId.nonEmpty, "No vcs repository id found!")
-          assert(createdMilestones.exists(_ === 1), "Test milestone was not created!")
-          foundMilestone.getOrElse(None) match {
-            case None                 => fail("Created milestone not found!")
-            case Some(foundMilestone) => assertEquals(foundMilestone, milestone.copy(id = foundMilestone.id))
-          }
-        }
-      case _ => fail("Could not generate data samples!")
-    }
-  }
-
-  test("createMilestone must fail if the milestone name already exists") {
-    (genValidAccount.sample, genValidVcsRepository.sample, genMilestone.sample) match {
-      case (Some(account), Some(repository), Some(milestone)) =>
-        val vcsRepository = repository.copy(owner = account.toVcsRepositoryOwner)
-        val dbConfig      = configuration.database
-        val tx = Transactor.fromDriverManager[IO](dbConfig.driver, dbConfig.url, dbConfig.user, dbConfig.pass)
-        val milestoneRepo = new DoobieMilestoneRepository[IO](tx)
-        val vcsRepo       = new DoobieVcsMetadataRepository[IO](tx)
-        val test = for {
-          _                 <- createAccount(account, PasswordHash("I am not a password hash!"), None, None)
-          createdRepos      <- vcsRepo.createVcsRepository(vcsRepository)
-          repoId            <- loadVcsRepositoryId(account.uid, vcsRepository.name)
-          createdMilestones <- repoId.traverse(id => milestoneRepo.createMilestone(id)(milestone))
-          _                 <- repoId.traverse(id => milestoneRepo.createMilestone(id)(milestone))
-        } yield (createdRepos, repoId, createdMilestones)
-        test.attempt.map(r => assert(r.isLeft, "Creating a milestone with a duplicate name must fail!"))
-      case _ => fail("Could not generate data samples!")
-    }
-  }
-
-  test("deleteMilestone must delete an existing milestone") {
-    (genValidAccount.sample, genValidVcsRepository.sample, genMilestone.sample) match {
-      case (Some(account), Some(repository), Some(milestone)) =>
-        val vcsRepository = repository.copy(owner = account.toVcsRepositoryOwner)
-        val dbConfig      = configuration.database
-        val tx = Transactor.fromDriverManager[IO](dbConfig.driver, dbConfig.url, dbConfig.user, dbConfig.pass)
-        val milestoneRepo = new DoobieMilestoneRepository[IO](tx)
-        val vcsRepo       = new DoobieVcsMetadataRepository[IO](tx)
-        val test = for {
-          _                 <- createAccount(account, PasswordHash("I am not a password hash!"), None, None)
-          createdRepos      <- vcsRepo.createVcsRepository(vcsRepository)
-          repoId            <- loadVcsRepositoryId(account.uid, vcsRepository.name)
-          createdMilestones <- repoId.traverse(id => milestoneRepo.createMilestone(id)(milestone))
-          milestoneId       <- findMilestoneId(account.uid, vcsRepository.name, milestone.title)
-          deletedMilestones <- milestoneRepo.deleteMilestone(milestone.copy(id = milestoneId.flatMap(MilestoneId.from)))
-          foundMilestone    <- repoId.traverse(id => milestoneRepo.findMilestone(id)(milestone.title))
-        } yield (createdRepos, repoId, createdMilestones, deletedMilestones, foundMilestone)
-        test.map { tuple =>
-          val (createdRepos, repoId, createdMilestones, deletedMilestones, foundMilestone) = tuple
-          assert(createdRepos === 1, "Test vcs repository was not created!")
-          assert(repoId.nonEmpty, "No vcs repository id found!")
-          assert(createdMilestones.exists(_ === 1), "Test milestone was not created!")
-          assert(deletedMilestones === 1, "Test milestone was not deleted!")
-          assert(foundMilestone.getOrElse(None).isEmpty, "Test milestone was not deleted!")
-        }
-      case _ => fail("Could not generate data samples!")
-    }
-  }
-
-  test("findMilestone must find existing milestones") {
-    (genValidAccount.sample, genValidVcsRepository.sample, genMilestones.sample) match {
-      case (Some(account), Some(repository), Some(milestones)) =>
-        val vcsRepository     = repository.copy(owner = account.toVcsRepositoryOwner)
-        val dbConfig          = configuration.database
-        val expectedMilestone = milestones(scala.util.Random.nextInt(milestones.size))
-        val tx = Transactor.fromDriverManager[IO](dbConfig.driver, dbConfig.url, dbConfig.user, dbConfig.pass)
-        val milestoneRepo = new DoobieMilestoneRepository[IO](tx)
-        val vcsRepo       = new DoobieVcsMetadataRepository[IO](tx)
-        val test = for {
-          _            <- createAccount(account, PasswordHash("I am not a password hash!"), None, None)
-          createdRepos <- vcsRepo.createVcsRepository(vcsRepository)
-          repoId       <- loadVcsRepositoryId(account.uid, vcsRepository.name)
-          createdMilestones <- repoId match {
-            case None         => IO.pure(List.empty)
-            case Some(repoId) => milestones.traverse(milestone => milestoneRepo.createMilestone(repoId)(milestone))
-          }
-          foundMilestone <- repoId.traverse(id => milestoneRepo.findMilestone(id)(expectedMilestone.title))
-        } yield foundMilestone.flatten
-        test.map { foundMilestone =>
-          assertEquals(foundMilestone.map(_.copy(id = expectedMilestone.id)), Option(expectedMilestone))
-        }
-      case _ => fail("Could not generate data samples!")
-    }
-  }
-
-  test("updateMilestone must update an existing milestone") {
-    (genValidAccount.sample, genValidVcsRepository.sample, genMilestone.sample) match {
-      case (Some(account), Some(repository), Some(milestone)) =>
-        val updatedMilestone = milestone.copy(
-          title = MilestoneTitle("updated milestone"),
-          description = Option(MilestoneDescription("I am an updated milestone description...")),
-          dueDate = Option(LocalDate.of(1879, 3, 14))
-        )
-        val vcsRepository = repository.copy(owner = account.toVcsRepositoryOwner)
-        val dbConfig      = configuration.database
-        val tx = Transactor.fromDriverManager[IO](dbConfig.driver, dbConfig.url, dbConfig.user, dbConfig.pass)
-        val milestoneRepo = new DoobieMilestoneRepository[IO](tx)
-        val vcsRepo       = new DoobieVcsMetadataRepository[IO](tx)
-        val test = for {
-          _                 <- createAccount(account, PasswordHash("I am not a password hash!"), None, None)
-          createdRepos      <- vcsRepo.createVcsRepository(vcsRepository)
-          repoId            <- loadVcsRepositoryId(account.uid, vcsRepository.name)
-          createdMilestones <- repoId.traverse(id => milestoneRepo.createMilestone(id)(milestone))
-          milestoneId       <- findMilestoneId(account.uid, vcsRepository.name, milestone.title)
-          updatedMilestones <- milestoneRepo.updateMilestone(
-            updatedMilestone.copy(id = milestoneId.map(MilestoneId.apply))
-          )
-          foundMilestone <- repoId.traverse(id => milestoneRepo.findMilestone(id)(updatedMilestone.title))
-        } yield (createdRepos, repoId, createdMilestones, updatedMilestones, foundMilestone.flatten)
-        test.map { tuple =>
-          val (createdRepos, repoId, createdMilestones, updatedMilestones, foundMilestone) = tuple
-          assert(createdRepos === 1, "Test vcs repository was not created!")
-          assert(repoId.nonEmpty, "No vcs repository id found!")
-          assert(createdMilestones.exists(_ === 1), "Test milestone was not created!")
-          assert(updatedMilestones === 1, "Test milestone was not updated!")
-          assert(foundMilestone.nonEmpty, "Updated milestone not found!")
-          foundMilestone.map { milestone =>
-            assertEquals(milestone, updatedMilestone.copy(id = milestone.id))
-          }
-        }
-      case _ => fail("Could not generate data samples!")
-    }
-  }
-
-  test("updateMilestone must do nothing if id attribute is empty") {
-    (genValidAccount.sample, genValidVcsRepository.sample, genMilestone.sample) match {
-      case (Some(account), Some(repository), Some(milestone)) =>
-        val updatedMilestone = milestone.copy(
-          id = None,
-          title = MilestoneTitle("updated milestone"),
-          description = Option(MilestoneDescription("I am an updated milestone description...")),
-          dueDate = Option(LocalDate.of(1879, 3, 14))
-        )
-        val vcsRepository = repository.copy(owner = account.toVcsRepositoryOwner)
-        val dbConfig      = configuration.database
-        val tx = Transactor.fromDriverManager[IO](dbConfig.driver, dbConfig.url, dbConfig.user, dbConfig.pass)
-        val milestoneRepo = new DoobieMilestoneRepository[IO](tx)
-        val vcsRepo       = new DoobieVcsMetadataRepository[IO](tx)
-        val test = for {
-          _                 <- createAccount(account, PasswordHash("I am not a password hash!"), None, None)
-          createdRepos      <- vcsRepo.createVcsRepository(vcsRepository)
-          repoId            <- loadVcsRepositoryId(account.uid, vcsRepository.name)
-          createdMilestones <- repoId.traverse(id => milestoneRepo.createMilestone(id)(milestone))
-          milestoneId       <- findMilestoneId(account.uid, vcsRepository.name, milestone.title)
-          updatedMilestones <- milestoneRepo.updateMilestone(updatedMilestone)
-        } yield (createdRepos, repoId, createdMilestones, updatedMilestones)
-        test.map { tuple =>
-          val (createdRepos, repoId, createdMilestones, updatedMilestones) = tuple
-          assert(createdRepos === 1, "Test vcs repository was not created!")
-          assert(repoId.nonEmpty, "No vcs repository id found!")
-          assert(createdMilestones.exists(_ === 1), "Test milestone was not created!")
-          assert(updatedMilestones === 0, "Milestone with empty id must not be updated!")
-        }
-      case _ => fail("Could not generate data samples!")
-    }
-  }
-}
diff -rN -u old-smederee/modules/hub/src/it/scala/de/smederee/tickets/Generators.scala new-smederee/modules/hub/src/it/scala/de/smederee/tickets/Generators.scala
--- old-smederee/modules/hub/src/it/scala/de/smederee/tickets/Generators.scala	2025-01-31 13:42:16.240771651 +0000
+++ new-smederee/modules/hub/src/it/scala/de/smederee/tickets/Generators.scala	1970-01-01 00:00:00.000000000 +0000
@@ -1,89 +0,0 @@
-/*
- * Copyright (C) 2022  Contributors as noted in the AUTHORS.md file
- *
- * This program is free software: you can redistribute it and/or modify
- * it under the terms of the GNU Affero General Public License as
- * published by the Free Software Foundation, either version 3 of the
- * License, or (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
- * GNU Affero General Public License for more details.
- *
- * You should have received a copy of the GNU Affero General Public License
- * along with this program.  If not, see <http://www.gnu.org/licenses/>.
- */
-
-package de.smederee.tickets
-
-import java.time._
-
-import org.scalacheck.{ Arbitrary, Gen }
-
-object Generators {
-  val MinimumYear: Int = -4713  // Lowest year supported by PostgreSQL
-  val MaximumYear: Int = 294276 // Highest year supported by PostgreSQL
-
-  /** Prepend a zero to a single character hexadecimal code.
-    *
-    * @param hexCode
-    *   A string supposed to contain a hexadecimal code between 0 and ff.
-    * @return
-    *   Either the given code prepended with a leading zero if it had only a single character or the originally given
-    *   code otherwise.
-    */
-  private def hexPadding(hexCode: String): String =
-    if (hexCode.length < 2)
-      "0" + hexCode
-    else
-      hexCode
-
-  val genLocalDate: Gen[LocalDate] =
-    for {
-      year  <- Gen.choose(MinimumYear, MaximumYear)
-      month <- Gen.choose(1, 12)
-      day   <- Gen.choose(1, Month.of(month).length(Year.of(year).isLeap))
-    } yield LocalDate.of(year, month, day)
-
-  given Arbitrary[LocalDate] = Arbitrary(genLocalDate)
-
-  val genLabelName: Gen[LabelName] =
-    Gen.nonEmptyListOf(Gen.alphaNumChar).map(_.take(LabelName.MaxLength).mkString).map(LabelName.apply)
-
-  val genLabelDescription: Gen[LabelDescription] =
-    Gen.nonEmptyListOf(Gen.alphaNumChar).map(_.take(LabelDescription.MaxLength).mkString).map(LabelDescription.apply)
-
-  val genColourCode: Gen[ColourCode] = for {
-    red   <- Gen.choose(0, 255).map(_.toHexString).map(hexPadding)
-    green <- Gen.choose(0, 255).map(_.toHexString).map(hexPadding)
-    blue  <- Gen.choose(0, 255).map(_.toHexString).map(hexPadding)
-    hexString = s"#$red$green$blue"
-  } yield ColourCode(hexString)
-
-  val genLabel: Gen[Label] = for {
-    id          <- Gen.option(Gen.choose(0L, Long.MaxValue).map(LabelId.apply))
-    name        <- genLabelName
-    description <- Gen.option(genLabelDescription)
-    colour      <- genColourCode
-  } yield Label(id, name, description, colour)
-
-  val genLabels: Gen[List[Label]] = Gen.nonEmptyListOf(genLabel).map(_.distinct)
-
-  val genMilestoneTitle: Gen[MilestoneTitle] =
-    Gen.nonEmptyListOf(Gen.alphaNumChar).map(_.take(MilestoneTitle.MaxLength).mkString).map(MilestoneTitle.apply)
-
-  val genMilestoneDescription: Gen[MilestoneDescription] =
-    Gen.nonEmptyListOf(Gen.alphaNumChar).map(_.mkString).map(MilestoneDescription.apply)
-
-  val genMilestone: Gen[Milestone] =
-    for {
-      id    <- Gen.option(Gen.choose(0L, Long.MaxValue).map(MilestoneId.apply))
-      title <- genMilestoneTitle
-      due   <- Gen.option(genLocalDate)
-      descr <- Gen.option(genMilestoneDescription)
-    } yield Milestone(id, title, descr, due)
-
-  val genMilestones: Gen[List[Milestone]] = Gen.nonEmptyListOf(genMilestone).map(_.distinct)
-
-}
diff -rN -u old-smederee/modules/hub/src/main/resources/assets/css/main.css new-smederee/modules/hub/src/main/resources/assets/css/main.css
--- old-smederee/modules/hub/src/main/resources/assets/css/main.css	2025-01-31 13:42:16.240771651 +0000
+++ new-smederee/modules/hub/src/main/resources/assets/css/main.css	2025-01-31 13:42:16.252771672 +0000
@@ -206,6 +206,7 @@
 
 .label-description {
   margin: auto;
+  overflow: overlay;
   padding: 0 0.25em;
   vertical-align: middle;
 }
@@ -217,12 +218,14 @@
 
 .label-name {
   margin: auto;
+  overflow: overlay;
   padding: 0 0.25em;
   vertical-align: middle;
 }
 
 .milestone-description {
   margin: auto;
+  overflow: overlay;
   padding: 0 0.25em;
   vertical-align: middle;
 }
@@ -234,6 +237,7 @@
 
 .milestone-title {
   margin: auto;
+  overflow: overlay;
   padding: 0 0.25em;
   vertical-align: middle;
 }
diff -rN -u old-smederee/modules/hub/src/main/resources/db/migration/V1__base_tables.sql new-smederee/modules/hub/src/main/resources/db/migration/V1__base_tables.sql
--- old-smederee/modules/hub/src/main/resources/db/migration/V1__base_tables.sql	2025-01-31 13:42:16.244771658 +0000
+++ new-smederee/modules/hub/src/main/resources/db/migration/V1__base_tables.sql	2025-01-31 13:42:16.252771672 +0000
@@ -1,4 +1,6 @@
-CREATE TABLE "accounts"
+CREATE SCHEMA IF NOT EXISTS "hub";
+
+CREATE TABLE "hub"."accounts"
 (
   "uid"              UUID                     NOT NULL,
   "name"             CHARACTER VARYING(32)    NOT NULL,
@@ -21,22 +23,22 @@
   OIDS=FALSE
 );
 
-COMMENT ON TABLE "accounts" IS 'All user accounts for the system live within this table.';
-COMMENT ON COLUMN "accounts"."uid" IS 'A globally unique ID for the related user account.';
-COMMENT ON COLUMN "accounts"."name" IS 'A username between 2 and 32 characters which must be globally unique, contain only lowercase alphanumeric characters and start with a character.';
-COMMENT ON COLUMN "accounts"."email" IS 'A globally unique email address associated with the account.';
-COMMENT ON COLUMN "accounts"."password" IS 'The hashed password for the account.';
-COMMENT ON COLUMN "accounts"."failed_attempts" IS 'The number of failed login attempts since the last sucessful login.';
-COMMENT ON COLUMN "accounts"."locked_at" IS 'A timestamp when the account was locked. If this value is not NULL then the account should be considered locked';
-COMMENT ON COLUMN "accounts"."unlock_token" IS 'An unlock token which can be used to unlock the account.';
-COMMENT ON COLUMN "accounts"."reset_expiry" IS 'The timestamp when the reset token will expire.';
-COMMENT ON COLUMN "accounts"."reset_token" IS 'A token which can be used for a password reset.';
-COMMENT ON COLUMN "accounts"."created_at" IS 'The timestamp of when the account was created.';
-COMMENT ON COLUMN "accounts"."updated_at" IS 'A timestamp when the account was last changed.';
-COMMENT ON COLUMN "accounts"."validated_email" IS 'This flag indicates if the email address of the user has been validated via a validation email.';
-COMMENT ON COLUMN "accounts"."validation_token" IS 'A token used to validate the email address of the user.';
+COMMENT ON TABLE "hub"."accounts" IS 'All user accounts for the system live within this table.';
+COMMENT ON COLUMN "hub"."accounts"."uid" IS 'A globally unique ID for the related user account.';
+COMMENT ON COLUMN "hub"."accounts"."name" IS 'A username between 2 and 32 characters which must be globally unique, contain only lowercase alphanumeric characters and start with a character.';
+COMMENT ON COLUMN "hub"."accounts"."email" IS 'A globally unique email address associated with the account.';
+COMMENT ON COLUMN "hub"."accounts"."password" IS 'The hashed password for the account.';
+COMMENT ON COLUMN "hub"."accounts"."failed_attempts" IS 'The number of failed login attempts since the last sucessful login.';
+COMMENT ON COLUMN "hub"."accounts"."locked_at" IS 'A timestamp when the account was locked. If this value is not NULL then the account should be considered locked';
+COMMENT ON COLUMN "hub"."accounts"."unlock_token" IS 'An unlock token which can be used to unlock the account.';
+COMMENT ON COLUMN "hub"."accounts"."reset_expiry" IS 'The timestamp when the reset token will expire.';
+COMMENT ON COLUMN "hub"."accounts"."reset_token" IS 'A token which can be used for a password reset.';
+COMMENT ON COLUMN "hub"."accounts"."created_at" IS 'The timestamp of when the account was created.';
+COMMENT ON COLUMN "hub"."accounts"."updated_at" IS 'A timestamp when the account was last changed.';
+COMMENT ON COLUMN "hub"."accounts"."validated_email" IS 'This flag indicates if the email address of the user has been validated via a validation email.';
+COMMENT ON COLUMN "hub"."accounts"."validation_token" IS 'A token used to validate the email address of the user.';
 
-CREATE TABLE "sessions"
+CREATE TABLE "hub"."sessions"
 (
   "id"         VARCHAR(32)              NOT NULL,
   "uid"        UUID                     NOT NULL,
@@ -44,19 +46,19 @@
   "updated_at" TIMESTAMP WITH TIME ZONE NOT NULL,
   CONSTRAINT "sessions_pk"     PRIMARY KEY ("id"),
   CONSTRAINT "sessions_fk_uid" FOREIGN KEY ("uid")
-    REFERENCES "accounts" ("uid") ON UPDATE CASCADE ON DELETE CASCADE
+    REFERENCES "hub"."accounts" ("uid") ON UPDATE CASCADE ON DELETE CASCADE
 )
 WITH (
   OIDS=FALSE
 );
 
-COMMENT ON TABLE "sessions" IS 'Keeps the sessions of users.';
-COMMENT ON COLUMN "sessions"."id" IS 'A globally unique session ID.';
-COMMENT ON COLUMN "sessions"."uid" IS 'The unique ID of the user account to whom the session belongs.';
-COMMENT ON COLUMN "sessions"."created_at" IS 'The timestamp of when the session was created.';
-COMMENT ON COLUMN "sessions"."updated_at" IS 'The session ID should be re-generated in regular intervals resulting in a copy of the old session entry with a new ID and the corresponding timestamp in this column.';
+COMMENT ON TABLE "hub"."sessions" IS 'Keeps the sessions of users.';
+COMMENT ON COLUMN "hub"."sessions"."id" IS 'A globally unique session ID.';
+COMMENT ON COLUMN "hub"."sessions"."uid" IS 'The unique ID of the user account to whom the session belongs.';
+COMMENT ON COLUMN "hub"."sessions"."created_at" IS 'The timestamp of when the session was created.';
+COMMENT ON COLUMN "hub"."sessions"."updated_at" IS 'The session ID should be re-generated in regular intervals resulting in a copy of the old session entry with a new ID and the corresponding timestamp in this column.';
 
-CREATE TABLE "ssh_keys"
+CREATE TABLE "hub"."ssh_keys"
 (
   "id"           UUID                     NOT NULL, 
   "uid"          UUID                     NOT NULL,
@@ -69,18 +71,18 @@
   CONSTRAINT "ssh_keys_pk"        PRIMARY KEY ("id"),
   CONSTRAINT "ssh_keys_unique_fp" UNIQUE ("fingerprint"),
   CONSTRAINT "ssh_keys_fk_uid"    FOREIGN KEY ("uid")
-    REFERENCES "accounts" ("uid") ON UPDATE CASCADE ON DELETE CASCADE
+    REFERENCES "hub"."accounts" ("uid") ON UPDATE CASCADE ON DELETE CASCADE
 )
 WITH (
   OIDS=FALSE
 );
 
-COMMENT ON TABLE "ssh_keys" IS 'SSH keys uploaded by users live within this table. Updates are not intended, so keys have to be deleted and re-uploaded upon changes.';
-COMMENT ON COLUMN "ssh_keys"."id" IS 'The globally unique ID of the ssh key.';
-COMMENT ON COLUMN "ssh_keys"."uid" IS 'The unique ID of the user account to whom the ssh key belongs.';
-COMMENT ON COLUMN "ssh_keys"."key_type" IS 'The type of the key e.g. ssh-rsa or ssh-ed25519 and so on.';
-COMMENT ON COLUMN "ssh_keys"."key" IS 'A base 64 string containing the public ssh key.';
-COMMENT ON COLUMN "ssh_keys"."fingerprint" IS 'The fingerprint of the ssh key. It must be unique because a key can only be used by one account.';
-COMMENT ON COLUMN "ssh_keys"."comment" IS 'An optional comment for the ssh key limited to 256 characters.';
-COMMENT ON COLUMN "ssh_keys"."created_at" IS 'The timestamp of when the ssh key was created.';
-COMMENT ON COLUMN "ssh_keys"."last_used_at" IS 'The timestamp of when the ssh key was last used by the user.';
+COMMENT ON TABLE "hub"."ssh_keys" IS 'SSH keys uploaded by users live within this table. Updates are not intended, so keys have to be deleted and re-uploaded upon changes.';
+COMMENT ON COLUMN "hub"."ssh_keys"."id" IS 'The globally unique ID of the ssh key.';
+COMMENT ON COLUMN "hub"."ssh_keys"."uid" IS 'The unique ID of the user account to whom the ssh key belongs.';
+COMMENT ON COLUMN "hub"."ssh_keys"."key_type" IS 'The type of the key e.g. ssh-rsa or ssh-ed25519 and so on.';
+COMMENT ON COLUMN "hub"."ssh_keys"."key" IS 'A base 64 string containing the public ssh key.';
+COMMENT ON COLUMN "hub"."ssh_keys"."fingerprint" IS 'The fingerprint of the ssh key. It must be unique because a key can only be used by one account.';
+COMMENT ON COLUMN "hub"."ssh_keys"."comment" IS 'An optional comment for the ssh key limited to 256 characters.';
+COMMENT ON COLUMN "hub"."ssh_keys"."created_at" IS 'The timestamp of when the ssh key was created.';
+COMMENT ON COLUMN "hub"."ssh_keys"."last_used_at" IS 'The timestamp of when the ssh key was last used by the user.';
diff -rN -u old-smederee/modules/hub/src/main/resources/db/migration/V2__repository_tables.sql new-smederee/modules/hub/src/main/resources/db/migration/V2__repository_tables.sql
--- old-smederee/modules/hub/src/main/resources/db/migration/V2__repository_tables.sql	2025-01-31 13:42:16.244771658 +0000
+++ new-smederee/modules/hub/src/main/resources/db/migration/V2__repository_tables.sql	2025-01-31 13:42:16.252771672 +0000
@@ -1,4 +1,4 @@
-CREATE TABLE "repositories"
+CREATE TABLE "hub"."repositories"
 (
   "id"          BIGINT                   GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
   "name"        CHARACTER VARYING(64)    NOT NULL,
@@ -11,20 +11,20 @@
   "updated_at"  TIMESTAMP WITH TIME ZONE NOT NULL,
   CONSTRAINT "repositories_unique_owner_name" UNIQUE ("owner", "name"),
   CONSTRAINT "repositories_fk_uid" FOREIGN KEY ("owner")
-    REFERENCES "accounts" ("uid") ON UPDATE CASCADE ON DELETE CASCADE
+    REFERENCES "hub"."accounts" ("uid") ON UPDATE CASCADE ON DELETE CASCADE
 )
 WITH (
   OIDS=FALSE
 );
 
-COMMENT ON TABLE "repositories" IS 'All repositories i.e. their metadata are stored within this table. The combination of owner id and repository name must always be unique.';
-COMMENT ON COLUMN "repositories"."id" IS 'An auto generated primary key.';
-COMMENT ON COLUMN "repositories"."name" IS 'A repository name must start with a letter or number and must contain only alphanumeric ASCII characters as well as minus or underscore signs. It must be between 2 and 64 characters long.';
-COMMENT ON COLUMN "repositories"."owner" IS 'The unique ID of the user account that owns the repository.';
-COMMENT ON COLUMN "repositories"."is_private" IS 'A flag indicating if this repository is private i.e. only visible / accessible for accounts with appropriate permissions.';
-COMMENT ON COLUMN "repositories"."description" IS 'An optional short text description of the repository.';
-COMMENT ON COLUMN "repositories"."vcs_type" IS 'The type of the underlying DVCS that manages the repository.';
-COMMENT ON COLUMN "repositories"."website" IS 'An optional uri pointing to a website related to the repository / project.';
-COMMENT ON COLUMN "repositories"."created_at" IS 'The timestamp of when the repository was created.';
-COMMENT ON COLUMN "repositories"."updated_at" IS 'A timestamp when the repository was last changed.';
+COMMENT ON TABLE "hub"."repositories" IS 'All repositories i.e. their metadata are stored within this table. The combination of owner id and repository name must always be unique.';
+COMMENT ON COLUMN "hub"."repositories"."id" IS 'An auto generated primary key.';
+COMMENT ON COLUMN "hub"."repositories"."name" IS 'A repository name must start with a letter or number and must contain only alphanumeric ASCII characters as well as minus or underscore signs. It must be between 2 and 64 characters long.';
+COMMENT ON COLUMN "hub"."repositories"."owner" IS 'The unique ID of the user account that owns the repository.';
+COMMENT ON COLUMN "hub"."repositories"."is_private" IS 'A flag indicating if this repository is private i.e. only visible / accessible for accounts with appropriate permissions.';
+COMMENT ON COLUMN "hub"."repositories"."description" IS 'An optional short text description of the repository.';
+COMMENT ON COLUMN "hub"."repositories"."vcs_type" IS 'The type of the underlying DVCS that manages the repository.';
+COMMENT ON COLUMN "hub"."repositories"."website" IS 'An optional uri pointing to a website related to the repository / project.';
+COMMENT ON COLUMN "hub"."repositories"."created_at" IS 'The timestamp of when the repository was created.';
+COMMENT ON COLUMN "hub"."repositories"."updated_at" IS 'A timestamp when the repository was last changed.';
 
diff -rN -u old-smederee/modules/hub/src/main/resources/db/migration/V3__fork_tables.sql new-smederee/modules/hub/src/main/resources/db/migration/V3__fork_tables.sql
--- old-smederee/modules/hub/src/main/resources/db/migration/V3__fork_tables.sql	2025-01-31 13:42:16.244771658 +0000
+++ new-smederee/modules/hub/src/main/resources/db/migration/V3__fork_tables.sql	2025-01-31 13:42:16.252771672 +0000
@@ -1,18 +1,18 @@
-CREATE TABLE "forks"
+CREATE TABLE "hub"."forks"
 (
   "original_repo" BIGINT NOT NULL,
   "forked_repo"  BIGINT NOT NULL,
   CONSTRAINT "forks_pk" PRIMARY KEY ("original_repo", "forked_repo"),
   CONSTRAINT "forks_fk_original_repo" FOREIGN KEY ("original_repo")
-    REFERENCES "repositories" ("id") ON UPDATE CASCADE ON DELETE CASCADE,
+    REFERENCES "hub"."repositories" ("id") ON UPDATE CASCADE ON DELETE CASCADE,
   CONSTRAINT "forks_fk_forked_repo" FOREIGN KEY ("forked_repo")
-    REFERENCES "repositories" ("id") ON UPDATE CASCADE ON DELETE CASCADE
+    REFERENCES "hub"."repositories" ("id") ON UPDATE CASCADE ON DELETE CASCADE
 )
 WITH (
   OIDS=FALSE
 );
 
-COMMENT ON TABLE "forks" IS 'Stores fork relationships between repositories.';
-COMMENT ON COLUMN "forks"."original_repo" IS 'The ID of the original repository from which was forked.';
-COMMENT ON COLUMN "forks"."forked_repo" IS 'The ID of the repository which is the fork.';
+COMMENT ON TABLE "hub"."forks" IS 'Stores fork relationships between repositories.';
+COMMENT ON COLUMN "hub"."forks"."original_repo" IS 'The ID of the original repository from which was forked.';
+COMMENT ON COLUMN "hub"."forks"."forked_repo" IS 'The ID of the repository which is the fork.';
 
diff -rN -u old-smederee/modules/hub/src/main/resources/db/migration/V4__ticket_tables.sql new-smederee/modules/hub/src/main/resources/db/migration/V4__ticket_tables.sql
--- old-smederee/modules/hub/src/main/resources/db/migration/V4__ticket_tables.sql	2025-01-31 13:42:16.244771658 +0000
+++ new-smederee/modules/hub/src/main/resources/db/migration/V4__ticket_tables.sql	1970-01-01 00:00:00.000000000 +0000
@@ -1,131 +0,0 @@
-CREATE TABLE "labels"
-(
-  "id"          BIGINT                 GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
-  "repository"  BIGINT                 NOT NULL,
-  "name"        CHARACTER VARYING(40)  NOT NULL,
-  "description" CHARACTER VARYING(254) DEFAULT NULL,
-  "colour"      CHARACTER VARYING(7)   NOT NULL,
-  CONSTRAINT "labels_unique_repo_label" UNIQUE ("repository", "name"),
-  CONSTRAINT "labels_fk_repo" FOREIGN KEY ("repository")
-    REFERENCES "repositories" ("id") ON UPDATE CASCADE ON DELETE CASCADE
-)
-WITH (
-  OIDS=FALSE
-);
-
-COMMENT ON TABLE "labels" IS 'Labels used to add information to tickets.';
-COMMENT ON COLUMN "labels"."id" IS 'An auto generated primary key.';
-COMMENT ON COLUMN "labels"."repository" IS 'The repository to which this label belongs.'; 
-COMMENT ON COLUMN "labels"."name" IS 'A short descriptive name for the label which is supposed to be unique in a project context.';
-COMMENT ON COLUMN "labels"."description" IS 'An optional description if needed.';
-COMMENT ON COLUMN "labels"."colour" IS 'A hexadecimal HTML colour code which can be used to mark the label on a rendered website.';
-
-CREATE TABLE "milestones"
-(
-  "id"          BIGINT                 GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
-  "repository"  BIGINT                 NOT NULL,
-  "title"       CHARACTER VARYING(64)  NOT NULL,
-  "due_date"    DATE                   DEFAULT NULL,
-  "description" TEXT                   DEFAULT NULL,
-  CONSTRAINT "milestones_unique_repo_title" UNIQUE ("repository", "title"),
-  CONSTRAINT "milestones_fk_repo" FOREIGN KEY ("repository")
-    REFERENCES "repositories" ("id") ON UPDATE CASCADE ON DELETE CASCADE
-)
-WITH (
-  OIDS=FALSE
-);
-
-COMMENT ON TABLE "milestones" IS 'Milestones used to organise tickets';
-COMMENT ON COLUMN "milestones"."repository" IS 'The repository to which this milestone belongs.';
-COMMENT ON COLUMN "milestones"."title" IS 'A title for the milestone, usually a version number, a word or a short phrase that is supposed to be unique within a project context.';
-COMMENT ON COLUMN "milestones"."due_date" IS 'An optional date on which the milestone is supposed to be reached.';
-COMMENT ON COLUMN "milestones"."description" IS 'An optional longer description of the milestone.';
-
-CREATE TABLE "tickets"
-(
-  "id"          BIGINT                   GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
-  "repository"  BIGINT                   NOT NULL,
-  "number"      INT                      NOT NULL,
-  "title"       CHARACTER VARYING(72)    NOT NULL,
-  "content"     TEXT                     DEFAULT NULL,
-  "status"      CHARACTER VARYING(16)    NOT NULL,
-  "submitter"   UUID                     DEFAULT NULL,
-  "created_at"  TIMESTAMP WITH TIME ZONE NOT NULL,
-  "updated_at"  TIMESTAMP WITH TIME ZONE NOT NULL,
-  CONSTRAINT "tickets_unique_repo_ticket" UNIQUE ("repository", "number"),
-  CONSTRAINT "tickets_fk_repo" FOREIGN KEY ("repository")
-    REFERENCES "repositories" ("id") ON UPDATE CASCADE ON DELETE CASCADE,
-  CONSTRAINT "tickets_fk_submitter" FOREIGN KEY ("submitter")
-    REFERENCES "accounts" ("uid") ON UPDATE CASCADE ON DELETE SET NULL
-)
-WITH (
-  OIDS=FALSE
-);
-
-CREATE INDEX "tickets_status" ON "tickets" ("status");
-
-COMMENT ON TABLE "tickets" IS 'Information about tickets for projects.';
-COMMENT ON COLUMN "tickets"."id" IS 'An auto generated primary key.';
-COMMENT ON COLUMN "tickets"."repository" IS 'The unique ID of the repository which is associated with the ticket.';
-COMMENT ON COLUMN "tickets"."number" IS 'The number of the ticket which must be unique within the scope of the project.';
-COMMENT ON COLUMN "tickets"."title" IS 'A concise and short description of the ticket which should not exceed 72 characters.';
-COMMENT ON COLUMN "tickets"."content" IS 'An optional field to describe the ticket in great detail if needed.';
-COMMENT ON COLUMN "tickets"."status" IS 'The current status of the ticket describing its life cycle.';
-COMMENT ON COLUMN "tickets"."submitter" IS 'The person who submitted (created) this ticket which is optional because of possible account deletion or other reasons.';
-COMMENT ON COLUMN "tickets"."created_at" IS 'The timestamp when the ticket was created / submitted.';
-COMMENT ON COLUMN "tickets"."updated_at" IS 'The timestamp when the ticket was last updated. Upon creation the update time equals the creation time.';
-
-CREATE TABLE "milestone_tickets"
-(
-  "milestone" BIGINT NOT NULL,
-  "ticket"    BIGINT NOT NULL,
-  CONSTRAINT "milestone_tickets_pk" PRIMARY KEY ("milestone", "ticket"),
-  CONSTRAINT "milestone_tickets_fk_milestone" FOREIGN KEY ("milestone")
-    REFERENCES "milestones" ("id") ON UPDATE CASCADE ON DELETE CASCADE,
-  CONSTRAINT "milestone_tickets_fk_ticket" FOREIGN KEY ("ticket")
-    REFERENCES "tickets" ("id") ON UPDATE CASCADE ON DELETE CASCADE
-)
-WITH (
-  OIDS=FALSE
-);
-
-COMMENT ON TABLE "milestone_tickets" IS 'This table stores the relation between milestones and their tickets.';
-COMMENT ON COLUMN "milestone_tickets"."milestone" IS 'The unique ID of the milestone.';
-COMMENT ON COLUMN "milestone_tickets"."ticket" IS 'The unique ID of the ticket that is attached to the milestone.';
-
-CREATE TABLE "ticket_assignees"
-(
-  "ticket"     BIGINT NOT NULL,
-  "assignee"   UUID NOT NULL,
-  CONSTRAINT "ticket_assignees_pk" PRIMARY KEY ("ticket", "assignee"),
-  CONSTRAINT "ticket_assignees_fk_ticket" FOREIGN KEY ("ticket")
-    REFERENCES "tickets" ("id") ON UPDATE CASCADE ON DELETE CASCADE,
-  CONSTRAINT "ticket_assignees_fk_assignee" FOREIGN KEY ("assignee")
-    REFERENCES "accounts" ("uid") ON UPDATE CASCADE ON DELETE CASCADE
-)
-WITH (
-  OIDS=FALSE
-);
-
-COMMENT ON TABLE "ticket_assignees" IS 'This table stores the relation between tickets and their assignees.';
-COMMENT ON COLUMN "ticket_assignees"."ticket" IS 'The unqiue ID of the ticket.';
-COMMENT ON COLUMN "ticket_assignees"."assignee" IS 'The unique ID of the user account that is assigned to the ticket.';
-
-CREATE TABLE "ticket_lables"
-(
-  "ticket" BIGINT NOT NULL,
-  "label"  BIGINT NOT NULL,
-  CONSTRAINT "ticket_lables_pk" PRIMARY KEY ("ticket", "label"),
-  CONSTRAINT "ticket_labels_fk_ticket" FOREIGN KEY ("ticket")
-    REFERENCES "tickets" ("id") ON UPDATE CASCADE ON DELETE CASCADE,
-  CONSTRAINT "ticket_labels_fk_label" FOREIGN KEY ("label")
-    REFERENCES "labels" ("id") ON UPDATE CASCADE ON DELETE CASCADE
-)
-WITH (
-  OIDS=FALSE
-);
-
-COMMENT ON TABLE "ticket_lables" IS 'This table stores the relation between tickets and their lables.';
-COMMENT ON COLUMN "ticket_lables"."ticket" IS 'The unqiue ID of the ticket.';
-COMMENT ON COLUMN "ticket_lables"."label" IS 'The unique ID of the label that is attached to the ticket.';
-
diff -rN -u old-smederee/modules/hub/src/main/resources/messages_en.properties new-smederee/modules/hub/src/main/resources/messages_en.properties
--- old-smederee/modules/hub/src/main/resources/messages_en.properties	2025-01-31 13:42:16.240771651 +0000
+++ new-smederee/modules/hub/src/main/resources/messages_en.properties	2025-01-31 13:42:16.252771672 +0000
@@ -57,7 +57,7 @@
 form.label.name.help=A label name can be up to 40 characters long, must not be empty and must be unique in the scope of a project.
 form.label.name.placeholder=label name
 form.label.edit.button.submit=Save label
-form.label.delete.button.submit=Delete label
+form.label.delete.button.submit=Delete
 form.label.delete.i-am-sure=Yes, I'm sure!
 form.login.button.submit=Login
 form.login.password=Password
@@ -206,7 +206,7 @@
 repository.edit.title=Edit the repository settings.
 
 repository.label.edit.title=Edit label >> {0} <<
-repository.label.edit.link=Edit label
+repository.label.edit.link=Edit
 repository.labels.add.title=Add a new label.
 repository.labels.edit.title=Manage your repository labels.
 repository.labels.view.title=Repository labels
diff -rN -u old-smederee/modules/hub/src/main/resources/reference.conf new-smederee/modules/hub/src/main/resources/reference.conf
--- old-smederee/modules/hub/src/main/resources/reference.conf	2025-01-31 13:42:16.240771651 +0000
+++ new-smederee/modules/hub/src/main/resources/reference.conf	2025-01-31 13:42:16.252771672 +0000
@@ -10,7 +10,7 @@
   driver = "org.postgresql.Driver"
   driver = ${?SMEDEREE_HUB_DB_DRIVER}
   # The JDBC connection URL **without** username and password.
-  url    = "jdbc:postgresql://localhost:5432/smederee_hub"
+  url    = "jdbc:postgresql://localhost:5432/smederee"
   url    = ${?SMEDEREE_HUB_DB_URL}
   # The username (login) needed to authenticate against the database.
   user   = "smederee_hub"
diff -rN -u old-smederee/modules/hub/src/main/scala/de/smederee/html/LinkTools.scala new-smederee/modules/hub/src/main/scala/de/smederee/html/LinkTools.scala
--- old-smederee/modules/hub/src/main/scala/de/smederee/html/LinkTools.scala	2025-01-31 13:42:16.244771658 +0000
+++ new-smederee/modules/hub/src/main/scala/de/smederee/html/LinkTools.scala	1970-01-01 00:00:00.000000000 +0000
@@ -1,58 +0,0 @@
-/*
- * Copyright (C) 2022  Contributors as noted in the AUTHORS.md file
- *
- * This program is free software: you can redistribute it and/or modify
- * it under the terms of the GNU Affero General Public License as
- * published by the Free Software Foundation, either version 3 of the
- * License, or (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
- * GNU Affero General Public License for more details.
- *
- * You should have received a copy of the GNU Affero General Public License
- * along with this program.  If not, see <http://www.gnu.org/licenses/>.
- */
-
-package de.smederee.html
-
-import cats.syntax.all._
-import de.smederee.hub.config.ExternalLinkConfig
-import org.http4s.Uri
-
-object LinkTools {
-
-  extension (linkConfig: ExternalLinkConfig) {
-
-    /** Take the given URI path and create a full URI using the specified configuration with a possible path prefix and
-      * append the given path to it.
-      *
-      * @param path
-      *   An URI containing a path with possible URL fragment and query parameters which will be used to construct the
-      *   full URI.
-      * @return
-      *   A full URI created from the values of the ExternalLinkConfig (scheme, host, port, possible path prefix) and
-      *   the path data from the given URI.
-      */
-    def createFullUri(path: Uri): Uri = {
-      val completePath = linkConfig.path match {
-        case None             => path.path
-        case Some(pathPrefix) => pathPrefix.path |+| path.path
-      }
-      val baseUri = Uri(
-        scheme = Option(linkConfig.scheme),
-        authority = Option(
-          Uri.Authority(
-            userInfo = None,
-            host = Uri.Host.fromIp4sHost(linkConfig.host),
-            port = linkConfig.port.map(_.value)
-          )
-        ),
-        path = completePath
-      ).withQueryParams(path.params)
-      path.fragment.fold(baseUri)(fragment => baseUri.withFragment(fragment))
-    }
-  }
-
-}
diff -rN -u old-smederee/modules/hub/src/main/scala/de/smederee/html/MetaTags.scala new-smederee/modules/hub/src/main/scala/de/smederee/html/MetaTags.scala
--- old-smederee/modules/hub/src/main/scala/de/smederee/html/MetaTags.scala	2025-01-31 13:42:16.244771658 +0000
+++ new-smederee/modules/hub/src/main/scala/de/smederee/html/MetaTags.scala	1970-01-01 00:00:00.000000000 +0000
@@ -1,107 +0,0 @@
-/*
- * Copyright (C) 2022  Contributors as noted in the AUTHORS.md file
- *
- * This program is free software: you can redistribute it and/or modify
- * it under the terms of the GNU Affero General Public License as
- * published by the Free Software Foundation, either version 3 of the
- * License, or (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
- * GNU Affero General Public License for more details.
- *
- * You should have received a copy of the GNU Affero General Public License
- * along with this program.  If not, see <http://www.gnu.org/licenses/>.
- */
-
-package de.smederee.html
-
-enum MetaRobotsDirective(val tag: String) {
-  case Follow   extends MetaRobotsDirective("follow")
-  case Index    extends MetaRobotsDirective("index")
-  case NoFollow extends MetaRobotsDirective("nofollow")
-  case NoIndex  extends MetaRobotsDirective("noindex")
-}
-
-opaque type MetaDescription = String
-object MetaDescription {
-
-  /** Create an instance of MetaDescription from the given String type.
-    *
-    * @param source
-    *   An instance of type String which will be returned as a MetaDescription.
-    * @return
-    *   The appropriate instance of MetaDescription.
-    */
-  def apply(source: String): MetaDescription = source
-
-  /** Try to create an instance of MetaDescription from the given String.
-    *
-    * @param source
-    *   A String that should fulfil the requirements to be converted into a MetaDescription.
-    * @return
-    *   An option to the successfully converted MetaDescription.
-    */
-  def from(source: String): Option[MetaDescription] = Option(source)
-
-}
-
-opaque type MetaKeyWords = List[String]
-object MetaKeyWords {
-
-  /** Create an instance of MetaKeyWords from the given List[String] type.
-    *
-    * @param source
-    *   An instance of type List[String] which will be returned as a MetaKeyWords.
-    * @return
-    *   The appropriate instance of MetaKeyWords.
-    */
-  def apply(source: List[String]): MetaKeyWords = source
-
-  /** Return an empty instance of MetaKeyWords.
-    *
-    * @return
-    *   An empty list.
-    */
-  def empty: MetaKeyWords = List.empty
-
-  /** Try to create an instance of MetaKeyWords from the given List[String].
-    *
-    * @param source
-    *   A List[String] that should fulfil the requirements to be converted into a MetaKeyWords.
-    * @return
-    *   An option to the successfully converted MetaKeyWords.
-    */
-  def from(source: List[String]): Option[MetaKeyWords] =
-    source.flatMap(string => Option(string)) match {
-      case Nil      => None
-      case keywords => Option(keywords)
-    }
-
-  extension (keywords: MetaKeyWords) {
-    def isEmpty: Boolean  = keywords.isEmpty
-    def mkString: String  = keywords.toList.mkString(", ")
-    def nonEmpty: Boolean = keywords.nonEmpty
-  }
-
-}
-
-/** HTML meta attributes which can be written into the header part of an HTML page.
-  *
-  * @param description
-  *   An optional description for the related meta tag which should not be too long (max. 160/200 characters).
-  * @param keywords
-  *   A list of keywords which can be empty and should not be too long.
-  */
-final case class MetaTags(description: Option[MetaDescription], keywords: MetaKeyWords)
-
-object MetaTags {
-
-  /** Return an empty meta tags instance.
-    *
-    * @return
-    *   An instance of meta tags containing no values, resulting in no tags being rendered.
-    */
-  def empty: MetaTags = MetaTags(description = None, keywords = MetaKeyWords.empty)
-}
diff -rN -u old-smederee/modules/hub/src/main/scala/de/smederee/hub/Account.scala new-smederee/modules/hub/src/main/scala/de/smederee/hub/Account.scala
--- old-smederee/modules/hub/src/main/scala/de/smederee/hub/Account.scala	2025-01-31 13:42:16.244771658 +0000
+++ new-smederee/modules/hub/src/main/scala/de/smederee/hub/Account.scala	2025-01-31 13:42:16.252771672 +0000
@@ -24,7 +24,6 @@
 import cats.data._
 import cats.syntax.all._
 import de.smederee.email.{ FromAddress, ToAddress }
-import de.smederee.tickets._
 import org.springframework.security.crypto.argon2.Argon2PasswordEncoder
 
 import scala.util.matching.Regex
@@ -417,20 +416,6 @@
 
   extension (account: Account) {
 
-    /** Create an assignee entity from this account.
-      *
-      * @return
-      *   An [[Assignee]] which is used to related to tickets being worked on.
-      */
-    def toAssignee: Assignee = Assignee(account.uid, account.name)
-
-    /** Create a submitter entity from this account.
-      *
-      * @return
-      *   A [[Submitter]] who created a ticket.
-      */
-    def toSubmitter: Submitter = Submitter(account.uid, account.name)
-
     /** Create vcs repository owner metadata from the account.
       *
       * @return
diff -rN -u old-smederee/modules/hub/src/main/scala/de/smederee/hub/config/SmedereeHubConfig.scala new-smederee/modules/hub/src/main/scala/de/smederee/hub/config/SmedereeHubConfig.scala
--- old-smederee/modules/hub/src/main/scala/de/smederee/hub/config/SmedereeHubConfig.scala	2025-01-31 13:42:16.244771658 +0000
+++ new-smederee/modules/hub/src/main/scala/de/smederee/hub/config/SmedereeHubConfig.scala	2025-01-31 13:42:16.252771672 +0000
@@ -24,6 +24,7 @@
 import cats.syntax.all._
 import com.comcast.ip4s.{ Host, Port }
 import de.smederee.email._
+import de.smederee.html.ExternalUrlConfiguration
 import de.smederee.security._
 import de.smederee.ssh._
 import org.http4s.Uri
@@ -299,7 +300,7 @@
     billing: BillingConfiguration,
     darcs: DarcsConfiguration,
     email: EmailMiddlewareConfiguration,
-    external: ExternalLinkConfig,
+    external: ExternalUrlConfiguration,
     signup: SignupConfiguration,
     ssh: SshServerConfiguration
 )
@@ -310,8 +311,11 @@
 
   given Eq[ServiceConfig] = Eq.fromUniversalEquals
 
-  given ConfigReader[Host]          = ConfigReader.fromStringOpt[Host](Host.fromString)
-  given ConfigReader[Port]          = ConfigReader.fromStringOpt[Port](Port.fromString)
+  given ConfigReader[Host]       = ConfigReader.fromStringOpt[Host](Host.fromString)
+  given ConfigReader[Port]       = ConfigReader.fromStringOpt[Port](Port.fromString)
+  given ConfigReader[Uri]        = ConfigReader.fromStringOpt(s => Uri.fromString(s).toOption)
+  given ConfigReader[Uri.Scheme] = ConfigReader.fromStringOpt(s => Uri.Scheme.fromString(s).toOption)
+
   given ConfigReader[DirectoryPath] = ConfigReader.fromStringOpt(DirectoryPath.fromString)
 
   given ConfigReader[EmailServerUsername] = ConfigReader.fromStringOpt[EmailServerUsername](EmailServerUsername.from)
@@ -324,6 +328,9 @@
       EmailMiddlewareConfiguration.apply
     )
 
+  given ConfigReader[ExternalUrlConfiguration] =
+    ConfigReader.forProduct4("host", "path", "port", "scheme")(ExternalUrlConfiguration.apply)
+
   given ConfigReader[ServiceConfig] =
     ConfigReader.forProduct11(
       "host",
@@ -334,7 +341,7 @@
       BillingConfiguration.parentKey.toString,
       DarcsConfiguration.parentKey.toString,
       "email",
-      ExternalLinkConfig.parentKey.toString,
+      "external",
       SignupConfiguration.parentKey.toString,
       SshServerConfiguration.parentKey.toString
     )(ServiceConfig.apply)
@@ -457,33 +464,6 @@
     ConfigReader.forProduct2("executable", "repositories-directory")(DarcsConfiguration.apply)
 }
 
-/** @param host
-  *   The official hostname of the service which will be used for the CSRF protection, generation of links in e-mails
-  *   etc.
-  * @param path
-  *   A possible path prefix that will be prepended to any paths used in link generation.
-  * @param port
-  *   The port number which defaults to the port the service is listening on. Please note that this is also relevant for
-  *   CSRF protection! It should not be defined if the service is running behind a reverse proxy listening on the
-  *   standard port for the given URL scheme (http/https).
-  * @param scheme
-  *   The URL scheme which is used for links and will also determine if cookies will have the secure flag enabled.
-  */
-final case class ExternalLinkConfig(host: Host, path: Option[Uri], port: Option[Port], scheme: Uri.Scheme)
-
-object ExternalLinkConfig {
-  // The default configuration key under which to lookup the external linking configuration.
-  final val parentKey: ConfigKey = ConfigKey("external")
-
-  given ConfigReader[Host]       = ConfigReader.fromStringOpt[Host](Host.fromString)
-  given ConfigReader[Port]       = ConfigReader.fromStringOpt[Port](Port.fromString)
-  given ConfigReader[Uri]        = ConfigReader.fromStringOpt(s => Uri.fromString(s).toOption)
-  given ConfigReader[Uri.Scheme] = ConfigReader.fromStringOpt(s => Uri.Scheme.fromString(s).toOption)
-
-  given ConfigReader[ExternalLinkConfig] =
-    ConfigReader.forProduct4("host", "path", "port", "scheme")(ExternalLinkConfig.apply)
-}
-
 /** Configuration for the signup feature.
   *
   * @param enabled
diff -rN -u old-smederee/modules/hub/src/main/scala/de/smederee/hub/DatabaseMigrator.scala new-smederee/modules/hub/src/main/scala/de/smederee/hub/DatabaseMigrator.scala
--- old-smederee/modules/hub/src/main/scala/de/smederee/hub/DatabaseMigrator.scala	2025-01-31 13:42:16.244771658 +0000
+++ new-smederee/modules/hub/src/main/scala/de/smederee/hub/DatabaseMigrator.scala	2025-01-31 13:42:16.252771672 +0000
@@ -19,8 +19,8 @@
 
 import cats.effect._
 import cats.syntax.all._
-import config._
 import org.flywaydb.core.Flyway
+import org.flywaydb.core.api.configuration.FluentConfiguration
 import org.flywaydb.core.api.output.MigrateResult
 
 /** Provide functionality to migrate the database used by the service.
@@ -41,7 +41,26 @@
     */
   def migrate(url: String, user: String, pass: String): F[MigrateResult] =
     for {
-      flyway <- Sync[F].delay(Flyway.configure().dataSource(url, user, pass).load())
+      flyway <- Sync[F].delay(DatabaseMigrator.configureFlyway(url, user, pass).load())
       result <- Sync[F].delay(flyway.migrate())
     } yield result
 }
+
+object DatabaseMigrator {
+
+  /** Configure an instance of flyway with our defaults and the given credentials for a database connection. The
+    * returned instance must be activated by calling the `.load()` method.
+    *
+    * @param url
+    *   The JDBC connection URL **without** username and password.
+    * @param user
+    *   The username (login) needed to authenticate against the database.
+    * @param pass
+    *   The password needed to authenticate against the database.
+    * @return
+    *   An instance configuration which can be turned into a flyway database migrator by calling the `.load()` method.
+    */
+  def configureFlyway(url: String, user: String, pass: String): FluentConfiguration =
+    Flyway.configure().defaultSchema("hub").dataSource(url, user, pass)
+
+}
diff -rN -u old-smederee/modules/hub/src/main/scala/de/smederee/hub/DoobieAccountManagementRepository.scala new-smederee/modules/hub/src/main/scala/de/smederee/hub/DoobieAccountManagementRepository.scala
--- old-smederee/modules/hub/src/main/scala/de/smederee/hub/DoobieAccountManagementRepository.scala	2025-01-31 13:42:16.244771658 +0000
+++ new-smederee/modules/hub/src/main/scala/de/smederee/hub/DoobieAccountManagementRepository.scala	2025-01-31 13:42:16.252771672 +0000
@@ -41,18 +41,18 @@
   given Meta[Username]        = Meta[String].timap(Username.apply)(_.toString)
   given Meta[ValidationToken] = Meta[String].timap(ValidationToken.apply)(_.toString)
 
-  private val selectAccountColumns = fr"""SELECT uid, name, email, validated_email FROM "accounts""""
+  private val selectAccountColumns = fr"""SELECT uid, name, email, validated_email FROM "hub"."accounts""""
 
   override def addSshKey(key: PublicSshKey): F[Int] =
-    sql"""INSERT INTO "ssh_keys" (id, uid, key_type, key, fingerprint, comment, created_at)
+    sql"""INSERT INTO "hub"."ssh_keys" (id, uid, key_type, key, fingerprint, comment, created_at)
       VALUES(${key.id}, ${key.ownerId}, ${key.keyType}, ${key.keyBytes}, ${key.fingerprint}, ${key.comment}, NOW())""".update.run
       .transact(tx)
 
   override def deleteAccount(uid: UserId): F[Int] =
-    sql"""DELETE FROM "accounts" WHERE uid = $uid""".update.run.transact(tx)
+    sql"""DELETE FROM "hub"."accounts" WHERE uid = $uid""".update.run.transact(tx)
 
   override def deleteSshKey(keyId: UUID, ownerId: UserId): F[Int] =
-    sql"""DELETE FROM "ssh_keys" WHERE id = $keyId AND uid = $ownerId""".update.run.transact(tx)
+    sql"""DELETE FROM "hub"."ssh_keys" WHERE id = $keyId AND uid = $ownerId""".update.run.transact(tx)
 
   override def findByValidationToken(token: ValidationToken): F[Option[Account]] = {
     val query = selectAccountColumns ++ fr"""WHERE validation_token = $token""" ++ fr"""LIMIT 1"""
@@ -60,19 +60,19 @@
   }
 
   override def findPasswordHash(uid: UserId): F[Option[PasswordHash]] =
-    sql"""SELECT password FROM "accounts" WHERE uid = $uid LIMIT 1""".query[PasswordHash].option.transact(tx)
+    sql"""SELECT password FROM "hub"."accounts" WHERE uid = $uid LIMIT 1""".query[PasswordHash].option.transact(tx)
 
   override def listSshKeys(uid: UserId): Stream[F, PublicSshKey] =
-    sql"""SELECT id, uid, key_type, key, fingerprint, comment, created_at, last_used_at FROM "ssh_keys" WHERE uid = $uid"""
+    sql"""SELECT id, uid, key_type, key, fingerprint, comment, created_at, last_used_at FROM "hub"."ssh_keys" WHERE uid = $uid"""
       .query[PublicSshKey]
       .stream
       .transact(tx)
 
   override def markAsValidated(uid: UserId): F[Int] =
-    sql"""UPDATE "accounts" SET validated_email = TRUE, validation_token = NULL WHERE uid = $uid""".update.run
+    sql"""UPDATE "hub"."accounts" SET validated_email = TRUE, validation_token = NULL WHERE uid = $uid""".update.run
       .transact(tx)
 
   override def setValidationToken(uid: UserId, token: ValidationToken): F[Int] =
-    sql"""UPDATE "accounts" SET validation_token = $token WHERE uid = $uid""".update.run.transact(tx)
+    sql"""UPDATE "hub"."accounts" SET validation_token = $token WHERE uid = $uid""".update.run.transact(tx)
 
 }
diff -rN -u old-smederee/modules/hub/src/main/scala/de/smederee/hub/DoobieAuthenticationRepository.scala new-smederee/modules/hub/src/main/scala/de/smederee/hub/DoobieAuthenticationRepository.scala
--- old-smederee/modules/hub/src/main/scala/de/smederee/hub/DoobieAuthenticationRepository.scala	2025-01-31 13:42:16.244771658 +0000
+++ new-smederee/modules/hub/src/main/scala/de/smederee/hub/DoobieAuthenticationRepository.scala	2025-01-31 13:42:16.252771672 +0000
@@ -35,14 +35,14 @@
 
   private val lockedFilter         = fr"""locked_at IS NOT NULL"""
   private val notLockedFilter      = fr"""locked_at IS NULL"""
-  private val selectAccountColumns = fr"""SELECT uid, name, email, validated_email FROM "accounts""""
+  private val selectAccountColumns = fr"""SELECT uid, name, email, validated_email FROM "hub"."accounts""""
 
   override def createUserSession(session: Session): F[Int] =
-    sql"""INSERT INTO "sessions" (id, uid, created_at, updated_at) VALUES (${session.id}, ${session.uid}, ${session.createdAt}, ${session.updatedAt})""".update.run
+    sql"""INSERT INTO "hub"."sessions" (id, uid, created_at, updated_at) VALUES (${session.id}, ${session.uid}, ${session.createdAt}, ${session.updatedAt})""".update.run
       .transact(tx)
 
   override def deleteUserSession(id: SessionId): F[Int] =
-    sql"""DELETE FROM "sessions" WHERE id = $id""".update.run.transact(tx)
+    sql"""DELETE FROM "hub"."sessions" WHERE id = $id""".update.run.transact(tx)
 
   override def findAccount(uid: UserId): F[Option[Account]] = {
     val uidFilter = fr"""uid = $uid"""
@@ -71,7 +71,7 @@
 
   override def findPasswordHashAndAttempts(uid: UserId): F[Option[(PasswordHash, Int)]] = {
     val uidFilter = fr"""uid = $uid"""
-    val query = fr"""SELECT password, failed_attempts FROM "accounts"""" ++ whereAnd(
+    val query = fr"""SELECT password, failed_attempts FROM "hub"."accounts"""" ++ whereAnd(
       notLockedFilter,
       uidFilter
     ) ++ fr"""LIMIT 1"""
@@ -79,23 +79,23 @@
   }
 
   override def findUserSession(id: SessionId): F[Option[Session]] =
-    sql"""SELECT id, uid, created_at, updated_at FROM "sessions" WHERE id = $id LIMIT 1"""
+    sql"""SELECT id, uid, created_at, updated_at FROM "hub"."sessions" WHERE id = $id LIMIT 1"""
       .query[Session]
       .option
       .transact(tx)
 
   override def incrementFailedAttempts(uid: UserId): F[Int] =
-    sql"""UPDATE "accounts" SET failed_attempts = failed_attempts + 1 WHERE uid = $uid""".update.run
+    sql"""UPDATE "hub"."accounts" SET failed_attempts = failed_attempts + 1 WHERE uid = $uid""".update.run
       .transact(tx)
 
   override def lockAccount(uid: UserId)(token: Option[UnlockToken]): F[Int] =
-    sql"""UPDATE "accounts" SET locked_at = NOW(), unlock_token = $token WHERE uid = $uid""".update.run
+    sql"""UPDATE "hub"."accounts" SET locked_at = NOW(), unlock_token = $token WHERE uid = $uid""".update.run
       .transact(tx)
 
   override def resetFailedAttempts(uid: UserId): F[Int] =
-    sql"""UPDATE "accounts" SET failed_attempts = 0 WHERE uid = $uid""".update.run.transact(tx)
+    sql"""UPDATE "hub"."accounts" SET failed_attempts = 0 WHERE uid = $uid""".update.run.transact(tx)
 
   override def unlockAccount(uid: UserId): F[Int] =
-    sql"""UPDATE "accounts" SET locked_at = NULL, unlock_token = NULL WHERE uid = $uid""".update.run
+    sql"""UPDATE "hub"."accounts" SET locked_at = NULL, unlock_token = NULL WHERE uid = $uid""".update.run
       .transact(tx)
 }
diff -rN -u old-smederee/modules/hub/src/main/scala/de/smederee/hub/DoobieSignupRepository.scala new-smederee/modules/hub/src/main/scala/de/smederee/hub/DoobieSignupRepository.scala
--- old-smederee/modules/hub/src/main/scala/de/smederee/hub/DoobieSignupRepository.scala	2025-01-31 13:42:16.244771658 +0000
+++ new-smederee/modules/hub/src/main/scala/de/smederee/hub/DoobieSignupRepository.scala	2025-01-31 13:42:16.252771672 +0000
@@ -32,13 +32,13 @@
   given Meta[Username]     = Meta[String].timap(Username.apply)(_.toString)
 
   override def createAccount(account: Account, hash: PasswordHash): F[Int] =
-    sql"""INSERT INTO "accounts" (uid, name, email, password, created_at, updated_at, validated_email) VALUES(${account.uid}, ${account.name}, ${account.email}, $hash, NOW(), NOW(), ${account.validatedEmail})""".update.run
+    sql"""INSERT INTO "hub"."accounts" (uid, name, email, password, created_at, updated_at, validated_email) VALUES(${account.uid}, ${account.name}, ${account.email}, $hash, NOW(), NOW(), ${account.validatedEmail})""".update.run
       .transact(tx)
 
   override def findEmail(address: Email): F[Option[Email]] =
-    sql"""SELECT email FROM "accounts" WHERE email = $address""".query[Email].option.transact(tx)
+    sql"""SELECT email FROM "hub"."accounts" WHERE email = $address""".query[Email].option.transact(tx)
 
   override def findUsername(name: Username): F[Option[Username]] =
-    sql"""SELECT name FROM "accounts" WHERE name = $name""".query[Username].option.transact(tx)
+    sql"""SELECT name FROM "hub"."accounts" WHERE name = $name""".query[Username].option.transact(tx)
 
 }
diff -rN -u old-smederee/modules/hub/src/main/scala/de/smederee/hub/DoobieVcsMetadataRepository.scala new-smederee/modules/hub/src/main/scala/de/smederee/hub/DoobieVcsMetadataRepository.scala
--- old-smederee/modules/hub/src/main/scala/de/smederee/hub/DoobieVcsMetadataRepository.scala	2025-01-31 13:42:16.244771658 +0000
+++ new-smederee/modules/hub/src/main/scala/de/smederee/hub/DoobieVcsMetadataRepository.scala	2025-01-31 13:42:16.252771672 +0000
@@ -39,17 +39,27 @@
   given Meta[Username]                 = Meta[String].timap(Username.apply)(_.toString)
 
   private val selectRepositoryColumns =
-    fr"""SELECT "repos".name AS name, "accounts".uid AS owner_id, "accounts".name AS owner_name, "repos".is_private AS is_private, "repos".description AS description, "repos".vcs_type AS vcs_type, "repos".website AS website FROM "repositories" AS "repos" JOIN "accounts" ON "repos".owner = "accounts".uid"""
+    fr"""SELECT
+          "repos".name AS name,
+          "accounts".uid AS owner_id,
+          "accounts".name AS owner_name,
+          "repos".is_private AS is_private,
+          "repos".description AS description,
+          "repos".vcs_type AS vcs_type,
+          "repos".website AS website
+        FROM "hub"."repositories" AS "repos"
+        JOIN "hub"."accounts" AS "accounts"
+        ON "repos".owner = "accounts".uid"""
 
   override def createFork(source: Long, target: Long): F[Int] =
-    sql"""INSERT INTO "forks" (original_repo, forked_repo) VALUES ($source, $target)""".update.run.transact(tx)
+    sql"""INSERT INTO "hub"."forks" (original_repo, forked_repo) VALUES ($source, $target)""".update.run.transact(tx)
 
   override def createVcsRepository(repository: VcsRepository): F[Int] =
-    sql"""INSERT INTO "repositories" (name, owner, is_private, description, vcs_type, website, created_at, updated_at) VALUES (${repository.name}, ${repository.owner.uid}, ${repository.isPrivate}, ${repository.description}, ${repository.vcsType}, ${repository.website}, NOW(), NOW())""".update.run
+    sql"""INSERT INTO "hub"."repositories" (name, owner, is_private, description, vcs_type, website, created_at, updated_at) VALUES (${repository.name}, ${repository.owner.uid}, ${repository.isPrivate}, ${repository.description}, ${repository.vcsType}, ${repository.website}, NOW(), NOW())""".update.run
       .transact(tx)
 
   override def deleteVcsRepository(repository: VcsRepository): F[Int] =
-    sql"""DELETE FROM "repositories" WHERE owner = ${repository.owner.uid} AND name = ${repository.name}""".update.run
+    sql"""DELETE FROM "hub"."repositories" WHERE owner = ${repository.owner.uid} AND name = ${repository.name}""".update.run
       .transact(tx)
 
   override def findVcsRepository(
@@ -65,12 +75,12 @@
   override def findVcsRepositoryId(owner: VcsRepositoryOwner, name: VcsRepositoryName): F[Option[Long]] = {
     val nameFilter  = fr"""name = $name"""
     val ownerFilter = fr"""owner = ${owner.uid}"""
-    val query       = fr"""SELECT id FROM "repositories"""" ++ whereAnd(ownerFilter, nameFilter) ++ fr"""LIMIT 1"""
+    val query = fr"""SELECT id FROM "hub"."repositories"""" ++ whereAnd(ownerFilter, nameFilter) ++ fr"""LIMIT 1"""
     query.query[Long].option.transact(tx)
   }
 
   override def findVcsRepositoryOwner(name: Username): F[Option[VcsRepositoryOwner]] =
-    sql"""SELECT uid, name FROM "accounts" WHERE name = $name LIMIT 1"""
+    sql"""SELECT uid, name FROM "hub"."accounts" WHERE name = $name LIMIT 1"""
       .query[VcsRepositoryOwner]
       .option
       .transact(tx)
@@ -80,7 +90,7 @@
       name: VcsRepositoryName
   ): F[Option[VcsRepository]] = {
     val query =
-      selectRepositoryColumns ++ fr"""WHERE "repos".id = (SELECT original_repo FROM "forks" WHERE forked_repo = (SELECT id FROM "repositories" WHERE name = $name AND owner = ${owner.uid}))"""
+      selectRepositoryColumns ++ fr"""WHERE "repos".id = (SELECT original_repo FROM "hub"."forks" WHERE forked_repo = (SELECT id FROM "hub"."repositories" WHERE name = $name AND owner = ${owner.uid}))"""
     query.query[VcsRepository].option.transact(tx)
   }
 
@@ -115,7 +125,7 @@
   }
 
   override def updateVcsRepository(repository: VcsRepository): F[Int] =
-    sql"""UPDATE "repositories" SET is_private = ${repository.isPrivate}, 
+    sql"""UPDATE "hub"."repositories" SET is_private = ${repository.isPrivate}, 
     description = ${repository.description}, 
     website = ${repository.website}, 
     updated_at = NOW() 
diff -rN -u old-smederee/modules/hub/src/main/scala/de/smederee/hub/HubServer.scala new-smederee/modules/hub/src/main/scala/de/smederee/hub/HubServer.scala
--- old-smederee/modules/hub/src/main/scala/de/smederee/hub/HubServer.scala	2025-01-31 13:42:16.244771658 +0000
+++ new-smederee/modules/hub/src/main/scala/de/smederee/hub/HubServer.scala	2025-01-31 13:42:16.252771672 +0000
@@ -28,11 +28,11 @@
 import com.typesafe.config._
 import de.smederee.darcs._
 import de.smederee.email.SimpleJavaMailMiddleware
+import de.smederee.html._
 import de.smederee.html.LinkTools._
 import de.smederee.hub.config._
 import de.smederee.security._
 import de.smederee.ssh._
-import de.smederee.tickets._
 import doobie._
 import org.http4s._
 import org.http4s.dsl.io._
@@ -61,7 +61,7 @@
     * @return
     *   A function which will check the correct origin of requests / cookies inside the CSRF middleware.
     */
-  private def createCsrfOriginCheck(linkConfig: ExternalLinkConfig): Request[IO] => Boolean = { request =>
+  private def createCsrfOriginCheck(linkConfig: ExternalUrlConfiguration): Request[IO] => Boolean = { request =>
     CSRF.defaultOriginCheck(request, linkConfig.host.toString, linkConfig.scheme, linkConfig.port.map(_.value))
   }
 
@@ -179,29 +179,19 @@
         authenticationRepo,
         signAndValidate
       )
-      signUpRepo = new DoobieSignupRepository[IO](transactor)
-      signUpRoutes = new SignupRoutes[IO](
-        configuration.service.external,
-        configuration.service.signup,
-        signUpRepo
-      )
-      landingPages    = new LandingPageRoutes[IO](configuration.service.external)
+      signUpRepo      = new DoobieSignupRepository[IO](transactor)
+      signUpRoutes    = new SignupRoutes[IO](configuration.service, signUpRepo)
+      landingPages    = new LandingPageRoutes[IO](configuration.service)
       vcsMetadataRepo = new DoobieVcsMetadataRepository[IO](transactor)
       vcsRepoRoutes = new VcsRepositoryRoutes[IO](
         configuration.service,
         darcsWrapper,
         vcsMetadataRepo
       )
-      labelRepo       = new DoobieLabelRepository[IO](transactor)
-      labelRoutes     = new LabelRoutes[IO](configuration.service, labelRepo, vcsMetadataRepo)
-      milestoneRepo   = new DoobieMilestoneRepository[IO](transactor)
-      milestoneRoutes = new MilestoneRoutes[IO](configuration.service, milestoneRepo, vcsMetadataRepo)
       protectedRoutesWithFallThrough = authenticationWithFallThrough(
         authenticationRoutes.protectedRoutes <+>
           accountManagementRoutes.protectedRoutes <+>
           signUpRoutes.protectedRoutes <+>
-          labelRoutes.protectedRoutes <+>
-          milestoneRoutes.protectedRoutes <+>
           vcsRepoRoutes.protectedRoutes <+>
           landingPages.protectedRoutes
       )
@@ -211,8 +201,6 @@
           authenticationRoutes.routes <+>
           accountManagementRoutes.routes <+>
           signUpRoutes.routes <+>
-          labelRoutes.routes <+>
-          milestoneRoutes.routes <+>
           vcsRepoRoutes.routes <+>
           landingPages.routes)
       ).orNotFound
diff -rN -u old-smederee/modules/hub/src/main/scala/de/smederee/hub/LandingPageRoutes.scala new-smederee/modules/hub/src/main/scala/de/smederee/hub/LandingPageRoutes.scala
--- old-smederee/modules/hub/src/main/scala/de/smederee/hub/LandingPageRoutes.scala	2025-01-31 13:42:16.244771658 +0000
+++ new-smederee/modules/hub/src/main/scala/de/smederee/hub/LandingPageRoutes.scala	2025-01-31 13:42:16.252771672 +0000
@@ -21,7 +21,7 @@
 import cats.syntax.all._
 import de.smederee.html.LinkTools._
 import de.smederee.hub.RequestHelpers.instances.given
-import de.smederee.hub.config.ExternalLinkConfig
+import de.smederee.hub.config._
 import org.http4s._
 import org.http4s.dsl.Http4sDsl
 import org.http4s.implicits._
@@ -32,12 +32,13 @@
   * Please note that due to the routing logic of http4s catch-all pages (`-> Root`) should be put last in the list of
   * routes!
   *
-  * @param linkConfig
-  *   The configuration needed to build correct links which are working from the outside.
+  * @param configuration
+  *   The hub service configuration.
   * @tparam F
   *   A higher kinded type providing needed functionality, which is usually an IO monad like Async or Sync.
   */
-final class LandingPageRoutes[F[_]: Async](linkConfig: ExternalLinkConfig) extends Http4sDsl[F] {
+final class LandingPageRoutes[F[_]: Async](configuration: ServiceConfig) extends Http4sDsl[F] {
+  private val linkConfig = configuration.external
   // The base URI for our site which that be passed into some templates which create links themselfes.
   private val baseUri = linkConfig.createFullUri(Uri())
   // The URL that shall be used in the `action` field of the form.
diff -rN -u old-smederee/modules/hub/src/main/scala/de/smederee/hub/SignupRoutes.scala new-smederee/modules/hub/src/main/scala/de/smederee/hub/SignupRoutes.scala
--- old-smederee/modules/hub/src/main/scala/de/smederee/hub/SignupRoutes.scala	2025-01-31 13:42:16.244771658 +0000
+++ new-smederee/modules/hub/src/main/scala/de/smederee/hub/SignupRoutes.scala	2025-01-31 13:42:16.252771672 +0000
@@ -39,21 +39,17 @@
 
 /** The routes for handling the user signup process.
   *
-  * @param linkConfig
-  *   The configuration needed to build correct links which are working from the outside.
-  * @param signupConfig
-  *   The configuration for the signup procedure.
+  * @param configuration
+  *   The hub service configuration.
   * @param repo
   *   The database repository providing needed functionality for checking and creating accounts.
   * @tparam F
   *   A higher kinded type providing needed functionality, which is usually an IO monad like Async or Sync.
   */
-final class SignupRoutes[F[_]: Async](
-    linkConfig: ExternalLinkConfig,
-    signupConfig: SignupConfiguration,
-    repo: SignupRepository[F]
-) extends Http4sDsl[F] {
-  private val log = LoggerFactory.getLogger(getClass)
+final class SignupRoutes[F[_]: Async](configuration: ServiceConfig, repo: SignupRepository[F]) extends Http4sDsl[F] {
+  private val log          = LoggerFactory.getLogger(getClass)
+  private val linkConfig   = configuration.external
+  private val signupConfig = configuration.signup
   // The base URI for our site which that be passed into some templates which create links themselfes.
   private val baseUri = linkConfig.createFullUri(Uri())
   // The URL path that shall be used in the `action` field of the form.
diff -rN -u old-smederee/modules/hub/src/main/scala/de/smederee/ssh/DoobieSshAuthenticationRepository.scala new-smederee/modules/hub/src/main/scala/de/smederee/ssh/DoobieSshAuthenticationRepository.scala
--- old-smederee/modules/hub/src/main/scala/de/smederee/ssh/DoobieSshAuthenticationRepository.scala	2025-01-31 13:42:16.244771658 +0000
+++ new-smederee/modules/hub/src/main/scala/de/smederee/ssh/DoobieSshAuthenticationRepository.scala	2025-01-31 13:42:16.252771672 +0000
@@ -39,18 +39,18 @@
   given Meta[VcsRepositoryName] = Meta[String].timap(VcsRepositoryName.apply)(_.toString)
 
   override def findSshKey(fingerprint: KeyFingerprint): F[Option[PublicSshKey]] =
-    sql"""SELECT id, uid, key_type, key, fingerprint, comment, created_at, last_used_at FROM "ssh_keys" WHERE fingerprint = $fingerprint"""
+    sql"""SELECT id, uid, key_type, key, fingerprint, comment, created_at, last_used_at FROM "hub"."ssh_keys" WHERE fingerprint = $fingerprint"""
       .query[PublicSshKey]
       .option
       .transact(tx)
 
   override def findVcsRepositoryOwner(name: Username): F[Option[VcsRepositoryOwner]] =
-    sql"""SELECT uid, name FROM "accounts" WHERE name = $name LIMIT 1"""
+    sql"""SELECT uid, name FROM "hub"."accounts" WHERE name = $name LIMIT 1"""
       .query[VcsRepositoryOwner]
       .option
       .transact(tx)
 
   override def updateLastUsed(keyId: UUID): F[Int] =
-    sql"""UPDATE "ssh_keys" SET last_used_at = NOW() WHERE id = $keyId""".update.run.transact(tx)
+    sql"""UPDATE "hub"."ssh_keys" SET last_used_at = NOW() WHERE id = $keyId""".update.run.transact(tx)
 
 }
diff -rN -u old-smederee/modules/hub/src/main/scala/de/smederee/tickets/Assignee.scala new-smederee/modules/hub/src/main/scala/de/smederee/tickets/Assignee.scala
--- old-smederee/modules/hub/src/main/scala/de/smederee/tickets/Assignee.scala	2025-01-31 13:42:16.244771658 +0000
+++ new-smederee/modules/hub/src/main/scala/de/smederee/tickets/Assignee.scala	1970-01-01 00:00:00.000000000 +0000
@@ -1,38 +0,0 @@
-/*
- * Copyright (C) 2022  Contributors as noted in the AUTHORS.md file
- *
- * This program is free software: you can redistribute it and/or modify
- * it under the terms of the GNU Affero General Public License as
- * published by the Free Software Foundation, either version 3 of the
- * License, or (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
- * GNU Affero General Public License for more details.
- *
- * You should have received a copy of the GNU Affero General Public License
- * along with this program.  If not, see <http://www.gnu.org/licenses/>.
- */
-
-package de.smederee.tickets
-
-import java.util.UUID
-
-import cats._
-import cats.data._
-import cats.syntax.all._
-import de.smederee.hub.{ UserId, Username }
-
-/** The assignee for a ticket i.e. the person supposed to be working on it.
-  *
-  * @param id
-  *   A globally unique ID identifying the assignee.
-  * @param name
-  *   The name associated with the assignee which is supposed to be unique.
-  */
-final case class Assignee(id: UserId, name: Username)
-
-object Assignee {
-  given Eq[Assignee] = Eq.fromUniversalEquals
-}
diff -rN -u old-smederee/modules/hub/src/main/scala/de/smederee/tickets/DoobieLabelRepository.scala new-smederee/modules/hub/src/main/scala/de/smederee/tickets/DoobieLabelRepository.scala
--- old-smederee/modules/hub/src/main/scala/de/smederee/tickets/DoobieLabelRepository.scala	2025-01-31 13:42:16.244771658 +0000
+++ new-smederee/modules/hub/src/main/scala/de/smederee/tickets/DoobieLabelRepository.scala	1970-01-01 00:00:00.000000000 +0000
@@ -1,79 +0,0 @@
-/*
- * Copyright (C) 2022  Contributors as noted in the AUTHORS.md file
- *
- * This program is free software: you can redistribute it and/or modify
- * it under the terms of the GNU Affero General Public License as
- * published by the Free Software Foundation, either version 3 of the
- * License, or (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
- * GNU Affero General Public License for more details.
- *
- * You should have received a copy of the GNU Affero General Public License
- * along with this program.  If not, see <http://www.gnu.org/licenses/>.
- */
-
-package de.smederee.tickets
-
-import cats.effect._
-import cats.syntax.all._
-import doobie._
-import doobie.Fragments._
-import doobie.implicits._
-import doobie.postgres.implicits._
-import fs2.Stream
-
-final class DoobieLabelRepository[F[_]: Sync](tx: Transactor[F]) extends LabelRepository[F] {
-  given Meta[ColourCode]       = Meta[String].timap(ColourCode.apply)(_.toString)
-  given Meta[LabelDescription] = Meta[String].timap(LabelDescription.apply)(_.toString)
-  given Meta[LabelId]          = Meta[Long].timap(LabelId.apply)(_.toLong)
-  given Meta[LabelName]        = Meta[String].timap(LabelName.apply)(_.toString)
-
-  override def allLabels(vcsRepositoryId: Long): Stream[F, Label] =
-    sql"""SELECT id, name, description, colour FROM "labels" WHERE repository = $vcsRepositoryId ORDER BY name ASC"""
-      .query[Label]
-      .stream
-      .transact(tx)
-
-  override def createLabel(vcsRepositoryId: Long)(label: Label): F[Int] =
-    sql"""INSERT INTO "labels"
-          (
-            repository,
-            name,
-            description,
-            colour
-          )
-          VALUES (
-            $vcsRepositoryId,
-            ${label.name},
-            ${label.description},
-            ${label.colour}
-          )""".update.run.transact(tx)
-
-  override def deleteLabel(label: Label): F[Int] =
-    label.id match {
-      case None => Sync[F].pure(0)
-      case Some(id) =>
-        sql"""DELETE FROM "labels" WHERE id = $id""".update.run.transact(tx)
-    }
-
-  override def findLabel(vcsRepositoryId: Long)(name: LabelName): F[Option[Label]] =
-    sql"""SELECT id, name, description, colour FROM "labels" WHERE repository = $vcsRepositoryId AND name = $name LIMIT 1"""
-      .query[Label]
-      .option
-      .transact(tx)
-
-  override def updateLabel(label: Label): F[Int] =
-    label.id match {
-      case None => Sync[F].pure(0)
-      case Some(id) =>
-        sql"""UPDATE "labels" 
-              SET name = ${label.name},
-              description = ${label.description},
-              colour = ${label.colour}
-              WHERE id = $id""".update.run.transact(tx)
-    }
-
-}
diff -rN -u old-smederee/modules/hub/src/main/scala/de/smederee/tickets/DoobieMilestoneRepository.scala new-smederee/modules/hub/src/main/scala/de/smederee/tickets/DoobieMilestoneRepository.scala
--- old-smederee/modules/hub/src/main/scala/de/smederee/tickets/DoobieMilestoneRepository.scala	2025-01-31 13:42:16.244771658 +0000
+++ new-smederee/modules/hub/src/main/scala/de/smederee/tickets/DoobieMilestoneRepository.scala	1970-01-01 00:00:00.000000000 +0000
@@ -1,77 +0,0 @@
-/*
- * Copyright (C) 2022  Contributors as noted in the AUTHORS.md file
- *
- * This program is free software: you can redistribute it and/or modify
- * it under the terms of the GNU Affero General Public License as
- * published by the Free Software Foundation, either version 3 of the
- * License, or (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
- * GNU Affero General Public License for more details.
- *
- * You should have received a copy of the GNU Affero General Public License
- * along with this program.  If not, see <http://www.gnu.org/licenses/>.
- */
-
-package de.smederee.tickets
-
-import cats.effect._
-import cats.syntax.all._
-import doobie._
-import doobie.Fragments._
-import doobie.implicits._
-import doobie.postgres.implicits._
-import fs2.Stream
-
-final class DoobieMilestoneRepository[F[_]: Sync](tx: Transactor[F]) extends MilestoneRepository[F] {
-  given Meta[MilestoneDescription] = Meta[String].timap(MilestoneDescription.apply)(_.toString)
-  given Meta[MilestoneId]          = Meta[Long].timap(MilestoneId.apply)(_.toLong)
-  given Meta[MilestoneTitle]       = Meta[String].timap(MilestoneTitle.apply)(_.toString)
-
-  override def allMilestones(vcsRepositoryId: Long): Stream[F, Milestone] =
-    sql"""SELECT id, title, description, due_date FROM "milestones" WHERE repository = $vcsRepositoryId ORDER BY due_date ASC, title ASC"""
-      .query[Milestone]
-      .stream
-      .transact(tx)
-
-  override def createMilestone(vcsRepositoryId: Long)(milestone: Milestone): F[Int] =
-    sql"""INSERT INTO "milestones"
-          (
-            repository,
-            title,
-            due_date,
-            description
-          )
-          VALUES (
-            $vcsRepositoryId,
-            ${milestone.title},
-            ${milestone.dueDate},
-            ${milestone.description}
-          )""".update.run.transact(tx)
-
-  override def deleteMilestone(milestone: Milestone): F[Int] =
-    milestone.id match {
-      case None     => Sync[F].pure(0)
-      case Some(id) => sql"""DELETE FROM "milestones" WHERE id = $id""".update.run.transact(tx)
-    }
-
-  override def findMilestone(vcsRepositoryId: Long)(title: MilestoneTitle): F[Option[Milestone]] =
-    sql"""SELECT id, title, description, due_date FROM "milestones" WHERE repository = $vcsRepositoryId AND title = $title LIMIT 1"""
-      .query[Milestone]
-      .option
-      .transact(tx)
-
-  override def updateMilestone(milestone: Milestone): F[Int] =
-    milestone.id match {
-      case None => Sync[F].pure(0)
-      case Some(id) =>
-        sql"""UPDATE "milestones"
-              SET title = ${milestone.title},
-                due_date = ${milestone.dueDate},
-                description = ${milestone.description}
-              WHERE id = $id""".update.run.transact(tx)
-    }
-
-}
diff -rN -u old-smederee/modules/hub/src/main/scala/de/smederee/tickets/LabelForm.scala new-smederee/modules/hub/src/main/scala/de/smederee/tickets/LabelForm.scala
--- old-smederee/modules/hub/src/main/scala/de/smederee/tickets/LabelForm.scala	2025-01-31 13:42:16.244771658 +0000
+++ new-smederee/modules/hub/src/main/scala/de/smederee/tickets/LabelForm.scala	1970-01-01 00:00:00.000000000 +0000
@@ -1,113 +0,0 @@
-/*
- * Copyright (C) 2022  Contributors as noted in the AUTHORS.md file
- *
- * This program is free software: you can redistribute it and/or modify
- * it under the terms of the GNU Affero General Public License as
- * published by the Free Software Foundation, either version 3 of the
- * License, or (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
- * GNU Affero General Public License for more details.
- *
- * You should have received a copy of the GNU Affero General Public License
- * along with this program.  If not, see <http://www.gnu.org/licenses/>.
- */
-
-package de.smederee.tickets
-
-import cats._
-import cats.data._
-import cats.syntax.all._
-import de.smederee.hub.forms.FormValidator
-import de.smederee.hub.forms.types._
-
-/** Data container to edit a label.
-  *
-  * @param id
-  *   An optional attribute containing the unique internal database ID for the label.
-  * @param name
-  *   A short descriptive name for the label which is supposed to be unique in a project context.
-  * @param description
-  *   An optional description if needed.
-  * @param colour
-  *   A hexadecimal HTML colour code which can be used to mark the label on a rendered website.
-  */
-final case class LabelForm(
-    id: Option[LabelId],
-    name: LabelName,
-    description: Option[LabelDescription],
-    colour: ColourCode
-)
-
-object LabelForm extends FormValidator[LabelForm] {
-  val fieldColour: FormField      = FormField("colour")
-  val fieldDescription: FormField = FormField("description")
-  val fieldId: FormField          = FormField("id")
-  val fieldName: FormField        = FormField("name")
-
-  /** Create a form for editing a label from the given label data.
-    *
-    * @param label
-    *   The label which provides the data for the edit form.
-    * @return
-    *   A label form filled with the data from the given label.
-    */
-  def fromLabel(label: Label): LabelForm =
-    LabelForm(id = label.id, name = label.name, description = label.description, colour = label.colour)
-
-  override def validate(data: Map[String, String]): ValidatedNec[FormErrors, LabelForm] = {
-    val id = data
-      .get(fieldId)
-      .fold(Option.empty[LabelId].validNec)(s =>
-        LabelId.fromString(s).fold(FormFieldError("Invalid label id!").invalidNec)(id => Option(id).validNec)
-      )
-      .leftMap(es => NonEmptyChain.of(Map(fieldId -> es.toList)))
-    val name = data
-      .get(fieldName)
-      .map(_.trim) // We strip leading and trailing whitespace!
-      .fold(FormFieldError("No label name given!").invalidNec)(s =>
-        LabelName.from(s).fold(FormFieldError("Invalid label name!").invalidNec)(_.validNec)
-      )
-      .leftMap(es => NonEmptyChain.of(Map(fieldName -> es.toList)))
-    val description = data
-      .get(fieldDescription)
-      .fold(Option.empty[LabelDescription].validNec) { s =>
-        if (s.trim.isEmpty)
-          Option.empty[LabelDescription].validNec // Sometimes "empty" strings are sent.
-        else
-          LabelDescription
-            .from(s)
-            .fold(FormFieldError("Invalid label description!").invalidNec)(descr => Option(descr).validNec)
-      }
-      .leftMap(es => NonEmptyChain.of(Map(fieldDescription -> es.toList)))
-    val colour = data
-      .get(fieldColour)
-      .fold(FormFieldError("No label colour given!").invalidNec)(s =>
-        ColourCode.from(s).fold(FormFieldError("Invalid label colour!").invalidNec)(_.validNec)
-      )
-      .leftMap(es => NonEmptyChain.of(Map(fieldColour -> es.toList)))
-    (id, name, description, colour).mapN { case (id, name, description, colour) =>
-      LabelForm(id, name, description, colour)
-    }
-  }
-
-  extension (form: LabelForm) {
-
-    /** Convert the form class into a stringified map which is used as underlying data type for form handling in the
-      * twirl templating library.
-      *
-      * @return
-      *   A stringified map containing the data of the form.
-      */
-    def toMap: Map[String, String] =
-      Map(
-        LabelForm.fieldId.toString          -> form.id.map(_.toString).getOrElse(""),
-        LabelForm.fieldName.toString        -> form.name.toString,
-        LabelForm.fieldDescription.toString -> form.description.map(_.toString).getOrElse(""),
-        LabelForm.fieldColour.toString      -> form.colour.toString
-      )
-  }
-
-}
diff -rN -u old-smederee/modules/hub/src/main/scala/de/smederee/tickets/LabelRepository.scala new-smederee/modules/hub/src/main/scala/de/smederee/tickets/LabelRepository.scala
--- old-smederee/modules/hub/src/main/scala/de/smederee/tickets/LabelRepository.scala	2025-01-31 13:42:16.244771658 +0000
+++ new-smederee/modules/hub/src/main/scala/de/smederee/tickets/LabelRepository.scala	1970-01-01 00:00:00.000000000 +0000
@@ -1,78 +0,0 @@
-/*
- * Copyright (C) 2022  Contributors as noted in the AUTHORS.md file
- *
- * This program is free software: you can redistribute it and/or modify
- * it under the terms of the GNU Affero General Public License as
- * published by the Free Software Foundation, either version 3 of the
- * License, or (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
- * GNU Affero General Public License for more details.
- *
- * You should have received a copy of the GNU Affero General Public License
- * along with this program.  If not, see <http://www.gnu.org/licenses/>.
- */
-
-package de.smederee.tickets
-
-import fs2.Stream
-
-/** The base class that defines the needed functionality to handle labels within a database.
-  *
-  * @tparam F
-  *   A higher kinded type which wraps the actual return values.
-  */
-abstract class LabelRepository[F[_]] {
-
-  /** Return all labels associated with the given repository.
-    *
-    * @param vcsRepositoryId
-    *   The unique internal ID of a vcs repository metadata entry for which all labels shall be returned.
-    * @return
-    *   A stream of labels associated with the vcs repository which may be empty.
-    */
-  def allLabels(vcsRepositoryId: Long): Stream[F, Label]
-
-  /** Create a database entry for the given label definition.
-    *
-    * @param vcsRepositoryId
-    *   The unique internal ID of a vcs repository metadata entry to which the label belongs.
-    * @param label
-    *   The label definition that shall be written to the database.
-    * @return
-    *   The number of affected database rows.
-    */
-  def createLabel(vcsRepositoryId: Long)(label: Label): F[Int]
-
-  /** Delete the label from the database.
-    *
-    * @param label
-    *   The label definition that shall be deleted from the database.
-    * @return
-    *   The number of affected database rows.
-    */
-  def deleteLabel(label: Label): F[Int]
-
-  /** Find the label with the given name for the given vcs repository.
-    *
-    * @param vcsRepositoryId
-    *   The unique internal ID of a vcs repository metadata entry to which the label belongs.
-    * @param name
-    *   The name of the label which is must be unique in the context of the repository.
-    * @return
-    *   An option to the found label.
-    */
-  def findLabel(vcsRepositoryId: Long)(name: LabelName): F[Option[Label]]
-
-  /** Update the database entry for the given label.
-    *
-    * @param label
-    *   The label definition that shall be updated within the database.
-    * @return
-    *   The number of affected database rows.
-    */
-  def updateLabel(label: Label): F[Int]
-
-}
diff -rN -u old-smederee/modules/hub/src/main/scala/de/smederee/tickets/LabelRoutes.scala new-smederee/modules/hub/src/main/scala/de/smederee/tickets/LabelRoutes.scala
--- old-smederee/modules/hub/src/main/scala/de/smederee/tickets/LabelRoutes.scala	2025-01-31 13:42:16.244771658 +0000
+++ new-smederee/modules/hub/src/main/scala/de/smederee/tickets/LabelRoutes.scala	1970-01-01 00:00:00.000000000 +0000
@@ -1,449 +0,0 @@
-/*
- * Copyright (C) 2022  Contributors as noted in the AUTHORS.md file
- *
- * This program is free software: you can redistribute it and/or modify
- * it under the terms of the GNU Affero General Public License as
- * published by the Free Software Foundation, either version 3 of the
- * License, or (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
- * GNU Affero General Public License for more details.
- *
- * You should have received a copy of the GNU Affero General Public License
- * along with this program.  If not, see <http://www.gnu.org/licenses/>.
- */
-
-package de.smederee.tickets
-
-import cats._
-import cats.data._
-import cats.effect._
-import cats.syntax.all._
-import de.smederee.html.LinkTools._
-import de.smederee.hub.RequestHelpers.instances.given
-import de.smederee.hub._
-import de.smederee.hub.config._
-import de.smederee.hub.forms.types._
-import org.http4s._
-import org.http4s.dsl.Http4sDsl
-import org.http4s.dsl.impl._
-import org.http4s.headers.Location
-import org.http4s.implicits._
-import org.http4s.twirl.TwirlInstances._
-import org.slf4j.LoggerFactory
-
-/** Routes for managing labels (basically CRUD functionality).
-  *
-  * @param configuration
-  *   The hub service configuration.
-  * @param labelRepo
-  *   A repository for handling database operations for labels.
-  * @param vcsMetadataRepo
-  *   A repository for handling database operations regarding our vcs repositories and their metadata.
-  * @tparam F
-  *   A higher kinded type providing needed functionality, which is usually an IO monad like Async or Sync.
-  */
-final class LabelRoutes[F[_]: Async](
-    configuration: ServiceConfig,
-    labelRepo: LabelRepository[F],
-    vcsMetadataRepo: VcsMetadataRepository[F]
-) extends Http4sDsl[F] {
-  private val log = LoggerFactory.getLogger(getClass)
-
-  val linkConfig = configuration.external
-
-  /** Logic for rendering a list of all labels for a repository and optionally management functionality.
-    *
-    * @param csrf
-    *   An optional CSRF-Token that shall be used.
-    * @param user
-    *   An optional user account for whom the list of labels shall be rendered.
-    * @param repositoryOwnerName
-    *   The username of the account who owns the repository.
-    * @param repositoryName
-    *   The name of the repository.
-    * @return
-    *   An HTTP response containing the rendered HTML.
-    */
-  private def doShowLabels(
-      csrf: Option[CsrfToken]
-  )(user: Option[Account])(repositoryOwnerName: Username)(repositoryName: VcsRepositoryName): F[Response[F]] =
-    for {
-      repoAndId <- loadRepo(user)(repositoryOwnerName, repositoryName)
-      resp <- repoAndId match {
-        case Some((repo, repoId)) =>
-          for {
-            labels <- labelRepo.allLabels(repoId).compile.toList
-            repositoryBaseUri <- Sync[F].delay(
-              linkConfig.createFullUri(
-                Uri(path =
-                  Uri.Path(
-                    Vector(Uri.Path.Segment(s"~$repositoryOwnerName"), Uri.Path.Segment(repositoryName.toString))
-                  )
-                )
-              )
-            )
-            resp <- Ok(
-              views.html.tickets.editLabels()(
-                repositoryBaseUri.addSegment("labels"),
-                csrf,
-                labels,
-                repositoryBaseUri,
-                "Manage your repository labels.".some,
-                user,
-                repo
-              )()
-            )
-          } yield resp
-        case _ => NotFound()
-      }
-    } yield resp
-
-  /** Load the repository metadata with the given owner and name from the database and return it and its primary key id
-    * if the repository exists and is readable by the given user account.
-    *
-    * @param currentUser
-    *   The user account that is requesting access to the repository or None for a guest user.
-    * @param repositoryOwnerName
-    *   The name of the account that owns the repository.
-    * @param repositoryName
-    *   The name of the repository. A repository name must start with a letter or number and must contain only
-    *   alphanumeric ASCII characters as well as minus or underscore signs. It must be between 2 and 64 characters long.
-    * @return
-    *   An option to a tuple holding the [[VcsRepository]] and its primary key id.
-    */
-  private def loadRepo(
-      currentUser: Option[Account]
-  )(repositoryOwnerName: Username, repositoryName: VcsRepositoryName): F[Option[(VcsRepository, Long)]] =
-    for {
-      owner <- vcsMetadataRepo.findVcsRepositoryOwner(repositoryOwnerName)
-      loadedRepo <- owner match {
-        case None => Sync[F].pure(None)
-        case Some(owner) =>
-          (
-            vcsMetadataRepo.findVcsRepository(owner, repositoryName),
-            vcsMetadataRepo.findVcsRepositoryId(owner, repositoryName)
-          ).mapN {
-            case (Some(repo), Some(repoId)) => (repo, repoId).some
-            case _                          => None
-          }
-      }
-      // TODO Replace with whatever we implement as proper permission model. ;-)
-      repoAndId = currentUser match {
-        case None => loadedRepo.filter(tuple => tuple._1.isPrivate === false)
-        case Some(user) =>
-          loadedRepo.filter(tuple => tuple._1.isPrivate === false || tuple._1.owner === user.toVcsRepositoryOwner)
-      }
-    } yield repoAndId
-
-  private val addLabel: AuthedRoutes[Account, F] = AuthedRoutes.of {
-    case ar @ POST -> Root / UsernamePathParameter(repositoryOwnerName) / VcsRepositoryNamePathParameter(
-          repositoryName
-        ) / "labels" as user =>
-      ar.req.decodeStrict[F, UrlForm] { urlForm =>
-        for {
-          csrf      <- Sync[F].delay(ar.req.getCsrfToken)
-          repoAndId <- loadRepo(user.some)(repositoryOwnerName, repositoryName)
-          resp <- repoAndId match {
-            case Some(repo, repoId) =>
-              for {
-                _ <- Sync[F].raiseUnless(repo.owner.uid === user.uid)(new Error("Only maintainers may add labels!"))
-                formData <- Sync[F].delay {
-                  urlForm.values.map { t =>
-                    val (key, values) = t
-                    (
-                      key,
-                      values.headOption.getOrElse("")
-                    ) // Pick the first value (a field might get submitted multiple times)!
-                  }
-                }
-                form   <- Sync[F].delay(LabelForm.validate(formData))
-                labels <- repoAndId.traverse(tuple => labelRepo.allLabels(tuple._2).compile.toList)
-                repositoryBaseUri <- Sync[F].delay(
-                  linkConfig.createFullUri(
-                    Uri(path =
-                      Uri.Path(
-                        Vector(Uri.Path.Segment(s"~$repositoryOwnerName"), Uri.Path.Segment(repositoryName.toString))
-                      )
-                    )
-                  )
-                )
-                resp <- form match {
-                  case Validated.Invalid(errors) =>
-                    BadRequest(
-                      views.html.tickets.editLabels()(
-                        repositoryBaseUri.addSegment("labels"),
-                        csrf,
-                        labels.getOrElse(List.empty),
-                        repositoryBaseUri,
-                        "Manage your repository labels.".some,
-                        user.some,
-                        repo
-                      )(formData, FormErrors.fromNec(errors))
-                    )
-                  case Validated.Valid(labelData) =>
-                    val label = Label(None, labelData.name, labelData.description, labelData.colour)
-                    for {
-                      checkDuplicate <- labelRepo.findLabel(repoId)(labelData.name)
-                      resp <- checkDuplicate match {
-                        case None =>
-                          labelRepo.createLabel(repoId)(label) *> SeeOther(
-                            Location(repositoryBaseUri.addSegment("labels"))
-                          )
-                        case Some(_) =>
-                          BadRequest(
-                            views.html.tickets.editLabels()(
-                              repositoryBaseUri.addSegment("labels"),
-                              csrf,
-                              labels.getOrElse(List.empty),
-                              repositoryBaseUri,
-                              "Manage your repository labels.".some,
-                              user.some,
-                              repo
-                            )(
-                              formData,
-                              Map(
-                                LabelForm.fieldName -> List(FormFieldError("A label with that name already exists!"))
-                              )
-                            )
-                          )
-                      }
-                    } yield resp
-                }
-              } yield resp
-            case _ => NotFound()
-          }
-        } yield resp
-      }
-  }
-
-  private val deleteLabel: AuthedRoutes[Account, F] = AuthedRoutes.of {
-    case ar @ POST -> Root / UsernamePathParameter(repositoryOwnerName) / VcsRepositoryNamePathParameter(
-          repositoryName
-        ) / "label" / LabelNamePathParameter(labelName) / "delete" as user =>
-      ar.req.decodeStrict[F, UrlForm] { urlForm =>
-        for {
-          csrf      <- Sync[F].delay(ar.req.getCsrfToken)
-          repoAndId <- loadRepo(user.some)(repositoryOwnerName, repositoryName)
-          resp <- repoAndId match {
-            case Some(repo, repoId) =>
-              for {
-                _     <- Sync[F].raiseUnless(repo.owner.uid === user.uid)(new Error("Only maintainers may add labels!"))
-                label <- labelRepo.findLabel(repoId)(labelName)
-                resp <- label match {
-                  case Some(label) =>
-                    for {
-                      formData <- Sync[F].delay {
-                        urlForm.values.map { t =>
-                          val (key, values) = t
-                          (
-                            key,
-                            values.headOption.getOrElse("")
-                          ) // Pick the first value (a field might get submitted multiple times)!
-                        }
-                      }
-                      repositoryBaseUri <- Sync[F].delay(
-                        linkConfig.createFullUri(
-                          Uri(path =
-                            Uri.Path(
-                              Vector(
-                                Uri.Path.Segment(s"~$repositoryOwnerName"),
-                                Uri.Path.Segment(repositoryName.toString)
-                              )
-                            )
-                          )
-                        )
-                      )
-                      userIsSure <- Sync[F].delay(formData.get("i-am-sure").exists(_ === "yes"))
-                      labelIdMatches <- Sync[F].delay(
-                        formData
-                          .get(LabelForm.fieldId)
-                          .flatMap(LabelId.fromString)
-                          .exists(id => label.id.exists(_ === id))
-                      )
-                      labelNameMatches <- Sync[F].delay(
-                        formData.get(LabelForm.fieldName).flatMap(LabelName.from).exists(_ === labelName)
-                      )
-                      resp <- (labelIdMatches && labelNameMatches && userIsSure) match {
-                        case false => BadRequest("Invalid form data!")
-                        case true =>
-                          labelRepo.deleteLabel(label) *> SeeOther(
-                            Location(repositoryBaseUri.addSegment("labels"))
-                          )
-                      }
-                    } yield resp
-                  case _ => NotFound("Label not found!")
-                }
-              } yield resp
-            case _ => NotFound("Repository not found!")
-          }
-        } yield resp
-      }
-  }
-
-  private val editLabel: AuthedRoutes[Account, F] = AuthedRoutes.of {
-    case ar @ POST -> Root / UsernamePathParameter(repositoryOwnerName) / VcsRepositoryNamePathParameter(
-          repositoryName
-        ) / "label" / LabelNamePathParameter(labelName) as user =>
-      ar.req.decodeStrict[F, UrlForm] { urlForm =>
-        for {
-          csrf      <- Sync[F].delay(ar.req.getCsrfToken)
-          repoAndId <- loadRepo(user.some)(repositoryOwnerName, repositoryName)
-          label <- repoAndId match {
-            case Some((_, repoId)) => labelRepo.findLabel(repoId)(labelName)
-            case _                 => Sync[F].delay(None)
-          }
-          resp <- (repoAndId, label) match {
-            case (Some(repo, repoId), Some(label)) =>
-              for {
-                _ <- Sync[F].raiseUnless(repo.owner.uid === user.uid)(new Error("Only maintainers may add labels!"))
-                repositoryBaseUri <- Sync[F].delay(
-                  linkConfig.createFullUri(
-                    Uri(path =
-                      Uri.Path(
-                        Vector(Uri.Path.Segment(s"~$repositoryOwnerName"), Uri.Path.Segment(repositoryName.toString))
-                      )
-                    )
-                  )
-                )
-                actionUri <- Sync[F].delay(repositoryBaseUri.addSegment("label").addSegment(label.name.toString))
-                formData <- Sync[F].delay {
-                  urlForm.values.map { t =>
-                    val (key, values) = t
-                    (
-                      key,
-                      values.headOption.getOrElse("")
-                    ) // Pick the first value (a field might get submitted multiple times)!
-                  }
-                }
-                labelIdMatches <- Sync[F].delay(
-                  formData
-                    .get(LabelForm.fieldId)
-                    .flatMap(LabelId.fromString)
-                    .exists(id => label.id.exists(_ === id)) match {
-                    case false =>
-                      NonEmptyChain
-                        .of(Map(LabelForm.fieldGlobal -> List(FormFieldError("Label ID does not match!"))))
-                        .invalidNec
-                    case true => label.id.validNec
-                  }
-                )
-                form <- Sync[F].delay(LabelForm.validate(formData))
-                resp <- form match {
-                  case Validated.Invalid(errors) =>
-                    BadRequest(
-                      views.html.tickets
-                        .editLabel()(
-                          actionUri,
-                          csrf,
-                          label,
-                          repositoryBaseUri,
-                          s"Edit label ${label.name}".some,
-                          user,
-                          repo
-                        )(
-                          formData.toMap,
-                          FormErrors.fromNec(errors)
-                        )
-                    )
-                  case Validated.Valid(labelData) =>
-                    val updatedLabel =
-                      label.copy(name = labelData.name, description = labelData.description, colour = labelData.colour)
-                    for {
-                      checkDuplicate <- labelRepo.findLabel(repoId)(updatedLabel.name)
-                      resp <- checkDuplicate.filterNot(_.id === updatedLabel.id) match {
-                        case None =>
-                          labelRepo.updateLabel(updatedLabel) *> SeeOther(
-                            Location(repositoryBaseUri.addSegment("labels"))
-                          )
-                        case Some(_) =>
-                          BadRequest(
-                            views.html.tickets.editLabel()(
-                              actionUri,
-                              csrf,
-                              label,
-                              repositoryBaseUri,
-                              s"Edit label ${label.name}".some,
-                              user,
-                              repo
-                            )(
-                              formData,
-                              Map(
-                                LabelForm.fieldName -> List(FormFieldError("A label with that name already exists!"))
-                              )
-                            )
-                          )
-                      }
-                    } yield resp
-                }
-              } yield resp
-            case _ => NotFound()
-          }
-        } yield resp
-      }
-  }
-
-  private val showEditLabelForm: AuthedRoutes[Account, F] = AuthedRoutes.of {
-    case ar @ GET -> Root / UsernamePathParameter(repositoryOwnerName) / VcsRepositoryNamePathParameter(
-          repositoryName
-        ) / "label" / LabelNamePathParameter(labelName) / "edit" as user =>
-      for {
-        csrf      <- Sync[F].delay(ar.req.getCsrfToken)
-        repoAndId <- loadRepo(user.some)(repositoryOwnerName, repositoryName)
-        label <- repoAndId match {
-          case Some((_, repoId)) => labelRepo.findLabel(repoId)(labelName)
-          case _                 => Sync[F].delay(None)
-        }
-        resp <- (repoAndId, label) match {
-          case (Some(repo, repoId), Some(label)) =>
-            for {
-              repositoryBaseUri <- Sync[F].delay(
-                linkConfig.createFullUri(
-                  Uri(path =
-                    Uri.Path(
-                      Vector(Uri.Path.Segment(s"~$repositoryOwnerName"), Uri.Path.Segment(repositoryName.toString))
-                    )
-                  )
-                )
-              )
-              actionUri <- Sync[F].delay(repositoryBaseUri.addSegment("label").addSegment(label.name.toString))
-              formData  <- Sync[F].delay(LabelForm.fromLabel(label))
-              resp <- Ok(
-                views.html.tickets
-                  .editLabel()(actionUri, csrf, label, repositoryBaseUri, s"Edit label ${label.name}".some, user, repo)(
-                    formData.toMap
-                  )
-              )
-            } yield resp
-          case _ => NotFound()
-        }
-      } yield resp
-  }
-
-  private val showEditLabelsPage: AuthedRoutes[Account, F] = AuthedRoutes.of {
-    case ar @ GET -> Root / UsernamePathParameter(repositoryOwnerName) / VcsRepositoryNamePathParameter(
-          repositoryName
-        ) / "labels" as user =>
-      for {
-        csrf <- Sync[F].delay(ar.req.getCsrfToken)
-        resp <- doShowLabels(csrf)(user.some)(repositoryOwnerName)(repositoryName)
-      } yield resp
-  }
-
-  private val showLabelsForGuests: HttpRoutes[F] = HttpRoutes.of {
-    case req @ GET -> Root / UsernamePathParameter(repositoryOwnerName) / VcsRepositoryNamePathParameter(
-          repositoryName
-        ) / "labels" =>
-      for {
-        csrf <- Sync[F].delay(req.getCsrfToken)
-        resp <- doShowLabels(csrf)(None)(repositoryOwnerName)(repositoryName)
-      } yield resp
-  }
-
-  val protectedRoutes = addLabel <+> deleteLabel <+> editLabel <+> showEditLabelForm <+> showEditLabelsPage
-
-  val routes = showLabelsForGuests
-
-}
diff -rN -u old-smederee/modules/hub/src/main/scala/de/smederee/tickets/Label.scala new-smederee/modules/hub/src/main/scala/de/smederee/tickets/Label.scala
--- old-smederee/modules/hub/src/main/scala/de/smederee/tickets/Label.scala	2025-01-31 13:42:16.244771658 +0000
+++ new-smederee/modules/hub/src/main/scala/de/smederee/tickets/Label.scala	1970-01-01 00:00:00.000000000 +0000
@@ -1,186 +0,0 @@
-/*
- * Copyright (C) 2022  Contributors as noted in the AUTHORS.md file
- *
- * This program is free software: you can redistribute it and/or modify
- * it under the terms of the GNU Affero General Public License as
- * published by the Free Software Foundation, either version 3 of the
- * License, or (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
- * GNU Affero General Public License for more details.
- *
- * You should have received a copy of the GNU Affero General Public License
- * along with this program.  If not, see <http://www.gnu.org/licenses/>.
- */
-
-package de.smederee.tickets
-
-import cats._
-import cats.syntax.all._
-
-import scala.util.matching.Regex
-
-opaque type LabelId = Long
-object LabelId {
-  given Eq[LabelId] = Eq.fromUniversalEquals
-
-  val Format: Regex = "^-?\\d+$".r
-
-  /** Create an instance of LabelId from the given Long type.
-    *
-    * @param source
-    *   An instance of type Long which will be returned as a LabelId.
-    * @return
-    *   The appropriate instance of LabelId.
-    */
-  def apply(source: Long): LabelId = source
-
-  /** Try to create an instance of LabelId from the given Long.
-    *
-    * @param source
-    *   A Long that should fulfil the requirements to be converted into a LabelId.
-    * @return
-    *   An option to the successfully converted LabelId.
-    */
-  def from(source: Long): Option[LabelId] = Option(source)
-
-  /** Try to create an instance of LabelId from the given String.
-    *
-    * @param source
-    *   A string that should fulfil the requirements to be converted into a LabelId.
-    * @return
-    *   An option to the successfully converted LabelId.
-    */
-  def fromString(source: String): Option[LabelId] = Option(source).filter(Format.matches).map(_.toLong).flatMap(from)
-
-  extension (id: LabelId) {
-    def toLong: Long = id
-  }
-
-}
-
-/** Extractor to retrieve an LabelId from a path parameter.
-  */
-object LabelIdPathParameter {
-  def unapply(str: String): Option[LabelId] = Option(str).flatMap(LabelId.fromString)
-}
-
-/** A short descriptive name for the label which is supposed to be unique in a project context. It must not be empty and
-  * not exceed 40 characters in length.
-  */
-opaque type LabelName = String
-object LabelName {
-  given Eq[LabelName]       = Eq.fromUniversalEquals
-  given Ordering[LabelName] = (x: LabelName, y: LabelName) => x.compareTo(y)
-  given Order[LabelName]    = Order.fromOrdering[LabelName]
-
-  val MaxLength: Int = 40
-
-  /** Create an instance of LabelName from the given String type.
-    *
-    * @param source
-    *   An instance of type String which will be returned as a LabelName.
-    * @return
-    *   The appropriate instance of LabelName.
-    */
-  def apply(source: String): LabelName = source
-
-  /** Try to create an instance of LabelName from the given String.
-    *
-    * @param source
-    *   A String that should fulfil the requirements to be converted into a LabelName.
-    * @return
-    *   An option to the successfully converted LabelName.
-    */
-  def from(source: String): Option[LabelName] =
-    Option(source).filter(string => string.nonEmpty && string.length <= MaxLength)
-
-}
-
-/** Extractor to retrieve an LabelName from a path parameter.
-  */
-object LabelNamePathParameter {
-  def unapply(str: String): Option[LabelName] = Option(str).flatMap(LabelName.from)
-}
-
-/** A maybe needed description of a label which must not be empty and not exceed 254 characters in length.
-  */
-opaque type LabelDescription = String
-object LabelDescription {
-  given Eq[LabelDescription] = Eq.fromUniversalEquals
-
-  val MaxLength: Int = 254
-
-  /** Create an instance of LabelDescription from the given String type.
-    *
-    * @param source
-    *   An instance of type String which will be returned as a LabelDescription.
-    * @return
-    *   The appropriate instance of LabelDescription.
-    */
-  def apply(source: String): LabelDescription = source
-
-  /** Try to create an instance of LabelDescription from the given String.
-    *
-    * @param source
-    *   A String that should fulfil the requirements to be converted into a LabelDescription.
-    * @return
-    *   An option to the successfully converted LabelDescription.
-    */
-  def from(source: String): Option[LabelDescription] =
-    Option(source).filter(string => string.nonEmpty && string.length <= MaxLength)
-}
-
-/** An HTML colour code in hexadecimal notation e.g. `#12ab3f`. It must match the format of starting with a hashtag
-  * followed by three 2-digit hexadecimal codes (`00-ff`).
-  */
-opaque type ColourCode = String
-object ColourCode {
-  given Eq[ColourCode] = Eq.instance((a, b) => a.equalsIgnoreCase(b))
-
-  val Format: Regex = "^#[0-9a-fA-F]{6}$".r
-
-  /** Create an instance of ColourCode from the given String type.
-    *
-    * @param source
-    *   An instance of type String which will be returned as a ColourCode.
-    * @return
-    *   The appropriate instance of ColourCode.
-    */
-  def apply(source: String): ColourCode = source
-
-  /** Try to create an instance of ColourCode from the given String.
-    *
-    * @param source
-    *   A String that should fulfil the requirements to be converted into a ColourCode.
-    * @return
-    *   An option to the successfully converted ColourCode.
-    */
-  def from(source: String): Option[ColourCode] = Option(source).filter(string => Format.matches(string))
-
-}
-
-/** A label is intended to mark tickets with keywords and colours to allow filtering on them.
-  *
-  * @param id
-  *   An optional attribute containing the unique internal database ID for the label.
-  * @param name
-  *   A short descriptive name for the label which is supposed to be unique in a project context.
-  * @param description
-  *   An optional description if needed.
-  * @param colour
-  *   A hexadecimal HTML colour code which can be used to mark the label on a rendered website.
-  */
-final case class Label(id: Option[LabelId], name: LabelName, description: Option[LabelDescription], colour: ColourCode)
-
-object Label {
-  given Eq[Label] =
-    Eq.instance((thisLabel, thatLabel) =>
-      thisLabel.id === thatLabel.id &&
-        thisLabel.name === thatLabel.name &&
-        thisLabel.description === thatLabel.description &&
-        thisLabel.colour === thatLabel.colour
-    )
-}
diff -rN -u old-smederee/modules/hub/src/main/scala/de/smederee/tickets/MilestoneForm.scala new-smederee/modules/hub/src/main/scala/de/smederee/tickets/MilestoneForm.scala
--- old-smederee/modules/hub/src/main/scala/de/smederee/tickets/MilestoneForm.scala	2025-01-31 13:42:16.244771658 +0000
+++ new-smederee/modules/hub/src/main/scala/de/smederee/tickets/MilestoneForm.scala	1970-01-01 00:00:00.000000000 +0000
@@ -1,126 +0,0 @@
-/*
- * Copyright (C) 2022  Contributors as noted in the AUTHORS.md file
- *
- * This program is free software: you can redistribute it and/or modify
- * it under the terms of the GNU Affero General Public License as
- * published by the Free Software Foundation, either version 3 of the
- * License, or (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
- * GNU Affero General Public License for more details.
- *
- * You should have received a copy of the GNU Affero General Public License
- * along with this program.  If not, see <http://www.gnu.org/licenses/>.
- */
-
-package de.smederee.tickets
-
-import java.time._
-
-import cats._
-import cats.data._
-import cats.syntax.all._
-import de.smederee.hub.forms.FormValidator
-import de.smederee.hub.forms.types._
-
-/** Data container to edit a milestone.
-  *
-  * @param id
-  *   An optional attribute containing the unique internal database ID for the milestone.
-  * @param title
-  *   A title for the milestone, usually a version number, a word or a short phrase that is supposed to be unique within
-  *   a project context.
-  * @param description
-  *   An optional longer description of the milestone.
-  * @param dueDate
-  *   An optional date on which the milestone is supposed to be reached.
-  */
-final case class MilestoneForm(
-    id: Option[MilestoneId],
-    title: MilestoneTitle,
-    description: Option[MilestoneDescription],
-    dueDate: Option[LocalDate]
-)
-
-object MilestoneForm extends FormValidator[MilestoneForm] {
-  val fieldDescription: FormField = FormField("description")
-  val fieldDueDate: FormField     = FormField("due_date")
-  val fieldId: FormField          = FormField("id")
-  val fieldTitle: FormField       = FormField("title")
-
-  /** Create a form for editing a milestone from the given milestone data.
-    *
-    * @param milestone
-    *   The milestone which provides the data for the edit form.
-    * @return
-    *   A milestone form filled with the data from the given milestone.
-    */
-  def fromMilestone(milestone: Milestone): MilestoneForm =
-    MilestoneForm(
-      id = milestone.id,
-      title = milestone.title,
-      description = milestone.description,
-      dueDate = milestone.dueDate
-    )
-
-  override def validate(data: Map[String, String]): ValidatedNec[FormErrors, MilestoneForm] = {
-    val id = data
-      .get(fieldId)
-      .fold(Option.empty[MilestoneId].validNec)(s =>
-        MilestoneId.fromString(s).fold(FormFieldError("Invalid milestone id!").invalidNec)(id => Option(id).validNec)
-      )
-      .leftMap(es => NonEmptyChain.of(Map(fieldId -> es.toList)))
-    val title = data
-      .get(fieldTitle)
-      .map(_.trim) // We strip leading and trailing whitespace!
-      .fold(FormFieldError("No milestone title given!").invalidNec)(s =>
-        MilestoneTitle.from(s).fold(FormFieldError("Invalid milestone title!").invalidNec)(_.validNec)
-      )
-      .leftMap(es => NonEmptyChain.of(Map(fieldTitle -> es.toList)))
-    val description = data
-      .get(fieldDescription)
-      .fold(Option.empty[MilestoneDescription].validNec) { s =>
-        if (s.trim.isEmpty)
-          Option.empty[MilestoneDescription].validNec // Sometimes "empty" strings are sent.
-        else
-          MilestoneDescription
-            .from(s)
-            .fold(FormFieldError("Invalid milestone description!").invalidNec)(descr => Option(descr).validNec)
-      }
-      .leftMap(es => NonEmptyChain.of(Map(fieldDescription -> es.toList)))
-    val dueDate = data
-      .get(fieldDueDate)
-      .fold(Option.empty[LocalDate].validNec) { s =>
-        if (s.trim.isEmpty)
-          Option.empty[LocalDate].validNec
-        else
-          Validated
-            .catchNonFatal(LocalDate.parse(s))
-            .map(date => Option(date))
-      }
-      .leftMap(_ => NonEmptyChain.of(Map(fieldDueDate -> List(FormFieldError("Invalid milestone due date!")))))
-    (id, title, description, dueDate).mapN { case (id, title, description, dueDate) =>
-      MilestoneForm(id, title, description, dueDate)
-    }
-  }
-
-  extension (form: MilestoneForm) {
-
-    /** Convert the form class into a stringified map which is used as underlying data type for form handling in the
-      * twirl templating library.
-      *
-      * @return
-      *   A stringified map containing the data of the form.
-      */
-    def toMap: Map[String, String] =
-      Map(
-        MilestoneForm.fieldId.toString          -> form.id.map(_.toString).getOrElse(""),
-        MilestoneForm.fieldTitle.toString       -> form.title.toString,
-        MilestoneForm.fieldDescription.toString -> form.description.map(_.toString).getOrElse(""),
-        MilestoneForm.fieldDueDate.toString     -> form.dueDate.map(_.toString).getOrElse("")
-      )
-  }
-
-}
diff -rN -u old-smederee/modules/hub/src/main/scala/de/smederee/tickets/MilestoneRepository.scala new-smederee/modules/hub/src/main/scala/de/smederee/tickets/MilestoneRepository.scala
--- old-smederee/modules/hub/src/main/scala/de/smederee/tickets/MilestoneRepository.scala	2025-01-31 13:42:16.244771658 +0000
+++ new-smederee/modules/hub/src/main/scala/de/smederee/tickets/MilestoneRepository.scala	1970-01-01 00:00:00.000000000 +0000
@@ -1,78 +0,0 @@
-/*
- * Copyright (C) 2022  Contributors as noted in the AUTHORS.md file
- *
- * This program is free software: you can redistribute it and/or modify
- * it under the terms of the GNU Affero General Public License as
- * published by the Free Software Foundation, either version 3 of the
- * License, or (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
- * GNU Affero General Public License for more details.
- *
- * You should have received a copy of the GNU Affero General Public License
- * along with this program.  If not, see <http://www.gnu.org/licenses/>.
- */
-
-package de.smederee.tickets
-
-import fs2.Stream
-
-/** The base class that defines the needed functionality to handle milestones within a database.
-  *
-  * @tparam F
-  *   A higher kinded type which wraps the actual return values.
-  */
-abstract class MilestoneRepository[F[_]] {
-
-  /** Return all milestones associated with the given repository.
-    *
-    * @param vcsRepositoryId
-    *   The unique internal ID of a vcs repository metadata entry for which all milestones shall be returned.
-    * @return
-    *   A stream of milestones associated with the vcs repository which may be empty.
-    */
-  def allMilestones(vcsRepositoryId: Long): Stream[F, Milestone]
-
-  /** Create a database entry for the given milestone definition.
-    *
-    * @param vcsRepositoryId
-    *   The unique internal ID of a vcs repository metadata entry to which the milestone belongs.
-    * @param milestone
-    *   The milestone definition that shall be written to the database.
-    * @return
-    *   The number of affected database rows.
-    */
-  def createMilestone(vcsRepositoryId: Long)(milestone: Milestone): F[Int]
-
-  /** Delete the milestone from the database.
-    *
-    * @param milestone
-    *   The milestone definition that shall be deleted from the database.
-    * @return
-    *   The number of affected database rows.
-    */
-  def deleteMilestone(milestone: Milestone): F[Int]
-
-  /** Find the milestone with the given title for the given vcs repository.
-    *
-    * @param vcsRepositoryId
-    *   The unique internal ID of a vcs repository metadata entry to which the milestone belongs.
-    * @param title
-    *   The title of the milestone which is must be unique in the context of the repository.
-    * @return
-    *   An option to the found milestone.
-    */
-  def findMilestone(vcsRepositoryId: Long)(title: MilestoneTitle): F[Option[Milestone]]
-
-  /** Update the database entry for the given milestone.
-    *
-    * @param milestone
-    *   The milestone definition that shall be updated within the database.
-    * @return
-    *   The number of affected database rows.
-    */
-  def updateMilestone(milestone: Milestone): F[Int]
-
-}
diff -rN -u old-smederee/modules/hub/src/main/scala/de/smederee/tickets/MilestoneRoutes.scala new-smederee/modules/hub/src/main/scala/de/smederee/tickets/MilestoneRoutes.scala
--- old-smederee/modules/hub/src/main/scala/de/smederee/tickets/MilestoneRoutes.scala	2025-01-31 13:42:16.244771658 +0000
+++ new-smederee/modules/hub/src/main/scala/de/smederee/tickets/MilestoneRoutes.scala	1970-01-01 00:00:00.000000000 +0000
@@ -1,469 +0,0 @@
-/*
- * Copyright (C) 2022  Contributors as noted in the AUTHORS.md file
- *
- * This program is free software: you can redistribute it and/or modify
- * it under the terms of the GNU Affero General Public License as
- * published by the Free Software Foundation, either version 3 of the
- * License, or (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
- * GNU Affero General Public License for more details.
- *
- * You should have received a copy of the GNU Affero General Public License
- * along with this program.  If not, see <http://www.gnu.org/licenses/>.
- */
-
-package de.smederee.tickets
-
-import cats._
-import cats.data._
-import cats.effect._
-import cats.syntax.all._
-import de.smederee.html.LinkTools._
-import de.smederee.hub.RequestHelpers.instances.given
-import de.smederee.hub._
-import de.smederee.hub.config._
-import de.smederee.hub.forms.types._
-import org.http4s._
-import org.http4s.dsl.Http4sDsl
-import org.http4s.dsl.impl._
-import org.http4s.headers.Location
-import org.http4s.implicits._
-import org.http4s.twirl.TwirlInstances._
-import org.slf4j.LoggerFactory
-
-/** Routes for managing milestones (basically CRUD functionality).
-  *
-  * @param configuration
-  *   The hub service configuration.
-  * @param milestoneRepo
-  *   A repository for handling database operations for milestones.
-  * @param vcsMetadataRepo
-  *   A repository for handling database operations regarding our vcs repositories and their metadata.
-  * @tparam F
-  *   A higher kinded type providing needed functionality, which is usually an IO monad like Async or Sync.
-  */
-final class MilestoneRoutes[F[_]: Async](
-    configuration: ServiceConfig,
-    milestoneRepo: MilestoneRepository[F],
-    vcsMetadataRepo: VcsMetadataRepository[F]
-) extends Http4sDsl[F] {
-  private val log = LoggerFactory.getLogger(getClass)
-
-  val linkConfig = configuration.external
-
-  /** Logic for rendering a list of all milestones for a repository and optionally management functionality.
-    *
-    * @param csrf
-    *   An optional CSRF-Token that shall be used.
-    * @param user
-    *   An optional user account for whom the list of milestones shall be rendered.
-    * @param repositoryOwnerName
-    *   The username of the account who owns the repository.
-    * @param repositoryName
-    *   The name of the repository.
-    * @return
-    *   An HTTP response containing the rendered HTML.
-    */
-  private def doShowMilestones(
-      csrf: Option[CsrfToken]
-  )(user: Option[Account])(repositoryOwnerName: Username)(repositoryName: VcsRepositoryName): F[Response[F]] =
-    for {
-      repoAndId <- loadRepo(user)(repositoryOwnerName, repositoryName)
-      resp <- repoAndId match {
-        case Some((repo, repoId)) =>
-          for {
-            milestones <- milestoneRepo.allMilestones(repoId).compile.toList
-            repositoryBaseUri <- Sync[F].delay(
-              linkConfig.createFullUri(
-                Uri(path =
-                  Uri.Path(
-                    Vector(Uri.Path.Segment(s"~$repositoryOwnerName"), Uri.Path.Segment(repositoryName.toString))
-                  )
-                )
-              )
-            )
-            resp <- Ok(
-              views.html.tickets.editMilestones()(
-                repositoryBaseUri.addSegment("milestones"),
-                csrf,
-                milestones,
-                repositoryBaseUri,
-                "Manage your repository milestones.".some,
-                user,
-                repo
-              )()
-            )
-          } yield resp
-        case _ => NotFound()
-      }
-    } yield resp
-
-  /** Load the repository metadata with the given owner and name from the database and return it and its primary key id
-    * if the repository exists and is readable by the given user account.
-    *
-    * @param currentUser
-    *   The user account that is requesting access to the repository or None for a guest user.
-    * @param repositoryOwnerName
-    *   The name of the account that owns the repository.
-    * @param repositoryName
-    *   The name of the repository. A repository name must start with a letter or number and must contain only
-    *   alphanumeric ASCII characters as well as minus or underscore signs. It must be between 2 and 64 characters long.
-    * @return
-    *   An option to a tuple holding the [[VcsRepository]] and its primary key id.
-    */
-  private def loadRepo(
-      currentUser: Option[Account]
-  )(repositoryOwnerName: Username, repositoryName: VcsRepositoryName): F[Option[(VcsRepository, Long)]] =
-    for {
-      owner <- vcsMetadataRepo.findVcsRepositoryOwner(repositoryOwnerName)
-      loadedRepo <- owner match {
-        case None => Sync[F].pure(None)
-        case Some(owner) =>
-          (
-            vcsMetadataRepo.findVcsRepository(owner, repositoryName),
-            vcsMetadataRepo.findVcsRepositoryId(owner, repositoryName)
-          ).mapN {
-            case (Some(repo), Some(repoId)) => (repo, repoId).some
-            case _                          => None
-          }
-      }
-      // TODO Replace with whatever we implement as proper permission model. ;-)
-      repoAndId = currentUser match {
-        case None => loadedRepo.filter(tuple => tuple._1.isPrivate === false)
-        case Some(user) =>
-          loadedRepo.filter(tuple => tuple._1.isPrivate === false || tuple._1.owner === user.toVcsRepositoryOwner)
-      }
-    } yield repoAndId
-
-  private val addMilestone: AuthedRoutes[Account, F] = AuthedRoutes.of {
-    case ar @ POST -> Root / UsernamePathParameter(repositoryOwnerName) / VcsRepositoryNamePathParameter(
-          repositoryName
-        ) / "milestones" as user =>
-      ar.req.decodeStrict[F, UrlForm] { urlForm =>
-        for {
-          csrf      <- Sync[F].delay(ar.req.getCsrfToken)
-          repoAndId <- loadRepo(user.some)(repositoryOwnerName, repositoryName)
-          resp <- repoAndId match {
-            case Some(repo, repoId) =>
-              for {
-                _ <- Sync[F].raiseUnless(repo.owner.uid === user.uid)(new Error("Only maintainers may add milestones!"))
-                formData <- Sync[F].delay {
-                  urlForm.values.map { t =>
-                    val (key, values) = t
-                    (
-                      key,
-                      values.headOption.getOrElse("")
-                    ) // Pick the first value (a field might get submitted multiple times)!
-                  }
-                }
-                form       <- Sync[F].delay(MilestoneForm.validate(formData))
-                milestones <- repoAndId.traverse(tuple => milestoneRepo.allMilestones(tuple._2).compile.toList)
-                repositoryBaseUri <- Sync[F].delay(
-                  linkConfig.createFullUri(
-                    Uri(path =
-                      Uri.Path(
-                        Vector(Uri.Path.Segment(s"~$repositoryOwnerName"), Uri.Path.Segment(repositoryName.toString))
-                      )
-                    )
-                  )
-                )
-                resp <- form match {
-                  case Validated.Invalid(errors) =>
-                    BadRequest(
-                      views.html.tickets.editMilestones()(
-                        repositoryBaseUri.addSegment("milestones"),
-                        csrf,
-                        milestones.getOrElse(List.empty),
-                        repositoryBaseUri,
-                        "Manage your repository milestones.".some,
-                        user.some,
-                        repo
-                      )(formData, FormErrors.fromNec(errors))
-                    )
-                  case Validated.Valid(milestoneData) =>
-                    val milestone =
-                      Milestone(None, milestoneData.title, milestoneData.description, milestoneData.dueDate)
-                    for {
-                      checkDuplicate <- milestoneRepo.findMilestone(repoId)(milestoneData.title)
-                      resp <- checkDuplicate match {
-                        case None =>
-                          milestoneRepo.createMilestone(repoId)(milestone) *> SeeOther(
-                            Location(repositoryBaseUri.addSegment("milestones"))
-                          )
-                        case Some(_) =>
-                          BadRequest(
-                            views.html.tickets.editMilestones()(
-                              repositoryBaseUri.addSegment("milestones"),
-                              csrf,
-                              milestones.getOrElse(List.empty),
-                              repositoryBaseUri,
-                              "Manage your repository milestones.".some,
-                              user.some,
-                              repo
-                            )(
-                              formData,
-                              Map(
-                                MilestoneForm.fieldTitle -> List(
-                                  FormFieldError("A milestone with that name already exists!")
-                                )
-                              )
-                            )
-                          )
-                      }
-                    } yield resp
-                }
-              } yield resp
-            case _ => NotFound()
-          }
-        } yield resp
-      }
-  }
-
-  private val deleteMilestone: AuthedRoutes[Account, F] = AuthedRoutes.of {
-    case ar @ POST -> Root / UsernamePathParameter(repositoryOwnerName) / VcsRepositoryNamePathParameter(
-          repositoryName
-        ) / "milestone" / MilestoneTitlePathParameter(milestoneTitle) / "delete" as user =>
-      ar.req.decodeStrict[F, UrlForm] { urlForm =>
-        for {
-          csrf      <- Sync[F].delay(ar.req.getCsrfToken)
-          repoAndId <- loadRepo(user.some)(repositoryOwnerName, repositoryName)
-          resp <- repoAndId match {
-            case Some(repo, repoId) =>
-              for {
-                _ <- Sync[F].raiseUnless(repo.owner.uid === user.uid)(new Error("Only maintainers may add milestones!"))
-                milestone <- milestoneRepo.findMilestone(repoId)(milestoneTitle)
-                resp <- milestone match {
-                  case Some(milestone) =>
-                    for {
-                      formData <- Sync[F].delay {
-                        urlForm.values.map { t =>
-                          val (key, values) = t
-                          (
-                            key,
-                            values.headOption.getOrElse("")
-                          ) // Pick the first value (a field might get submitted multiple times)!
-                        }
-                      }
-                      repositoryBaseUri <- Sync[F].delay(
-                        linkConfig.createFullUri(
-                          Uri(path =
-                            Uri.Path(
-                              Vector(
-                                Uri.Path.Segment(s"~$repositoryOwnerName"),
-                                Uri.Path.Segment(repositoryName.toString)
-                              )
-                            )
-                          )
-                        )
-                      )
-                      userIsSure <- Sync[F].delay(formData.get("i-am-sure").exists(_ === "yes"))
-                      milestoneIdMatches <- Sync[F].delay(
-                        formData
-                          .get(MilestoneForm.fieldId)
-                          .flatMap(MilestoneId.fromString)
-                          .exists(id => milestone.id.exists(_ === id))
-                      )
-                      milestoneTitleMatches <- Sync[F].delay(
-                        formData.get(MilestoneForm.fieldTitle).flatMap(MilestoneTitle.from).exists(_ === milestoneTitle)
-                      )
-                      resp <- (milestoneIdMatches && milestoneTitleMatches && userIsSure) match {
-                        case false => BadRequest("Invalid form data!")
-                        case true =>
-                          milestoneRepo.deleteMilestone(milestone) *> SeeOther(
-                            Location(repositoryBaseUri.addSegment("milestones"))
-                          )
-                      }
-                    } yield resp
-                  case _ => NotFound("Milestone not found!")
-                }
-              } yield resp
-            case _ => NotFound("Repository not found!")
-          }
-        } yield resp
-      }
-  }
-
-  private val editMilestone: AuthedRoutes[Account, F] = AuthedRoutes.of {
-    case ar @ POST -> Root / UsernamePathParameter(repositoryOwnerName) / VcsRepositoryNamePathParameter(
-          repositoryName
-        ) / "milestone" / MilestoneTitlePathParameter(milestoneTitle) as user =>
-      ar.req.decodeStrict[F, UrlForm] { urlForm =>
-        for {
-          csrf      <- Sync[F].delay(ar.req.getCsrfToken)
-          repoAndId <- loadRepo(user.some)(repositoryOwnerName, repositoryName)
-          milestone <- repoAndId match {
-            case Some((_, repoId)) => milestoneRepo.findMilestone(repoId)(milestoneTitle)
-            case _                 => Sync[F].delay(None)
-          }
-          resp <- (repoAndId, milestone) match {
-            case (Some(repo, repoId), Some(milestone)) =>
-              for {
-                _ <- Sync[F].raiseUnless(repo.owner.uid === user.uid)(new Error("Only maintainers may add milestones!"))
-                repositoryBaseUri <- Sync[F].delay(
-                  linkConfig.createFullUri(
-                    Uri(path =
-                      Uri.Path(
-                        Vector(Uri.Path.Segment(s"~$repositoryOwnerName"), Uri.Path.Segment(repositoryName.toString))
-                      )
-                    )
-                  )
-                )
-                actionUri <- Sync[F].delay(
-                  repositoryBaseUri.addSegment("milestone").addSegment(milestone.title.toString)
-                )
-                formData <- Sync[F].delay {
-                  urlForm.values.map { t =>
-                    val (key, values) = t
-                    (
-                      key,
-                      values.headOption.getOrElse("")
-                    ) // Pick the first value (a field might get submitted multiple times)!
-                  }
-                }
-                milestoneIdMatches <- Sync[F].delay(
-                  formData
-                    .get(MilestoneForm.fieldId)
-                    .flatMap(MilestoneId.fromString)
-                    .exists(id => milestone.id.exists(_ === id)) match {
-                    case false =>
-                      NonEmptyChain
-                        .of(Map(MilestoneForm.fieldGlobal -> List(FormFieldError("Milestone ID does not match!"))))
-                        .invalidNec
-                    case true => milestone.id.validNec
-                  }
-                )
-                form <- Sync[F].delay(MilestoneForm.validate(formData))
-                resp <- form match {
-                  case Validated.Invalid(errors) =>
-                    BadRequest(
-                      views.html.tickets
-                        .editMilestone()(
-                          actionUri,
-                          csrf,
-                          milestone,
-                          repositoryBaseUri,
-                          s"Edit milestone ${milestone.title}".some,
-                          user,
-                          repo
-                        )(
-                          formData.toMap,
-                          FormErrors.fromNec(errors)
-                        )
-                    )
-                  case Validated.Valid(milestoneData) =>
-                    val updatedMilestone =
-                      milestone.copy(
-                        title = milestoneData.title,
-                        description = milestoneData.description,
-                        dueDate = milestoneData.dueDate
-                      )
-                    for {
-                      checkDuplicate <- milestoneRepo.findMilestone(repoId)(updatedMilestone.title)
-                      resp <- checkDuplicate.filterNot(_.id === updatedMilestone.id) match {
-                        case None =>
-                          milestoneRepo.updateMilestone(updatedMilestone) *> SeeOther(
-                            Location(repositoryBaseUri.addSegment("milestones"))
-                          )
-                        case Some(_) =>
-                          BadRequest(
-                            views.html.tickets.editMilestone()(
-                              actionUri,
-                              csrf,
-                              milestone,
-                              repositoryBaseUri,
-                              s"Edit milestone ${milestone.title}".some,
-                              user,
-                              repo
-                            )(
-                              formData,
-                              Map(
-                                MilestoneForm.fieldTitle -> List(
-                                  FormFieldError("A milestone with that name already exists!")
-                                )
-                              )
-                            )
-                          )
-                      }
-                    } yield resp
-                }
-              } yield resp
-            case _ => NotFound()
-          }
-        } yield resp
-      }
-  }
-
-  private val showEditMilestoneForm: AuthedRoutes[Account, F] = AuthedRoutes.of {
-    case ar @ GET -> Root / UsernamePathParameter(repositoryOwnerName) / VcsRepositoryNamePathParameter(
-          repositoryName
-        ) / "milestone" / MilestoneTitlePathParameter(milestoneTitle) / "edit" as user =>
-      for {
-        csrf      <- Sync[F].delay(ar.req.getCsrfToken)
-        repoAndId <- loadRepo(user.some)(repositoryOwnerName, repositoryName)
-        milestone <- repoAndId match {
-          case Some((_, repoId)) => milestoneRepo.findMilestone(repoId)(milestoneTitle)
-          case _                 => Sync[F].delay(None)
-        }
-        resp <- (repoAndId, milestone) match {
-          case (Some(repo, repoId), Some(milestone)) =>
-            for {
-              repositoryBaseUri <- Sync[F].delay(
-                linkConfig.createFullUri(
-                  Uri(path =
-                    Uri.Path(
-                      Vector(Uri.Path.Segment(s"~$repositoryOwnerName"), Uri.Path.Segment(repositoryName.toString))
-                    )
-                  )
-                )
-              )
-              actionUri <- Sync[F].delay(repositoryBaseUri.addSegment("milestone").addSegment(milestone.title.toString))
-              formData  <- Sync[F].delay(MilestoneForm.fromMilestone(milestone))
-              resp <- Ok(
-                views.html.tickets
-                  .editMilestone()(
-                    actionUri,
-                    csrf,
-                    milestone,
-                    repositoryBaseUri,
-                    s"Edit milestone ${milestone.title}".some,
-                    user,
-                    repo
-                  )(
-                    formData.toMap
-                  )
-              )
-            } yield resp
-          case _ => NotFound()
-        }
-      } yield resp
-  }
-
-  private val showEditMilestonesPage: AuthedRoutes[Account, F] = AuthedRoutes.of {
-    case ar @ GET -> Root / UsernamePathParameter(repositoryOwnerName) / VcsRepositoryNamePathParameter(
-          repositoryName
-        ) / "milestones" as user =>
-      for {
-        csrf <- Sync[F].delay(ar.req.getCsrfToken)
-        resp <- doShowMilestones(csrf)(user.some)(repositoryOwnerName)(repositoryName)
-      } yield resp
-  }
-
-  private val showMilestonesForGuests: HttpRoutes[F] = HttpRoutes.of {
-    case req @ GET -> Root / UsernamePathParameter(repositoryOwnerName) / VcsRepositoryNamePathParameter(
-          repositoryName
-        ) / "milestones" =>
-      for {
-        csrf <- Sync[F].delay(req.getCsrfToken)
-        resp <- doShowMilestones(csrf)(None)(repositoryOwnerName)(repositoryName)
-      } yield resp
-  }
-
-  val protectedRoutes =
-    addMilestone <+> deleteMilestone <+> editMilestone <+> showEditMilestoneForm <+> showEditMilestonesPage
-
-  val routes = showMilestonesForGuests
-
-}
diff -rN -u old-smederee/modules/hub/src/main/scala/de/smederee/tickets/Milestone.scala new-smederee/modules/hub/src/main/scala/de/smederee/tickets/Milestone.scala
--- old-smederee/modules/hub/src/main/scala/de/smederee/tickets/Milestone.scala	2025-01-31 13:42:16.244771658 +0000
+++ new-smederee/modules/hub/src/main/scala/de/smederee/tickets/Milestone.scala	1970-01-01 00:00:00.000000000 +0000
@@ -1,164 +0,0 @@
-/*
- * Copyright (C) 2022  Contributors as noted in the AUTHORS.md file
- *
- * This program is free software: you can redistribute it and/or modify
- * it under the terms of the GNU Affero General Public License as
- * published by the Free Software Foundation, either version 3 of the
- * License, or (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
- * GNU Affero General Public License for more details.
- *
- * You should have received a copy of the GNU Affero General Public License
- * along with this program.  If not, see <http://www.gnu.org/licenses/>.
- */
-
-package de.smederee.tickets
-
-import java.time.LocalDate
-
-import cats._
-import cats.syntax.all._
-
-import scala.util.matching.Regex
-
-opaque type MilestoneId = Long
-object MilestoneId {
-  given Eq[MilestoneId] = Eq.fromUniversalEquals
-
-  val Format: Regex = "^-?\\d+$".r
-
-  /** Create an instance of MilestoneId from the given Long type.
-    *
-    * @param source
-    *   An instance of type Long which will be returned as a MilestoneId.
-    * @return
-    *   The appropriate instance of MilestoneId.
-    */
-  def apply(source: Long): MilestoneId = source
-
-  /** Try to create an instance of MilestoneId from the given Long.
-    *
-    * @param source
-    *   A Long that should fulfil the requirements to be converted into a MilestoneId.
-    * @return
-    *   An option to the successfully converted MilestoneId.
-    */
-  def from(source: Long): Option[MilestoneId] = Option(source)
-
-  /** Try to create an instance of MilestoneId from the given String.
-    *
-    * @param source
-    *   A string that should fulfil the requirements to be converted into a MilestoneId.
-    * @return
-    *   An option to the successfully converted MilestoneId.
-    */
-  def fromString(source: String): Option[MilestoneId] =
-    Option(source).filter(Format.matches).map(_.toLong).flatMap(from)
-
-  extension (id: MilestoneId) {
-    def toLong: Long = id
-  }
-}
-
-/** Extractor to retrieve an MilestoneId from a path parameter.
-  */
-object MilestoneIdPathParameter {
-  def unapply(str: String): Option[MilestoneId] = Option(str).flatMap(MilestoneId.fromString)
-}
-
-/** A title for a milestone, usually a version number, a word or a short phrase that is supposed to be unique within a
-  * project context. It must not be empty and not exceed 64 characters in length.
-  */
-opaque type MilestoneTitle = String
-object MilestoneTitle {
-  given Eq[MilestoneTitle]       = Eq.fromUniversalEquals
-  given Ordering[MilestoneTitle] = (x: MilestoneTitle, y: MilestoneTitle) => x.compareTo(y)
-  given Order[MilestoneTitle]    = Order.fromOrdering[MilestoneTitle]
-
-  val MaxLength: Int = 64
-
-  /** Create an instance of MilestoneTitle from the given String type.
-    *
-    * @param source
-    *   An instance of type String which will be returned as a MilestoneTitle.
-    * @return
-    *   The appropriate instance of MilestoneTitle.
-    */
-  def apply(source: String): MilestoneTitle = source
-
-  /** Try to create an instance of MilestoneTitle from the given String.
-    *
-    * @param source
-    *   A String that should fulfil the requirements to be converted into a MilestoneTitle.
-    * @return
-    *   An option to the successfully converted MilestoneTitle.
-    */
-  def from(source: String): Option[MilestoneTitle] =
-    Option(source).filter(string => string.nonEmpty && string.length <= MaxLength)
-
-}
-
-/** Extractor to retrieve an MilestoneTitle from a path parameter.
-  */
-object MilestoneTitlePathParameter {
-  def unapply(str: String): Option[MilestoneTitle] = Option(str).flatMap(MilestoneTitle.from)
-}
-
-/** A longer detailed description of a project milestone which must not be empty.
-  */
-opaque type MilestoneDescription = String
-object MilestoneDescription {
-  given Eq[MilestoneDescription] = Eq.fromUniversalEquals
-
-  /** Create an instance of MilestoneDescription from the given String type.
-    *
-    * @param source
-    *   An instance of type String which will be returned as a MilestoneDescription.
-    * @return
-    *   The appropriate instance of MilestoneDescription.
-    */
-  def apply(source: String): MilestoneDescription = source
-
-  /** Try to create an instance of MilestoneDescription from the given String.
-    *
-    * @param source
-    *   A String that should fulfil the requirements to be converted into a MilestoneDescription.
-    * @return
-    *   An option to the successfully converted MilestoneDescription.
-    */
-  def from(source: String): Option[MilestoneDescription] = Option(source).map(_.trim).filter(_.nonEmpty)
-
-}
-
-/** A milestone can be used to organise tickets and progress inside a project.
-  *
-  * @param id
-  *   An optional attribute containing the unique internal database ID for the milestone.
-  * @param title
-  *   A title for the milestone, usually a version number, a word or a short phrase that is supposed to be unique within
-  *   a project context.
-  * @param description
-  *   An optional longer description of the milestone.
-  * @param dueDate
-  *   An optional date on which the milestone is supposed to be reached.
-  */
-final case class Milestone(
-    id: Option[MilestoneId],
-    title: MilestoneTitle,
-    description: Option[MilestoneDescription],
-    dueDate: Option[LocalDate]
-)
-
-object Milestone {
-
-  given Eq[LocalDate] = Eq.instance((a, b) => a.compareTo(b) === 0)
-
-  given Eq[Milestone] =
-    Eq.instance((a, b) =>
-      a.id === b.id && a.title === b.title && a.dueDate === b.dueDate && a.description === b.description
-    )
-
-}
diff -rN -u old-smederee/modules/hub/src/main/scala/de/smederee/tickets/Submitter.scala new-smederee/modules/hub/src/main/scala/de/smederee/tickets/Submitter.scala
--- old-smederee/modules/hub/src/main/scala/de/smederee/tickets/Submitter.scala	2025-01-31 13:42:16.244771658 +0000
+++ new-smederee/modules/hub/src/main/scala/de/smederee/tickets/Submitter.scala	1970-01-01 00:00:00.000000000 +0000
@@ -1,38 +0,0 @@
-/*
- * Copyright (C) 2022  Contributors as noted in the AUTHORS.md file
- *
- * This program is free software: you can redistribute it and/or modify
- * it under the terms of the GNU Affero General Public License as
- * published by the Free Software Foundation, either version 3 of the
- * License, or (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
- * GNU Affero General Public License for more details.
- *
- * You should have received a copy of the GNU Affero General Public License
- * along with this program.  If not, see <http://www.gnu.org/licenses/>.
- */
-
-package de.smederee.tickets
-
-import java.util.UUID
-
-import cats._
-import cats.data._
-import cats.syntax.all._
-import de.smederee.hub.{ UserId, Username }
-
-/** The submitter for a ticket i.e. the person supposed to be working on it.
-  *
-  * @param id
-  *   A globally unique ID identifying the submitter.
-  * @param name
-  *   The name associated with the submitter which is supposed to be unique.
-  */
-final case class Submitter(id: UserId, name: Username)
-
-object Submitter {
-  given Eq[Submitter] = Eq.fromUniversalEquals
-}
diff -rN -u old-smederee/modules/hub/src/main/scala/de/smederee/tickets/TicketRepository.scala new-smederee/modules/hub/src/main/scala/de/smederee/tickets/TicketRepository.scala
--- old-smederee/modules/hub/src/main/scala/de/smederee/tickets/TicketRepository.scala	2025-01-31 13:42:16.244771658 +0000
+++ new-smederee/modules/hub/src/main/scala/de/smederee/tickets/TicketRepository.scala	1970-01-01 00:00:00.000000000 +0000
@@ -1,144 +0,0 @@
-/*
- * Copyright (C) 2022  Contributors as noted in the AUTHORS.md file
- *
- * This program is free software: you can redistribute it and/or modify
- * it under the terms of the GNU Affero General Public License as
- * published by the Free Software Foundation, either version 3 of the
- * License, or (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
- * GNU Affero General Public License for more details.
- *
- * You should have received a copy of the GNU Affero General Public License
- * along with this program.  If not, see <http://www.gnu.org/licenses/>.
- */
-
-package de.smederee.tickets
-
-import fs2.Stream
-
-/** The base class that defines the needed functionality to handle tickets and related data types within a database.
-  *
-  * @tparam F
-  *   A higher kinded type which wraps the actual return values.
-  */
-abstract class TicketRepository[F[_]] {
-
-  /** Return all labels associated with the given repository.
-    *
-    * @param vcsRepositoryId
-    *   The unique internal ID of a vcs repository metadata entry for which all labels shall be returned.
-    * @return
-    *   A stream of labels associated with the vcs repository which may be empty.
-    */
-  def allLables(vcsRepositoryId: Long): Stream[F, Label]
-
-  /** Return all milestones associated with the given repository.
-    *
-    * @param vcsRepositoryId
-    *   The unique internal ID of a vcs repository metadata entry for which all milestones shall be returned.
-    * @return
-    *   A stream of milestones associated with the vcs repository which may be empty.
-    */
-  def allMilestones(vcsRepositoryId: Long): Stream[F, Milestone]
-
-  /** Return all tickets associated with the given repository.
-    *
-    * @param vcsRepositoryId
-    *   The unique internal ID of a vcs repository metadata entry for which all tickets shall be returned.
-    * @return
-    *   A stream of tickets associated with the vcs repository which may be empty.
-    */
-  def allTickets(vcsRepositoryId: Long): Stream[F, Ticket]
-
-  /** Create a database entry for the given label definition.
-    *
-    * @param vcsRepositoryId
-    *   The unique internal ID of a vcs repository metadata entry to which the label belongs.
-    * @param label
-    *   The label definition that shall be written to the database.
-    * @return
-    *   The number of affected database rows.
-    */
-  def createLabel(vcsRepositoryId: Long)(label: Label): F[Int]
-
-  /** Create a database entry for the given milestone definition.
-    *
-    * @param vcsRepositoryId
-    *   The unique internal ID of a vcs repository metadata entry to which the milestone belongs.
-    * @param milestone
-    *   The milestone definition that shall be written to the database.
-    * @return
-    *   The number of affected database rows.
-    */
-  def createMilestone(vcsRepositoryId: Long)(milestone: Milestone): F[Int]
-
-  /** Create a database entry for the given ticket definition.
-    *
-    * @param vcsRepositoryId
-    *   The unique internal ID of a vcs repository metadata entry to which the ticket belongs.
-    * @param ticket
-    *   The ticket definition that shall be written to the database.
-    * @return
-    *   The number of affected database rows.
-    */
-  def createTicket(vcsRepositoryId: Long)(ticket: Ticket): F[Int]
-
-  /** Delete the label from the database.
-    *
-    * @param vcsRepositoryId
-    *   The unique internal ID of a vcs repository metadata entry to which the label belongs.
-    * @param label
-    *   The label definition that shall be deleted.
-    * @return
-    *   The number of affected database rows.
-    */
-  def deleteLabel(vcsRepositoryId: Long)(label: Label): F[Int]
-
-  /** Delete the milestone from the database.
-    *
-    * @param vcsRepositoryId
-    *   The unique internal ID of a vcs repository metadata entry to which the milestone belongs.
-    * @param milestone
-    *   The milestone definition that shall be deleted.
-    * @return
-    *   The number of affected database rows.
-    */
-  def deleteMilestone(vcsRepositoryId: Long)(milestone: Milestone): F[Int]
-
-  /** Update the database entry for the given label.
-    *
-    * @param vcsRepositoryId
-    *   The unique internal ID of a vcs repository metadata entry to which the label belongs.
-    * @param label
-    *   The label definition that shall be updated within the database.
-    * @return
-    *   The number of affected database rows.
-    */
-  def updateLabel(vcsRepositoryId: Long)(label: Label): F[Int]
-
-  /** Update the database entry for the given milestone.
-    *
-    * @param vcsRepositoryId
-    *   The unique internal ID of a vcs repository metadata entry to which the milestone belongs.
-    * @param milestone
-    *   The milestone definition that shall be updated within the database.
-    * @return
-    *   The number of affected database rows.
-    */
-  def updateMilestone(vcsRepositoryId: Long)(milestone: Milestone): F[Int]
-
-  /** Update the database entry for the given ticket.
-    *
-    * @param vcsRepositoryId
-    *   The unique internal ID of a vcs repository metadata entry to which the ticket belongs.
-    * @param ticket
-    *   The ticket definition that shall be updated within the database.
-    * @return
-    *   The number of affected database rows.
-    */
-  def updateTicket(vcsRepositoryId: Long)(ticket: Ticket): F[Int]
-
-}
diff -rN -u old-smederee/modules/hub/src/main/scala/de/smederee/tickets/Ticket.scala new-smederee/modules/hub/src/main/scala/de/smederee/tickets/Ticket.scala
--- old-smederee/modules/hub/src/main/scala/de/smederee/tickets/Ticket.scala	2025-01-31 13:42:16.244771658 +0000
+++ new-smederee/modules/hub/src/main/scala/de/smederee/tickets/Ticket.scala	1970-01-01 00:00:00.000000000 +0000
@@ -1,163 +0,0 @@
-/*
- * Copyright (C) 2022  Contributors as noted in the AUTHORS.md file
- *
- * This program is free software: you can redistribute it and/or modify
- * it under the terms of the GNU Affero General Public License as
- * published by the Free Software Foundation, either version 3 of the
- * License, or (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
- * GNU Affero General Public License for more details.
- *
- * You should have received a copy of the GNU Affero General Public License
- * along with this program.  If not, see <http://www.gnu.org/licenses/>.
- */
-
-package de.smederee.tickets
-
-import java.time.OffsetDateTime
-
-import cats._
-
-/** An unlimited text field which must be not empty to describe the ticket in great detail if needed.
-  */
-opaque type TicketContent = String
-object TicketContent {
-
-  /** Create an instance of TicketContent from the given String type.
-    *
-    * @param source
-    *   An instance of type String which will be returned as a TicketContent.
-    * @return
-    *   The appropriate instance of TicketContent.
-    */
-  def apply(source: String): TicketContent = source
-
-  /** Try to create an instance of TicketContent from the given String.
-    *
-    * @param source
-    *   A String that should fulfil the requirements to be converted into a TicketContent.
-    * @return
-    *   An option to the successfully converted TicketContent.
-    */
-  def from(source: String): Option[TicketContent] = Option(source).filter(_.nonEmpty)
-
-}
-
-/** A ticket number maps to an integer beneath and has the requirement to be greater than zero.
-  */
-opaque type TicketNumber = Int
-object TicketNumber {
-
-  /** Create an instance of TicketNumber from the given Int type.
-    *
-    * @param source
-    *   An instance of type Int which will be returned as a TicketNumber.
-    * @return
-    *   The appropriate instance of TicketNumber.
-    */
-  def apply(source: Int): TicketNumber = source
-
-  /** Try to create an instance of TicketNumber from the given Int.
-    *
-    * @param source
-    *   A Int that should fulfil the requirements to be converted into a TicketNumber.
-    * @return
-    *   An option to the successfully converted TicketNumber.
-    */
-  def from(source: Int): Option[TicketNumber] = Option(source).filter(_ > 0)
-}
-
-/** Possible states of a ticket which shall model the life cycle from ticket creation until it is closed. To keep things
-  * simple we do not model the kind of a resolution (e.g. fixed, won't fix or so) for a ticket.
-  */
-enum TicketStatus {
-
-  /** The ticket is considered to be a valid ticket / ready to be worked on e.g. a bug has been confirmed to be present.
-    */
-  case Confirmed
-
-  /** The ticket is resolved (i.e. closed) and considered done.
-    */
-  case Done
-
-  /** The ticket is being worked on i.e. it is in progress.
-    */
-  case InProgress
-
-  /** The ticket was reported and is open for further investigation. This might map to what is often called "Backlog"
-    * nowadays.
-    */
-  case Reported
-
-  /** The ticket is being reviewed and might be moved to another status after the review process is being done.
-    */
-  case Review
-
-}
-
-/** A concise and short description of the ticket which should not exceed 80 characters.
-  */
-opaque type TicketTitle = String
-object TicketTitle {
-
-  val MaxLength: Int = 72
-
-  /** Create an instance of TicketTitle from the given String type.
-    *
-    * @param source
-    *   An instance of type String which will be returned as a TicketTitle.
-    * @return
-    *   The appropriate instance of TicketTitle.
-    */
-  def apply(source: String): TicketTitle = source
-
-  /** Try to create an instance of TicketTitle from the given String.
-    *
-    * @param source
-    *   A String that should fulfil the requirements to be converted into a TicketTitle.
-    * @return
-    *   An option to the successfully converted TicketTitle.
-    */
-  def from(source: String): Option[TicketTitle] =
-    Option(source).filter(string => string.nonEmpty && string.length <= MaxLength)
-}
-
-/** An ticket used to describe a problem or a task (e.g. implement a concrete feature) within the scope of a project.
-  *
-  * @param number
-  *   The unique identifier of a ticket within the project scope is its number.
-  * @param title
-  *   A concise and short description of the ticket which should not exceed 72 characters.
-  * @param content
-  *   An optional field to describe the ticket in great detail if needed.
-  * @param status
-  *   The current status of the ticket describing its life cycle.
-  * @param labels
-  *   A list of labels assigned to this ticket.
-  * @param milestones
-  *   A list of milestones to which this ticket is assigned.
-  * @param submitter
-  *   The person who submitted (created) this ticket which is optional because of possible account deletion or other
-  *   reasons.
-  * @param assignees
-  *   A list of assignees working on this ticket which might be no-one.
-  * @param createdAt
-  *   The timestamp when the ticket was created / submitted.
-  * @param updatedAt
-  *   The timestamp when the ticket was last updated. Upon creation the update time equals the creation time.
-  */
-final case class Ticket(
-    number: TicketNumber,
-    title: TicketTitle,
-    content: Option[TicketContent],
-    status: TicketStatus,
-    labels: List[Label],
-    milestones: List[Milestone],
-    submitter: Option[Submitter],
-    assignees: List[Assignee],
-    createdAt: OffsetDateTime,
-    updatedAt: OffsetDateTime
-)
diff -rN -u old-smederee/modules/hub/src/main/twirl/de/smederee/hub/views/showRepositoryMenu.scala.html new-smederee/modules/hub/src/main/twirl/de/smederee/hub/views/showRepositoryMenu.scala.html
--- old-smederee/modules/hub/src/main/twirl/de/smederee/hub/views/showRepositoryMenu.scala.html	2025-01-31 13:42:16.244771658 +0000
+++ new-smederee/modules/hub/src/main/twirl/de/smederee/hub/views/showRepositoryMenu.scala.html	2025-01-31 13:42:16.252771672 +0000
@@ -15,12 +15,6 @@
     @defining(repositoryBaseUri.addSegment("history")) { uri =>
     <li class="pure-menu-item@if(activeUri.exists(_ === uri)){ pure-menu-active}else{}"><a class="pure-menu-link" href="@uri">@icon(baseUri)("list") @Messages("repository.menu.changes")</a></li>
     }
-    @defining(repositoryBaseUri.addSegment("labels")) { uri =>
-    <li class="pure-menu-item@if(activeUri.exists(_ === uri)){ pure-menu-active}else{}"><a class="pure-menu-link" href="@uri">@icon(baseUri)("tag") @Messages("repository.menu.labels")</a></li>
-    }
-    @defining(repositoryBaseUri.addSegment("milestones")) { uri =>
-    <li class="pure-menu-item@if(activeUri.exists(_ === uri)){ pure-menu-active}else{}"><a class="pure-menu-link" href="@uri">@icon(baseUri)("flag") @Messages("repository.menu.milestones")</a></li>
-    }
     @if(activeUri.exists(uri => uri === repositoryBaseUri || uri === repositoryBaseUri.addSegment("edit") || uri === repositoryBaseUri.addSegment("delete"))) {
       @if(user.exists(_.uid === vcsRepository.owner.uid)) {
       @defining(repositoryBaseUri.addSegment("edit")) { uri =>
diff -rN -u old-smederee/modules/hub/src/main/twirl/de/smederee/hub/views/tickets/editLabel.scala.html new-smederee/modules/hub/src/main/twirl/de/smederee/hub/views/tickets/editLabel.scala.html
--- old-smederee/modules/hub/src/main/twirl/de/smederee/hub/views/tickets/editLabel.scala.html	2025-01-31 13:42:16.244771658 +0000
+++ new-smederee/modules/hub/src/main/twirl/de/smederee/hub/views/tickets/editLabel.scala.html	1970-01-01 00:00:00.000000000 +0000
@@ -1,87 +0,0 @@
-@import de.smederee.hub.views.html.showRepositoryMenu
-@import de.smederee.tickets.LabelForm._
-@import de.smederee.tickets._
-
-@(baseUri: Uri = Uri(path = Uri.Path.Root),
-  lang: LanguageCode = LanguageCode("en")
-)(action: Uri,
-  csrf: Option[CsrfToken] = None,
-  label: Label,
-  repositoryBaseUri: Uri,
-  title: Option[String] = None,
-  user: Account,
-  vcsRepository: VcsRepository
-)(formData: Map[String, String] = Map.empty,
-  formErrors: FormErrors = FormErrors.empty
-)
-@main(baseUri, lang)()(csrf, title, user.some) {
-@defining(lang.toLocale) { implicit locale =>
-<div class="content">
-  <div class="pure-g">
-    <div class="pure-u-1">
-      <div class="l-box-left-right">
-        <h2><a href="@{baseUri.addSegment(s"~${vcsRepository.owner.name}")}">~@vcsRepository.owner.name</a>/@vcsRepository.name</h2>
-        @showRepositoryMenu(baseUri)(repositoryBaseUri.addSegment("labels").some, repositoryBaseUri, user.some, vcsRepository)
-        <div class="repo-summary-description">
-          @Messages("repository.labels.edit.title")
-        </div>
-      </div>
-    </div>
-  </div>
-  <div class="pure-g">
-    @if(user.uid === vcsRepository.owner.uid) {
-    <div class="pure-u-1-1 pure-u-md-1-1">
-      <div class="l-box">
-        <h4>@Messages("repository.label.edit.title", label.name)</h4>
-        <div class="form-errors">
-          @formErrors.get(fieldGlobal).map { es =>
-            @for(error <- es) {
-              <p class="alert alert-error">
-                <span class="glyphicon glyphicon-exclamation-sign" aria-hidden="true"></span>
-                <span class="sr-only">Fehler:</span>
-                @error
-              </p>
-            }
-          }
-        </div>
-        <div class="edit-labels-form">
-          <form action="@action" class="pure-form pure-form-aligned" method="POST" accept-charset="UTF-8">
-            <fieldset>
-              <input type="hidden" id="@fieldId" name="@fieldId" readonly="" value="@label.id">
-              <div class="pure-control-group">
-                <label for="@{fieldName}">@Messages("form.label.name")</label>
-                <input id="@{fieldName}" name="@{fieldName}" maxlength="40" placeholder="@Messages("form.label.name.placeholder")" required="" type="text" value="@{formData.get(fieldName)}">
-                <span class="pure-form-message" id="@{fieldName}.help" style="margin-left: 13em;">@Messages("form.label.name.help")</span>
-                @renderFormErrors(fieldName, formErrors)
-              </div>
-              <div class="pure-control-group">
-                <label for="@{fieldDescription}">@Messages("form.label.description")</label>
-                <input class="pure-input-3-4" id="@{fieldDescription}" name="@{fieldDescription}" maxlength="254" placeholder="@Messages("form.label.description.placeholder")" type="text" value="@{formData.get(fieldDescription)}">
-                <span class="pure-form-message" id="@{fieldDescription}.help" style="margin-left: 13em;">@Messages("form.label.description.help")</span>
-                @renderFormErrors(fieldDescription, formErrors)
-              </div>
-              <div class="pure-control-group">
-                <label for="@{fieldColour}">@Messages("form.label.colour")</label>
-                <input id="@{fieldColour}" name="@{fieldColour}" required="" type="color" value="@{formData.get(fieldColour).getOrElse("#B48EAD")}">
-                <span class="pure-form-message" id="@{fieldColour}.help" style="margin-left: 13em;">@Messages("form.label.colour.help")</span>
-                @renderFormErrors(fieldColour, formErrors)
-              </div>
-              @csrfToken(csrf)
-              <button type="submit" class="pure-button pure-button-success">@Messages("form.label.edit.button.submit")</button>
-            </fieldset>
-          </form>
-        </div>
-      </div>
-    </div>
-    } else { }
-  </div>
-  <div class="pure-g">
-    <div class="pure-u-1-1 pure-u-md-1-1">
-      <div class="l-box">
-      </div>
-    </div>
-  </div>
-</div>
-}
-}
-
diff -rN -u old-smederee/modules/hub/src/main/twirl/de/smederee/hub/views/tickets/editLabels.scala.html new-smederee/modules/hub/src/main/twirl/de/smederee/hub/views/tickets/editLabels.scala.html
--- old-smederee/modules/hub/src/main/twirl/de/smederee/hub/views/tickets/editLabels.scala.html	2025-01-31 13:42:16.248771665 +0000
+++ new-smederee/modules/hub/src/main/twirl/de/smederee/hub/views/tickets/editLabels.scala.html	1970-01-01 00:00:00.000000000 +0000
@@ -1,125 +0,0 @@
-@import de.smederee.hub.views.html.showRepositoryMenu
-@import de.smederee.tickets.LabelForm._
-@import de.smederee.tickets._
-
-@(baseUri: Uri = Uri(path = Uri.Path.Root),
-  lang: LanguageCode = LanguageCode("en")
-)(action: Uri,
-  csrf: Option[CsrfToken] = None,
-  labels: List[Label],
-  repositoryBaseUri: Uri,
-  title: Option[String] = None,
-  user: Option[Account],
-  vcsRepository: VcsRepository
-)(formData: Map[String, String] = Map.empty,
-  formErrors: FormErrors = FormErrors.empty
-)
-@main(baseUri, lang)()(csrf, title, user) {
-@defining(lang.toLocale) { implicit locale =>
-<div class="content">
-  <div class="pure-g">
-    <div class="pure-u-1">
-      <div class="l-box-left-right">
-        <h2><a href="@{baseUri.addSegment(s"~${vcsRepository.owner.name}")}">~@vcsRepository.owner.name</a>/@vcsRepository.name</h2>
-        @showRepositoryMenu(baseUri)(action.some, repositoryBaseUri, user, vcsRepository)
-        <div class="repo-summary-description">
-          @if(user.exists(_.uid === vcsRepository.owner.uid)) {
-            @Messages("repository.labels.edit.title")
-          } else {
-            @Messages("repository.labels.view.title")
-          }
-        </div>
-      </div>
-    </div>
-  </div>
-  <div class="pure-g">
-    @if(user.exists(_.uid === vcsRepository.owner.uid)) {
-    <div class="pure-u-1-1 pure-u-md-1-1">
-      <div class="l-box">
-        <h4>@Messages("repository.labels.add.title")</h4>
-        <div class="form-errors">
-          @formErrors.get(fieldGlobal).map { es =>
-            @for(error <- es) {
-              <p class="alert alert-error">
-                <span class="glyphicon glyphicon-exclamation-sign" aria-hidden="true"></span>
-                <span class="sr-only">Fehler:</span>
-                @error
-              </p>
-            }
-          }
-        </div>
-        <div class="edit-labels-form">
-          <form action="@repositoryBaseUri.addSegment("labels")" class="pure-form pure-form-aligned" method="POST" accept-charset="UTF-8">
-            <fieldset>
-              <div class="pure-control-group">
-                <label for="@{fieldName}">@Messages("form.label.name")</label>
-                <input id="@{fieldName}" name="@{fieldName}" maxlength="40" placeholder="@Messages("form.label.name.placeholder")" required="" type="text" value="@{formData.get(fieldName)}">
-                <span class="pure-form-message" id="@{fieldName}.help" style="margin-left: 13em;">@Messages("form.label.name.help")</span>
-                @renderFormErrors(fieldName, formErrors)
-              </div>
-              <div class="pure-control-group">
-                <label for="@{fieldDescription}">@Messages("form.label.description")</label>
-                <input class="pure-input-3-4" id="@{fieldDescription}" name="@{fieldDescription}" maxlength="254" placeholder="@Messages("form.label.description.placeholder")" type="text" value="@{formData.get(fieldDescription)}">
-                <span class="pure-form-message" id="@{fieldDescription}.help" style="margin-left: 13em;">@Messages("form.label.description.help")</span>
-                @renderFormErrors(fieldDescription, formErrors)
-              </div>
-              <div class="pure-control-group">
-                <label for="@{fieldColour}">@Messages("form.label.colour")</label>
-                <input id="@{fieldColour}" name="@{fieldColour}" required="" type="color" value="@{formData.get(fieldColour).getOrElse("#B48EAD")}">
-                <span class="pure-form-message" id="@{fieldColour}.help" style="margin-left: 13em;">@Messages("form.label.colour.help")</span>
-                @renderFormErrors(fieldColour, formErrors)
-              </div>
-              @csrfToken(csrf)
-              <button type="submit" class="pure-button pure-button-success">@Messages("form.label.create.button.submit")</button>
-            </fieldset>
-          </form>
-        </div>
-      </div>
-    </div>
-    } else { }
-  </div>
-  <div class="pure-g">
-    <div class="pure-u-1-1 pure-u-md-1-1">
-      <div class="l-box">
-        <div class="label-list">
-          <h4>@Messages("repository.labels.list.title", labels.size)</h4>
-          @if(labels.size === 0) {
-            <div class="alert alert-info">@Messages("repository.labels.list.empty")</div>
-          } else {
-            @defining(32) { lineHeight =>
-              @for(label <- labels) {
-                <div class="pure-g label">
-                  <div class="pure-u-1-24 label-icon" style="color: @label.colour;">
-                    @icon(baseUri)("tag", lineHeight.some)
-                  </div>
-                  <div class="pure-u-5-24 label-name" style="background: @label.colour; height: @{lineHeight}px; line-height: @{lineHeight}px;">@label.name</div>
-                  <div class="pure-u-10-24 label-description" style="height: @{lineHeight}px; line-height: @{lineHeight}px; overflow: overlay;">@label.description</div>
-                  <div class="pure-u-2-24" style="height: @{lineHeight}px; line-height: @{lineHeight}px; margin: auto;">
-                    @if(user.exists(_.uid === vcsRepository.owner.uid)) {
-                    <a class="pure-button" href="@repositoryBaseUri.addSegment("label").addSegment(label.name.toString).addSegment("edit")" title="@Messages("repository.label.edit.title", label.name)">@Messages("repository.label.edit.link")</a>
-                    } else { }
-                  </div>
-                  <div class="pure-u-6-24 label-form" style="height: @{lineHeight}px; line-height: @{lineHeight}px; padding-left: 1em;">
-                    @if(user.exists(_.uid === vcsRepository.owner.uid)) {
-                    <form action="@repositoryBaseUri.addSegment("label").addSegment(label.name.toString).addSegment("delete")" class="pure-form" method="POST" accept-charset="UTF-8">
-                      <fieldset>
-                        <input type="hidden" id="@fieldId-@label.name" name="@fieldId" readonly="" value="@label.id">
-                        <input type="hidden" id="@fieldName-@label.name" name="@fieldName" readonly="" value="@label.name">
-                        <label for="i-am-sure-@label.name"><input id="i-am-sure-@label.name" name="i-am-sure" required="" type="checkbox" value="yes"/> @Messages("form.label.delete.i-am-sure")</label>
-                        @csrfToken(csrf)
-                        <button type="submit" class="pure-button pure-button-warning">@Messages("form.label.delete.button.submit")</button>
-                      </fieldset>
-                    </form>
-                    } else { }
-                  </div>
-                </div>
-              }
-            }
-          }
-        </div>
-      </div>
-    </div>
-  </div>
-</div>
-}
-}
diff -rN -u old-smederee/modules/hub/src/main/twirl/de/smederee/hub/views/tickets/editMilestone.scala.html new-smederee/modules/hub/src/main/twirl/de/smederee/hub/views/tickets/editMilestone.scala.html
--- old-smederee/modules/hub/src/main/twirl/de/smederee/hub/views/tickets/editMilestone.scala.html	2025-01-31 13:42:16.248771665 +0000
+++ new-smederee/modules/hub/src/main/twirl/de/smederee/hub/views/tickets/editMilestone.scala.html	1970-01-01 00:00:00.000000000 +0000
@@ -1,87 +0,0 @@
-@import de.smederee.hub.views.html.showRepositoryMenu
-@import de.smederee.tickets.MilestoneForm._
-@import de.smederee.tickets._
-
-@(baseUri: Uri = Uri(path = Uri.Path.Root),
-  lang: LanguageCode = LanguageCode("en")
-)(action: Uri,
-  csrf: Option[CsrfToken] = None,
-  milestone: Milestone,
-  repositoryBaseUri: Uri,
-  title: Option[String] = None,
-  user: Account,
-  vcsRepository: VcsRepository
-)(formData: Map[String, String] = Map.empty,
-  formErrors: FormErrors = FormErrors.empty
-)
-@main(baseUri, lang)()(csrf, title, user.some) {
-@defining(lang.toLocale) { implicit locale =>
-<div class="content">
-  <div class="pure-g">
-    <div class="pure-u-1">
-      <div class="l-box-left-right">
-        <h2><a href="@{baseUri.addSegment(s"~${vcsRepository.owner.name}")}">~@vcsRepository.owner.name</a>/@vcsRepository.name</h2>
-        @showRepositoryMenu(baseUri)(repositoryBaseUri.addSegment("milestones").some, repositoryBaseUri, user.some, vcsRepository)
-        <div class="repo-summary-description">
-          @Messages("repository.milestones.edit.title")
-        </div>
-      </div>
-    </div>
-  </div>
-  <div class="pure-g">
-    @if(user.uid === vcsRepository.owner.uid) {
-    <div class="pure-u-1-1 pure-u-md-1-1">
-      <div class="l-box">
-        <h4>@Messages("repository.milestone.edit.title", milestone.title)</h4>
-        <div class="form-errors">
-          @formErrors.get(fieldGlobal).map { es =>
-            @for(error <- es) {
-              <p class="alert alert-error">
-                <span class="glyphicon glyphicon-exclamation-sign" aria-hidden="true"></span>
-                <span class="sr-only">Fehler:</span>
-                @error
-              </p>
-            }
-          }
-        </div>
-        <div class="edit-milestones-form">
-          <form action="@action" class="pure-form pure-form-aligned" method="POST" accept-charset="UTF-8">
-            <fieldset>
-              <input type="hidden" id="@fieldId" name="@fieldId" readonly="" value="@milestone.id">
-              <div class="pure-control-group">
-                <milestone for="@{fieldTitle}">@Messages("form.milestone.title")</milestone>
-                <input id="@{fieldTitle}" name="@{fieldTitle}" maxlength="40" placeholder="@Messages("form.milestone.title.placeholder")" required="" type="text" value="@{formData.get(fieldTitle)}">
-                <span class="pure-form-message" id="@{fieldTitle}.help" style="margin-left: 13em;">@Messages("form.milestone.title.help")</span>
-                @renderFormErrors(fieldTitle, formErrors)
-              </div>
-              <div class="pure-control-group">
-                <milestone for="@{fieldDescription}">@Messages("form.milestone.description")</milestone>
-                <input class="pure-input-3-4" id="@{fieldDescription}" name="@{fieldDescription}" maxlength="254" placeholder="@Messages("form.milestone.description.placeholder")" type="text" value="@{formData.get(fieldDescription)}">
-                <span class="pure-form-message" id="@{fieldDescription}.help" style="margin-left: 13em;">@Messages("form.milestone.description.help")</span>
-                @renderFormErrors(fieldDescription, formErrors)
-              </div>
-              <div class="pure-control-group">
-                <milestone for="@{fieldDueDate}">@Messages("form.milestone.due-date")</milestone>
-                <input id="@{fieldDueDate}" name="@{fieldDueDate}" type="date" value="@{formData.get(fieldDueDate)}">
-                <span class="pure-form-message" id="@{fieldDueDate}.help" style="margin-left: 13em;">@Messages("form.milestone.due-date.help")</span>
-                @renderFormErrors(fieldDueDate, formErrors)
-              </div>
-              @csrfToken(csrf)
-              <button type="submit" class="pure-button pure-button-success">@Messages("form.milestone.edit.button.submit")</button>
-            </fieldset>
-          </form>
-        </div>
-      </div>
-    </div>
-    } else { }
-  </div>
-  <div class="pure-g">
-    <div class="pure-u-1-1 pure-u-md-1-1">
-      <div class="l-box">
-      </div>
-    </div>
-  </div>
-</div>
-}
-}
-
diff -rN -u old-smederee/modules/hub/src/main/twirl/de/smederee/hub/views/tickets/editMilestones.scala.html new-smederee/modules/hub/src/main/twirl/de/smederee/hub/views/tickets/editMilestones.scala.html
--- old-smederee/modules/hub/src/main/twirl/de/smederee/hub/views/tickets/editMilestones.scala.html	2025-01-31 13:42:16.248771665 +0000
+++ new-smederee/modules/hub/src/main/twirl/de/smederee/hub/views/tickets/editMilestones.scala.html	1970-01-01 00:00:00.000000000 +0000
@@ -1,127 +0,0 @@
-@import java.time._
-@import de.smederee.hub.views.html.format._
-@import de.smederee.hub.views.html.showRepositoryMenu
-@import de.smederee.tickets.MilestoneForm._
-@import de.smederee.tickets._
-
-@(baseUri: Uri = Uri(path = Uri.Path.Root),
-  lang: LanguageCode = LanguageCode("en")
-)(action: Uri,
-  csrf: Option[CsrfToken] = None,
-  milestones: List[Milestone],
-  repositoryBaseUri: Uri,
-  title: Option[String] = None,
-  user: Option[Account],
-  vcsRepository: VcsRepository
-)(formData: Map[String, String] = Map.empty,
-  formErrors: FormErrors = FormErrors.empty
-)
-@main(baseUri, lang)()(csrf, title, user) {
-@defining(lang.toLocale) { implicit locale =>
-<div class="content">
-  <div class="pure-g">
-    <div class="pure-u-1">
-      <div class="l-box-left-right">
-        <h2><a href="@{baseUri.addSegment(s"~${vcsRepository.owner.name}")}">~@vcsRepository.owner.name</a>/@vcsRepository.name</h2>
-        @showRepositoryMenu(baseUri)(action.some, repositoryBaseUri, user, vcsRepository)
-        <div class="repo-summary-description">
-          @if(user.exists(_.uid === vcsRepository.owner.uid)) {
-            @Messages("repository.milestones.edit.title")
-          } else {
-            @Messages("repository.milestones.view.title")
-          }
-        </div>
-      </div>
-    </div>
-  </div>
-  <div class="pure-g">
-    @if(user.exists(_.uid === vcsRepository.owner.uid)) {
-    <div class="pure-u-1-1 pure-u-md-1-1">
-      <div class="l-box">
-        <h4>@Messages("repository.milestones.add.title")</h4>
-        <div class="form-errors">
-          @formErrors.get(fieldGlobal).map { es =>
-            @for(error <- es) {
-              <p class="alert alert-error">
-                <span class="glyphicon glyphicon-exclamation-sign" aria-hidden="true"></span>
-                <span class="sr-only">Fehler:</span>
-                @error
-              </p>
-            }
-          }
-        </div>
-        <div class="edit-milestones-form">
-          <form action="@repositoryBaseUri.addSegment("milestones")" class="pure-form pure-form-stacked" method="POST" accept-charset="UTF-8">
-            <fieldset>
-              <div class="pure-control-group">
-                <label for="@{fieldTitle}">@Messages("form.milestone.title")</label>
-                <input class="pure-input-3-4" id="@{fieldTitle}" name="@{fieldTitle}" maxlength="40" placeholder="@Messages("form.milestone.title.placeholder")" required="" type="text" value="@{formData.get(fieldTitle)}">
-                <span class="pure-form-message" id="@{fieldTitle}.help">@Messages("form.milestone.title.help")</span>
-                @renderFormErrors(fieldTitle, formErrors)
-              </div>
-              <div class="pure-control-group">
-                <label for="@{fieldDescription}">@Messages("form.milestone.description")</label>
-                <textarea class="pure-input-3-4" id="@{fieldDescription}" name="@{fieldDescription}" placeholder="@Messages("form.milestone.description.placeholder")" rows="4" value="@{formData.get(fieldDescription)}"></textarea>
-                <span class="pure-form-message" id="@{fieldDescription}.help">@Messages("form.milestone.description.help")</span>
-                @renderFormErrors(fieldDescription, formErrors)
-              </div>
-              <div class="pure-control-group">
-                <label for="@{fieldDueDate}">@Messages("form.milestone.due-date")</label>
-                <input id="@{fieldDueDate}" name="@{fieldDueDate}" type="date" value="@{formData.get(fieldDueDate)}">
-                <span class="pure-form-message" id="@{fieldDueDate}.help">@Messages("form.milestone.due-date.help")</span>
-                @renderFormErrors(fieldDueDate, formErrors)
-              </div>
-              @csrfToken(csrf)
-              <button type="submit" class="pure-button pure-button-success">@Messages("form.milestone.create.button.submit")</button>
-            </fieldset>
-          </form>
-        </div>
-      </div>
-    </div>
-    } else { }
-  </div>
-  <div class="pure-g">
-    <div class="pure-u-1-1 pure-u-md-1-1">
-      <div class="l-box">
-        <div class="milestone-list">
-          <h4>@Messages("repository.milestones.list.title", milestones.size)</h4>
-          @if(milestones.size === 0) {
-            <div class="alert alert-info">@Messages("repository.milestones.list.empty")</div>
-          } else {
-            @defining(32) { lineHeight =>
-              @for(milestone <- milestones) {
-                <div class="pure-g milestone">
-                  <div class="pure-u-1-24 milestone-icon">
-                    @icon(baseUri)("flag", lineHeight.some)
-                  </div>
-                  <div class="pure-u-5-24 milestone-title" style="height: @{lineHeight}px; line-height: @{lineHeight}px;">@milestone.title @for(dueDate <- milestone.dueDate) { @formatDate(dueDate) }</div>
-                  <div class="pure-u-10-24 milestone-description" style="height: @{lineHeight}px; line-height: @{lineHeight}px; overflow: overlay;">@milestone.description</div>
-                  <div class="pure-u-2-24" style="height: @{lineHeight}px; line-height: @{lineHeight}px; margin: auto;">
-                    @if(user.exists(_.uid === vcsRepository.owner.uid)) {
-                    <a class="pure-button" href="@repositoryBaseUri.addSegment("milestone").addSegment(milestone.title.toString).addSegment("edit")" title="@Messages("repository.milestone.edit.title", milestone.title)">@Messages("repository.milestone.edit.link")</a>
-                    } else { }
-                  </div>
-                  <div class="pure-u-6-24 milestone-form" style="height: @{lineHeight}px; line-height: @{lineHeight}px; padding-left: 1em;">
-                    @if(user.exists(_.uid === vcsRepository.owner.uid)) {
-                    <form action="@repositoryBaseUri.addSegment("milestone").addSegment(milestone.title.toString).addSegment("delete")" class="pure-form" method="POST" accept-charset="UTF-8">
-                      <fieldset>
-                        <input type="hidden" id="@fieldId-@milestone.title" name="@fieldId" readonly="" value="@milestone.id">
-                        <input type="hidden" id="@fieldTitle-@milestone.title" name="@fieldTitle" readonly="" value="@milestone.title">
-                        <milestone for="i-am-sure-@milestone.title"><input id="i-am-sure-@milestone.title" name="i-am-sure" required="" type="checkbox" value="yes"/> @Messages("form.milestone.delete.i-am-sure")</milestone>
-                        @csrfToken(csrf)
-                        <button type="submit" class="pure-button pure-button-warning">@Messages("form.milestone.delete.button.submit")</button>
-                      </fieldset>
-                    </form>
-                    } else { }
-                  </div>
-                </div>
-              }
-            }
-          }
-        </div>
-      </div>
-    </div>
-  </div>
-</div>
-}
-}
diff -rN -u old-smederee/modules/hub/src/test/scala/de/smederee/tickets/ColourCodeTest.scala new-smederee/modules/hub/src/test/scala/de/smederee/tickets/ColourCodeTest.scala
--- old-smederee/modules/hub/src/test/scala/de/smederee/tickets/ColourCodeTest.scala	2025-01-31 13:42:16.248771665 +0000
+++ new-smederee/modules/hub/src/test/scala/de/smederee/tickets/ColourCodeTest.scala	1970-01-01 00:00:00.000000000 +0000
@@ -1,41 +0,0 @@
-/*
- * Copyright (C) 2022  Contributors as noted in the AUTHORS.md file
- *
- * This program is free software: you can redistribute it and/or modify
- * it under the terms of the GNU Affero General Public License as
- * published by the Free Software Foundation, either version 3 of the
- * License, or (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
- * GNU Affero General Public License for more details.
- *
- * You should have received a copy of the GNU Affero General Public License
- * along with this program.  If not, see <http://www.gnu.org/licenses/>.
- */
-
-package de.smederee.tickets
-
-import de.smederee.tickets.Generators._
-
-import munit._
-import org.scalacheck._
-import org.scalacheck.Prop._
-
-final class ColourCodeTest extends ScalaCheckSuite {
-  given Arbitrary[ColourCode] = Arbitrary(genColourCode)
-
-  property("ColourCode.from must fail on invalid input") {
-    forAll { (input: String) =>
-      assertEquals(ColourCode.from(input), None)
-    }
-  }
-
-  property("ColourCode.from must succeed on valid input") {
-    forAll { (colourCode: ColourCode) =>
-      val input = colourCode.toString
-      assertEquals(ColourCode.from(input), Option(colourCode))
-    }
-  }
-}
diff -rN -u old-smederee/modules/hub/src/test/scala/de/smederee/tickets/Generators.scala new-smederee/modules/hub/src/test/scala/de/smederee/tickets/Generators.scala
--- old-smederee/modules/hub/src/test/scala/de/smederee/tickets/Generators.scala	2025-01-31 13:42:16.248771665 +0000
+++ new-smederee/modules/hub/src/test/scala/de/smederee/tickets/Generators.scala	1970-01-01 00:00:00.000000000 +0000
@@ -1,89 +0,0 @@
-/*
- * Copyright (C) 2022  Contributors as noted in the AUTHORS.md file
- *
- * This program is free software: you can redistribute it and/or modify
- * it under the terms of the GNU Affero General Public License as
- * published by the Free Software Foundation, either version 3 of the
- * License, or (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
- * GNU Affero General Public License for more details.
- *
- * You should have received a copy of the GNU Affero General Public License
- * along with this program.  If not, see <http://www.gnu.org/licenses/>.
- */
-
-package de.smederee.tickets
-
-import java.time._
-
-import org.scalacheck.{ Arbitrary, Gen }
-
-object Generators {
-  val MinimumYear: Int = -4713  // Lowest year supported by PostgreSQL
-  val MaximumYear: Int = 294276 // Highest year supported by PostgreSQL
-
-  /** Prepend a zero to a single character hexadecimal code.
-    *
-    * @param hexCode
-    *   A string supposed to contain a hexadecimal code between 0 and ff.
-    * @return
-    *   Either the given code prepended with a leading zero if it had only a single character or the originally given
-    *   code otherwise.
-    */
-  private def hexPadding(hexCode: String): String =
-    if (hexCode.length < 2)
-      "0" + hexCode
-    else
-      hexCode
-
-  val genLocalDate: Gen[LocalDate] =
-    for {
-      year  <- Gen.choose(MinimumYear, MaximumYear)
-      month <- Gen.choose(1, 12)
-      day   <- Gen.choose(1, Month.of(month).length(Year.of(year).isLeap))
-    } yield LocalDate.of(year, month, day)
-
-  given Arbitrary[LocalDate] = Arbitrary(genLocalDate)
-
-  val genLabelName: Gen[LabelName] =
-    Gen.nonEmptyListOf(Gen.alphaNumChar).map(_.take(LabelName.MaxLength).mkString).map(LabelName.apply)
-
-  val genLabelDescription: Gen[LabelDescription] =
-    Gen.nonEmptyListOf(Gen.alphaNumChar).map(_.take(LabelDescription.MaxLength).mkString).map(LabelDescription.apply)
-
-  val genColourCode: Gen[ColourCode] = for {
-    red   <- Gen.choose(0, 255).map(_.toHexString).map(hexPadding)
-    green <- Gen.choose(0, 255).map(_.toHexString).map(hexPadding)
-    blue  <- Gen.choose(0, 255).map(_.toHexString).map(hexPadding)
-    hexString = s"#$red$green$blue"
-  } yield ColourCode(hexString)
-
-  val genLabel: Gen[Label] = for {
-    id          <- Gen.option(Gen.choose(0L, Long.MaxValue).map(LabelId.apply))
-    name        <- genLabelName
-    description <- Gen.option(genLabelDescription)
-    colour      <- genColourCode
-  } yield Label(id, name, description, colour)
-
-  val genLabels: Gen[List[Label]] = Gen.nonEmptyListOf(genLabel).map(_.distinct)
-
-  val genMilestoneTitle: Gen[MilestoneTitle] =
-    Gen.nonEmptyListOf(Gen.alphaNumChar).map(_.take(MilestoneTitle.MaxLength).mkString).map(MilestoneTitle.apply)
-
-  val genMilestoneDescription: Gen[MilestoneDescription] =
-    Gen.nonEmptyListOf(Gen.alphaNumChar).map(_.mkString).map(MilestoneDescription.apply)
-
-  val genMilestone: Gen[Milestone] =
-    for {
-      id    <- Gen.option(Gen.choose(0L, Long.MaxValue).map(MilestoneId.apply))
-      title <- genMilestoneTitle
-      due   <- Gen.option(genLocalDate)
-      descr <- Gen.option(genMilestoneDescription)
-    } yield Milestone(id, title, descr, due)
-
-  val genMilestones: Gen[List[Milestone]] = Gen.nonEmptyListOf(genMilestone).map(_.distinct)
-
-}
diff -rN -u old-smederee/modules/hub/src/test/scala/de/smederee/tickets/LabelDescriptionTest.scala new-smederee/modules/hub/src/test/scala/de/smederee/tickets/LabelDescriptionTest.scala
--- old-smederee/modules/hub/src/test/scala/de/smederee/tickets/LabelDescriptionTest.scala	2025-01-31 13:42:16.248771665 +0000
+++ new-smederee/modules/hub/src/test/scala/de/smederee/tickets/LabelDescriptionTest.scala	1970-01-01 00:00:00.000000000 +0000
@@ -1,46 +0,0 @@
-/*
- * Copyright (C) 2022  Contributors as noted in the AUTHORS.md file
- *
- * This program is free software: you can redistribute it and/or modify
- * it under the terms of the GNU Affero General Public License as
- * published by the Free Software Foundation, either version 3 of the
- * License, or (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
- * GNU Affero General Public License for more details.
- *
- * You should have received a copy of the GNU Affero General Public License
- * along with this program.  If not, see <http://www.gnu.org/licenses/>.
- */
-
-package de.smederee.tickets
-
-import de.smederee.tickets.Generators._
-
-import munit._
-import org.scalacheck._
-import org.scalacheck.Prop._
-
-final class LabelDescriptionTest extends ScalaCheckSuite {
-  given Arbitrary[LabelDescription] = Arbitrary(genLabelDescription)
-
-  test("LabelDescription.from must fail on empty input") {
-    assertEquals(LabelDescription.from(""), None)
-  }
-
-  property("LabelDescription.from must fail on too long input") {
-    forAll { (input: String) =>
-      if (input.length > LabelDescription.MaxLength)
-        assertEquals(LabelDescription.from(input), None)
-    }
-  }
-
-  property("LabelDescription.from must succeed on valid input") {
-    forAll { (label: LabelDescription) =>
-      val input = label.toString
-      assertEquals(LabelDescription.from(input), Option(label))
-    }
-  }
-}
diff -rN -u old-smederee/modules/hub/src/test/scala/de/smederee/tickets/LabelNameTest.scala new-smederee/modules/hub/src/test/scala/de/smederee/tickets/LabelNameTest.scala
--- old-smederee/modules/hub/src/test/scala/de/smederee/tickets/LabelNameTest.scala	2025-01-31 13:42:16.248771665 +0000
+++ new-smederee/modules/hub/src/test/scala/de/smederee/tickets/LabelNameTest.scala	1970-01-01 00:00:00.000000000 +0000
@@ -1,46 +0,0 @@
-/*
- * Copyright (C) 2022  Contributors as noted in the AUTHORS.md file
- *
- * This program is free software: you can redistribute it and/or modify
- * it under the terms of the GNU Affero General Public License as
- * published by the Free Software Foundation, either version 3 of the
- * License, or (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
- * GNU Affero General Public License for more details.
- *
- * You should have received a copy of the GNU Affero General Public License
- * along with this program.  If not, see <http://www.gnu.org/licenses/>.
- */
-
-package de.smederee.tickets
-
-import de.smederee.tickets.Generators._
-
-import munit._
-import org.scalacheck._
-import org.scalacheck.Prop._
-
-final class LabelNameTest extends ScalaCheckSuite {
-  given Arbitrary[LabelName] = Arbitrary(genLabelName)
-
-  test("LabelName.from must fail on empty input") {
-    assertEquals(LabelName.from(""), None)
-  }
-
-  property("LabelName.from must fail on too long input") {
-    forAll { (input: String) =>
-      if (input.length > LabelName.MaxLength)
-        assertEquals(LabelName.from(input), None)
-    }
-  }
-
-  property("LabelName.from must succeed on valid input") {
-    forAll { (label: LabelName) =>
-      val input = label.toString
-      assertEquals(LabelName.from(input), Option(label))
-    }
-  }
-}
diff -rN -u old-smederee/modules/hub/src/test/scala/de/smederee/tickets/LabelTest.scala new-smederee/modules/hub/src/test/scala/de/smederee/tickets/LabelTest.scala
--- old-smederee/modules/hub/src/test/scala/de/smederee/tickets/LabelTest.scala	2025-01-31 13:42:16.248771665 +0000
+++ new-smederee/modules/hub/src/test/scala/de/smederee/tickets/LabelTest.scala	1970-01-01 00:00:00.000000000 +0000
@@ -1,35 +0,0 @@
-/*
- * Copyright (C) 2022  Contributors as noted in the AUTHORS.md file
- *
- * This program is free software: you can redistribute it and/or modify
- * it under the terms of the GNU Affero General Public License as
- * published by the Free Software Foundation, either version 3 of the
- * License, or (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
- * GNU Affero General Public License for more details.
- *
- * You should have received a copy of the GNU Affero General Public License
- * along with this program.  If not, see <http://www.gnu.org/licenses/>.
- */
-
-package de.smederee.tickets
-
-import cats.syntax.all._
-import de.smederee.tickets.Generators._
-
-import munit._
-import org.scalacheck._
-import org.scalacheck.Prop._
-
-final class LabelTest extends ScalaCheckSuite {
-  given Arbitrary[Label] = Arbitrary(genLabel)
-
-  property("Eq must hold") {
-    forAll { (label: Label) =>
-      assert(label === label, "Identical labels must be considered equal!")
-    }
-  }
-}
diff -rN -u old-smederee/modules/hub/src/test/scala/de/smederee/tickets/MilestoneDescriptionTest.scala new-smederee/modules/hub/src/test/scala/de/smederee/tickets/MilestoneDescriptionTest.scala
--- old-smederee/modules/hub/src/test/scala/de/smederee/tickets/MilestoneDescriptionTest.scala	2025-01-31 13:42:16.248771665 +0000
+++ new-smederee/modules/hub/src/test/scala/de/smederee/tickets/MilestoneDescriptionTest.scala	1970-01-01 00:00:00.000000000 +0000
@@ -1,39 +0,0 @@
-/*
- * Copyright (C) 2022  Contributors as noted in the AUTHORS.md file
- *
- * This program is free software: you can redistribute it and/or modify
- * it under the terms of the GNU Affero General Public License as
- * published by the Free Software Foundation, either version 3 of the
- * License, or (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
- * GNU Affero General Public License for more details.
- *
- * You should have received a copy of the GNU Affero General Public License
- * along with this program.  If not, see <http://www.gnu.org/licenses/>.
- */
-
-package de.smederee.tickets
-
-import de.smederee.tickets.Generators._
-
-import munit._
-import org.scalacheck._
-import org.scalacheck.Prop._
-
-final class MilestoneDescriptionTest extends ScalaCheckSuite {
-  given Arbitrary[MilestoneDescription] = Arbitrary(genMilestoneDescription)
-
-  test("MilestoneDescription.from must fail on empty input") {
-    assertEquals(MilestoneDescription.from(""), None)
-  }
-
-  property("MilestoneDescription.from must succeed on valid input") {
-    forAll { (label: MilestoneDescription) =>
-      val input = label.toString
-      assertEquals(MilestoneDescription.from(input), Option(label))
-    }
-  }
-}
diff -rN -u old-smederee/modules/hub/src/test/scala/de/smederee/tickets/MilestoneTest.scala new-smederee/modules/hub/src/test/scala/de/smederee/tickets/MilestoneTest.scala
--- old-smederee/modules/hub/src/test/scala/de/smederee/tickets/MilestoneTest.scala	2025-01-31 13:42:16.248771665 +0000
+++ new-smederee/modules/hub/src/test/scala/de/smederee/tickets/MilestoneTest.scala	1970-01-01 00:00:00.000000000 +0000
@@ -1,35 +0,0 @@
-/*
- * Copyright (C) 2022  Contributors as noted in the AUTHORS.md file
- *
- * This program is free software: you can redistribute it and/or modify
- * it under the terms of the GNU Affero General Public License as
- * published by the Free Software Foundation, either version 3 of the
- * License, or (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
- * GNU Affero General Public License for more details.
- *
- * You should have received a copy of the GNU Affero General Public License
- * along with this program.  If not, see <http://www.gnu.org/licenses/>.
- */
-
-package de.smederee.tickets
-
-import cats.syntax.all._
-import de.smederee.tickets.Generators._
-
-import munit._
-import org.scalacheck._
-import org.scalacheck.Prop._
-
-final class MilestoneTest extends ScalaCheckSuite {
-  given Arbitrary[Milestone] = Arbitrary(genMilestone)
-
-  property("Eq must hold") {
-    forAll { (label: Milestone) =>
-      assert(label === label, "Identical milestones must be considered equal!")
-    }
-  }
-}
diff -rN -u old-smederee/modules/hub/src/test/scala/de/smederee/tickets/MilestoneTitleTest.scala new-smederee/modules/hub/src/test/scala/de/smederee/tickets/MilestoneTitleTest.scala
--- old-smederee/modules/hub/src/test/scala/de/smederee/tickets/MilestoneTitleTest.scala	2025-01-31 13:42:16.248771665 +0000
+++ new-smederee/modules/hub/src/test/scala/de/smederee/tickets/MilestoneTitleTest.scala	1970-01-01 00:00:00.000000000 +0000
@@ -1,46 +0,0 @@
-/*
- * Copyright (C) 2022  Contributors as noted in the AUTHORS.md file
- *
- * This program is free software: you can redistribute it and/or modify
- * it under the terms of the GNU Affero General Public License as
- * published by the Free Software Foundation, either version 3 of the
- * License, or (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
- * GNU Affero General Public License for more details.
- *
- * You should have received a copy of the GNU Affero General Public License
- * along with this program.  If not, see <http://www.gnu.org/licenses/>.
- */
-
-package de.smederee.tickets
-
-import de.smederee.tickets.Generators._
-
-import munit._
-import org.scalacheck._
-import org.scalacheck.Prop._
-
-final class MilestoneTitleTest extends ScalaCheckSuite {
-  given Arbitrary[MilestoneTitle] = Arbitrary(genMilestoneTitle)
-
-  test("MilestoneTitle.from must fail on empty input") {
-    assertEquals(MilestoneTitle.from(""), None)
-  }
-
-  property("MilestoneTitle.from must fail on too long input") {
-    forAll { (input: String) =>
-      if (input.length > MilestoneTitle.MaxLength)
-        assertEquals(MilestoneTitle.from(input), None)
-    }
-  }
-
-  property("MilestoneTitle.from must succeed on valid input") {
-    forAll { (label: MilestoneTitle) =>
-      val input = label.toString
-      assertEquals(MilestoneTitle.from(input), Option(label))
-    }
-  }
-}
diff -rN -u old-smederee/modules/hub/src/test/scala/de/smederee/tickets/TicketContentTest.scala new-smederee/modules/hub/src/test/scala/de/smederee/tickets/TicketContentTest.scala
--- old-smederee/modules/hub/src/test/scala/de/smederee/tickets/TicketContentTest.scala	2025-01-31 13:42:16.248771665 +0000
+++ new-smederee/modules/hub/src/test/scala/de/smederee/tickets/TicketContentTest.scala	1970-01-01 00:00:00.000000000 +0000
@@ -1,35 +0,0 @@
-/*
- * Copyright (C) 2022  Contributors as noted in the AUTHORS.md file
- *
- * This program is free software: you can redistribute it and/or modify
- * it under the terms of the GNU Affero General Public License as
- * published by the Free Software Foundation, either version 3 of the
- * License, or (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
- * GNU Affero General Public License for more details.
- *
- * You should have received a copy of the GNU Affero General Public License
- * along with this program.  If not, see <http://www.gnu.org/licenses/>.
- */
-
-package de.smederee.tickets
-
-import munit._
-import org.scalacheck._
-import org.scalacheck.Prop._
-
-final class TicketContentTest extends ScalaCheckSuite {
-
-  property("TicketContent.from must only accept valid input") {
-    forAll { (input: String) =>
-      if (input.nonEmpty)
-        assertEquals(TicketContent.from(input), Some(TicketContent(input)))
-      else
-        assertEquals(TicketContent.from(input), None)
-    }
-  }
-
-}
diff -rN -u old-smederee/modules/hub/src/test/scala/de/smederee/tickets/TicketNumberTest.scala new-smederee/modules/hub/src/test/scala/de/smederee/tickets/TicketNumberTest.scala
--- old-smederee/modules/hub/src/test/scala/de/smederee/tickets/TicketNumberTest.scala	2025-01-31 13:42:16.248771665 +0000
+++ new-smederee/modules/hub/src/test/scala/de/smederee/tickets/TicketNumberTest.scala	1970-01-01 00:00:00.000000000 +0000
@@ -1,35 +0,0 @@
-/*
- * Copyright (C) 2022  Contributors as noted in the AUTHORS.md file
- *
- * This program is free software: you can redistribute it and/or modify
- * it under the terms of the GNU Affero General Public License as
- * published by the Free Software Foundation, either version 3 of the
- * License, or (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
- * GNU Affero General Public License for more details.
- *
- * You should have received a copy of the GNU Affero General Public License
- * along with this program.  If not, see <http://www.gnu.org/licenses/>.
- */
-
-package de.smederee.tickets
-
-import munit._
-import org.scalacheck._
-import org.scalacheck.Prop._
-
-final class TicketNumberTest extends ScalaCheckSuite {
-
-  property("TicketNumber.from must only accept valid input") {
-    forAll { (integer: Int) =>
-      if (integer > 0)
-        assertEquals(TicketNumber.from(integer), Some(TicketNumber(integer)))
-      else
-        assertEquals(TicketNumber.from(integer), None)
-    }
-  }
-
-}
diff -rN -u old-smederee/modules/hub/src/test/scala/de/smederee/tickets/TicketTitleTest.scala new-smederee/modules/hub/src/test/scala/de/smederee/tickets/TicketTitleTest.scala
--- old-smederee/modules/hub/src/test/scala/de/smederee/tickets/TicketTitleTest.scala	2025-01-31 13:42:16.248771665 +0000
+++ new-smederee/modules/hub/src/test/scala/de/smederee/tickets/TicketTitleTest.scala	1970-01-01 00:00:00.000000000 +0000
@@ -1,35 +0,0 @@
-/*
- * Copyright (C) 2022  Contributors as noted in the AUTHORS.md file
- *
- * This program is free software: you can redistribute it and/or modify
- * it under the terms of the GNU Affero General Public License as
- * published by the Free Software Foundation, either version 3 of the
- * License, or (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
- * GNU Affero General Public License for more details.
- *
- * You should have received a copy of the GNU Affero General Public License
- * along with this program.  If not, see <http://www.gnu.org/licenses/>.
- */
-
-package de.smederee.tickets
-
-import munit._
-import org.scalacheck._
-import org.scalacheck.Prop._
-
-final class TicketTitleTest extends ScalaCheckSuite {
-
-  property("TicketTitle.from must only accept valid input") {
-    forAll { (input: String) =>
-      if (input.nonEmpty && input.length <= TicketTitle.MaxLength)
-        assertEquals(TicketTitle.from(input), Some(TicketTitle(input)))
-      else
-        assertEquals(TicketTitle.from(input), None)
-    }
-  }
-
-}