krz/octosentry
macOS menu bar app to monitor GitHub security alerts
clone: git clone https://gitbay.org/krz/octosentry.git
d2c42c2a5cd10ae144b715dec80a2555d2bbc1bc
signed_unknown_key
author: Christian Cleberg <hello@cleberg.net> · 2026-08-22T21:15:24Z
committer: <noreply@github.com>
octosentry/AlertHistory.swift | 143 ++++++++++++++++++++ octosentry/AlertHistoryView.swift | 99 ++++++++++++++ octosentry/PersistedState.swift | 11 +- octosentry/SecurityEventListView.swift | 15 ++- octosentry/SecurityEventStore.swift | 13 +- octosentryTests/AlertHistoryTests.swift | 217 ++++++++++++++++++++++++++++++ octosentryTests/PersistedStateTests.swift | 3 +- 7 files changed, 495 insertions(+), 6 deletions(-) new file mode 100644 @@ -0,0 +1,143 @@ +// +// AlertHistory.swift +// octosentry +// +// A record of what the feed looked like over time, so the app can answer +// "are we getting better or worse?" and "how long do alerts sit open?" +// +// Two parts, because they answer different questions: periodic snapshots +// give the open-count trend, and a per-alert lifecycle gives new-vs-resolved +// and time to resolution. +// +// Both are bounded. Snapshots are taken at most every six hours and kept for +// 90 days (≈360 entries); resolved lifecycles are dropped after 90 days. +// Unbounded growth is the obvious failure mode for a per-poll time series, +// and this lives in the same state.json as everything else. +// + +import Foundation + +nonisolated struct AlertHistory: Codable, Equatable { + static let snapshotInterval: TimeInterval = 6 * 3600 + static let retention: TimeInterval = 90 * 24 * 3600 + + var snapshots: [Snapshot] = [] + var lifecycles: [String: Lifecycle] = [:] + + nonisolated struct Snapshot: Codable, Equatable { + var recordedAt: Date + var openCount: Int + /// Keyed by SecurityEventSource.rawValue / SecurityEventSeverity + /// displayName so the file stays readable. + var countsBySource: [String: Int] + var countsBySeverity: [String: Int] + } + + nonisolated struct Lifecycle: Codable, Equatable { + var repoFullName: String + var source: String + var severity: SecurityEventSeverity + /// When GitHub opened the alert, not when octosentry first saw it — + /// otherwise time-to-resolution would be measured from install day. + var openedAt: Date + var lastSeenAt: Date + /// First poll that no longer reported it. "No longer reported" is the + /// only resolution signal the API gives. + var resolvedAt: Date? + } + + /// Folds one complete poll into the history. + /// + /// `events` must come from a poll where every watched repo answered: + /// an alert missing because its repo errored is not a resolved alert. + mutating func record(_ events: [SecurityEvent], at date: Date) { + let presentIDs = Set(events.map(\.id)) + + for event in events { + if var lifecycle = lifecycles[event.id] { + lifecycle.lastSeenAt = date + lifecycle.severity = event.severity + // Back from the dead: GitHub re-reported it. + lifecycle.resolvedAt = nil + lifecycles[event.id] = lifecycle + } else { + lifecycles[event.id] = Lifecycle( + repoFullName: event.repoFullName, + source: event.source.rawValue, + severity: event.severity, + openedAt: event.createdAt, + lastSeenAt: date, + resolvedAt: nil + ) + } + } + + for (id, var lifecycle) in lifecycles where !presentIDs.contains(id) && lifecycle.resolvedAt == nil { + lifecycle.resolvedAt = date + lifecycles[id] = lifecycle + } + + appendSnapshot(for: events, at: date) + prune(now: date) + } + + private mutating func appendSnapshot(for events: [SecurityEvent], at date: Date) { + if let last = snapshots.last, date.timeIntervalSince(last.recordedAt) < Self.snapshotInterval { + return + } + + var countsBySource: [String: Int] = [:] + var countsBySeverity: [String: Int] = [:] + for event in events { + countsBySource[event.source.rawValue, default: 0] += 1 + countsBySeverity[event.severity.displayName, default: 0] += 1 + } + + snapshots.append( + Snapshot( + recordedAt: date, + openCount: events.count, + countsBySource: countsBySource, + countsBySeverity: countsBySeverity + ) + ) + } + + private mutating func prune(now: Date) { + let cutoff = now.addingTimeInterval(-Self.retention) + snapshots.removeAll { $0.recordedAt < cutoff } + lifecycles = lifecycles.filter { _, lifecycle in + guard let resolvedAt = lifecycle.resolvedAt else { return true } + return resolvedAt >= cutoff + } + } + + // MARK: - Trends + + var openCountOverTime: [Snapshot] { + snapshots.sorted { $0.recordedAt < $1.recordedAt } + } + + func openedCount(since date: Date) -> Int { + lifecycles.values.filter { $0.openedAt >= date }.count + } + + func resolvedCount(since date: Date) -> Int { + lifecycles.values.filter { ($0.resolvedAt ?? .distantFuture) >= date && $0.resolvedAt != nil }.count + } + + var currentlyOpenCount: Int { + lifecycles.values.filter { $0.resolvedAt == nil }.count + } + + /// Mean time from GitHub opening an alert to octosentry no longer seeing + /// it. nil when nothing has been resolved yet. + var meanTimeToResolution: TimeInterval? { + let durations = lifecycles.values.compactMap { lifecycle -> TimeInterval? in + guard let resolvedAt = lifecycle.resolvedAt else { return nil } + return resolvedAt.timeIntervalSince(lifecycle.openedAt) + } + guard !durations.isEmpty else { return nil } + return durations.reduce(0, +) / Double(durations.count) + } +} new file mode 100644 @@ -0,0 +1,99 @@ +// +// AlertHistoryView.swift +// octosentry +// +// Trends over time. Lives in the dedicated window (#10) rather than the +// popover — the popover is 380pt wide and meant for at-a-glance triage. +// + +import Charts +import SwiftUI + +struct AlertHistoryView: View { + var store: SecurityEventStore + + private static let windowLength: TimeInterval = 30 * 24 * 3600 + + private var since: Date { + Date().addingTimeInterval(-Self.windowLength) + } + + var body: some View { + ScrollView { + VStack(alignment: .leading, spacing: 16) { + summary + Divider() + openOverTime + Divider() + resolutionNote + } + .padding(16) + .frame(maxWidth: .infinity, alignment: .leading) + } + } + + private var summary: some View { + HStack(alignment: .top, spacing: 24) { + Statistic(label: "Open now", value: "\(store.history.currentlyOpenCount)") + Statistic(label: "New (30d)", value: "\(store.history.openedCount(since: since))") + Statistic(label: "Resolved (30d)", value: "\(store.history.resolvedCount(since: since))") + Statistic(label: "Mean time to resolution", value: meanTimeToResolutionText) + } + } + + private var meanTimeToResolutionText: String { + guard let interval = store.history.meanTimeToResolution else { return "—" } + let days = interval / 86_400 + return days >= 1 + ? String(format: "%.1f d", days) + : String(format: "%.0f h", interval / 3600) + } + + @ViewBuilder + private var openOverTime: some View { + VStack(alignment: .leading, spacing: 8) { + Text("Open alerts over time") + .font(.subheadline.weight(.semibold)) + + let snapshots = store.history.openCountOverTime + if snapshots.count < 2 { + Text("Not enough history yet — octosentry records a snapshot every few hours.") + .font(.callout) + .foregroundStyle(.secondary) + } else { + Chart(snapshots, id: \.recordedAt) { snapshot in + LineMark( + x: .value("Date", snapshot.recordedAt), + y: .value("Open", snapshot.openCount) + ) + .interpolationMethod(.monotone) + } + .chartYScale(domain: .automatic(includesZero: true)) + .frame(height: 180) + } + } + } + + private var resolutionNote: some View { + Text("An alert counts as resolved once GitHub stops reporting it — the API gives no other signal, " + + "so a repo losing access or leaving the watch list can look the same. Only polls where every " + + "watched repo answered are recorded.") + .font(.caption) + .foregroundStyle(.secondary) + } +} + +private struct Statistic: View { + let label: String + let value: String + + var body: some View { + VStack(alignment: .leading, spacing: 2) { + Text(value) + .font(.title2.weight(.semibold)) + Text(label) + .font(.caption) + .foregroundStyle(.secondary) + } + } +} @@ -32,9 +32,13 @@ nonisolated struct PersistedState: Codable { /// What the user has hidden locally, and until when. var triage: AlertTriage + /// Snapshots and per-alert lifecycles behind the trends view. Bounded by + /// AlertHistory's own retention rules. + var history: AlertHistory + enum CodingKeys: String, CodingKey { case watchedRepos, seenEventIDs, lastFetchByRepo, minimumSeverity, hasRepoScope, sortOrder - case notifiedEventIDsByRepo, triage + case notifiedEventIDsByRepo, triage, history } init( @@ -45,7 +49,8 @@ nonisolated struct PersistedState: Codable { hasRepoScope: Bool = false, sortOrder: AlertSortOrder = .severity, notifiedEventIDsByRepo: [String: Set<String>]? = nil, - triage: AlertTriage = AlertTriage() + triage: AlertTriage = AlertTriage(), + history: AlertHistory = AlertHistory() ) { self.watchedRepos = watchedRepos self.seenEventIDs = seenEventIDs @@ -55,6 +60,7 @@ nonisolated struct PersistedState: Codable { self.sortOrder = sortOrder self.notifiedEventIDsByRepo = notifiedEventIDsByRepo self.triage = triage + self.history = history } // Custom decode so existing state.json files saved before hasRepoScope @@ -72,6 +78,7 @@ nonisolated struct PersistedState: Codable { forKey: .notifiedEventIDsByRepo ) triage = try container.decodeIfPresent(AlertTriage.self, forKey: .triage) ?? AlertTriage() + history = try container.decodeIfPresent(AlertHistory.self, forKey: .history) ?? AlertHistory() } static let placeholder = PersistedState( @@ -13,6 +13,7 @@ struct SecurityEventListView: View { var updateStore: UpdateStore var isStandaloneWindow: Bool = false @State private var showingRepoManager = false + @State private var showingHistory = false @State private var exportErrorMessage: String? @Environment(\.openWindow) private var openWindow @@ -27,6 +28,8 @@ struct SecurityEventListView: View { SignInView(authStore: authStore) } else if showingRepoManager { RepoManagerView(store: store, authStore: authStore) + } else if showingHistory { + AlertHistoryView(store: store) } else { filterBar Divider() @@ -87,7 +90,7 @@ struct SecurityEventListView: View { Spacer() - if authStore.isSignedIn && !showingRepoManager { + if authStore.isSignedIn && !showingRepoManager && !showingHistory { Picker("Minimum severity", selection: Binding( get: { store.minimumSeverity }, set: { newValue in Task { await store.setMinimumSeverity(newValue) } } @@ -123,7 +126,15 @@ struct SecurityEventListView: View { } if authStore.isSignedIn { - if !isStandaloneWindow { + if isStandaloneWindow { + Button { + showingHistory.toggle() + } label: { + Image(systemName: showingHistory ? "list.bullet" : "chart.xyaxis.line") + } + .buttonStyle(.plain) + .help(showingHistory ? "Back to alerts" : "Trends") + } else { Button { openWindow(id: SecurityEventWindow.id) } label: { @@ -38,6 +38,10 @@ final class SecurityEventStore { /// so the feed can be re-derived without touching disk. private(set) var triage = AlertTriage() + /// Trend data for the dedicated window (#10). Mirrored from + /// PersistedState so the view doesn't touch disk. + private(set) var history = AlertHistory() + /// Repos represented in the current fetch, for the repo filter menu — /// the watch list can contain repos that returned nothing. var reposInFeed: [String] { @@ -70,6 +74,7 @@ final class SecurityEventStore { sortOrder = state.sortOrder watchedRepos = state.watchedRepos triage = state.triage + history = state.history guard let token = KeychainTokenStore.load() else { errorMessages = [stateLoadFailure, GitHubAPIError.missingToken.errorDescription ?? "Not signed in."] @@ -138,12 +143,18 @@ final class SecurityEventStore { // 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 { + let now = Date() state.triage = state.triage.pruned( presentEventIDs: Set(fetchedEvents.map(\.id)), - now: Date() + now: now ) triage = state.triage applyFilters() + + // Same completeness rule: an alert missing because its repo + // errored has not been resolved. + state.history.record(fetchedEvents, at: now) + history = state.history } let newEvents = AlertDiff.newlyAppeared( new file mode 100644 @@ -0,0 +1,217 @@ +// +// AlertHistoryTests.swift +// octosentryTests +// + +import Foundation +import Testing +@testable import octosentry + +struct AlertHistoryTests { + + private let day0 = Date(timeIntervalSince1970: 1_785_000_000) + private func days(_ count: Double) -> TimeInterval { count * 86_400 } + + private func event(_ id: String, severity: SecurityEventSeverity = .high, openedDaysAgo: Double = 0) -> SecurityEvent { + TestEvents.event(id: id, severity: severity, ageInHours: openedDaysAgo * 24) + } + + // MARK: - Lifecycles + + @Test func recordingCreatesALifecyclePerAlert() { + var history = AlertHistory() + history.record([event("a"), event("b")], at: day0) + + #expect(Set(history.lifecycles.keys) == ["a", "b"]) + #expect(history.currentlyOpenCount == 2) + #expect(history.lifecycles["a"]?.resolvedAt == nil) + } + + // openedAt comes from GitHub, not from when octosentry first polled, + // otherwise time-to-resolution starts at install day. + @Test func lifecycleOpenedAtComesFromTheAlertNotThePoll() { + var history = AlertHistory() + let alert = event("a", openedDaysAgo: 10) + history.record([alert], at: day0) + + #expect(history.lifecycles["a"]?.openedAt == alert.createdAt) + #expect(history.lifecycles["a"]?.openedAt != day0) + } + + @Test func anAlertThatDisappearsIsMarkedResolved() { + var history = AlertHistory() + history.record([event("a"), event("b")], at: day0) + + let later = day0.addingTimeInterval(days(1)) + history.record([event("a")], at: later) + + #expect(history.lifecycles["b"]?.resolvedAt == later) + #expect(history.lifecycles["a"]?.resolvedAt == nil) + #expect(history.currentlyOpenCount == 1) + } + + @Test func resolutionTimeIsNotOverwrittenByLaterPolls() { + var history = AlertHistory() + history.record([event("a")], at: day0) + let resolvedAt = day0.addingTimeInterval(days(1)) + history.record([], at: resolvedAt) + history.record([], at: day0.addingTimeInterval(days(2))) + + #expect(history.lifecycles["a"]?.resolvedAt == resolvedAt) + } + + @Test func aReReportedAlertBecomesOpenAgain() { + var history = AlertHistory() + history.record([event("a")], at: day0) + history.record([], at: day0.addingTimeInterval(days(1))) + history.record([event("a")], at: day0.addingTimeInterval(days(2))) + + #expect(history.lifecycles["a"]?.resolvedAt == nil) + #expect(history.currentlyOpenCount == 1) + } + + // MARK: - Snapshots + + @Test func theFirstPollRecordsASnapshot() { + var history = AlertHistory() + history.record([event("a", severity: .critical), event("b", severity: .low)], at: day0) + + #expect(history.snapshots.count == 1) + let snapshot = history.snapshots[0] + #expect(snapshot.openCount == 2) + #expect(snapshot.countsBySeverity["Critical"] == 1) + #expect(snapshot.countsBySeverity["Low"] == 1) + #expect(snapshot.countsBySource["dependabot"] == 2) + } + + // Polls run every 15 minutes; a snapshot per poll would be 96 a day. + @Test func snapshotsAreRateLimited() { + var history = AlertHistory() + history.record([event("a")], at: day0) + history.record([event("a")], at: day0.addingTimeInterval(900)) + history.record([event("a")], at: day0.addingTimeInterval(3600)) + + #expect(history.snapshots.count == 1) + } + + @Test func aSnapshotIsTakenOnceTheIntervalHasPassed() { + var history = AlertHistory() + history.record([event("a")], at: day0) + history.record([event("a")], at: day0.addingTimeInterval(AlertHistory.snapshotInterval)) + + #expect(history.snapshots.count == 2) + } + + @Test func openCountOverTimeIsChronological() { + var history = AlertHistory() + for step in 0..<4 { + history.record([event("a")], at: day0.addingTimeInterval(AlertHistory.snapshotInterval * Double(step))) + } + + let dates = history.openCountOverTime.map(\.recordedAt) + #expect(dates == dates.sorted()) + } + + // MARK: - Retention + + @Test func snapshotsOlderThanRetentionAreDropped() { + var history = AlertHistory() + history.record([event("a")], at: day0) + history.record([event("a")], at: day0.addingTimeInterval(AlertHistory.retention + days(1))) + + #expect(history.snapshots.count == 1) + #expect(history.snapshots[0].recordedAt > day0) + } + + @Test func resolvedLifecyclesAreDroppedAfterRetentionButOpenOnesAreKept() { + var history = AlertHistory() + history.record([event("old"), event("survivor")], at: day0) + history.record([event("survivor")], at: day0.addingTimeInterval(days(1))) + + // Long enough that "old" resolved outside the retention window. + history.record([event("survivor")], at: day0.addingTimeInterval(AlertHistory.retention + days(2))) + + #expect(history.lifecycles["old"] == nil) + #expect(history.lifecycles["survivor"] != nil) + } + + // The growth question: a per-poll series must stay bounded. + @Test func aYearOfPollingStaysBounded() { + var history = AlertHistory() + // Every 15 minutes for 365 days. + let pollInterval: TimeInterval = 900 + var date = day0 + for _ in 0..<(365 * 96) { + history.record([event("a")], at: date) + date = date.addingTimeInterval(pollInterval) + } + + let maximumSnapshots = Int(AlertHistory.retention / AlertHistory.snapshotInterval) + 2 + #expect(history.snapshots.count <= maximumSnapshots) + #expect(history.snapshots.count > 0) + } + + // MARK: - Trends + + @Test func newAndResolvedCountsAreWindowed() { + var history = AlertHistory() + history.record([event("old", openedDaysAgo: 60), event("recent", openedDaysAgo: 1)], at: day0) + + let since = day0.addingTimeInterval(-days(30)) + #expect(history.openedCount(since: since) == 1) + #expect(history.resolvedCount(since: since) == 0) + + history.record([event("old", openedDaysAgo: 60)], at: day0.addingTimeInterval(days(1))) + #expect(history.resolvedCount(since: since) == 1) + } + + @Test func meanTimeToResolutionIsNilUntilSomethingResolves() { + var history = AlertHistory() + history.record([event("a")], at: day0) + + #expect(history.meanTimeToResolution == nil) + } + + @Test func meanTimeToResolutionAveragesOpenToResolved() { + var history = AlertHistory() + // "a" opened 2 days before day0, "b" opened 4 days before. + history.record([event("a", openedDaysAgo: 2), event("b", openedDaysAgo: 4)], at: day0) + history.record([], at: day0) + + let mean = try? #require(history.meanTimeToResolution) + // Resolved at day0, so durations are 2 and 4 days; mean is 3. + #expect(mean != nil) + if let mean { + #expect(abs(mean - days(3)) < 1) + } + } + + @Test func meanTimeToResolutionIgnoresStillOpenAlerts() { + var history = AlertHistory() + history.record([event("resolved", openedDaysAgo: 2), event("open", openedDaysAgo: 100)], at: day0) + history.record([event("open", openedDaysAgo: 100)], at: day0) + + if let mean = history.meanTimeToResolution { + #expect(abs(mean - days(2)) < 1) + } else { + Issue.record("expected a mean time to resolution") + } + } + + // MARK: - Persistence + + @Test func roundTripsThroughCodable() throws { + var history = AlertHistory() + history.record([event("a", severity: .critical)], at: day0) + history.record([], at: day0.addingTimeInterval(AlertHistory.snapshotInterval)) + + let encoder = JSONEncoder() + encoder.dateEncodingStrategy = .iso8601 + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .iso8601 + + let decoded = try decoder.decode(AlertHistory.self, from: try encoder.encode(history)) + + #expect(decoded == history) + } +} @@ -65,7 +65,7 @@ struct PersistedStateTests { #expect(Set(object.keys) == [ "watchedRepos", "seenEventIDs", "lastFetchByRepo", "minimumSeverity", "hasRepoScope", "sortOrder", - "triage", + "triage", "history", ]) // notifiedEventIDsByRepo is optional and nil on the placeholder, so it // encodes to nothing rather than a null. @@ -92,6 +92,7 @@ struct PersistedStateTests { #expect(state.sortOrder == .severity) #expect(state.notifiedEventIDsByRepo == nil) #expect(state.triage == AlertTriage()) + #expect(state.history == AlertHistory()) } @Test func rejectsStateMissingARequiredField() {