krz/octosentry

macOS menu bar app to monitor GitHub security alerts

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

3471ab6b0fe481cd06e49b4bafb263578b161b81

signed_unknown_key

author: Christian Cleberg <hello@cleberg.net> · 2026-08-22T21:10:14Z
committer: <noreply@github.com>

Add snooze and dismiss (#31)

Dismiss hides an alert from the feed; snooze hides it until an hour
from now, tomorrow, or next week, after which a later poll brings it
back. Local only — the alert is still open on GitHub.

Both live in one AlertTriage type behind a single isHidden check: they
are the same idea, hidden forever versus hidden until a date. Seen (#8)
stays separate, since it is an acknowledgement feeding the menu bar
badge rather than a visibility control.

State for alerts that are gone upstream, and snoozes that have elapsed,
is pruned on refresh — but only when every watched repo answered.
Pruning against a partial fetch would read a failed repo's missing
alerts as resolved and forget they were hidden.

A Hidden toggle in the filter bar reveals dismissed and snoozed alerts
with a restore action, so hiding an alert isn't irreversible.

Closes #18
 octosentry/AlertFilter.swift              |   6 +-
 octosentry/AlertTriage.swift              |  93 ++++++++++++++
 octosentry/PersistedState.swift           |  14 ++-
 octosentry/SecurityEventListView.swift    |  27 +++-
 octosentry/SecurityEventRow.swift         |  63 ++++++++--
 octosentry/SecurityEventStore.swift       |  46 ++++++-
 octosentryTests/AlertFilterTests.swift    |  10 ++
 octosentryTests/AlertTriageTests.swift    | 196 ++++++++++++++++++++++++++++++
 octosentryTests/PersistedStateTests.swift |  11 +-
 9 files changed, 449 insertions(+), 17 deletions(-)

diff --git a/octosentry/AlertFilter.swift b/octosentry/AlertFilter.swift
index cadc9e5..393383c 100644
--- a/octosentry/AlertFilter.swift
+++ b/octosentry/AlertFilter.swift
@@ -15,8 +15,12 @@ nonisolated struct AlertFilter: Equatable {
     var sources: Set<SecurityEventSource> = []
     var repos: Set<String> = []
 
+    /// Reveals dismissed and snoozed alerts so they can be brought back —
+    /// without it, hiding an alert would be irreversible.
+    var showsHidden = false
+
     var isActive: Bool {
-        !sources.isEmpty || !repos.isEmpty
+        !sources.isEmpty || !repos.isEmpty || showsHidden
     }
 
     func matches(_ event: SecurityEvent) -> Bool {
diff --git a/octosentry/AlertTriage.swift b/octosentry/AlertTriage.swift
new file mode 100644
index 0000000..8fef1c3
--- /dev/null
+++ b/octosentry/AlertTriage.swift
@@ -0,0 +1,93 @@
+//
+//  AlertTriage.swift
+//  octosentry
+//
+//  Local-only triage: what the user has hidden, and until when. octosentry
+//  deep-links out to github.com to actually resolve an alert, so dismissing
+//  here means "hide from my feed", not "dismiss on GitHub".
+//
+//  Dismiss and snooze are two shapes of one idea — an alert is hidden, either
+//  forever or until a date — so they live in one type behind a single
+//  isHidden check. "Seen" (#8) stays separate: it's an acknowledgement that
+//  feeds the menu bar badge, not a visibility control.
+//
+
+import Foundation
+
+nonisolated struct AlertTriage: Codable, Equatable {
+    var dismissedEventIDs: Set<String> = []
+    var snoozedUntilByEventID: [String: Date] = [:]
+
+    func isHidden(_ eventID: String, now: Date) -> Bool {
+        if dismissedEventIDs.contains(eventID) { return true }
+        guard let until = snoozedUntilByEventID[eventID] else { return false }
+        return until > now
+    }
+
+    func isDismissed(_ eventID: String) -> Bool {
+        dismissedEventIDs.contains(eventID)
+    }
+
+    func snoozedUntil(_ eventID: String, now: Date) -> Date? {
+        guard let until = snoozedUntilByEventID[eventID], until > now else { return nil }
+        return until
+    }
+
+    mutating func dismiss(_ eventID: String) {
+        snoozedUntilByEventID.removeValue(forKey: eventID)
+        dismissedEventIDs.insert(eventID)
+    }
+
+    mutating func snooze(_ eventID: String, until: Date) {
+        dismissedEventIDs.remove(eventID)
+        snoozedUntilByEventID[eventID] = until
+    }
+
+    mutating func restore(_ eventID: String) {
+        dismissedEventIDs.remove(eventID)
+        snoozedUntilByEventID.removeValue(forKey: eventID)
+    }
+
+    /// Drops state that can no longer apply: alerts resolved upstream, and
+    /// snoozes that have already elapsed. Without this the file grows for the
+    /// life of the install.
+    ///
+    /// `presentEventIDs` must come from a poll where every watched repo
+    /// answered — pruning against a partial fetch would forget that a repo's
+    /// alerts were dismissed.
+    func pruned(presentEventIDs: Set<String>, now: Date) -> AlertTriage {
+        var pruned = self
+        pruned.dismissedEventIDs = dismissedEventIDs.intersection(presentEventIDs)
+        pruned.snoozedUntilByEventID = snoozedUntilByEventID.filter { eventID, until in
+            presentEventIDs.contains(eventID) && until > now
+        }
+        return pruned
+    }
+}
+
+nonisolated enum SnoozeDuration: String, CaseIterable, Hashable {
+    case anHour
+    case tomorrow
+    case nextWeek
+
+    var displayName: String {
+        switch self {
+        case .anHour: "For an hour"
+        case .tomorrow: "Until tomorrow"
+        case .nextWeek: "Until next week"
+        }
+    }
+
+    /// Tomorrow and next week mean the start of that day, not "24 hours from
+    /// now" — snoozing at 23:50 should not resurface the alert at midnight.
+    func date(from now: Date, calendar: Calendar = .current) -> Date {
+        switch self {
+        case .anHour:
+            now.addingTimeInterval(3600)
+        case .tomorrow:
+            calendar.startOfDay(for: calendar.date(byAdding: .day, value: 1, to: now) ?? now)
+        case .nextWeek:
+            calendar.startOfDay(for: calendar.date(byAdding: .day, value: 7, to: now) ?? now)
+        }
+    }
+}
diff --git a/octosentry/PersistedState.swift b/octosentry/PersistedState.swift
index 609d0d8..8e4dec2 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, the feed sort order, the per-repo alert IDs
-//  the notifier has already accounted for, and whether the current token
+//  minimum severity filter, the feed sort order, local triage state, the
+//  per-repo alert IDs the notifier has already accounted for, 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
@@ -29,9 +29,12 @@ nonisolated struct PersistedState: Codable {
     /// arrive as a burst.
     var notifiedEventIDsByRepo: [String: Set<String>]?
 
+    /// What the user has hidden locally, and until when.
+    var triage: AlertTriage
+
     enum CodingKeys: String, CodingKey {
         case watchedRepos, seenEventIDs, lastFetchByRepo, minimumSeverity, hasRepoScope, sortOrder
-        case notifiedEventIDsByRepo
+        case notifiedEventIDsByRepo, triage
     }
 
     init(
@@ -41,7 +44,8 @@ nonisolated struct PersistedState: Codable {
         minimumSeverity: SecurityEventSeverity,
         hasRepoScope: Bool = false,
         sortOrder: AlertSortOrder = .severity,
-        notifiedEventIDsByRepo: [String: Set<String>]? = nil
+        notifiedEventIDsByRepo: [String: Set<String>]? = nil,
+        triage: AlertTriage = AlertTriage()
     ) {
         self.watchedRepos = watchedRepos
         self.seenEventIDs = seenEventIDs
@@ -50,6 +54,7 @@ nonisolated struct PersistedState: Codable {
         self.hasRepoScope = hasRepoScope
         self.sortOrder = sortOrder
         self.notifiedEventIDsByRepo = notifiedEventIDsByRepo
+        self.triage = triage
     }
 
     // Custom decode so existing state.json files saved before hasRepoScope
@@ -66,6 +71,7 @@ nonisolated struct PersistedState: Codable {
             [String: Set<String>].self,
             forKey: .notifiedEventIDsByRepo
         )
+        triage = try container.decodeIfPresent(AlertTriage.self, forKey: .triage) ?? AlertTriage()
     }
 
     static let placeholder = PersistedState(
diff --git a/octosentry/SecurityEventListView.swift b/octosentry/SecurityEventListView.swift
index 0d53a76..ab58e74 100644
--- a/octosentry/SecurityEventListView.swift
+++ b/octosentry/SecurityEventListView.swift
@@ -194,6 +194,19 @@ struct SecurityEventListView: View {
             .menuStyle(.borderlessButton)
             .fixedSize()
 
+            Button {
+                store.filter.showsHidden.toggle()
+            } label: {
+                FilterLabel(
+                    title: "Hidden",
+                    count: 0,
+                    systemImage: store.filter.showsHidden ? "eye" : "eye.slash"
+                )
+                .foregroundStyle(store.filter.showsHidden ? Color.accentColor : .secondary)
+            }
+            .buttonStyle(.plain)
+            .help("Show dismissed and snoozed alerts")
+
             Spacer()
 
             if store.filter.isActive {
@@ -274,9 +287,17 @@ struct SecurityEventListView: View {
                         Divider()
                     }
                     ForEach(store.events) { event in
-                        SecurityEventRow(event: event) {
-                            Task { await store.markSeen(event.id) }
-                        }
+                        SecurityEventRow(
+                            event: event,
+                            isHidden: store.triage.isHidden(event.id, now: .now),
+                            snoozedUntil: store.triage.snoozedUntil(event.id, now: .now),
+                            onMarkSeen: { Task { await store.markSeen(event.id) } },
+                            onDismiss: { Task { await store.dismiss(event.id) } },
+                            onSnooze: { duration in
+                                Task { await store.snooze(event.id, until: duration.date(from: .now)) }
+                            },
+                            onRestore: { Task { await store.restore(event.id) } }
+                        )
                         Divider()
                     }
                 }
diff --git a/octosentry/SecurityEventRow.swift b/octosentry/SecurityEventRow.swift
index 13a9ba1..9cc5224 100644
--- a/octosentry/SecurityEventRow.swift
+++ b/octosentry/SecurityEventRow.swift
@@ -8,7 +8,12 @@ import SwiftUI
 
 struct SecurityEventRow: View {
     let event: SecurityEvent
+    var isHidden = false
+    var snoozedUntil: Date?
     var onMarkSeen: () -> Void
+    var onDismiss: () -> Void = {}
+    var onSnooze: (SnoozeDuration) -> Void = { _ in }
+    var onRestore: () -> Void = {}
 
     private static let relativeFormatter: RelativeDateTimeFormatter = {
         let formatter = RelativeDateTimeFormatter()
@@ -42,6 +47,15 @@ struct SecurityEventRow: View {
 
                         Spacer()
 
+                        if let hiddenLabel {
+                            Text(hiddenLabel)
+                                .font(.caption2)
+                                .foregroundStyle(.secondary)
+                                .padding(.horizontal, 5)
+                                .padding(.vertical, 1)
+                                .background(.secondary.opacity(0.15), in: Capsule())
+                        }
+
                         Text(Self.relativeFormatter.localizedString(for: event.createdAt, relativeTo: .now))
                             .font(.caption2)
                             .foregroundStyle(.secondary)
@@ -57,16 +71,51 @@ struct SecurityEventRow: View {
             }
             .buttonStyle(.plain)
 
-            Button(action: onMarkSeen) {
-                Image(systemName: "checkmark.circle")
+            if isHidden {
+                Button(action: onRestore) {
+                    Image(systemName: "arrow.uturn.backward.circle")
+                }
+                .buttonStyle(.plain)
+                .foregroundStyle(.secondary)
+                .help("Bring back")
+                .padding(.top, 12)
+                .padding(.trailing, 10)
+            } else {
+                Button(action: onMarkSeen) {
+                    Image(systemName: "checkmark.circle")
+                }
+                .buttonStyle(.plain)
+                .foregroundStyle(.secondary)
+                .help("Mark as seen")
+                .padding(.top, 12)
+                .padding(.trailing, 6)
+
+                Menu {
+                    Menu("Snooze") {
+                        ForEach(SnoozeDuration.allCases, id: \.self) { duration in
+                            Button(duration.displayName) { onSnooze(duration) }
+                        }
+                    }
+                    Button("Dismiss", action: onDismiss)
+                } label: {
+                    Image(systemName: "ellipsis.circle")
+                }
+                .menuStyle(.borderlessButton)
+                .menuIndicator(.hidden)
+                .fixedSize()
+                .foregroundStyle(.secondary)
+                .help("Snooze or dismiss")
+                .padding(.top, 10)
+                .padding(.trailing, 10)
             }
-            .buttonStyle(.plain)
-            .foregroundStyle(.secondary)
-            .help("Mark as seen")
-            .padding(.top, 12)
-            .padding(.trailing, 10)
         }
     }
+
+    private var hiddenLabel: String? {
+        guard isHidden else { return nil }
+        guard let snoozedUntil else { return "Dismissed" }
+        return "Snoozed until \(snoozedUntil.formatted(date: .abbreviated, time: .shortened))"
+    }
 }
 
 #Preview {
diff --git a/octosentry/SecurityEventStore.swift b/octosentry/SecurityEventStore.swift
index 1f2da0c..0ee0665 100644
--- a/octosentry/SecurityEventStore.swift
+++ b/octosentry/SecurityEventStore.swift
@@ -34,6 +34,10 @@ final class SecurityEventStore {
         didSet { applyFilters() }
     }
 
+    /// Local triage state (dismissed / snoozed), mirrored from PersistedState
+    /// so the feed can be re-derived without touching disk.
+    private(set) var triage = AlertTriage()
+
     /// Repos represented in the current fetch, for the repo filter menu —
     /// the watch list can contain repos that returned nothing.
     var reposInFeed: [String] {
@@ -65,6 +69,7 @@ final class SecurityEventStore {
         minimumSeverity = state.minimumSeverity
         sortOrder = state.sortOrder
         watchedRepos = state.watchedRepos
+        triage = state.triage
 
         guard let token = KeychainTokenStore.load() else {
             errorMessages = [stateLoadFailure, GitHubAPIError.missingToken.errorDescription ?? "Not signed in."]
@@ -130,6 +135,17 @@ final class SecurityEventStore {
         errorMessages = errors
         unavailableNotices = notices
 
+        // Only prune against a complete picture: if a repo failed this round
+        // its alerts are missing, and pruning would forget they were hidden.
+        if fetchedEventsByRepo.count == state.watchedRepos.count {
+            state.triage = state.triage.pruned(
+                presentEventIDs: Set(fetchedEvents.map(\.id)),
+                now: Date()
+            )
+            triage = state.triage
+            applyFilters()
+        }
+
         let newEvents = AlertDiff.newlyAppeared(
             in: fetchedEventsByRepo,
             baseline: state.notifiedEventIDsByRepo,
@@ -214,6 +230,30 @@ final class SecurityEventStore {
         applyFilters()
     }
 
+    /// Hides an alert until the user brings it back. Local only — the alert
+    /// is still open on GitHub.
+    func dismiss(_ eventID: String) async {
+        await updateTriage { $0.dismiss(eventID) }
+    }
+
+    /// Hides an alert until `date`; a later poll brings it back.
+    func snooze(_ eventID: String, until date: Date) async {
+        await updateTriage { $0.snooze(eventID, until: date) }
+    }
+
+    func restore(_ eventID: String) async {
+        await updateTriage { $0.restore(eventID) }
+    }
+
+    private func updateTriage(_ change: (inout AlertTriage) -> Void) async {
+        var state = await persistenceStore.load()
+        change(&state.triage)
+        await persistenceStore.save(state)
+
+        triage = state.triage
+        applyFilters()
+    }
+
     func removeRepo(_ repoFullName: String) async {
         var state = await persistenceStore.load()
         state.watchedRepos.removeAll { $0 == repoFullName }
@@ -239,7 +279,11 @@ final class SecurityEventStore {
     }
 
     private func applyFilters() {
-        let admitted = rawEvents.filter { $0.severity >= minimumSeverity }
+        let now = Date()
+        let admitted = rawEvents.filter { event in
+            guard event.severity >= minimumSeverity else { return false }
+            return filter.showsHidden || !triage.isHidden(event.id, now: now)
+        }
         events = sortOrder.sorted(filter.apply(to: admitted))
     }
 
diff --git a/octosentryTests/AlertFilterTests.swift b/octosentryTests/AlertFilterTests.swift
index ff9e124..c592a10 100644
--- a/octosentryTests/AlertFilterTests.swift
+++ b/octosentryTests/AlertFilterTests.swift
@@ -58,6 +58,16 @@ struct AlertFilterTests {
         #expect(filter.apply(to: allEvents).map(\.id) == ["a"])
     }
 
+    @Test func showingHiddenCountsAsAnActiveFilter() {
+        var filter = AlertFilter()
+        #expect(filter.isActive == false)
+
+        filter.showsHidden = true
+        #expect(filter.isActive)
+        // It reveals rows rather than removing them, so nothing is filtered out.
+        #expect(filter.apply(to: allEvents).map(\.id) == ["a", "b", "c"])
+    }
+
     @Test func filterPreservesInputOrder() {
         var filter = AlertFilter()
         filter.repos = ["octocat/hello-world"]
diff --git a/octosentryTests/AlertTriageTests.swift b/octosentryTests/AlertTriageTests.swift
new file mode 100644
index 0000000..102b1a8
--- /dev/null
+++ b/octosentryTests/AlertTriageTests.swift
@@ -0,0 +1,196 @@
+//
+//  AlertTriageTests.swift
+//  octosentryTests
+//
+
+import Foundation
+import Testing
+@testable import octosentry
+
+struct AlertTriageTests {
+
+    private let now = Date(timeIntervalSince1970: 1_785_000_000)
+    private var later: Date { now.addingTimeInterval(3600) }
+    private var earlier: Date { now.addingTimeInterval(-3600) }
+
+    // MARK: - Dismiss
+
+    @Test func dismissHidesAnAlert() {
+        var triage = AlertTriage()
+        triage.dismiss("a")
+
+        #expect(triage.isHidden("a", now: now))
+        #expect(triage.isDismissed("a"))
+        #expect(!triage.isHidden("b", now: now))
+    }
+
+    @Test func dismissedAlertsStayHiddenIndefinitely() {
+        var triage = AlertTriage()
+        triage.dismiss("a")
+
+        #expect(triage.isHidden("a", now: now.addingTimeInterval(60 * 60 * 24 * 365)))
+    }
+
+    // MARK: - Snooze
+
+    @Test func snoozeHidesUntilItsDeadline() {
+        var triage = AlertTriage()
+        triage.snooze("a", until: later)
+
+        #expect(triage.isHidden("a", now: now))
+        #expect(triage.snoozedUntil("a", now: now) == later)
+    }
+
+    // The resurfacing behaviour: a later poll sees the deadline has passed.
+    @Test func snoozeStopsHidingOnceItsDeadlinePasses() {
+        var triage = AlertTriage()
+        triage.snooze("a", until: later)
+
+        #expect(!triage.isHidden("a", now: later.addingTimeInterval(1)))
+        #expect(triage.snoozedUntil("a", now: later.addingTimeInterval(1)) == nil)
+    }
+
+    @Test func snoozeExactlyAtItsDeadlineIsNoLongerHidden() {
+        var triage = AlertTriage()
+        triage.snooze("a", until: later)
+
+        #expect(!triage.isHidden("a", now: later))
+    }
+
+    // MARK: - The two are exclusive
+
+    @Test func snoozingADismissedAlertReplacesTheDismissal() {
+        var triage = AlertTriage()
+        triage.dismiss("a")
+        triage.snooze("a", until: later)
+
+        #expect(!triage.isDismissed("a"))
+        #expect(triage.isHidden("a", now: now))
+        #expect(!triage.isHidden("a", now: later))
+    }
+
+    @Test func dismissingASnoozedAlertReplacesTheSnooze() {
+        var triage = AlertTriage()
+        triage.snooze("a", until: later)
+        triage.dismiss("a")
+
+        #expect(triage.isDismissed("a"))
+        #expect(triage.snoozedUntil("a", now: now) == nil)
+        #expect(triage.isHidden("a", now: later.addingTimeInterval(1)))
+    }
+
+    @Test func restoreClearsBothStates() {
+        var triage = AlertTriage()
+        triage.dismiss("a")
+        triage.snooze("b", until: later)
+
+        triage.restore("a")
+        triage.restore("b")
+
+        #expect(!triage.isHidden("a", now: now))
+        #expect(!triage.isHidden("b", now: now))
+    }
+
+    // MARK: - Pruning
+
+    // The case the issue calls out: an alert resolved on GitHub must not leave
+    // local state behind forever.
+    @Test func pruningDropsStateForAlertsGoneUpstream() {
+        var triage = AlertTriage()
+        triage.dismiss("resolved")
+        triage.dismiss("still-open")
+        triage.snooze("also-resolved", until: later)
+
+        let pruned = triage.pruned(presentEventIDs: ["still-open"], now: now)
+
+        #expect(pruned.dismissedEventIDs == ["still-open"])
+        #expect(pruned.snoozedUntilByEventID.isEmpty)
+    }
+
+    @Test func pruningDropsElapsedSnoozes() {
+        var triage = AlertTriage()
+        triage.snooze("expired", until: earlier)
+        triage.snooze("active", until: later)
+
+        let pruned = triage.pruned(presentEventIDs: ["expired", "active"], now: now)
+
+        #expect(Set(pruned.snoozedUntilByEventID.keys) == ["active"])
+    }
+
+    @Test func pruningKeepsStateForAlertsStillPresent() {
+        var triage = AlertTriage()
+        triage.dismiss("a")
+        triage.snooze("b", until: later)
+
+        let pruned = triage.pruned(presentEventIDs: ["a", "b"], now: now)
+
+        #expect(pruned == triage)
+    }
+
+    @Test func pruningAnEmptyTriageIsEmpty() {
+        let pruned = AlertTriage().pruned(presentEventIDs: ["a"], now: now)
+
+        #expect(pruned == AlertTriage())
+    }
+
+    // MARK: - Persistence
+
+    @Test func roundTripsThroughCodable() throws {
+        var triage = AlertTriage()
+        triage.dismiss("a")
+        triage.snooze("b", until: later)
+
+        let encoder = JSONEncoder()
+        encoder.dateEncodingStrategy = .iso8601
+        let decoder = JSONDecoder()
+        decoder.dateDecodingStrategy = .iso8601
+
+        let decoded = try decoder.decode(AlertTriage.self, from: try encoder.encode(triage))
+
+        #expect(decoded == triage)
+    }
+}
+
+struct SnoozeDurationTests {
+
+    private let calendar: Calendar = {
+        var calendar = Calendar(identifier: .gregorian)
+        calendar.timeZone = TimeZone(identifier: "UTC")!
+        return calendar
+    }()
+
+    // 2026-07-25T17:20:00Z
+    private let now = Date(timeIntervalSince1970: 1_785_000_000)
+
+    @Test func anHourIsAnHourLater() {
+        #expect(SnoozeDuration.anHour.date(from: now, calendar: calendar) == now.addingTimeInterval(3600))
+    }
+
+    // Snoozing late at night shouldn't resurface the alert minutes later at
+    // midnight, so "tomorrow" is the start of the next day.
+    @Test func tomorrowIsTheStartOfTheNextDay() {
+        let date = SnoozeDuration.tomorrow.date(from: now, calendar: calendar)
+        let components = calendar.dateComponents([.year, .month, .day, .hour, .minute], from: date)
+
+        #expect(components.day == 26)
+        #expect(components.hour == 0)
+        #expect(components.minute == 0)
+        #expect(date > now)
+    }
+
+    @Test func nextWeekIsTheStartOfTheDaySevenDaysOn() {
+        let date = SnoozeDuration.nextWeek.date(from: now, calendar: calendar)
+        let components = calendar.dateComponents([.month, .day, .hour], from: date)
+
+        #expect(components.month == 8)
+        #expect(components.day == 1)
+        #expect(components.hour == 0)
+    }
+
+    @Test func everyDurationMovesForward() {
+        for duration in SnoozeDuration.allCases {
+            #expect(duration.date(from: now, calendar: calendar) > now)
+            #expect(!duration.displayName.isEmpty)
+        }
+    }
+}
diff --git a/octosentryTests/PersistedStateTests.swift b/octosentryTests/PersistedStateTests.swift
index b3a637e..47b81b0 100644
--- a/octosentryTests/PersistedStateTests.swift
+++ b/octosentryTests/PersistedStateTests.swift
@@ -33,7 +33,13 @@ struct PersistedStateTests {
             minimumSeverity: .high,
             hasRepoScope: true,
             sortOrder: .repo,
-            notifiedEventIDsByRepo: ["octocat/hello-world": ["dependabot-octocat/hello-world-1"]]
+            notifiedEventIDsByRepo: ["octocat/hello-world": ["dependabot-octocat/hello-world-1"]],
+            triage: {
+                var triage = AlertTriage()
+                triage.dismiss("dependabot-octocat/hello-world-2")
+                triage.snooze("codeScanning-octocat/spoon-knife-7", until: Date(timeIntervalSince1970: 1_786_000_000))
+                return triage
+            }()
         )
 
         let decoded = try Self.decoder.decode(
@@ -48,6 +54,7 @@ struct PersistedStateTests {
         #expect(decoded.hasRepoScope == original.hasRepoScope)
         #expect(decoded.sortOrder == original.sortOrder)
         #expect(decoded.notifiedEventIDsByRepo == original.notifiedEventIDsByRepo)
+        #expect(decoded.triage == original.triage)
     }
 
     @Test func encodesTheKeysOnDiskReadersDependOn() throws {
@@ -58,6 +65,7 @@ struct PersistedStateTests {
 
         #expect(Set(object.keys) == [
             "watchedRepos", "seenEventIDs", "lastFetchByRepo", "minimumSeverity", "hasRepoScope", "sortOrder",
+            "triage",
         ])
         // notifiedEventIDsByRepo is optional and nil on the placeholder, so it
         // encodes to nothing rather than a null.
@@ -83,6 +91,7 @@ struct PersistedStateTests {
         #expect(state.hasRepoScope == false)
         #expect(state.sortOrder == .severity)
         #expect(state.notifiedEventIDsByRepo == nil)
+        #expect(state.triage == AlertTriage())
     }
 
     @Test func rejectsStateMissingARequiredField() {