~jan0sch/smederee
Showing details for patch 83ea9d658c446f09b65a2a064c963e3ce9c86b90.
diff -rN -u old-smederee/CHANGELOG.md new-smederee/CHANGELOG.md --- old-smederee/CHANGELOG.md 2025-01-31 19:50:42.609163326 +0000 +++ new-smederee/CHANGELOG.md 2025-01-31 19:50:42.613163332 +0000 @@ -23,6 +23,7 @@ ### Added - support for labels per repository +- support for milestones per repository ## 0.4.0 (2023-01-09) 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 1970-01-01 00:00:00.000000000 +0000 +++ new-smederee/modules/hub/src/it/scala/de/smederee/tickets/DoobieMilestoneRepositoryTest.scala 2025-01-31 19:50:42.613163332 +0000 @@ -0,0 +1,299 @@ +/* + * 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 19:50:42.609163326 +0000 +++ new-smederee/modules/hub/src/it/scala/de/smederee/tickets/Generators.scala 2025-01-31 19:50:42.613163332 +0000 @@ -17,10 +17,13 @@ package de.smederee.tickets +import java.time._ + import org.scalacheck.{ Arbitrary, Gen } -import java.time.LocalDate 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. * @@ -36,6 +39,15 @@ 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) @@ -58,14 +70,6 @@ val genLabels: Gen[List[Label]] = Gen.nonEmptyListOf(genLabel).map(_.distinct) - val genLocalDate: Gen[LocalDate] = - for { - year <- Gen.choose(LocalDate.MIN.getYear(), LocalDate.MAX.getYear()) - month <- Gen.choose(1, 12) - maxDays = LocalDate.of(year, month, 1).lengthOfMonth() - day <- Gen.choose(1, maxDays) - } yield LocalDate.of(year, month, day) - val genMilestoneTitle: Gen[MilestoneTitle] = Gen.nonEmptyListOf(Gen.alphaNumChar).map(_.take(MilestoneTitle.MaxLength).mkString).map(MilestoneTitle.apply) @@ -74,9 +78,12 @@ 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(title, due, descr) + } 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 19:50:42.609163326 +0000 +++ new-smederee/modules/hub/src/main/resources/assets/css/main.css 2025-01-31 19:50:42.613163332 +0000 @@ -221,6 +221,23 @@ vertical-align: middle; } +.milestone-description { + margin: auto; + padding: 0 0.25em; + vertical-align: middle; +} + +.milestone-icon { + margin: auto; + padding: 0.25em 0 0 0; +} + +.milestone-title { + margin: auto; + padding: 0 0.25em; + vertical-align: middle; +} + .overview-latest-changes { font-size: 85%; } diff -rN -u old-smederee/modules/hub/src/main/resources/messages_en.properties new-smederee/modules/hub/src/main/resources/messages_en.properties --- old-smederee/modules/hub/src/main/resources/messages_en.properties 2025-01-31 19:50:42.609163326 +0000 +++ new-smederee/modules/hub/src/main/resources/messages_en.properties 2025-01-31 19:50:42.613163332 +0000 @@ -64,6 +64,18 @@ form.login.password.placeholder=Please enter your password here. form.login.username=Username form.login.username.placeholder=Please enter your username. +form.milestone.create.button.submit=Create milestone +form.milestone.due-date=Due date +form.milestone.due-date.help=You may pick an optional date to indicate until when the milestone is planned to be reached. +form.milestone.description=Description +form.milestone.description.help=An optional description of the milestone. +form.milestone.description.placeholder=description +form.milestone.title=Title +form.milestone.title.help=A milestone title can be up to 64 characters long, must not be empty and must be unique in the scope of a project. +form.milestone.title.placeholder=milestone title +form.milestone.edit.button.submit=Save milestone +form.milestone.delete.button.submit=Delete +form.milestone.delete.i-am-sure=Yes, I'm sure! form.signup.button.submit=Sign up for an account form.signup.email=Email address form.signup.email.help=Please enter your email address. @@ -207,9 +219,20 @@ repository.menu.edit=Edit repository.menu.files=Files repository.menu.labels=Labels +repository.menu.milestones=Milestones repository.menu.overview=Overview repository.menu.website=Website repository.menu.website.tooltip=Click here to open the project website ({0}) in a new tab or window. + +repository.milestone.edit.title=Edit milestone >> {0} << +repository.milestone.edit.link=Edit +repository.milestone.title.date=({0,date,yyyy-MM-dd (E)}) +repository.milestones.add.title=Add a new milestone. +repository.milestones.edit.title=Manage your repository milestones. +repository.milestones.view.title=Repository milestones +repository.milestones.list.empty=There are no milestones defined. +repository.milestones.list.title={0} milestones. + repository.description.title=Summary: repository.description.forked-from=Forked from: 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 19:50:42.613163332 +0000 +++ new-smederee/modules/hub/src/main/scala/de/smederee/hub/HubServer.scala 2025-01-31 19:50:42.613163332 +0000 @@ -32,6 +32,7 @@ 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._ @@ -43,8 +44,6 @@ import org.slf4j.LoggerFactory import pureconfig._ import scodec.bits.ByteVector -import de.smederee.tickets.DoobieLabelRepository -import de.smederee.tickets.LabelRoutes /** This is the main entry point for the hub service. * @@ -193,13 +192,16 @@ darcsWrapper, vcsMetadataRepo ) - labelRepo = new DoobieLabelRepository[IO](transactor) - labelRoutes = new LabelRoutes[IO](configuration.service, labelRepo, 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 ) @@ -210,6 +212,7 @@ 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/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 1970-01-01 00:00:00.000000000 +0000 +++ new-smederee/modules/hub/src/main/scala/de/smederee/tickets/DoobieMilestoneRepository.scala 2025-01-31 19:50:42.613163332 +0000 @@ -0,0 +1,77 @@ +/* + * 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/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 1970-01-01 00:00:00.000000000 +0000 +++ new-smederee/modules/hub/src/main/scala/de/smederee/tickets/MilestoneForm.scala 2025-01-31 19:50:42.613163332 +0000 @@ -0,0 +1,126 @@ +/* + * 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 1970-01-01 00:00:00.000000000 +0000 +++ new-smederee/modules/hub/src/main/scala/de/smederee/tickets/MilestoneRepository.scala 2025-01-31 19:50:42.613163332 +0000 @@ -0,0 +1,78 @@ +/* + * Copyright (C) 2022 Contributors as noted in the AUTHORS.md file + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see <http://www.gnu.org/licenses/>. + */ + +package de.smederee.tickets + +import fs2.Stream + +/** The base class that defines the needed functionality to handle 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 1970-01-01 00:00:00.000000000 +0000 +++ new-smederee/modules/hub/src/main/scala/de/smederee/tickets/MilestoneRoutes.scala 2025-01-31 19:50:42.613163332 +0000 @@ -0,0 +1,469 @@ +/* + * 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 19:50:42.613163332 +0000 +++ new-smederee/modules/hub/src/main/scala/de/smederee/tickets/Milestone.scala 2025-01-31 19:50:42.613163332 +0000 @@ -22,12 +22,61 @@ 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 Eq[MilestoneTitle] = Eq.fromUniversalEquals + given Ordering[MilestoneTitle] = (x: MilestoneTitle, y: MilestoneTitle) => x.compareTo(y) + given Order[MilestoneTitle] = Order.fromOrdering[MilestoneTitle] val MaxLength: Int = 64 @@ -52,6 +101,12 @@ } +/** 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 @@ -80,21 +135,30 @@ /** 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 dueDate - * An optional date on which the milestone is supposed to be reached. * @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(title: MilestoneTitle, dueDate: Option[LocalDate], description: Option[MilestoneDescription]) +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.title === b.title && a.dueDate === b.dueDate && a.description === b.description) + 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/twirl/de/smederee/hub/views/format/formatDate.scala.html new-smederee/modules/hub/src/main/twirl/de/smederee/hub/views/format/formatDate.scala.html --- old-smederee/modules/hub/src/main/twirl/de/smederee/hub/views/format/formatDate.scala.html 1970-01-01 00:00:00.000000000 +0000 +++ new-smederee/modules/hub/src/main/twirl/de/smederee/hub/views/format/formatDate.scala.html 2025-01-31 19:50:42.617163338 +0000 @@ -0,0 +1,6 @@ +@import java.time._ +@import java.time.format._ +@import java.util.Locale + +@(date: LocalDate, style: FormatStyle = FormatStyle.MEDIUM)(implicit locale: Locale) +(@DateTimeFormatter.ofLocalizedDate(style).withLocale(locale).format(date)) 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 19:50:42.613163332 +0000 +++ new-smederee/modules/hub/src/main/twirl/de/smederee/hub/views/showRepositoryMenu.scala.html 2025-01-31 19:50:42.613163332 +0000 @@ -18,6 +18,9 @@ @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/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 1970-01-01 00:00:00.000000000 +0000 +++ new-smederee/modules/hub/src/main/twirl/de/smederee/hub/views/tickets/editMilestone.scala.html 2025-01-31 19:50:42.617163338 +0000 @@ -0,0 +1,87 @@ +@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 1970-01-01 00:00:00.000000000 +0000 +++ new-smederee/modules/hub/src/main/twirl/de/smederee/hub/views/tickets/editMilestones.scala.html 2025-01-31 19:50:42.617163338 +0000 @@ -0,0 +1,127 @@ +@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/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 19:50:42.613163332 +0000 +++ new-smederee/modules/hub/src/test/scala/de/smederee/tickets/Generators.scala 2025-01-31 19:50:42.617163338 +0000 @@ -17,10 +17,13 @@ package de.smederee.tickets +import java.time._ + import org.scalacheck.{ Arbitrary, Gen } -import java.time.LocalDate 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. * @@ -36,6 +39,15 @@ 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) @@ -58,14 +70,6 @@ val genLabels: Gen[List[Label]] = Gen.nonEmptyListOf(genLabel).map(_.distinct) - val genLocalDate: Gen[LocalDate] = - for { - year <- Gen.choose(LocalDate.MIN.getYear(), LocalDate.MAX.getYear()) - month <- Gen.choose(1, 12) - maxDays = LocalDate.of(year, month, 1).lengthOfMonth() - day <- Gen.choose(1, maxDays) - } yield LocalDate.of(year, month, day) - val genMilestoneTitle: Gen[MilestoneTitle] = Gen.nonEmptyListOf(Gen.alphaNumChar).map(_.take(MilestoneTitle.MaxLength).mkString).map(MilestoneTitle.apply) @@ -74,9 +78,12 @@ 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(title, due, descr) + } yield Milestone(id, title, descr, due) + + val genMilestones: Gen[List[Milestone]] = Gen.nonEmptyListOf(genMilestone).map(_.distinct) }