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