Commit e1de03985c
Unsigned
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 | |||
| 15 | import Foundation | ||
| 16 | |||
| 17 | nonisolated 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 | |||
| 68 | nonisolated 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 | ||
| 9 | struct SecurityEventRow: View { | 9 | struct 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 | |||
| 6 | import Foundation | ||
| 7 | import Testing | ||
| 8 | @testable import octosentry | ||
| 9 | |||
| 10 | struct 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 | |||
| 154 | struct 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() { |