~jan0sch/smederee

Showing details for patch 2fdbbeb715dd112a1d1a1c24dc030a2be3bc0dce.
2023-01-23 (Mon), 1:48 PM - Jens Grassel - 2fdbbeb715dd112a1d1a1c24dc030a2be3bc0dce

Tickets: Prepare foundations for ticketing support.

This introduces necessary work for ticket support to the Smederee. =)
The idea is as follows:

1. Per repository you can create labels, milestones and tickets.
2. A ticket can be linked with multiple labels and/or milestones.
3. A ticket can be assigned to multiple accounts (assignees).

Several things are hard wired (e.g. ticket status) to reduce code
complexity.

This is all still incomplete but it compiles and does not interfere with
existing functionality. It also includes a dependency update.
Summary of changes
18 files added
  • modules/hub/src/main/resources/db/migration/V4__ticket_tables.sql
  • modules/hub/src/main/scala/de/smederee/tickets/Assignee.scala
  • modules/hub/src/main/scala/de/smederee/tickets/Label.scala
  • modules/hub/src/main/scala/de/smederee/tickets/Milestone.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/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
3 files modified with 30 lines added and 1 lines removed
  • build.sbt with 4 added and 1 removed lines
  • modules/hub/src/main/scala/de/smederee/hub/Account.scala with 24 added and 0 removed lines
  • modules/hub/src/main/scala/de/smederee/hub/types.scala with 2 added and 0 removed lines
diff -rN -u old-smederee/build.sbt new-smederee/build.sbt
--- old-smederee/build.sbt	2025-02-01 01:55:04.502884805 +0000
+++ new-smederee/build.sbt	2025-02-01 01:55:04.506884811 +0000
@@ -323,7 +323,8 @@
       val circe           = "0.14.3"
       val commonMark      = "0.21.0"
       val doobie          = "1.0.0-RC2"
-      val flyway          = "9.11.0"
+      val flyway          = "9.12.0"
+      val fs2             = "3.5.0"
       val http4s          = "1.0.0-M38"
       val ip4s            = "3.2.0"
       val jansi           = "2.4.2"
@@ -354,6 +355,8 @@
     val doobiePostgres       = "org.tpolecat"                 %% "doobie-postgres"        % Version.doobie
     val doobieScalaTest      = "org.tpolecat"                 %% "doobie-scalatest"       % Version.doobie
     val flywayCore           = "org.flywaydb"                 %  "flyway-core"            % Version.flyway
+    val fs2Core              = "co.fs2"                       %% "fs2-core"               % Version.fs2
+    val fs2IO                = "co.fs2"                       %% "fs2-io"                 % Version.fs2
     val http4sCirce          = "org.http4s"                   %% "http4s-circe"           % Version.http4s
     val http4sCore           = "org.http4s"                   %% "http4s-core"            % Version.http4s
     val http4sDsl            = "org.http4s"                   %% "http4s-dsl"             % Version.http4s
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	1970-01-01 00:00:00.000000000 +0000
+++ new-smederee/modules/hub/src/main/resources/db/migration/V4__ticket_tables.sql	2025-02-01 01:55:04.506884811 +0000
@@ -0,0 +1,131 @@
+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/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-02-01 01:55:04.502884805 +0000
+++ new-smederee/modules/hub/src/main/scala/de/smederee/hub/Account.scala	2025-02-01 01:55:04.506884811 +0000
@@ -24,10 +24,13 @@
 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
 
+/** An email address must fulfil several format requirements which in detail should be looked up in the implementation.
+  */
 opaque type Email = String
 object Email {
   given Eq[Email] = Eq.fromUniversalEquals
@@ -96,6 +99,9 @@
   def toToAddress: ToAddress = ToAddress(email.toString)
 }
 
+/** A password is stored as an `Array[Byte]` internally and its `validate(source: String)` function will check that the
+  * input has a minimum length.
+  */
 opaque type Password = Array[Byte]
 object Password {
 
@@ -275,6 +281,10 @@
 
 }
 
+/** A username for an account has to obey several restrictions which are similiar to the ones found for Unix usernames.
+  * It must start with a letter, contain only alphanumeric characters, be at least 2 and at most 31 characters long and
+  * be all lowercase.
+  */
 opaque type Username = String
 object Username {
   given Eq[Username] = Eq.fromUniversalEquals
@@ -407,6 +417,20 @@
 
 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/types.scala new-smederee/modules/hub/src/main/scala/de/smederee/hub/types.scala
--- old-smederee/modules/hub/src/main/scala/de/smederee/hub/types.scala	2025-02-01 01:55:04.502884805 +0000
+++ new-smederee/modules/hub/src/main/scala/de/smederee/hub/types.scala	2025-02-01 01:55:04.506884811 +0000
@@ -102,6 +102,8 @@
   }
 }
 
+/** A user id is supposed to be globally unique and maps to the [[java.util.UUID]] type beneath.
+  */
 opaque type UserId = UUID
 object UserId {
   val Format = "^[0-9a-f]{8}-[0-9a-f]{4}-[0-5][0-9a-f]{3}-[089ab][0-9a-f]{3}-[0-9a-f]{12}$".r
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	1970-01-01 00:00:00.000000000 +0000
+++ new-smederee/modules/hub/src/main/scala/de/smederee/tickets/Assignee.scala	2025-02-01 01:55:04.506884811 +0000
@@ -0,0 +1,38 @@
+/*
+ * 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/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	1970-01-01 00:00:00.000000000 +0000
+++ new-smederee/modules/hub/src/main/scala/de/smederee/tickets/Label.scala	2025-02-01 01:55:04.506884811 +0000
@@ -0,0 +1,128 @@
+/*
+ * 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
+
+/** 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
+
+  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)
+
+}
+
+/** 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.fromUniversalEquals
+
+  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 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(name: LabelName, description: Option[LabelDescription], colour: ColourCode)
+
+object Label {
+  given Eq[Label] =
+    Eq.instance((thisLabel, thatLabel) =>
+      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/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	1970-01-01 00:00:00.000000000 +0000
+++ new-smederee/modules/hub/src/main/scala/de/smederee/tickets/Milestone.scala	2025-02-01 01:55:04.506884811 +0000
@@ -0,0 +1,100 @@
+/*
+ * 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._
+
+/** 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
+
+  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)
+
+}
+
+/** 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 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 dueDate
+  *   An optional date on which the milestone is supposed to be reached.
+  * @param description
+  *   An optional longer description of the milestone.
+  */
+final case class Milestone(title: MilestoneTitle, dueDate: Option[LocalDate], description: Option[MilestoneDescription])
+
+object Milestone {
+
+  given Eq[LocalDate] = Eq.instance((a, b) => a.compareTo(b) === 0)
+
+  given Eq[Milestone] =
+    Eq.instance((a, b) => 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	1970-01-01 00:00:00.000000000 +0000
+++ new-smederee/modules/hub/src/main/scala/de/smederee/tickets/Submitter.scala	2025-02-01 01:55:04.506884811 +0000
@@ -0,0 +1,38 @@
+/*
+ * 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	1970-01-01 00:00:00.000000000 +0000
+++ new-smederee/modules/hub/src/main/scala/de/smederee/tickets/TicketRepository.scala	2025-02-01 01:55:04.506884811 +0000
@@ -0,0 +1,144 @@
+/*
+ * 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	1970-01-01 00:00:00.000000000 +0000
+++ new-smederee/modules/hub/src/main/scala/de/smederee/tickets/Ticket.scala	2025-02-01 01:55:04.506884811 +0000
@@ -0,0 +1,163 @@
+/*
+ * 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/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	1970-01-01 00:00:00.000000000 +0000
+++ new-smederee/modules/hub/src/test/scala/de/smederee/tickets/ColourCodeTest.scala	2025-02-01 01:55:04.506884811 +0000
@@ -0,0 +1,41 @@
+/*
+ * 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	1970-01-01 00:00:00.000000000 +0000
+++ new-smederee/modules/hub/src/test/scala/de/smederee/tickets/Generators.scala	2025-02-01 01:55:04.506884811 +0000
@@ -0,0 +1,79 @@
+/*
+ * 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 org.scalacheck.{ Arbitrary, Gen }
+import java.time.LocalDate
+
+object Generators {
+
+  /** 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 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 {
+    name        <- genLabelName
+    description <- Gen.option(genLabelDescription)
+    colour      <- genColourCode
+  } yield Label(name, description, colour)
+
+  val genLocalDate: Gen[LocalDate] =
+    for {
+      year  <- Gen.choose(LocalDate.MIN.getYear(), LocalDate.MAX.getYear())
+      month <- Gen.choose(1, 12)
+      maxDays = LocalDate.of(year, month, 1).lengthOfMonth()
+      day <- Gen.choose(1, maxDays)
+    } yield LocalDate.of(year, month, day)
+
+  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 {
+      title <- genMilestoneTitle
+      due   <- Gen.option(genLocalDate)
+      descr <- Gen.option(genMilestoneDescription)
+    } yield Milestone(title, due, descr)
+
+}
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	1970-01-01 00:00:00.000000000 +0000
+++ new-smederee/modules/hub/src/test/scala/de/smederee/tickets/LabelDescriptionTest.scala	2025-02-01 01:55:04.506884811 +0000
@@ -0,0 +1,46 @@
+/*
+ * 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	1970-01-01 00:00:00.000000000 +0000
+++ new-smederee/modules/hub/src/test/scala/de/smederee/tickets/LabelNameTest.scala	2025-02-01 01:55:04.506884811 +0000
@@ -0,0 +1,46 @@
+/*
+ * 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	1970-01-01 00:00:00.000000000 +0000
+++ new-smederee/modules/hub/src/test/scala/de/smederee/tickets/LabelTest.scala	2025-02-01 01:55:04.506884811 +0000
@@ -0,0 +1,35 @@
+/*
+ * 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	1970-01-01 00:00:00.000000000 +0000
+++ new-smederee/modules/hub/src/test/scala/de/smederee/tickets/MilestoneDescriptionTest.scala	2025-02-01 01:55:04.506884811 +0000
@@ -0,0 +1,39 @@
+/*
+ * 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	1970-01-01 00:00:00.000000000 +0000
+++ new-smederee/modules/hub/src/test/scala/de/smederee/tickets/MilestoneTest.scala	2025-02-01 01:55:04.506884811 +0000
@@ -0,0 +1,35 @@
+/*
+ * 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	1970-01-01 00:00:00.000000000 +0000
+++ new-smederee/modules/hub/src/test/scala/de/smederee/tickets/MilestoneTitleTest.scala	2025-02-01 01:55:04.506884811 +0000
@@ -0,0 +1,46 @@
+/*
+ * 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	1970-01-01 00:00:00.000000000 +0000
+++ new-smederee/modules/hub/src/test/scala/de/smederee/tickets/TicketContentTest.scala	2025-02-01 01:55:04.506884811 +0000
@@ -0,0 +1,35 @@
+/*
+ * 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	1970-01-01 00:00:00.000000000 +0000
+++ new-smederee/modules/hub/src/test/scala/de/smederee/tickets/TicketNumberTest.scala	2025-02-01 01:55:04.506884811 +0000
@@ -0,0 +1,35 @@
+/*
+ * 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	1970-01-01 00:00:00.000000000 +0000
+++ new-smederee/modules/hub/src/test/scala/de/smederee/tickets/TicketTitleTest.scala	2025-02-01 01:55:04.506884811 +0000
@@ -0,0 +1,35 @@
+/*
+ * 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)
+    }
+  }
+
+}