~jan0sch/smederee
Showing details for patch 8edc6b02b0b50cdfb9bad26a27e3a01d532701d2.
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 {