Commit e1de03985c
Unsigned
Layout: unified · split
octosentry/AlertFilter.swift +5 −1
| @@ -15,8 +15,12 @@ nonisolated struct AlertFilter: Equatable { | ||
| 15 | 15 | var sources: Set<SecurityEventSource> = [] |
| 16 | 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 | 22 | var isActive: Bool { |
| 19 | !sources.isEmpty || !repos.isEmpty | |
| 23 | !sources.isEmpty || !repos.isEmpty || showsHidden | |
| 20 | 24 | } |
| 21 | 25 | |
| 22 | 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 | 5 | // Everything the app remembers across launches: the repo watch list, |
| 6 | 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 | |
| 8 | // the notifier has already accounted for, and whether the current token | |
| 7 | // minimum severity filter, the feed sort order, local triage state, the | |
| 8 | // per-repo alert IDs the notifier has already accounted for, and whether the current token | |
| 9 | 9 | // has the broader "repo" scope needed to list repos. Flat JSON over SwiftData |
| 10 | 10 | // (see #1) — small, inspectable, and these are already plain Codable |
| 11 | 11 | // values passed across actor boundaries, not reference types tied to a |
| @@ -29,9 +29,12 @@ nonisolated struct PersistedState: Codable { | ||
| 29 | 29 | /// arrive as a burst. |
| 30 | 30 | var notifiedEventIDsByRepo: [String: Set<String>]? |
| 31 | 31 | |
| 32 | /// What the user has hidden locally, and until when. | |
| 33 | var triage: AlertTriage | |
| 34 | ||
| 32 | 35 | enum CodingKeys: String, CodingKey { |
| 33 | 36 | case watchedRepos, seenEventIDs, lastFetchByRepo, minimumSeverity, hasRepoScope, sortOrder |
| 34 | case notifiedEventIDsByRepo | |
| 37 | case notifiedEventIDsByRepo, triage | |
| 35 | 38 | } |
| 36 | 39 | |
| 37 | 40 | init( |
| @@ -41,7 +44,8 @@ nonisolated struct PersistedState: Codable { | ||
| 41 | 44 | minimumSeverity: SecurityEventSeverity, |
| 42 | 45 | hasRepoScope: Bool = false, |
| 43 | 46 | sortOrder: AlertSortOrder = .severity, |
| 44 | notifiedEventIDsByRepo: [String: Set<String>]? = nil | |
| 47 | notifiedEventIDsByRepo: [String: Set<String>]? = nil, | |
| 48 | triage: AlertTriage = AlertTriage() | |
| 45 | 49 | ) { |
| 46 | 50 | self.watchedRepos = watchedRepos |
| 47 | 51 | self.seenEventIDs = seenEventIDs |
| @@ -50,6 +54,7 @@ nonisolated struct PersistedState: Codable { | ||
| 50 | 54 | self.hasRepoScope = hasRepoScope |
| 51 | 55 | self.sortOrder = sortOrder |
| 52 | 56 | self.notifiedEventIDsByRepo = notifiedEventIDsByRepo |
| 57 | self.triage = triage | |
| 53 | 58 | } |
| 54 | 59 | |
| 55 | 60 | // Custom decode so existing state.json files saved before hasRepoScope |
| @@ -66,6 +71,7 @@ nonisolated struct PersistedState: Codable { | ||
| 66 | 71 | [String: Set<String>].self, |
| 67 | 72 | forKey: .notifiedEventIDsByRepo |
| 68 | 73 | ) |
| 74 | triage = try container.decodeIfPresent(AlertTriage.self, forKey: .triage) ?? AlertTriage() | |
| 69 | 75 | } |
| 70 | 76 | |
| 71 | 77 | static let placeholder = PersistedState( |
octosentry/SecurityEventListView.swift +24 −3
| @@ -194,6 +194,19 @@ struct SecurityEventListView: View { | ||
| 194 | 194 | .menuStyle(.borderlessButton) |
| 195 | 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 | 210 | Spacer() |
| 198 | 211 | |
| 199 | 212 | if store.filter.isActive { |
| @@ -274,9 +287,17 @@ struct SecurityEventListView: View { | ||
| 274 | 287 | Divider() |
| 275 | 288 | } |
| 276 | 289 | ForEach(store.events) { event in |
| 277 | SecurityEventRow(event: event) { | |
| 278 | Task { await store.markSeen(event.id) } | |
| 279 | } | |
| 290 | SecurityEventRow( | |
| 291 | event: event, | |
| 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 | 301 | Divider() |
| 281 | 302 | } |
| 282 | 303 | } |
octosentry/SecurityEventRow.swift +56 −7
| @@ -8,7 +8,12 @@ import SwiftUI | ||
| 8 | 8 | |
| 9 | 9 | struct SecurityEventRow: View { |
| 10 | 10 | let event: SecurityEvent |
| 11 | var isHidden = false | |
| 12 | var snoozedUntil: Date? | |
| 11 | 13 | var onMarkSeen: () -> Void |
| 14 | var onDismiss: () -> Void = {} | |
| 15 | var onSnooze: (SnoozeDuration) -> Void = { _ in } | |
| 16 | var onRestore: () -> Void = {} | |
| 12 | 17 | |
| 13 | 18 | private static let relativeFormatter: RelativeDateTimeFormatter = { |
| 14 | 19 | let formatter = RelativeDateTimeFormatter() |
| @@ -42,6 +47,15 @@ struct SecurityEventRow: View { | ||
| 42 | 47 | |
| 43 | 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 | 59 | Text(Self.relativeFormatter.localizedString(for: event.createdAt, relativeTo: .now)) |
| 46 | 60 | .font(.caption2) |
| 47 | 61 | .foregroundStyle(.secondary) |
| @@ -57,16 +71,51 @@ struct SecurityEventRow: View { | ||
| 57 | 71 | } |
| 58 | 72 | .buttonStyle(.plain) |
| 59 | 73 | |
| 60 | Button(action: onMarkSeen) { | |
| 61 | Image(systemName: "checkmark.circle") | |
| 74 | if isHidden { | |
| 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 | 121 | #Preview { |
octosentry/SecurityEventStore.swift +45 −1
| @@ -34,6 +34,10 @@ final class SecurityEventStore { | ||
| 34 | 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 | 41 | /// Repos represented in the current fetch, for the repo filter menu — |
| 38 | 42 | /// the watch list can contain repos that returned nothing. |
| 39 | 43 | var reposInFeed: [String] { |
| @@ -65,6 +69,7 @@ final class SecurityEventStore { | ||
| 65 | 69 | minimumSeverity = state.minimumSeverity |
| 66 | 70 | sortOrder = state.sortOrder |
| 67 | 71 | watchedRepos = state.watchedRepos |
| 72 | triage = state.triage | |
| 68 | 73 | |
| 69 | 74 | guard let token = KeychainTokenStore.load() else { |
| 70 | 75 | errorMessages = [stateLoadFailure, GitHubAPIError.missingToken.errorDescription ?? "Not signed in."] |
| @@ -130,6 +135,17 @@ final class SecurityEventStore { | ||
| 130 | 135 | errorMessages = errors |
| 131 | 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 | 149 | let newEvents = AlertDiff.newlyAppeared( |
| 134 | 150 | in: fetchedEventsByRepo, |
| 135 | 151 | baseline: state.notifiedEventIDsByRepo, |
| @@ -214,6 +230,30 @@ final class SecurityEventStore { | ||
| 214 | 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 | 257 | func removeRepo(_ repoFullName: String) async { |
| 218 | 258 | var state = await persistenceStore.load() |
| 219 | 259 | state.watchedRepos.removeAll { $0 == repoFullName } |
| @@ -239,7 +279,11 @@ final class SecurityEventStore { | ||
| 239 | 279 | } |
| 240 | 280 | |
| 241 | 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 | 287 | events = sortOrder.sorted(filter.apply(to: admitted)) |
| 244 | 288 | } |
| 245 | 289 | |
octosentryTests/AlertFilterTests.swift +10
| @@ -58,6 +58,16 @@ struct AlertFilterTests { | ||
| 58 | 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 | 71 | @Test func filterPreservesInputOrder() { |
| 62 | 72 | var filter = AlertFilter() |
| 63 | 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 | 33 | minimumSeverity: .high, |
| 34 | 34 | hasRepoScope: true, |
| 35 | 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 | 45 | let decoded = try Self.decoder.decode( |
| @@ -48,6 +54,7 @@ struct PersistedStateTests { | ||
| 48 | 54 | #expect(decoded.hasRepoScope == original.hasRepoScope) |
| 49 | 55 | #expect(decoded.sortOrder == original.sortOrder) |
| 50 | 56 | #expect(decoded.notifiedEventIDsByRepo == original.notifiedEventIDsByRepo) |
| 57 | #expect(decoded.triage == original.triage) | |
| 51 | 58 | } |
| 52 | 59 | |
| 53 | 60 | @Test func encodesTheKeysOnDiskReadersDependOn() throws { |
| @@ -58,6 +65,7 @@ struct PersistedStateTests { | ||
| 58 | 65 | |
| 59 | 66 | #expect(Set(object.keys) == [ |
| 60 | 67 | "watchedRepos", "seenEventIDs", "lastFetchByRepo", "minimumSeverity", "hasRepoScope", "sortOrder", |
| 68 | "triage", | |
| 61 | 69 | ]) |
| 62 | 70 | // notifiedEventIDsByRepo is optional and nil on the placeholder, so it |
| 63 | 71 | // encodes to nothing rather than a null. |
| @@ -83,6 +91,7 @@ struct PersistedStateTests { | ||
| 83 | 91 | #expect(state.hasRepoScope == false) |
| 84 | 92 | #expect(state.sortOrder == .severity) |
| 85 | 93 | #expect(state.notifiedEventIDsByRepo == nil) |
| 94 | #expect(state.triage == AlertTriage()) | |
| 86 | 95 | } |
| 87 | 96 | |
| 88 | 97 | @Test func rejectsStateMissingARequiredField() { |