~jan0sch/smederee

Showing details for patch e227c36f9d1f60b111d53a0afecd1fb407577a4e.
2023-05-12 (Fri), 1:47 PM - Jens Grassel - e227c36f9d1f60b111d53a0afecd1fb407577a4e

Ticket routes and filtering

- add basic proof of concept functionality for a tickets route
- fix a bug in the JOIN for fetching tickets and submitters
- add more tests for the filtering capabilities of the DoobieTicketRepository
- fix possible stack overflow in Ticket Order / Ordering instances
Summary of changes
2 files added
  • modules/hub/src/main/scala/de/smederee/tickets/TicketRoutes.scala
  • modules/hub/src/main/twirl/de/smederee/tickets/views/showTickets.scala.html
8 files modified with 246 lines added and 10 lines removed
  • modules/hub/src/main/resources/assets/css/main.css with 9 added and 0 removed lines
  • modules/hub/src/main/resources/messages.properties with 4 added and 0 removed lines
  • modules/hub/src/main/scala/de/smederee/hub/HubServer.scala with 4 added and 0 removed lines
  • modules/hub/src/main/twirl/de/smederee/tickets/views/showProjectMenu.scala.html with 3 added and 0 removed lines
  • modules/tickets/src/it/scala/de/smederee/tickets/DoobieTicketRepositoryTest.scala with 167 added and 4 removed lines
  • modules/tickets/src/main/scala/de/smederee/tickets/DoobieTicketRepository.scala with 22 added and 3 removed lines
  • modules/tickets/src/main/scala/de/smederee/tickets/Ticket.scala with 34 added and 2 removed lines
  • modules/tickets/src/main/scala/de/smederee/tickets/TicketRepository.scala with 3 added and 1 removed lines
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)
+}