krz/octosentry

macOS menu bar app to monitor GitHub security alerts

clone: git clone https://gitbay.org/krz/octosentry.git

1fb81fcd2b9dcc46bdbc68ff52b020820cd92d4d

signed_unknown_key

author: Christian Cleberg <hello@cleberg.net> · 2026-08-22T20:37:28Z
committer: <noreply@github.com>

Add source/repo filtering and sort order to the feed (#27)

Filter menus for source and repo, plus a sort control (severity, oldest
first, repository). Sort order persists via PersistedState; the
source/repo filters are per-session.

Minimum severity stays a separate persisted floor rather than becoming
part of the new severity filtering: the menu bar badge reads from it,
and notifications (#16) are specified against it, so making it
transient UI state would make those ambiguous.

SecurityEvent and SecurityEventSource are now nonisolated, matching the
other value types — the target defaults to MainActor isolation, which
blocked key paths to them from test code.

Closes #17
 octosentry/AlertFilter.swift              |  30 +++++++++
 octosentry/AlertSortOrder.swift           |  44 ++++++++++++
 octosentry/PersistedState.swift           |  14 ++--
 octosentry/SecurityEvent.swift            |   2 +-
 octosentry/SecurityEventListView.swift    | 107 +++++++++++++++++++++++++++++-
 octosentry/SecurityEventSource.swift      |   2 +-
 octosentry/SecurityEventStore.swift       |  43 +++++++++---
 octosentryTests/AlertFilterTests.swift    |  67 +++++++++++++++++++
 octosentryTests/AlertSortOrderTests.swift |  68 +++++++++++++++++++
 octosentryTests/PersistedStateTests.swift |  12 ++--
 octosentryTests/TestEvents.swift          |  34 ++++++++++
 11 files changed, 402 insertions(+), 21 deletions(-)

diff --git a/octosentry/AlertFilter.swift b/octosentry/AlertFilter.swift
new file mode 100644
index 0000000..cadc9e5
--- /dev/null
+++ b/octosentry/AlertFilter.swift
@@ -0,0 +1,30 @@
+//
+//  AlertFilter.swift
+//  octosentry
+//
+//  Per-session narrowing of the feed by source and repo. An empty set means
+//  "no restriction" rather than "match nothing", so the default value shows
+//  everything. Severity is not here: the minimum-severity threshold already
+//  filters on it, and it stays a persisted floor because the badge and
+//  notifications key off it.
+//
+
+import Foundation
+
+nonisolated struct AlertFilter: Equatable {
+    var sources: Set<SecurityEventSource> = []
+    var repos: Set<String> = []
+
+    var isActive: Bool {
+        !sources.isEmpty || !repos.isEmpty
+    }
+
+    func matches(_ event: SecurityEvent) -> Bool {
+        (sources.isEmpty || sources.contains(event.source))
+            && (repos.isEmpty || repos.contains(event.repoFullName))
+    }
+
+    func apply(to events: [SecurityEvent]) -> [SecurityEvent] {
+        isActive ? events.filter(matches) : events
+    }
+}
diff --git a/octosentry/AlertSortOrder.swift b/octosentry/AlertSortOrder.swift
new file mode 100644
index 0000000..9e84599
--- /dev/null
+++ b/octosentry/AlertSortOrder.swift
@@ -0,0 +1,44 @@
+//
+//  AlertSortOrder.swift
+//  octosentry
+//
+//  How the feed is ordered. Persisted (see PersistedState) because it's a
+//  standing preference, unlike the source/repo filters which are per-session.
+//
+
+import Foundation
+
+nonisolated enum AlertSortOrder: String, Codable, CaseIterable, Hashable {
+    case severity
+    case oldest
+    case repo
+
+    var displayName: String {
+        switch self {
+        case .severity: "Severity"
+        case .oldest: "Oldest first"
+        case .repo: "Repository"
+        }
+    }
+
+    func sorted(_ events: [SecurityEvent]) -> [SecurityEvent] {
+        switch self {
+        case .severity:
+            events.sorted { lhs, rhs in
+                lhs.severity != rhs.severity
+                    ? lhs.severity > rhs.severity
+                    : lhs.createdAt > rhs.createdAt
+            }
+        case .oldest:
+            events.sorted { $0.createdAt < $1.createdAt }
+        case .repo:
+            events.sorted { lhs, rhs in
+                let order = lhs.repoFullName.localizedCaseInsensitiveCompare(rhs.repoFullName)
+                guard order == .orderedSame else { return order == .orderedAscending }
+                return lhs.severity != rhs.severity
+                    ? lhs.severity > rhs.severity
+                    : lhs.createdAt > rhs.createdAt
+            }
+        }
+    }
+}
diff --git a/octosentry/PersistedState.swift b/octosentry/PersistedState.swift
index c8c1bae..90695ef 100644
--- a/octosentry/PersistedState.swift
+++ b/octosentry/PersistedState.swift
@@ -4,8 +4,8 @@
 //
 //  Everything the app remembers across launches: the repo watch list,
 //  local-only seen-state per event, last-fetch timestamp per repo, the
-//  minimum severity filter, and whether the current token has the
-//  broader "repo" scope needed to list repos. Flat JSON over SwiftData
+//  minimum severity filter, the feed sort order, and whether the current
+//  token has the broader "repo" scope needed to list repos. Flat JSON over SwiftData
 //  (see #1) — small, inspectable, and these are already plain Codable
 //  values passed across actor boundaries, not reference types tied to a
 //  persistence context.
@@ -19,9 +19,10 @@ nonisolated struct PersistedState: Codable {
     var lastFetchByRepo: [String: Date]
     var minimumSeverity: SecurityEventSeverity
     var hasRepoScope: Bool
+    var sortOrder: AlertSortOrder
 
     enum CodingKeys: String, CodingKey {
-        case watchedRepos, seenEventIDs, lastFetchByRepo, minimumSeverity, hasRepoScope
+        case watchedRepos, seenEventIDs, lastFetchByRepo, minimumSeverity, hasRepoScope, sortOrder
     }
 
     init(
@@ -29,17 +30,19 @@ nonisolated struct PersistedState: Codable {
         seenEventIDs: Set<String>,
         lastFetchByRepo: [String: Date],
         minimumSeverity: SecurityEventSeverity,
-        hasRepoScope: Bool = false
+        hasRepoScope: Bool = false,
+        sortOrder: AlertSortOrder = .severity
     ) {
         self.watchedRepos = watchedRepos
         self.seenEventIDs = seenEventIDs
         self.lastFetchByRepo = lastFetchByRepo
         self.minimumSeverity = minimumSeverity
         self.hasRepoScope = hasRepoScope
+        self.sortOrder = sortOrder
     }
 
     // Custom decode so existing state.json files saved before hasRepoScope
-    // existed still load instead of falling back to .placeholder.
+    // and sortOrder existed still load instead of falling back to .placeholder.
     init(from decoder: Decoder) throws {
         let container = try decoder.container(keyedBy: CodingKeys.self)
         watchedRepos = try container.decode([String].self, forKey: .watchedRepos)
@@ -47,6 +50,7 @@ nonisolated struct PersistedState: Codable {
         lastFetchByRepo = try container.decode([String: Date].self, forKey: .lastFetchByRepo)
         minimumSeverity = try container.decode(SecurityEventSeverity.self, forKey: .minimumSeverity)
         hasRepoScope = try container.decodeIfPresent(Bool.self, forKey: .hasRepoScope) ?? false
+        sortOrder = try container.decodeIfPresent(AlertSortOrder.self, forKey: .sortOrder) ?? .severity
     }
 
     static let placeholder = PersistedState(
diff --git a/octosentry/SecurityEvent.swift b/octosentry/SecurityEvent.swift
index ca5de0d..03b3e4a 100644
--- a/octosentry/SecurityEvent.swift
+++ b/octosentry/SecurityEvent.swift
@@ -5,7 +5,7 @@
 
 import Foundation
 
-struct SecurityEvent: Identifiable, Codable, Sendable {
+nonisolated struct SecurityEvent: Identifiable, Codable, Sendable {
     let id: String
     let source: SecurityEventSource
     let repoFullName: String
diff --git a/octosentry/SecurityEventListView.swift b/octosentry/SecurityEventListView.swift
index 84e6638..37f433c 100644
--- a/octosentry/SecurityEventListView.swift
+++ b/octosentry/SecurityEventListView.swift
@@ -26,6 +26,8 @@ struct SecurityEventListView: View {
             } else if showingRepoManager {
                 RepoManagerView(store: store, authStore: authStore)
             } else {
+                filterBar
+                Divider()
                 content
             }
         }
@@ -101,13 +103,104 @@ struct SecurityEventListView: View {
         .padding(12)
     }
 
+    private var filterBar: some View {
+        HStack(spacing: 8) {
+            Menu {
+                ForEach(SecurityEventSource.allCases, id: \.self) { source in
+                    Toggle(source.displayName, isOn: binding(for: source))
+                }
+            } label: {
+                FilterLabel(title: "Source", count: store.filter.sources.count)
+            }
+            .menuStyle(.borderlessButton)
+            .fixedSize()
+
+            Menu {
+                if store.reposInFeed.isEmpty {
+                    Text("No repos in the current feed")
+                } else {
+                    ForEach(store.reposInFeed, id: \.self) { repo in
+                        Toggle(repo, isOn: binding(for: repo))
+                    }
+                }
+            } label: {
+                FilterLabel(title: "Repo", count: store.filter.repos.count)
+            }
+            .menuStyle(.borderlessButton)
+            .fixedSize()
+            .disabled(store.reposInFeed.isEmpty)
+
+            Menu {
+                Picker("Sort", selection: Binding(
+                    get: { store.sortOrder },
+                    set: { newValue in Task { await store.setSortOrder(newValue) } }
+                )) {
+                    ForEach(AlertSortOrder.allCases, id: \.self) { order in
+                        Text(order.displayName).tag(order)
+                    }
+                }
+                .pickerStyle(.inline)
+                .labelsHidden()
+            } label: {
+                FilterLabel(title: store.sortOrder.displayName, count: 0, systemImage: "arrow.up.arrow.down")
+            }
+            .menuStyle(.borderlessButton)
+            .fixedSize()
+
+            Spacer()
+
+            if store.filter.isActive {
+                Button("Clear") {
+                    store.filter = AlertFilter()
+                }
+                .buttonStyle(.plain)
+                .font(.caption)
+                .foregroundStyle(Color.accentColor)
+            }
+        }
+        .padding(.horizontal, 12)
+        .padding(.vertical, 6)
+    }
+
+    private func binding(for source: SecurityEventSource) -> Binding<Bool> {
+        Binding(
+            get: { store.filter.sources.contains(source) },
+            set: { isOn in
+                if isOn {
+                    store.filter.sources.insert(source)
+                } else {
+                    store.filter.sources.remove(source)
+                }
+            }
+        )
+    }
+
+    private func binding(for repo: String) -> Binding<Bool> {
+        Binding(
+            get: { store.filter.repos.contains(repo) },
+            set: { isOn in
+                if isOn {
+                    store.filter.repos.insert(repo)
+                } else {
+                    store.filter.repos.remove(repo)
+                }
+            }
+        )
+    }
+
     @ViewBuilder
     private var content: some View {
         if store.events.isEmpty && !store.errorMessages.isEmpty {
             StatusView(systemImage: "exclamationmark.triangle", tint: .orange, message: store.errorMessages.joined(separator: "\n\n"))
         } else if store.events.isEmpty && !store.isLoading {
             VStack(spacing: 8) {
-                if store.totalFetchedCount > 0 {
+                if store.filter.isActive && store.filteredOutCount > 0 {
+                    StatusView(
+                        systemImage: "line.3.horizontal.decrease.circle",
+                        tint: .secondary,
+                        message: "\(store.filteredOutCount) alert(s) hidden by the current filter"
+                    )
+                } else if store.totalFetchedCount > 0 {
                     StatusView(
                         systemImage: "line.3.horizontal.decrease.circle",
                         tint: .secondary,
@@ -298,6 +391,18 @@ private struct RepoManagerView: View {
     }
 }
 
+private struct FilterLabel: View {
+    let title: String
+    let count: Int
+    var systemImage = "line.3.horizontal.decrease.circle"
+
+    var body: some View {
+        Label(count > 0 ? "\(title) (\(count))" : title, systemImage: systemImage)
+            .font(.caption)
+            .foregroundStyle(count > 0 ? Color.accentColor : .secondary)
+    }
+}
+
 private struct UpdateBanner: View {
     let release: UpdateChecker.LatestRelease
 
diff --git a/octosentry/SecurityEventSource.swift b/octosentry/SecurityEventSource.swift
index 9c68296..2455bd9 100644
--- a/octosentry/SecurityEventSource.swift
+++ b/octosentry/SecurityEventSource.swift
@@ -5,7 +5,7 @@
 
 import Foundation
 
-enum SecurityEventSource: String, Codable, CaseIterable {
+nonisolated enum SecurityEventSource: String, Codable, CaseIterable {
     case dependabot
     case codeScanning
     case secretScanning
diff --git a/octosentry/SecurityEventStore.swift b/octosentry/SecurityEventStore.swift
index 820bf06..6548bcd 100644
--- a/octosentry/SecurityEventStore.swift
+++ b/octosentry/SecurityEventStore.swift
@@ -24,10 +24,28 @@ final class SecurityEventStore {
     private(set) var errorMessages: [String] = []
     private(set) var unavailableNotices: [String] = []
     private(set) var minimumSeverity: SecurityEventSeverity = .low
+    private(set) var sortOrder: AlertSortOrder = .severity
     private(set) var totalFetchedCount = 0
     private(set) var watchedRepos: [String] = []
     private(set) var watchListErrorMessage: String?
 
+    /// Per-session narrowing, not persisted. Setting it re-derives `events`.
+    var filter = AlertFilter() {
+        didSet { applyFilters() }
+    }
+
+    /// Repos represented in the current fetch, for the repo filter menu —
+    /// the watch list can contain repos that returned nothing.
+    var reposInFeed: [String] {
+        Set(rawEvents.map(\.repoFullName)).sorted { $0.localizedCaseInsensitiveCompare($1) == .orderedAscending }
+    }
+
+    /// Alerts held back by the source/repo filter, as opposed to the
+    /// severity floor, so the empty state can say which one is hiding them.
+    var filteredOutCount: Int {
+        rawEvents.filter { $0.severity >= minimumSeverity }.count - events.count
+    }
+
     var unseenCriticalCount: Int {
         rawEvents.filter { $0.severity == .critical && !$0.seenLocally }.count
     }
@@ -45,6 +63,7 @@ final class SecurityEventStore {
         var state = await persistenceStore.load()
         let stateLoadFailure = await persistenceStore.loadFailureMessage
         minimumSeverity = state.minimumSeverity
+        sortOrder = state.sortOrder
         watchedRepos = state.watchedRepos
 
         guard let token = KeychainTokenStore.load() else {
@@ -99,7 +118,7 @@ final class SecurityEventStore {
             return event
         }
         totalFetchedCount = rawEvents.count
-        applyMinimumSeverityFilter()
+        applyFilters()
 
         errorMessages = errors
         unavailableNotices = notices
@@ -108,13 +127,22 @@ final class SecurityEventStore {
 
     func setMinimumSeverity(_ severity: SecurityEventSeverity) async {
         minimumSeverity = severity
-        applyMinimumSeverityFilter()
+        applyFilters()
 
         var state = await persistenceStore.load()
         state.minimumSeverity = severity
         await persistenceStore.save(state)
     }
 
+    func setSortOrder(_ order: AlertSortOrder) async {
+        sortOrder = order
+        applyFilters()
+
+        var state = await persistenceStore.load()
+        state.sortOrder = order
+        await persistenceStore.save(state)
+    }
+
     func addRepo(_ input: String) async {
         watchListErrorMessage = nil
         let trimmed = input.trimmingCharacters(in: .whitespacesAndNewlines)
@@ -163,7 +191,7 @@ final class SecurityEventStore {
 
         rawEvents.removeAll { $0.id == eventID }
         totalFetchedCount = rawEvents.count
-        applyMinimumSeverityFilter()
+        applyFilters()
     }
 
     func removeRepo(_ repoFullName: String) async {
@@ -190,12 +218,9 @@ final class SecurityEventStore {
         }
     }
 
-    private func applyMinimumSeverityFilter() {
-        events = rawEvents
-            .filter { $0.severity >= minimumSeverity }
-            .sorted { lhs, rhs in
-                lhs.severity != rhs.severity ? lhs.severity > rhs.severity : lhs.createdAt > rhs.createdAt
-            }
+    private func applyFilters() {
+        let admitted = rawEvents.filter { $0.severity >= minimumSeverity }
+        events = sortOrder.sorted(filter.apply(to: admitted))
     }
 
     private enum SourceOutcome {
diff --git a/octosentryTests/AlertFilterTests.swift b/octosentryTests/AlertFilterTests.swift
new file mode 100644
index 0000000..ff9e124
--- /dev/null
+++ b/octosentryTests/AlertFilterTests.swift
@@ -0,0 +1,67 @@
+//
+//  AlertFilterTests.swift
+//  octosentryTests
+//
+
+import Foundation
+import Testing
+@testable import octosentry
+
+struct AlertFilterTests {
+
+    private let dependabotHello = TestEvents.event(id: "a", source: .dependabot, repo: "octocat/hello-world")
+    private let codeScanningHello = TestEvents.event(id: "b", source: .codeScanning, repo: "octocat/hello-world")
+    private let secretScanningSpoon = TestEvents.event(id: "c", source: .secretScanning, repo: "octocat/spoon-knife")
+
+    private var allEvents: [SecurityEvent] {
+        [dependabotHello, codeScanningHello, secretScanningSpoon]
+    }
+
+    @Test func emptyFilterIsInactiveAndMatchesEverything() {
+        let filter = AlertFilter()
+
+        #expect(filter.isActive == false)
+        #expect(filter.apply(to: allEvents).map(\.id) == ["a", "b", "c"])
+    }
+
+    @Test func filtersBySource() {
+        var filter = AlertFilter()
+        filter.sources = [.dependabot]
+
+        #expect(filter.isActive)
+        #expect(filter.apply(to: allEvents).map(\.id) == ["a"])
+    }
+
+    @Test func sourceFilterUnionsSelectedSources() {
+        var filter = AlertFilter()
+        filter.sources = [.dependabot, .secretScanning]
+
+        #expect(filter.apply(to: allEvents).map(\.id) == ["a", "c"])
+    }
+
+    @Test func filtersByRepo() {
+        var filter = AlertFilter()
+        filter.repos = ["octocat/spoon-knife"]
+
+        #expect(filter.apply(to: allEvents).map(\.id) == ["c"])
+    }
+
+    // Source and repo intersect: an event has to satisfy both.
+    @Test func sourceAndRepoAreCombinedWithAnd() {
+        var filter = AlertFilter()
+        filter.sources = [.dependabot]
+        filter.repos = ["octocat/spoon-knife"]
+
+        #expect(filter.apply(to: allEvents).isEmpty)
+
+        filter.repos = ["octocat/hello-world"]
+        #expect(filter.apply(to: allEvents).map(\.id) == ["a"])
+    }
+
+    @Test func filterPreservesInputOrder() {
+        var filter = AlertFilter()
+        filter.repos = ["octocat/hello-world"]
+
+        #expect(filter.apply(to: allEvents.reversed()).map(\.id) == ["b", "a"])
+    }
+}
diff --git a/octosentryTests/AlertSortOrderTests.swift b/octosentryTests/AlertSortOrderTests.swift
new file mode 100644
index 0000000..0fdfaa0
--- /dev/null
+++ b/octosentryTests/AlertSortOrderTests.swift
@@ -0,0 +1,68 @@
+//
+//  AlertSortOrderTests.swift
+//  octosentryTests
+//
+
+import Foundation
+import Testing
+@testable import octosentry
+
+struct AlertSortOrderTests {
+
+    // Deliberately out of order on every axis.
+    private let events = [
+        TestEvents.event(id: "old-low", repo: "zulu/repo", severity: .low, ageInHours: 500),
+        TestEvents.event(id: "new-critical", repo: "alpha/repo", severity: .critical, ageInHours: 1),
+        TestEvents.event(id: "old-critical", repo: "mike/repo", severity: .critical, ageInHours: 100),
+        TestEvents.event(id: "new-medium", repo: "alpha/repo", severity: .medium, ageInHours: 2),
+    ]
+
+    @Test func severityOrdersHighestFirstThenNewest() {
+        let sorted = AlertSortOrder.severity.sorted(events)
+
+        #expect(sorted.map(\.id) == ["new-critical", "old-critical", "new-medium", "old-low"])
+    }
+
+    @Test func oldestOrdersLongestOpenFirst() {
+        let sorted = AlertSortOrder.oldest.sorted(events)
+
+        #expect(sorted.map(\.id) == ["old-low", "old-critical", "new-medium", "new-critical"])
+    }
+
+    @Test func repoGroupsByNameThenSeverityWithinRepo() {
+        let sorted = AlertSortOrder.repo.sorted(events)
+
+        #expect(sorted.map(\.repoFullName) == ["alpha/repo", "alpha/repo", "mike/repo", "zulu/repo"])
+        // Within alpha/repo, critical sorts above medium.
+        #expect(sorted.prefix(2).map(\.id) == ["new-critical", "new-medium"])
+    }
+
+    @Test func repoOrderIsCaseInsensitive() {
+        let mixedCase = [
+            TestEvents.event(id: "upper", repo: "Zulu/repo"),
+            TestEvents.event(id: "lower", repo: "alpha/repo"),
+        ]
+
+        #expect(AlertSortOrder.repo.sorted(mixedCase).map(\.id) == ["lower", "upper"])
+    }
+
+    @Test func sortingNeverDropsOrDuplicatesEvents() {
+        for order in AlertSortOrder.allCases {
+            #expect(Set(order.sorted(events).map(\.id)) == Set(events.map(\.id)))
+            #expect(order.sorted(events).count == events.count)
+        }
+    }
+
+    @Test func sortingAnEmptyFeedIsEmpty() {
+        for order in AlertSortOrder.allCases {
+            #expect(order.sorted([]).isEmpty)
+        }
+    }
+
+    // Raw values are persisted in state.json.
+    @Test func rawValuesAreStable() {
+        #expect(AlertSortOrder.severity.rawValue == "severity")
+        #expect(AlertSortOrder.oldest.rawValue == "oldest")
+        #expect(AlertSortOrder.repo.rawValue == "repo")
+    }
+}
diff --git a/octosentryTests/PersistedStateTests.swift b/octosentryTests/PersistedStateTests.swift
index d4781fc..fd1b302 100644
--- a/octosentryTests/PersistedStateTests.swift
+++ b/octosentryTests/PersistedStateTests.swift
@@ -31,7 +31,8 @@ struct PersistedStateTests {
             seenEventIDs: ["dependabot-octocat/hello-world-1", "codeScanning-octocat/spoon-knife-7"],
             lastFetchByRepo: ["octocat/hello-world": fetchedAt],
             minimumSeverity: .high,
-            hasRepoScope: true
+            hasRepoScope: true,
+            sortOrder: .repo
         )
 
         let decoded = try Self.decoder.decode(
@@ -44,6 +45,7 @@ struct PersistedStateTests {
         #expect(decoded.lastFetchByRepo == original.lastFetchByRepo)
         #expect(decoded.minimumSeverity == original.minimumSeverity)
         #expect(decoded.hasRepoScope == original.hasRepoScope)
+        #expect(decoded.sortOrder == original.sortOrder)
     }
 
     @Test func encodesTheKeysOnDiskReadersDependOn() throws {
@@ -53,12 +55,12 @@ struct PersistedStateTests {
         )
 
         #expect(Set(object.keys) == [
-            "watchedRepos", "seenEventIDs", "lastFetchByRepo", "minimumSeverity", "hasRepoScope",
+            "watchedRepos", "seenEventIDs", "lastFetchByRepo", "minimumSeverity", "hasRepoScope", "sortOrder",
         ])
     }
 
-    // A state.json written before hasRepoScope existed must still load.
-    @Test func decodesLegacyStateWithoutRepoScope() throws {
+    // A state.json written before hasRepoScope and sortOrder existed must still load.
+    @Test func decodesLegacyStateWithoutRepoScopeOrSortOrder() throws {
         let legacy = """
         {
           "watchedRepos": ["octocat/hello-world"],
@@ -74,6 +76,7 @@ struct PersistedStateTests {
         #expect(state.seenEventIDs == ["dependabot-octocat/hello-world-1"])
         #expect(state.minimumSeverity == .medium)
         #expect(state.hasRepoScope == false)
+        #expect(state.sortOrder == .severity)
     }
 
     @Test func rejectsStateMissingARequiredField() {
@@ -91,5 +94,6 @@ struct PersistedStateTests {
         #expect(PersistedState.placeholder.lastFetchByRepo.isEmpty)
         #expect(PersistedState.placeholder.minimumSeverity == .low)
         #expect(PersistedState.placeholder.hasRepoScope == false)
+        #expect(PersistedState.placeholder.sortOrder == .severity)
     }
 }
diff --git a/octosentryTests/TestEvents.swift b/octosentryTests/TestEvents.swift
new file mode 100644
index 0000000..9d84271
--- /dev/null
+++ b/octosentryTests/TestEvents.swift
@@ -0,0 +1,34 @@
+//
+//  TestEvents.swift
+//  octosentryTests
+//
+
+import Foundation
+@testable import octosentry
+
+enum TestEvents {
+    static let referenceDate = Date(timeIntervalSince1970: 1_785_000_000)
+
+    static func event(
+        id: String,
+        source: SecurityEventSource = .dependabot,
+        repo: String = "octocat/hello-world",
+        severity: SecurityEventSeverity = .high,
+        summary: String = "A summary",
+        ageInHours: Double = 0
+    ) -> SecurityEvent {
+        let createdAt = referenceDate.addingTimeInterval(-ageInHours * 3600)
+        return SecurityEvent(
+            id: id,
+            source: source,
+            repoFullName: repo,
+            severity: severity,
+            nativeSeverityLabel: severity.displayName,
+            summary: summary,
+            detailURL: URL(string: "https://github.com/\(repo)/security/\(id)")!,
+            createdAt: createdAt,
+            updatedAt: createdAt,
+            seenLocally: false
+        )
+    }
+}