~jan0sch/smederee

Showing details for patch 8edc6b02b0b50cdfb9bad26a27e3a01d532701d2.
2023-02-11 (Sat), 6:24 PM - Jens Grassel - 8edc6b02b0b50cdfb9bad26a27e3a01d532701d2

Labels: A first throw at support of labels.

Labels are intended to be used to label / tag tickets. They have a unique
name (within the project / repo scope) and a colour and may have an optional
description.

There are still some rough edges (regarding safety checks erroring out hard) but
it should be enough to have create, edit and delete functionality for labels.
Summary of changes
8 files added
  • modules/hub/src/it/scala/de/smederee/tickets/DoobieLabelRepositoryTest.scala
  • modules/hub/src/it/scala/de/smederee/tickets/Generators.scala
  • modules/hub/src/main/scala/de/smederee/tickets/DoobieLabelRepository.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/twirl/de/smederee/hub/views/tickets/editLabel.scala.html
  • modules/hub/src/main/twirl/de/smederee/hub/views/tickets/editLabels.scala.html
7 files modified with 116 lines added and 6 lines removed
  • CHANGELOG.md with 4 added and 0 removed lines
  • modules/hub/src/main/resources/assets/css/main.css with 17 added and 0 removed lines
  • modules/hub/src/main/resources/messages_en.properties with 20 added and 1 removed lines
  • modules/hub/src/main/scala/de/smederee/hub/HubServer.scala with 6 added and 0 removed lines
  • modules/hub/src/main/scala/de/smederee/tickets/Label.scala with 62 added and 4 removed lines
  • modules/hub/src/main/twirl/de/smederee/hub/views/showRepositoryMenu.scala.html with 3 added and 0 removed lines
  • modules/hub/src/test/scala/de/smederee/tickets/Generators.scala with 4 added and 1 removed lines
diff -rN -u old-smederee/CHANGELOG.md new-smederee/CHANGELOG.md
--- old-smederee/CHANGELOG.md	2025-01-31 20:01:30.790128842 +0000
+++ new-smederee/CHANGELOG.md	2025-01-31 20:01:30.794128847 +0000
@@ -20,6 +20,10 @@
 
 ## Unreleased
 
+### Added
+
+- support for labels per repository
+
 ## 0.4.0 (2023-01-09)
 
 ### Added
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	1970-01-01 00:00:00.000000000 +0000
+++ new-smederee/modules/hub/src/it/scala/de/smederee/tickets/DoobieLabelRepositoryTest.scala	2025-01-31 20:01:30.794128847 +0000
@@ -0,0 +1,328 @@
+/*
+ * 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 repository ID for the given owner and repository name.
+    *
+    * @param owner
+    *   The unique ID of the user account that owns the repository.
+    * @param name
+    *   The repository name which must be unique in regard to the owner.
+    * @return
+    *   An option to the internal database ID.
+    */
+  @throws[java.sql.SQLException]("Errors from the underlying SQL procedures will throw exceptions!")
+  protected def findVcsRepositoryId(owner: UserId, name: VcsRepositoryName): IO[Option[Long]] =
+    connectToDb(configuration).use { con =>
+      for {
+        statement <- IO.delay(
+          con.prepareStatement(
+            """SELECT id FROM "repositories" WHERE owner = ? AND name = ? LIMIT 1"""
+          )
+        )
+        _      <- IO.delay(statement.setObject(1, owner))
+        _      <- IO.delay(statement.setString(2, name.toString))
+        result <- IO.delay(statement.executeQuery)
+        account <- IO.delay {
+          if (result.next()) {
+            Option(result.getLong("id"))
+          } else {
+            None
+          }
+        }
+        _ <- IO(statement.close())
+      } yield account
+    }
+
+  /** 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       <- findVcsRepositoryId(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.allLables(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        <- findVcsRepositoryId(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        <- findVcsRepositoryId(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        <- findVcsRepositoryId(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       <- findVcsRepositoryId(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        <- findVcsRepositoryId(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        <- findVcsRepositoryId(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/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	1970-01-01 00:00:00.000000000 +0000
+++ new-smederee/modules/hub/src/it/scala/de/smederee/tickets/Generators.scala	2025-01-31 20:01:30.794128847 +0000
@@ -0,0 +1,82 @@
+/*
+ * 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 {
+    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 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/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 20:01:30.794128847 +0000
+++ new-smederee/modules/hub/src/main/resources/assets/css/main.css	2025-01-31 20:01:30.794128847 +0000
@@ -204,6 +204,23 @@
   padding: 0em 0.5em 0em 0.5em;
 }
 
+.label-description {
+  margin: auto;
+  padding: 0 0.25em;
+  vertical-align: middle;
+}
+
+.label-icon {
+  margin: auto;
+  padding: 0.25em 0 0 0;
+}
+
+.label-name {
+  margin: auto;
+  padding: 0 0.25em;
+  vertical-align: middle;
+}
+
 .overview-latest-changes {
   font-size: 85%;
 }
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 20:01:30.794128847 +0000
+++ new-smederee/modules/hub/src/main/resources/messages_en.properties	2025-01-31 20:01:30.794128847 +0000
@@ -47,6 +47,18 @@
 form.edit-repo.website.placeholder=https://example.com
 form.edit-repo.website.help=An optional URI pointing to the website of your project.
 form.fork.button.submit=Clone to your account.
+form.label.create.button.submit=Create label
+form.label.colour=Colour
+form.label.colour.help=Pick a colour which will be used as background colour for the label.
+form.label.description=Description
+form.label.description.help=The description is optional and may contain up to 254 characters.
+form.label.description.placeholder=description
+form.label.name=Name
+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.i-am-sure=Yes, I'm sure!
 form.login.button.submit=Login
 form.login.password=Password
 form.login.password.placeholder=Please enter your password here.
@@ -181,13 +193,20 @@
 
 repository.edit.title=Edit the repository settings.
 
-repository.labels.edit.title=Edit your repository labels.
+repository.label.edit.title=Edit label >> {0} <<
+repository.label.edit.link=Edit label
+repository.labels.add.title=Add a new label.
+repository.labels.edit.title=Manage your repository labels.
+repository.labels.view.title=Repository labels
+repository.labels.list.empty=There are no labels defined.
+repository.labels.list.title={0} labels.
 
 repository.menu.changes.next=Next
 repository.menu.changes=Changes
 repository.menu.delete=Delete
 repository.menu.edit=Edit
 repository.menu.files=Files
+repository.menu.labels=Labels
 repository.menu.overview=Overview
 repository.menu.website=Website
 repository.menu.website.tooltip=Click here to open the project website ({0}) in a new tab or window.
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 20:01:30.794128847 +0000
+++ new-smederee/modules/hub/src/main/scala/de/smederee/hub/HubServer.scala	2025-01-31 20:01:30.794128847 +0000
@@ -43,6 +43,8 @@
 import org.slf4j.LoggerFactory
 import pureconfig._
 import scodec.bits.ByteVector
+import de.smederee.tickets.DoobieLabelRepository
+import de.smederee.tickets.LabelRoutes
 
 /** This is the main entry point for the hub service.
   *
@@ -191,10 +193,13 @@
         darcsWrapper,
         vcsMetadataRepo
       )
+      labelRepo   = new DoobieLabelRepository[IO](transactor)
+      labelRoutes = new LabelRoutes[IO](configuration.service, labelRepo, vcsMetadataRepo)
       protectedRoutesWithFallThrough = authenticationWithFallThrough(
         authenticationRoutes.protectedRoutes <+>
           accountManagementRoutes.protectedRoutes <+>
           signUpRoutes.protectedRoutes <+>
+          labelRoutes.protectedRoutes <+>
           vcsRepoRoutes.protectedRoutes <+>
           landingPages.protectedRoutes
       )
@@ -204,6 +209,7 @@
           authenticationRoutes.routes <+>
           accountManagementRoutes.routes <+>
           signUpRoutes.routes <+>
+          labelRoutes.routes <+>
           vcsRepoRoutes.routes <+>
           landingPages.routes)
       ).orNotFound
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	1970-01-01 00:00:00.000000000 +0000
+++ new-smederee/modules/hub/src/main/scala/de/smederee/tickets/DoobieLabelRepository.scala	2025-01-31 20:01:30.794128847 +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 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 allLables(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/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	1970-01-01 00:00:00.000000000 +0000
+++ new-smederee/modules/hub/src/main/scala/de/smederee/tickets/LabelForm.scala	2025-01-31 20:01:30.794128847 +0000
@@ -0,0 +1,113 @@
+/*
+ * 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
+)
+
+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
+    )
+}
+
+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)
+    }
+  }
+
+}
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	1970-01-01 00:00:00.000000000 +0000
+++ new-smederee/modules/hub/src/main/scala/de/smederee/tickets/LabelRepository.scala	2025-01-31 20:01:30.794128847 +0000
@@ -0,0 +1,78 @@
+/*
+ * 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 allLables(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	1970-01-01 00:00:00.000000000 +0000
+++ new-smederee/modules/hub/src/main/scala/de/smederee/tickets/LabelRoutes.scala	2025-01-31 20:01:30.798128854 +0000
@@ -0,0 +1,450 @@
+/*
+ * 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 de.smederee.tickets.toMap // TODO Maybe convert the extions methods into a proper type class?
+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.allLables(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.allLables(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 20:01:30.794128847 +0000
+++ new-smederee/modules/hub/src/main/scala/de/smederee/tickets/Label.scala	2025-01-31 20:01:30.794128847 +0000
@@ -22,12 +22,59 @@
 
 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 Eq[LabelName]       = Eq.fromUniversalEquals
+  given Ordering[LabelName] = (x: LabelName, y: LabelName) => x.compareTo(y)
+  given Order[LabelName]    = Order.fromOrdering[LabelName]
 
   val MaxLength: Int = 40
 
@@ -52,6 +99,12 @@
 
 }
 
+/** 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
@@ -85,7 +138,7 @@
   */
 opaque type ColourCode = String
 object ColourCode {
-  given Eq[ColourCode] = Eq.fromUniversalEquals
+  given Eq[ColourCode] = Eq.instance((a, b) => a.equalsIgnoreCase(b))
 
   val Format: Regex = "^#[0-9a-fA-F]{6}$".r
 
@@ -111,6 +164,8 @@
 
 /** 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
@@ -118,11 +173,14 @@
   * @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)
+final case class Label(id: Option[LabelId], 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
+      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/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 20:01:30.794128847 +0000
+++ new-smederee/modules/hub/src/main/twirl/de/smederee/hub/views/showRepositoryMenu.scala.html	2025-01-31 20:01:30.798128854 +0000
@@ -15,6 +15,9 @@
     @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>
+    }
     @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	1970-01-01 00:00:00.000000000 +0000
+++ new-smederee/modules/hub/src/main/twirl/de/smederee/hub/views/tickets/editLabel.scala.html	2025-01-31 20:01:30.798128854 +0000
@@ -0,0 +1,87 @@
+@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	1970-01-01 00:00:00.000000000 +0000
+++ new-smederee/modules/hub/src/main/twirl/de/smederee/hub/views/tickets/editLabels.scala.html	2025-01-31 20:01:30.798128854 +0000
@@ -0,0 +1,125 @@
+@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/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 20:01:30.794128847 +0000
+++ new-smederee/modules/hub/src/test/scala/de/smederee/tickets/Generators.scala	2025-01-31 20:01:30.798128854 +0000
@@ -50,10 +50,13 @@
   } 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(name, description, colour)
+  } yield Label(id, name, description, colour)
+
+  val genLabels: Gen[List[Label]] = Gen.nonEmptyListOf(genLabel).map(_.distinct)
 
   val genLocalDate: Gen[LocalDate] =
     for {