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>
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(-) @@ -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 { new file mode 100644 @@ -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) + } + } +} @@ -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( @@ -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() } } @@ -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 { @@ -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)) } @@ -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"] new file mode 100644 @@ -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) + } + } +} @@ -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() {