~jan0sch/smederee
Showing details for patch e227c36f9d1f60b111d53a0afecd1fb407577a4e.
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-30 16:01:22.114095733 +0000 +++ new-smederee/modules/hub/src/main/resources/assets/css/main.css 2025-01-30 16:01:22.114095733 +0000 @@ -170,6 +170,15 @@ text-decoration: underline; } +.project-summary-description { + background-color: var(--background2); + padding: 0em 0.5em 0em 0.5em; +} + +.project-summary-description code { + word-wrap: break-word; +} + .repo-summary-description { background-color: var(--background2); padding: 0em 0.5em 0em 0.5em; diff -rN -u old-smederee/modules/hub/src/main/resources/messages.properties new-smederee/modules/hub/src/main/resources/messages.properties --- old-smederee/modules/hub/src/main/resources/messages.properties 2025-01-30 16:01:22.114095733 +0000 +++ new-smederee/modules/hub/src/main/resources/messages.properties 2025-01-30 16:01:22.114095733 +0000 @@ -296,6 +296,7 @@ project.menu.labels=Labels project.menu.milestones=Milestones project.menu.overview=Overview +project.menu.tickets=Tickets project.milestone.edit.link=Edit project.milestone.edit.title=Edit milestone ''{0}''. project.milestones.add.title=Add a new milestone. @@ -303,3 +304,6 @@ project.milestones.list.empty=There are no milestones defined for this project. project.milestones.list.title={0} milestones found. project.milestones.view.title=Milestones +project.tickets.view.title=Tickets +project.tickets.list.empty=There are no tickets defined for this project. +project.tickets.list.title={0} tickets found. 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-30 16:01:22.114095733 +0000 +++ new-smederee/modules/hub/src/main/scala/de/smederee/hub/HubServer.scala 2025-01-30 16:01:22.118095738 +0000 @@ -159,6 +159,8 @@ ticketLabelsRepo = new DoobieLabelRepository[IO](ticketsTransactor) ticketMilestonesRepo = new DoobieMilestoneRepository[IO](ticketsTransactor) ticketProjectsRepo = new DoobieProjectRepository[IO](ticketsTransactor) + ticketsRepo = new DoobieTicketRepository[IO](ticketsTransactor) + ticketRoutes = new TicketRoutes[IO](ticketsConfiguration, ticketProjectsRepo, ticketsRepo) ticketLabelRoutes = new LabelRoutes[IO](ticketsConfiguration, ticketLabelsRepo, ticketProjectsRepo) ticketMilestoneRoutes = new MilestoneRoutes[IO](ticketsConfiguration, ticketMilestonesRepo, ticketProjectsRepo) cryptoClock = java.time.Clock.systemUTC @@ -225,6 +227,7 @@ signUpRoutes.protectedRoutes <+> ticketLabelRoutes.protectedRoutes <+> ticketMilestoneRoutes.protectedRoutes <+> + ticketRoutes.protectedRoutes <+> vcsRepoRoutes.protectedRoutes <+> landingPages.protectedRoutes ) @@ -236,6 +239,7 @@ signUpRoutes.routes <+> ticketLabelRoutes.routes <+> ticketMilestoneRoutes.routes <+> + ticketRoutes.routes <+> vcsRepoRoutes.routes <+> landingPages.routes) ).orNotFound diff -rN -u old-smederee/modules/hub/src/main/scala/de/smederee/tickets/TicketRoutes.scala new-smederee/modules/hub/src/main/scala/de/smederee/tickets/TicketRoutes.scala --- old-smederee/modules/hub/src/main/scala/de/smederee/tickets/TicketRoutes.scala 1970-01-01 00:00:00.000000000 +0000 +++ new-smederee/modules/hub/src/main/scala/de/smederee/tickets/TicketRoutes.scala 2025-01-30 16:01:22.118095738 +0000 @@ -0,0 +1,171 @@ +/* + * 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.effect._ +import cats.syntax.all._ +import de.smederee.html.LinkTools._ +import de.smederee.html._ +import de.smederee.hub.RequestHelpers.instances.given +import de.smederee.hub.Account +import de.smederee.i18n.LanguageCode +import de.smederee.security.{ CsrfToken, Username } +import de.smederee.tickets.config._ +import org.http4s._ +import org.http4s.dsl.Http4sDsl +import org.http4s.twirl.TwirlInstances._ +import org.slf4j.LoggerFactory + +/** Routes for managing tickets. + * + * @param configuration + * The ticket service configuration. + * @param projectRepo + * A repository for handling database operations regarding our projects and their metadata. + * @param ticketRepo + * A repository for handling database operations regarding tickets and related metadata. + * @tparam F + * A higher kinded type providing needed functionality, which is usually an IO monad like Async or Sync. + */ +final class TicketRoutes[F[_]: Async]( + configuration: SmedereeTicketsConfiguration, + projectRepo: ProjectRepository[F], + ticketRepo: TicketRepository[F] +) extends Http4sDsl[F] { + private val log = LoggerFactory.getLogger(getClass) + + given CsrfProtectionConfiguration = configuration.csrfProtection + + val linkConfig = configuration.externalUrl + + /** Load the project metadata with the given owner and name from the database and return it and its primary key id if + * the project exists and is readable by the given user account. + * + * @param currentUser + * The user account that is requesting access to the project or None for a guest user. + * @param projectOwnerName + * The name of the account that owns the project. + * @param projectName + * The name of the project. A project 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 [[Project]] and its primary key id. + */ + private def loadProject( + currentUser: Option[Account] + )(projectOwnerName: ProjectOwnerName, projectName: ProjectName): F[Option[(Project, ProjectId)]] = + for { + owner <- projectRepo.findProjectOwner(projectOwnerName) + loadedRepo <- owner match { + case None => Sync[F].pure(None) + case Some(owner) => + ( + projectRepo.findProject(owner, projectName), + projectRepo.findProjectId(owner, projectName) + ).mapN { + case (Some(project), Some(projectId)) => (project, projectId).some + case _ => None + } + } + // TODO: Replace with whatever we implement as proper permission model. ;-) + projectAndId = currentUser match { + case None => loadedRepo.filter(tuple => tuple._1.isPrivate === false) + case Some(user) => + loadedRepo.filter(tuple => + tuple._1.isPrivate === false || tuple._1.owner.uid === ProjectOwnerId.fromUserId(user.uid) + ) + } + } yield projectAndId + + /** Logic for rendering a list of all tickets for a project and optionally management functionality. + * + * @param filter + * An optional ticket filter containing possible values which will be used to filter the list of tickets. + * @param csrf + * An optional CSRF-Token that shall be used. + * @param user + * An optional user account for whom the list of tickets shall be rendered. + * @param projectOwnerName + * The username of the account who owns the project. + * @param projectName + * The name of the project. + * @return + * An HTTP response containing the rendered HTML. + */ + private def doShowTickets(filter: Option[TicketFilter])(csrf: Option[CsrfToken])(user: Option[Account])( + projectOwnerName: Username + )(projectName: ProjectName): F[Response[F]] = + for { + _ <- Sync[F].delay(log.debug(s"doShowTickets: $csrf, $user, $projectOwnerName, $projectName, $filter")) + language <- Sync[F].delay(user.flatMap(_.language).getOrElse(LanguageCode("en"))) + projectAndId <- loadProject(user)(projectOwnerName, projectName) + resp <- projectAndId match { + case Some((project, projectId)) => + for { + tickets <- ticketRepo.allTickets(filter)(projectId).compile.toList + projectBaseUri <- Sync[F].delay( + linkConfig.createFullUri( + Uri(path = + Uri.Path( + Vector(Uri.Path.Segment(s"~$projectOwnerName"), Uri.Path.Segment(projectName.toString)) + ) + ) + ) + ) + resp <- Ok( + views.html.showTickets(lang = language)( + projectBaseUri.addSegment("tickets"), + csrf, + tickets, + projectBaseUri, + "Manage your project tickets.".some, + user, + project + ) + ) + } yield resp + case _ => NotFound("Ticket project not found!") + } + } yield resp + + private val showTicketsPage: AuthedRoutes[Account, F] = AuthedRoutes.of { + case ar @ GET -> Root / UsernamePathParameter(projectOwnerName) / ProjectNamePathParameter( + projectName + ) / "tickets" as user => + for { + csrf <- Sync[F].delay(ar.req.getCsrfToken) + resp <- doShowTickets(None)(csrf)(user.some)(projectOwnerName)(projectName) + } yield resp + } + + private val showTicketsForGuests: HttpRoutes[F] = HttpRoutes.of { + case req @ GET -> Root / UsernamePathParameter(projectOwnerName) / ProjectNamePathParameter( + projectName + ) / "tickets" => + for { + csrf <- Sync[F].delay(req.getCsrfToken) + resp <- doShowTickets(None)(csrf)(None)(projectOwnerName)(projectName) + } yield resp + } + + val protectedRoutes = showTicketsPage + + val routes = showTicketsForGuests + +} diff -rN -u old-smederee/modules/hub/src/main/twirl/de/smederee/tickets/views/showProjectMenu.scala.html new-smederee/modules/hub/src/main/twirl/de/smederee/tickets/views/showProjectMenu.scala.html --- old-smederee/modules/hub/src/main/twirl/de/smederee/tickets/views/showProjectMenu.scala.html 2025-01-30 16:01:22.114095733 +0000 +++ new-smederee/modules/hub/src/main/twirl/de/smederee/tickets/views/showProjectMenu.scala.html 2025-01-30 16:01:22.118095738 +0000 @@ -12,6 +12,9 @@ @defining(projectBaseUri) { uri => <li class="pure-menu-item@if(activeUri.exists(_ === uri)){ pure-menu-active}else{}"><a class="pure-menu-link" href="@uri">@icon(baseUri)("eye") @Messages("project.menu.overview")</a></li> } + @defining(projectBaseUri.addSegment("tickets")) { uri => + <li class="pure-menu-item@if(activeUri.exists(_ === uri)){ pure-menu-active}else{}"><a class="pure-menu-link" href="@uri">@icon(baseUri)("crosshair") @Messages("project.menu.tickets")</a></li> + } @defining(projectBaseUri.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("project.menu.labels")</a></li> } diff -rN -u old-smederee/modules/hub/src/main/twirl/de/smederee/tickets/views/showTickets.scala.html new-smederee/modules/hub/src/main/twirl/de/smederee/tickets/views/showTickets.scala.html --- old-smederee/modules/hub/src/main/twirl/de/smederee/tickets/views/showTickets.scala.html 1970-01-01 00:00:00.000000000 +0000 +++ new-smederee/modules/hub/src/main/twirl/de/smederee/tickets/views/showTickets.scala.html 2025-01-30 16:01:22.118095738 +0000 @@ -0,0 +1,68 @@ +@import de.smederee.hub.Account +@import de.smederee.hub.views.html.main +@import de.smederee.tickets._ +@import de.smederee.tickets.views.html.showProjectMenu + +@(baseUri: Uri = Uri(path = Uri.Path.Root), + lang: LanguageCode = LanguageCode("en") +)(action: Uri, + csrf: Option[CsrfToken] = None, + tickets: List[Ticket], + projectBaseUri: Uri, + title: Option[String] = None, + user: Option[Account], + project: Project +) +@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"~${project.owner.name}")}">~@project.owner.name</a>/@project.name</h2> + @showProjectMenu(baseUri)(action.some, projectBaseUri, user, project) + <div class="project-summary-description"> + @Messages("project.tickets.view.title") + </div> + </div> + </div> + </div> + <div class="pure-g"> + <div class="pure-u-1-1 pure-u-md-1-1"> + <div class="l-box"> + <div class="ticket-list"> + <h4>@Messages("project.tickets.list.title", tickets.size)</h4> + @if(tickets.size === 0) { + <div class="alert alert-info">@Messages("project.tickets.list.empty")</div> + } else { + @defining(32) { lineHeight => + @for(ticket <- tickets) { + <div class="pure-g ticket"> + <div class="pure-u-1-24 ticket-icon">@icon(baseUri)("crosshair", lineHeight.some)</div> + <div class="pure-u-3-24">@ticket.number</div> + <div class="pure-u-18-24">@ticket.title</div> + <div class="pure-u-4-24"></div> + </div> + <div class="pure-g ticket-content"> + <div class="pure-u-20-24">@ticket.content.take(176) ...</div> + <div class="pure-u-4-24"></div> + </div> + <div class="pure-g ticket-details"> + <div class="pure-u-3-24"></div> + <div class="pure-u-3-24"></div> + <div class="pure-u-3-24"></div> + <div class="pure-u-3-24">@ticket.status</div> + <div class="pure-u-3-24">@ticket.resolution</div> + <div class="pure-u-3-24">@ticket.submitter</div> + <div class="pure-u-3-24">@ticket.updatedAt</div> + </div> + } + } + } + </div> + </div> + </div> + </div> +</div> +} +} diff -rN -u old-smederee/modules/tickets/src/it/scala/de/smederee/tickets/DoobieTicketRepositoryTest.scala new-smederee/modules/tickets/src/it/scala/de/smederee/tickets/DoobieTicketRepositoryTest.scala --- old-smederee/modules/tickets/src/it/scala/de/smederee/tickets/DoobieTicketRepositoryTest.scala 2025-01-30 16:01:22.114095733 +0000 +++ new-smederee/modules/tickets/src/it/scala/de/smederee/tickets/DoobieTicketRepositoryTest.scala 2025-01-30 16:01:22.118095738 +0000 @@ -191,7 +191,7 @@ } } - test("allTickets must return all tickets for the project".ignore) { + test("allTickets must return all tickets for the project") { (genProjectOwner.sample, genProject.sample, genTickets.sample) match { case (Some(owner), Some(generatedProject), Some(generatedTickets)) => val defaultTimestamp = OffsetDateTime.now() @@ -212,20 +212,183 @@ case Some(projectId) => for { writtenTickets <- tickets.traverse(ticket => ticketRepo.createTicket(projectId)(ticket)) - foundTickets <- ticketRepo.allTickets(projectId).compile.toList + foundTickets <- ticketRepo.allTickets(filter = None)(projectId).compile.toList } yield (writtenTickets.sum, foundTickets) } } yield result test.map { result => val (writtenTickets, foundTickets) = result - assertEquals(writtenTickets, tickets.size, "Wrong number of tickets written to database!") - assertEquals(foundTickets.size, tickets.size, "Wrong number of tickets returned!") + assertEquals(writtenTickets, tickets.size, "Wrong number of tickets created in the database!") + assertEquals( + foundTickets.size, + writtenTickets, + "Number of returned tickets differs from number of created tickets!" + ) assertEquals( foundTickets.sortBy(_.number).map(_.copy(createdAt = defaultTimestamp, updatedAt = defaultTimestamp)), tickets.sortBy(_.number) ) } case _ => fail("Could not generate data samples!") + } + } + + test("allTickets must respect given filters for numbers") { + (genProjectOwner.sample, genProject.sample, genTickets.sample) match { + case (Some(owner), Some(generatedProject), Some(generatedTickets)) => + val defaultTimestamp = OffsetDateTime.now() + val tickets = + generatedTickets.sortBy(_.number).map(_.copy(createdAt = defaultTimestamp, updatedAt = defaultTimestamp)) + val expectedTickets = tickets.take(tickets.size / 2) + val filter = TicketFilter(number = expectedTickets.map(_.number), Nil, Nil, Nil) + val submitters = tickets.map(_.submitter).flatten + val project = generatedProject.copy(owner = owner) + val dbConfig = configuration.database + val tx = Transactor.fromDriverManager[IO](dbConfig.driver, dbConfig.url, dbConfig.user, dbConfig.pass) + val ticketRepo = new DoobieTicketRepository[IO](tx) + val test = for { + _ <- createProjectOwner(owner) + _ <- submitters.traverse(createTicketsSubmitter) + _ <- createTicketsProject(project) + projectId <- loadProjectId(owner.uid, project.name) + result <- projectId match { + case None => IO.pure((0, Nil)) + case Some(projectId) => + for { + writtenTickets <- tickets.traverse(ticket => ticketRepo.createTicket(projectId)(ticket)) + foundTickets <- ticketRepo.allTickets(filter.some)(projectId).compile.toList + } yield (writtenTickets.sum, foundTickets) + } + } yield result + test.map { result => + val (writtenTickets, foundTickets) = result + assertEquals(writtenTickets, tickets.size, "Wrong number of tickets created in the database!") + assertEquals( + foundTickets.sortBy(_.number).map(_.copy(createdAt = defaultTimestamp, updatedAt = defaultTimestamp)), + expectedTickets.sortBy(_.number) + ) + } + case _ => fail("Could not generate data samples!") + } + } + + test("allTickets must respect given filters for status") { + (genProjectOwner.sample, genProject.sample, genTickets.sample) match { + case (Some(owner), Some(generatedProject), Some(generatedTickets)) => + val defaultTimestamp = OffsetDateTime.now() + val tickets = + generatedTickets.sortBy(_.number).map(_.copy(createdAt = defaultTimestamp, updatedAt = defaultTimestamp)) + val statusFlags = tickets.map(_.status).distinct.take(2) + val expectedTickets = tickets.filter(t => statusFlags.exists(_ === t.status)) + val filter = TicketFilter(Nil, status = statusFlags, Nil, Nil) + val submitters = tickets.map(_.submitter).flatten + val project = generatedProject.copy(owner = owner) + val dbConfig = configuration.database + val tx = Transactor.fromDriverManager[IO](dbConfig.driver, dbConfig.url, dbConfig.user, dbConfig.pass) + val ticketRepo = new DoobieTicketRepository[IO](tx) + val test = for { + _ <- createProjectOwner(owner) + _ <- submitters.traverse(createTicketsSubmitter) + _ <- createTicketsProject(project) + projectId <- loadProjectId(owner.uid, project.name) + result <- projectId match { + case None => IO.pure((0, Nil)) + case Some(projectId) => + for { + writtenTickets <- tickets.traverse(ticket => ticketRepo.createTicket(projectId)(ticket)) + foundTickets <- ticketRepo.allTickets(filter.some)(projectId).compile.toList + } yield (writtenTickets.sum, foundTickets) + } + } yield result + test.map { result => + val (writtenTickets, foundTickets) = result + assertEquals(writtenTickets, tickets.size, "Wrong number of tickets created in the database!") + assertEquals( + foundTickets.sortBy(_.number).map(_.copy(createdAt = defaultTimestamp, updatedAt = defaultTimestamp)), + expectedTickets.sortBy(_.number) + ) + } + case _ => fail("Could not generate data samples!") + } + } + + test("allTickets must respect given filters for resolution") { + (genProjectOwner.sample, genProject.sample, genTickets.sample) match { + case (Some(owner), Some(generatedProject), Some(generatedTickets)) => + val defaultTimestamp = OffsetDateTime.now() + val tickets = + generatedTickets.sortBy(_.number).map(_.copy(createdAt = defaultTimestamp, updatedAt = defaultTimestamp)) + val resolutions = tickets.map(_.resolution).flatten.distinct.take(2) + val expectedTickets = tickets.filter(t => t.resolution.exists(r => resolutions.exists(_ === r))) + val filter = TicketFilter(Nil, Nil, resolution = resolutions, Nil) + val submitters = tickets.map(_.submitter).flatten + val project = generatedProject.copy(owner = owner) + val dbConfig = configuration.database + val tx = Transactor.fromDriverManager[IO](dbConfig.driver, dbConfig.url, dbConfig.user, dbConfig.pass) + val ticketRepo = new DoobieTicketRepository[IO](tx) + val test = for { + _ <- createProjectOwner(owner) + _ <- submitters.traverse(createTicketsSubmitter) + _ <- createTicketsProject(project) + projectId <- loadProjectId(owner.uid, project.name) + result <- projectId match { + case None => IO.pure((0, Nil)) + case Some(projectId) => + for { + writtenTickets <- tickets.traverse(ticket => ticketRepo.createTicket(projectId)(ticket)) + foundTickets <- ticketRepo.allTickets(filter.some)(projectId).compile.toList + } yield (writtenTickets.sum, foundTickets) + } + } yield result + test.map { result => + val (writtenTickets, foundTickets) = result + assertEquals(writtenTickets, tickets.size, "Wrong number of tickets created in the database!") + assertEquals( + foundTickets.sortBy(_.number).map(_.copy(createdAt = defaultTimestamp, updatedAt = defaultTimestamp)), + expectedTickets.sortBy(_.number) + ) + } + case _ => fail("Could not generate data samples!") + } + } + + test("allTickets must respect given filters for submitter") { + (genProjectOwner.sample, genProject.sample, genTickets.sample) match { + case (Some(owner), Some(generatedProject), Some(generatedTickets)) => + val defaultTimestamp = OffsetDateTime.now() + val tickets = + generatedTickets.sortBy(_.number).map(_.copy(createdAt = defaultTimestamp, updatedAt = defaultTimestamp)) + val submitters = tickets.map(_.submitter).flatten + val wantedSubmitters = submitters.take(submitters.size / 2) + val expectedTickets = tickets.filter(t => t.submitter.exists(s => wantedSubmitters.exists(_ === s))) + val filter = TicketFilter(Nil, Nil, Nil, submitter = wantedSubmitters) + val project = generatedProject.copy(owner = owner) + val dbConfig = configuration.database + val tx = Transactor.fromDriverManager[IO](dbConfig.driver, dbConfig.url, dbConfig.user, dbConfig.pass) + val ticketRepo = new DoobieTicketRepository[IO](tx) + val test = for { + _ <- createProjectOwner(owner) + _ <- submitters.traverse(createTicketsSubmitter) + _ <- createTicketsProject(project) + projectId <- loadProjectId(owner.uid, project.name) + result <- projectId match { + case None => IO.pure((0, Nil)) + case Some(projectId) => + for { + writtenTickets <- tickets.traverse(ticket => ticketRepo.createTicket(projectId)(ticket)) + foundTickets <- ticketRepo.allTickets(filter.some)(projectId).compile.toList + } yield (writtenTickets.sum, foundTickets) + } + } yield result + test.map { result => + val (writtenTickets, foundTickets) = result + assertEquals(writtenTickets, tickets.size, "Wrong number of tickets created in the database!") + assertEquals( + foundTickets.sortBy(_.number).map(_.copy(createdAt = defaultTimestamp, updatedAt = defaultTimestamp)), + expectedTickets.sortBy(_.number) + ) + } + case _ => fail("Could not generate data samples!") } } diff -rN -u old-smederee/modules/tickets/src/main/scala/de/smederee/tickets/DoobieTicketRepository.scala new-smederee/modules/tickets/src/main/scala/de/smederee/tickets/DoobieTicketRepository.scala --- old-smederee/modules/tickets/src/main/scala/de/smederee/tickets/DoobieTicketRepository.scala 2025-01-30 16:01:22.114095733 +0000 +++ new-smederee/modules/tickets/src/main/scala/de/smederee/tickets/DoobieTicketRepository.scala 2025-01-30 16:01:22.118095738 +0000 @@ -19,7 +19,10 @@ import java.util.UUID +import cats._ +import cats.data._ import cats.effect._ +import cats.syntax.all._ import doobie.Fragments._ import doobie._ import doobie.implicits._ @@ -63,7 +66,7 @@ "tickets".created_at AS created_at, "tickets".updated_at AS updated_at FROM "tickets"."tickets" AS "tickets" - JOIN "tickets"."users" AS "submitters" + LEFT OUTER JOIN "tickets"."users" AS "submitters" ON "tickets".submitter = "submitters".uid""" /** Construct a query fragment that fetches the internal unique ticket id from the tickets table via the given project @@ -124,9 +127,25 @@ AND number = $ticketNumber""".update.run.transact(tx) } - override def allTickets(projectId: ProjectId): Stream[F, Ticket] = { + override def allTickets(filter: Option[TicketFilter])(projectId: ProjectId): Stream[F, Ticket] = { val projectFilter = fr""""tickets".project = $projectId""" - val tickets = selectTicketColumns ++ whereAnd(projectFilter) + val tickets = filter match { + case None => selectTicketColumns ++ whereAnd(projectFilter) + case Some(filter) => + val numberFilter = filter.number.toNel.map(numbers => Fragments.in(fr""""tickets".number""", numbers)) + val statusFilter = filter.status.toNel.map(status => Fragments.in(fr""""tickets".status""", status)) + val resolutionFilter = + filter.resolution.toNel.map(resolutions => Fragments.in(fr""""tickets".resolution""", resolutions)) + val submitterFilter = + filter.submitter.toNel.map(submitters => Fragments.in(fr""""tickets".submitter""", submitters.map(_.id))) + selectTicketColumns ++ whereAndOpt( + projectFilter.some, + numberFilter, + statusFilter, + resolutionFilter, + submitterFilter + ) + } tickets.query[Ticket].stream.transact(tx) } diff -rN -u old-smederee/modules/tickets/src/main/scala/de/smederee/tickets/TicketRepository.scala new-smederee/modules/tickets/src/main/scala/de/smederee/tickets/TicketRepository.scala --- old-smederee/modules/tickets/src/main/scala/de/smederee/tickets/TicketRepository.scala 2025-01-30 16:01:22.114095733 +0000 +++ new-smederee/modules/tickets/src/main/scala/de/smederee/tickets/TicketRepository.scala 2025-01-30 16:01:22.118095738 +0000 @@ -67,12 +67,14 @@ /** Return all tickets associated with the given repository. * + * @param filter + * A ticket filter containing possible values which will be used to filter the list of tickets. * @param projectId * The unique internal ID of a ticket tracking project.for which all tickets shall be returned. * @return * A stream of tickets associated with the vcs repository which may be empty. */ - def allTickets(projectId: ProjectId): Stream[F, Ticket] + def allTickets(filter: Option[TicketFilter])(projectId: ProjectId): Stream[F, Ticket] /** Create a database entry for the given ticket definition within the scope of the repository with the given id. * diff -rN -u old-smederee/modules/tickets/src/main/scala/de/smederee/tickets/Ticket.scala new-smederee/modules/tickets/src/main/scala/de/smederee/tickets/Ticket.scala --- old-smederee/modules/tickets/src/main/scala/de/smederee/tickets/Ticket.scala 2025-01-30 16:01:22.114095733 +0000 +++ new-smederee/modules/tickets/src/main/scala/de/smederee/tickets/Ticket.scala 2025-01-30 16:01:22.118095738 +0000 @@ -20,6 +20,7 @@ import java.time.OffsetDateTime import cats._ +import cats.syntax.all._ import scala.util.matching.Regex @@ -91,8 +92,8 @@ opaque type TicketNumber = Int object TicketNumber { given Eq[TicketNumber] = Eq.fromUniversalEquals - given Order[TicketNumber] = Order.from((a, b) => a.compare(b)) - given Ordering[TicketNumber] = implicitly[Order[TicketNumber]].toOrdering + given Ordering[TicketNumber] = (x: TicketNumber, y: TicketNumber) => x.compareTo(y) + given Order[TicketNumber] = Order.fromOrdering /** Create an instance of TicketNumber from the given Int type. * @@ -247,3 +248,34 @@ createdAt: OffsetDateTime, updatedAt: OffsetDateTime ) + +/** A data container for values that can be used to filter a list of tickets by. + * + * @param number + * A list of ticket numbers that must be matched. + * @param status + * A list of ticket status flags that must be matched. + * @param resolution + * A list of ticket resolution kinds that must be matched. + * @param submitter + * A list of submitters from whom the ticket must have been submitted. + */ +final case class TicketFilter( + number: List[TicketNumber], + status: List[TicketStatus], + resolution: List[TicketResolution], + submitter: List[Submitter] +) + +object TicketFilter { + // Only "open" tickets. + val OpenTicketsOnly = TicketFilter( + number = Nil, + status = TicketStatus.values.filterNot(_ === TicketStatus.Resolved).toList, + resolution = Nil, + submitter = Nil + ) + // Only resolved (closed) tickets. + val ResolvedTicketsOnly = + TicketFilter(number = Nil, status = List(TicketStatus.Resolved), resolution = Nil, submitter = Nil) +}