krz/octosentry

macOS menu bar app to monitor GitHub security alerts github macos menubar security

Commit e1de03985c

e1de03985cc354580b91eb66223d596fb3a7bc7d

parent: 33379cb538

Unsigned

cmc <hello@cleberg.net> · 2026-08-22 21:10 UTC
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

Layout: unified · split

octosentry/AlertFilter.swift +5 −1
@@ -15,8 +15,12 @@ nonisolated struct AlertFilter: Equatable {
15 var sources: Set<SecurityEventSource> = [] 15 var sources: Set<SecurityEventSource> = []
16 var repos: Set<String> = [] 16 var repos: Set<String> = []
17 17
18 /// Reveals dismissed and snoozed alerts so they can be brought back —
19 /// without it, hiding an alert would be irreversible.
20 var showsHidden = false
21
18 var isActive: Bool { 22 var isActive: Bool {
19 !sources.isEmpty || !repos.isEmpty 23 !sources.isEmpty || !repos.isEmpty || showsHidden
20 } 24 }
21 25
22 func matches(_ event: SecurityEvent) -> Bool { 26 func matches(_ event: SecurityEvent) -> Bool {
octosentry/AlertTriage.swift added +93
@@ -0,0 +1,93 @@
1//
2// AlertTriage.swift
3// octosentry
4//
5// Local-only triage: what the user has hidden, and until when. octosentry
6// deep-links out to github.com to actually resolve an alert, so dismissing
7// here means "hide from my feed", not "dismiss on GitHub".
8//
9// Dismiss and snooze are two shapes of one idea — an alert is hidden, either
10// forever or until a date — so they live in one type behind a single
11// isHidden check. "Seen" (#8) stays separate: it's an acknowledgement that
12// feeds the menu bar badge, not a visibility control.
13//
14
15import Foundation
16
17nonisolated struct AlertTriage: Codable, Equatable {
18 var dismissedEventIDs: Set<String> = []
19 var snoozedUntilByEventID: [String: Date] = [:]
20
21 func isHidden(_ eventID: String, now: Date) -> Bool {
22 if dismissedEventIDs.contains(eventID) { return true }
23 guard let until = snoozedUntilByEventID[eventID] else { return false }
24 return until > now
25 }
26
27 func isDismissed(_ eventID: String) -> Bool {
28 dismissedEventIDs.contains(eventID)
29 }
30
31 func snoozedUntil(_ eventID: String, now: Date) -> Date? {
32 guard let until = snoozedUntilByEventID[eventID], until > now else { return nil }
33 return until
34 }
35
36 mutating func dismiss(_ eventID: String) {
37 snoozedUntilByEventID.removeValue(forKey: eventID)
38 dismissedEventIDs.insert(eventID)
39 }
40
41 mutating func snooze(_ eventID: String, until: Date) {
42 dismissedEventIDs.remove(eventID)
43 snoozedUntilByEventID[eventID] = until
44 }
45
46 mutating func restore(_ eventID: String) {
47 dismissedEventIDs.remove(eventID)
48 snoozedUntilByEventID.removeValue(forKey: eventID)
49 }
50
51 /// Drops state that can no longer apply: alerts resolved upstream, and
52 /// snoozes that have already elapsed. Without this the file grows for the
53 /// life of the install.
54 ///
55 /// `presentEventIDs` must come from a poll where every watched repo
56 /// answered — pruning against a partial fetch would forget that a repo's
57 /// alerts were dismissed.
58 func pruned(presentEventIDs: Set<String>, now: Date) -> AlertTriage {
59 var pruned = self
60 pruned.dismissedEventIDs = dismissedEventIDs.intersection(presentEventIDs)
61 pruned.snoozedUntilByEventID = snoozedUntilByEventID.filter { eventID, until in
62 presentEventIDs.contains(eventID) && until > now
63 }
64 return pruned
65 }
66}
67
68nonisolated enum SnoozeDuration: String, CaseIterable, Hashable {
69 case anHour
70 case tomorrow
71 case nextWeek
72
73 var displayName: String {
74 switch self {
75 case .anHour: "For an hour"
76 case .tomorrow: "Until tomorrow"
77 case .nextWeek: "Until next week"
78 }
79 }
80
81 /// Tomorrow and next week mean the start of that day, not "24 hours from
82 /// now" — snoozing at 23:50 should not resurface the alert at midnight.
83 func date(from now: Date, calendar: Calendar = .current) -> Date {
84 switch self {
85 case .anHour:
86 now.addingTimeInterval(3600)
87 case .tomorrow:
88 calendar.startOfDay(for: calendar.date(byAdding: .day, value: 1, to: now) ?? now)
89 case .nextWeek:
90 calendar.startOfDay(for: calendar.date(byAdding: .day, value: 7, to: now) ?? now)
91 }
92 }
93}
octosentry/PersistedState.swift +10 −4
@@ -4,8 +4,8 @@
4// 4//
5// Everything the app remembers across launches: the repo watch list, 5// Everything the app remembers across launches: the repo watch list,
6// local-only seen-state per event, last-fetch timestamp per repo, the 6// local-only seen-state per event, last-fetch timestamp per repo, the
7// minimum severity filter, the feed sort order, the per-repo alert IDs 7// minimum severity filter, the feed sort order, local triage state, the
8// the notifier has already accounted for, and whether the current token 8// per-repo alert IDs the notifier has already accounted for, and whether the current token
9// has the broader "repo" scope needed to list repos. Flat JSON over SwiftData 9// has the broader "repo" scope needed to list repos. Flat JSON over SwiftData
10// (see #1) — small, inspectable, and these are already plain Codable 10// (see #1) — small, inspectable, and these are already plain Codable
11// values passed across actor boundaries, not reference types tied to a 11// values passed across actor boundaries, not reference types tied to a
@@ -29,9 +29,12 @@ nonisolated struct PersistedState: Codable {
29 /// arrive as a burst. 29 /// arrive as a burst.
30 var notifiedEventIDsByRepo: [String: Set<String>]? 30 var notifiedEventIDsByRepo: [String: Set<String>]?
31 31
32 /// What the user has hidden locally, and until when.
33 var triage: AlertTriage
34
32 enum CodingKeys: String, CodingKey { 35 enum CodingKeys: String, CodingKey {
33 case watchedRepos, seenEventIDs, lastFetchByRepo, minimumSeverity, hasRepoScope, sortOrder 36 case watchedRepos, seenEventIDs, lastFetchByRepo, minimumSeverity, hasRepoScope, sortOrder
34 case notifiedEventIDsByRepo 37 case notifiedEventIDsByRepo, triage
35 } 38 }
36 39
37 init( 40 init(
@@ -41,7 +44,8 @@ nonisolated struct PersistedState: Codable {
41 minimumSeverity: SecurityEventSeverity, 44 minimumSeverity: SecurityEventSeverity,
42 hasRepoScope: Bool = false, 45 hasRepoScope: Bool = false,
43 sortOrder: AlertSortOrder = .severity, 46 sortOrder: AlertSortOrder = .severity,
44 notifiedEventIDsByRepo: [String: Set<String>]? = nil 47 notifiedEventIDsByRepo: [String: Set<String>]? = nil,
48 triage: AlertTriage = AlertTriage()
45 ) { 49 ) {
46 self.watchedRepos = watchedRepos 50 self.watchedRepos = watchedRepos
47 self.seenEventIDs = seenEventIDs 51 self.seenEventIDs = seenEventIDs
@@ -50,6 +54,7 @@ nonisolated struct PersistedState: Codable {
50 self.hasRepoScope = hasRepoScope 54 self.hasRepoScope = hasRepoScope
51 self.sortOrder = sortOrder 55 self.sortOrder = sortOrder
52 self.notifiedEventIDsByRepo = notifiedEventIDsByRepo 56 self.notifiedEventIDsByRepo = notifiedEventIDsByRepo
57 self.triage = triage
53 } 58 }
54 59
55 // Custom decode so existing state.json files saved before hasRepoScope 60 // Custom decode so existing state.json files saved before hasRepoScope
@@ -66,6 +71,7 @@ nonisolated struct PersistedState: Codable {
66 [String: Set<String>].self, 71 [String: Set<String>].self,
67 forKey: .notifiedEventIDsByRepo 72 forKey: .notifiedEventIDsByRepo
68 ) 73 )
74 triage = try container.decodeIfPresent(AlertTriage.self, forKey: .triage) ?? AlertTriage()
69 } 75 }
70 76
71 static let placeholder = PersistedState( 77 static let placeholder = PersistedState(
octosentry/SecurityEventListView.swift +24 −3
@@ -194,6 +194,19 @@ struct SecurityEventListView: View {
194 .menuStyle(.borderlessButton) 194 .menuStyle(.borderlessButton)
195 .fixedSize() 195 .fixedSize()
196 196
197 Button {
198 store.filter.showsHidden.toggle()
199 } label: {
200 FilterLabel(
201 title: "Hidden",
202 count: 0,
203 systemImage: store.filter.showsHidden ? "eye" : "eye.slash"
204 )
205 .foregroundStyle(store.filter.showsHidden ? Color.accentColor : .secondary)
206 }
207 .buttonStyle(.plain)
208 .help("Show dismissed and snoozed alerts")
209
197 Spacer() 210 Spacer()
198 211
199 if store.filter.isActive { 212 if store.filter.isActive {
@@ -274,9 +287,17 @@ struct SecurityEventListView: View {
274 Divider() 287 Divider()
275 } 288 }
276 ForEach(store.events) { event in 289 ForEach(store.events) { event in
277 SecurityEventRow(event: event) { 290 SecurityEventRow(
278 Task { await store.markSeen(event.id) } 291 event: event,
279 } 292 isHidden: store.triage.isHidden(event.id, now: .now),
293 snoozedUntil: store.triage.snoozedUntil(event.id, now: .now),
294 onMarkSeen: { Task { await store.markSeen(event.id) } },
295 onDismiss: { Task { await store.dismiss(event.id) } },
296 onSnooze: { duration in
297 Task { await store.snooze(event.id, until: duration.date(from: .now)) }
298 },
299 onRestore: { Task { await store.restore(event.id) } }
300 )
280 Divider() 301 Divider()
281 } 302 }
282 } 303 }
octosentry/SecurityEventRow.swift +56 −7
@@ -8,7 +8,12 @@ import SwiftUI
8 8
9struct SecurityEventRow: View { 9struct SecurityEventRow: View {
10 let event: SecurityEvent 10 let event: SecurityEvent
11 var isHidden = false
12 var snoozedUntil: Date?
11 var onMarkSeen: () -> Void 13 var onMarkSeen: () -> Void
14 var onDismiss: () -> Void = {}
15 var onSnooze: (SnoozeDuration) -> Void = { _ in }
16 var onRestore: () -> Void = {}
12 17
13 private static let relativeFormatter: RelativeDateTimeFormatter = { 18 private static let relativeFormatter: RelativeDateTimeFormatter = {
14 let formatter = RelativeDateTimeFormatter() 19 let formatter = RelativeDateTimeFormatter()
@@ -42,6 +47,15 @@ struct SecurityEventRow: View {
42 47
43 Spacer() 48 Spacer()
44 49
50 if let hiddenLabel {
51 Text(hiddenLabel)
52 .font(.caption2)
53 .foregroundStyle(.secondary)
54 .padding(.horizontal, 5)
55 .padding(.vertical, 1)
56 .background(.secondary.opacity(0.15), in: Capsule())
57 }
58
45 Text(Self.relativeFormatter.localizedString(for: event.createdAt, relativeTo: .now)) 59 Text(Self.relativeFormatter.localizedString(for: event.createdAt, relativeTo: .now))
46 .font(.caption2) 60 .font(.caption2)
47 .foregroundStyle(.secondary) 61 .foregroundStyle(.secondary)
@@ -57,16 +71,51 @@ struct SecurityEventRow: View {
57 } 71 }
58 .buttonStyle(.plain) 72 .buttonStyle(.plain)
59 73
60 Button(action: onMarkSeen) { 74 if isHidden {
61 Image(systemName: "checkmark.circle") 75 Button(action: onRestore) {
76 Image(systemName: "arrow.uturn.backward.circle")
77 }
78 .buttonStyle(.plain)
79 .foregroundStyle(.secondary)
80 .help("Bring back")
81 .padding(.top, 12)
82 .padding(.trailing, 10)
83 } else {
84 Button(action: onMarkSeen) {
85 Image(systemName: "checkmark.circle")
86 }
87 .buttonStyle(.plain)
88 .foregroundStyle(.secondary)
89 .help("Mark as seen")
90 .padding(.top, 12)
91 .padding(.trailing, 6)
92
93 Menu {
94 Menu("Snooze") {
95 ForEach(SnoozeDuration.allCases, id: \.self) { duration in
96 Button(duration.displayName) { onSnooze(duration) }
97 }
98 }
99 Button("Dismiss", action: onDismiss)
100 } label: {
101 Image(systemName: "ellipsis.circle")
102 }
103 .menuStyle(.borderlessButton)
104 .menuIndicator(.hidden)
105 .fixedSize()
106 .foregroundStyle(.secondary)
107 .help("Snooze or dismiss")
108 .padding(.top, 10)
109 .padding(.trailing, 10)
62 } 110 }
63 .buttonStyle(.plain)
64 .foregroundStyle(.secondary)
65 .help("Mark as seen")
66 .padding(.top, 12)
67 .padding(.trailing, 10)
68 } 111 }
69 } 112 }
113
114 private var hiddenLabel: String? {
115 guard isHidden else { return nil }
116 guard let snoozedUntil else { return "Dismissed" }
117 return "Snoozed until \(snoozedUntil.formatted(date: .abbreviated, time: .shortened))"
118 }
70} 119}
71 120
72#Preview { 121#Preview {
octosentry/SecurityEventStore.swift +45 −1
@@ -34,6 +34,10 @@ final class SecurityEventStore {
34 didSet { applyFilters() } 34 didSet { applyFilters() }
35 } 35 }
36 36
37 /// Local triage state (dismissed / snoozed), mirrored from PersistedState
38 /// so the feed can be re-derived without touching disk.
39 private(set) var triage = AlertTriage()
40
37 /// Repos represented in the current fetch, for the repo filter menu — 41 /// Repos represented in the current fetch, for the repo filter menu —
38 /// the watch list can contain repos that returned nothing. 42 /// the watch list can contain repos that returned nothing.
39 var reposInFeed: [String] { 43 var reposInFeed: [String] {
@@ -65,6 +69,7 @@ final class SecurityEventStore {
65 minimumSeverity = state.minimumSeverity 69 minimumSeverity = state.minimumSeverity
66 sortOrder = state.sortOrder 70 sortOrder = state.sortOrder
67 watchedRepos = state.watchedRepos 71 watchedRepos = state.watchedRepos
72 triage = state.triage
68 73
69 guard let token = KeychainTokenStore.load() else { 74 guard let token = KeychainTokenStore.load() else {
70 errorMessages = [stateLoadFailure, GitHubAPIError.missingToken.errorDescription ?? "Not signed in."] 75 errorMessages = [stateLoadFailure, GitHubAPIError.missingToken.errorDescription ?? "Not signed in."]
@@ -130,6 +135,17 @@ final class SecurityEventStore {
130 errorMessages = errors 135 errorMessages = errors
131 unavailableNotices = notices 136 unavailableNotices = notices
132 137
138 // Only prune against a complete picture: if a repo failed this round
139 // its alerts are missing, and pruning would forget they were hidden.
140 if fetchedEventsByRepo.count == state.watchedRepos.count {
141 state.triage = state.triage.pruned(
142 presentEventIDs: Set(fetchedEvents.map(\.id)),
143 now: Date()
144 )
145 triage = state.triage
146 applyFilters()
147 }
148
133 let newEvents = AlertDiff.newlyAppeared( 149 let newEvents = AlertDiff.newlyAppeared(
134 in: fetchedEventsByRepo, 150 in: fetchedEventsByRepo,
135 baseline: state.notifiedEventIDsByRepo, 151 baseline: state.notifiedEventIDsByRepo,
@@ -214,6 +230,30 @@ final class SecurityEventStore {
214 applyFilters() 230 applyFilters()
215 } 231 }
216 232
233 /// Hides an alert until the user brings it back. Local only — the alert
234 /// is still open on GitHub.
235 func dismiss(_ eventID: String) async {
236 await updateTriage { $0.dismiss(eventID) }
237 }
238
239 /// Hides an alert until `date`; a later poll brings it back.
240 func snooze(_ eventID: String, until date: Date) async {
241 await updateTriage { $0.snooze(eventID, until: date) }
242 }
243
244 func restore(_ eventID: String) async {
245 await updateTriage { $0.restore(eventID) }
246 }
247
248 private func updateTriage(_ change: (inout AlertTriage) -> Void) async {
249 var state = await persistenceStore.load()
250 change(&state.triage)
251 await persistenceStore.save(state)
252
253 triage = state.triage
254 applyFilters()
255 }
256
217 func removeRepo(_ repoFullName: String) async { 257 func removeRepo(_ repoFullName: String) async {
218 var state = await persistenceStore.load() 258 var state = await persistenceStore.load()
219 state.watchedRepos.removeAll { $0 == repoFullName } 259 state.watchedRepos.removeAll { $0 == repoFullName }
@@ -239,7 +279,11 @@ final class SecurityEventStore {
239 } 279 }
240 280
241 private func applyFilters() { 281 private func applyFilters() {
242 let admitted = rawEvents.filter { $0.severity >= minimumSeverity } 282 let now = Date()
283 let admitted = rawEvents.filter { event in
284 guard event.severity >= minimumSeverity else { return false }
285 return filter.showsHidden || !triage.isHidden(event.id, now: now)
286 }
243 events = sortOrder.sorted(filter.apply(to: admitted)) 287 events = sortOrder.sorted(filter.apply(to: admitted))
244 } 288 }
245 289
octosentryTests/AlertFilterTests.swift +10
@@ -58,6 +58,16 @@ struct AlertFilterTests {
58 #expect(filter.apply(to: allEvents).map(\.id) == ["a"]) 58 #expect(filter.apply(to: allEvents).map(\.id) == ["a"])
59 } 59 }
60 60
61 @Test func showingHiddenCountsAsAnActiveFilter() {
62 var filter = AlertFilter()
63 #expect(filter.isActive == false)
64
65 filter.showsHidden = true
66 #expect(filter.isActive)
67 // It reveals rows rather than removing them, so nothing is filtered out.
68 #expect(filter.apply(to: allEvents).map(\.id) == ["a", "b", "c"])
69 }
70
61 @Test func filterPreservesInputOrder() { 71 @Test func filterPreservesInputOrder() {
62 var filter = AlertFilter() 72 var filter = AlertFilter()
63 filter.repos = ["octocat/hello-world"] 73 filter.repos = ["octocat/hello-world"]
octosentryTests/AlertTriageTests.swift added +196
@@ -0,0 +1,196 @@
1//
2// AlertTriageTests.swift
3// octosentryTests
4//
5
6import Foundation
7import Testing
8@testable import octosentry
9
10struct AlertTriageTests {
11
12 private let now = Date(timeIntervalSince1970: 1_785_000_000)
13 private var later: Date { now.addingTimeInterval(3600) }
14 private var earlier: Date { now.addingTimeInterval(-3600) }
15
16 // MARK: - Dismiss
17
18 @Test func dismissHidesAnAlert() {
19 var triage = AlertTriage()
20 triage.dismiss("a")
21
22 #expect(triage.isHidden("a", now: now))
23 #expect(triage.isDismissed("a"))
24 #expect(!triage.isHidden("b", now: now))
25 }
26
27 @Test func dismissedAlertsStayHiddenIndefinitely() {
28 var triage = AlertTriage()
29 triage.dismiss("a")
30
31 #expect(triage.isHidden("a", now: now.addingTimeInterval(60 * 60 * 24 * 365)))
32 }
33
34 // MARK: - Snooze
35
36 @Test func snoozeHidesUntilItsDeadline() {
37 var triage = AlertTriage()
38 triage.snooze("a", until: later)
39
40 #expect(triage.isHidden("a", now: now))
41 #expect(triage.snoozedUntil("a", now: now) == later)
42 }
43
44 // The resurfacing behaviour: a later poll sees the deadline has passed.
45 @Test func snoozeStopsHidingOnceItsDeadlinePasses() {
46 var triage = AlertTriage()
47 triage.snooze("a", until: later)
48
49 #expect(!triage.isHidden("a", now: later.addingTimeInterval(1)))
50 #expect(triage.snoozedUntil("a", now: later.addingTimeInterval(1)) == nil)
51 }
52
53 @Test func snoozeExactlyAtItsDeadlineIsNoLongerHidden() {
54 var triage = AlertTriage()
55 triage.snooze("a", until: later)
56
57 #expect(!triage.isHidden("a", now: later))
58 }
59
60 // MARK: - The two are exclusive
61
62 @Test func snoozingADismissedAlertReplacesTheDismissal() {
63 var triage = AlertTriage()
64 triage.dismiss("a")
65 triage.snooze("a", until: later)
66
67 #expect(!triage.isDismissed("a"))
68 #expect(triage.isHidden("a", now: now))
69 #expect(!triage.isHidden("a", now: later))
70 }
71
72 @Test func dismissingASnoozedAlertReplacesTheSnooze() {
73 var triage = AlertTriage()
74 triage.snooze("a", until: later)
75 triage.dismiss("a")
76
77 #expect(triage.isDismissed("a"))
78 #expect(triage.snoozedUntil("a", now: now) == nil)
79 #expect(triage.isHidden("a", now: later.addingTimeInterval(1)))
80 }
81
82 @Test func restoreClearsBothStates() {
83 var triage = AlertTriage()
84 triage.dismiss("a")
85 triage.snooze("b", until: later)
86
87 triage.restore("a")
88 triage.restore("b")
89
90 #expect(!triage.isHidden("a", now: now))
91 #expect(!triage.isHidden("b", now: now))
92 }
93
94 // MARK: - Pruning
95
96 // The case the issue calls out: an alert resolved on GitHub must not leave
97 // local state behind forever.
98 @Test func pruningDropsStateForAlertsGoneUpstream() {
99 var triage = AlertTriage()
100 triage.dismiss("resolved")
101 triage.dismiss("still-open")
102 triage.snooze("also-resolved", until: later)
103
104 let pruned = triage.pruned(presentEventIDs: ["still-open"], now: now)
105
106 #expect(pruned.dismissedEventIDs == ["still-open"])
107 #expect(pruned.snoozedUntilByEventID.isEmpty)
108 }
109
110 @Test func pruningDropsElapsedSnoozes() {
111 var triage = AlertTriage()
112 triage.snooze("expired", until: earlier)
113 triage.snooze("active", until: later)
114
115 let pruned = triage.pruned(presentEventIDs: ["expired", "active"], now: now)
116
117 #expect(Set(pruned.snoozedUntilByEventID.keys) == ["active"])
118 }
119
120 @Test func pruningKeepsStateForAlertsStillPresent() {
121 var triage = AlertTriage()
122 triage.dismiss("a")
123 triage.snooze("b", until: later)
124
125 let pruned = triage.pruned(presentEventIDs: ["a", "b"], now: now)
126
127 #expect(pruned == triage)
128 }
129
130 @Test func pruningAnEmptyTriageIsEmpty() {
131 let pruned = AlertTriage().pruned(presentEventIDs: ["a"], now: now)
132
133 #expect(pruned == AlertTriage())
134 }
135
136 // MARK: - Persistence
137
138 @Test func roundTripsThroughCodable() throws {
139 var triage = AlertTriage()
140 triage.dismiss("a")
141 triage.snooze("b", until: later)
142
143 let encoder = JSONEncoder()
144 encoder.dateEncodingStrategy = .iso8601
145 let decoder = JSONDecoder()
146 decoder.dateDecodingStrategy = .iso8601
147
148 let decoded = try decoder.decode(AlertTriage.self, from: try encoder.encode(triage))
149
150 #expect(decoded == triage)
151 }
152}
153
154struct SnoozeDurationTests {
155
156 private let calendar: Calendar = {
157 var calendar = Calendar(identifier: .gregorian)
158 calendar.timeZone = TimeZone(identifier: "UTC")!
159 return calendar
160 }()
161
162 // 2026-07-25T17:20:00Z
163 private let now = Date(timeIntervalSince1970: 1_785_000_000)
164
165 @Test func anHourIsAnHourLater() {
166 #expect(SnoozeDuration.anHour.date(from: now, calendar: calendar) == now.addingTimeInterval(3600))
167 }
168
169 // Snoozing late at night shouldn't resurface the alert minutes later at
170 // midnight, so "tomorrow" is the start of the next day.
171 @Test func tomorrowIsTheStartOfTheNextDay() {
172 let date = SnoozeDuration.tomorrow.date(from: now, calendar: calendar)
173 let components = calendar.dateComponents([.year, .month, .day, .hour, .minute], from: date)
174
175 #expect(components.day == 26)
176 #expect(components.hour == 0)
177 #expect(components.minute == 0)
178 #expect(date > now)
179 }
180
181 @Test func nextWeekIsTheStartOfTheDaySevenDaysOn() {
182 let date = SnoozeDuration.nextWeek.date(from: now, calendar: calendar)
183 let components = calendar.dateComponents([.month, .day, .hour], from: date)
184
185 #expect(components.month == 8)
186 #expect(components.day == 1)
187 #expect(components.hour == 0)
188 }
189
190 @Test func everyDurationMovesForward() {
191 for duration in SnoozeDuration.allCases {
192 #expect(duration.date(from: now, calendar: calendar) > now)
193 #expect(!duration.displayName.isEmpty)
194 }
195 }
196}
octosentryTests/PersistedStateTests.swift +10 −1
@@ -33,7 +33,13 @@ struct PersistedStateTests {
33 minimumSeverity: .high, 33 minimumSeverity: .high,
34 hasRepoScope: true, 34 hasRepoScope: true,
35 sortOrder: .repo, 35 sortOrder: .repo,
36 notifiedEventIDsByRepo: ["octocat/hello-world": ["dependabot-octocat/hello-world-1"]] 36 notifiedEventIDsByRepo: ["octocat/hello-world": ["dependabot-octocat/hello-world-1"]],
37 triage: {
38 var triage = AlertTriage()
39 triage.dismiss("dependabot-octocat/hello-world-2")
40 triage.snooze("codeScanning-octocat/spoon-knife-7", until: Date(timeIntervalSince1970: 1_786_000_000))
41 return triage
42 }()
37 ) 43 )
38 44
39 let decoded = try Self.decoder.decode( 45 let decoded = try Self.decoder.decode(
@@ -48,6 +54,7 @@ struct PersistedStateTests {
48 #expect(decoded.hasRepoScope == original.hasRepoScope) 54 #expect(decoded.hasRepoScope == original.hasRepoScope)
49 #expect(decoded.sortOrder == original.sortOrder) 55 #expect(decoded.sortOrder == original.sortOrder)
50 #expect(decoded.notifiedEventIDsByRepo == original.notifiedEventIDsByRepo) 56 #expect(decoded.notifiedEventIDsByRepo == original.notifiedEventIDsByRepo)
57 #expect(decoded.triage == original.triage)
51 } 58 }
52 59
53 @Test func encodesTheKeysOnDiskReadersDependOn() throws { 60 @Test func encodesTheKeysOnDiskReadersDependOn() throws {
@@ -58,6 +65,7 @@ struct PersistedStateTests {
58 65
59 #expect(Set(object.keys) == [ 66 #expect(Set(object.keys) == [
60 "watchedRepos", "seenEventIDs", "lastFetchByRepo", "minimumSeverity", "hasRepoScope", "sortOrder", 67 "watchedRepos", "seenEventIDs", "lastFetchByRepo", "minimumSeverity", "hasRepoScope", "sortOrder",
68 "triage",
61 ]) 69 ])
62 // notifiedEventIDsByRepo is optional and nil on the placeholder, so it 70 // notifiedEventIDsByRepo is optional and nil on the placeholder, so it
63 // encodes to nothing rather than a null. 71 // encodes to nothing rather than a null.
@@ -83,6 +91,7 @@ struct PersistedStateTests {
83 #expect(state.hasRepoScope == false) 91 #expect(state.hasRepoScope == false)
84 #expect(state.sortOrder == .severity) 92 #expect(state.sortOrder == .severity)
85 #expect(state.notifiedEventIDsByRepo == nil) 93 #expect(state.notifiedEventIDsByRepo == nil)
94 #expect(state.triage == AlertTriage())
86 } 95 }
87 96
88 @Test func rejectsStateMissingARequiredField() { 97 @Test func rejectsStateMissingARequiredField() {