krz/octosentry
macOS menu bar app to monitor GitHub security alerts
clone: git clone https://gitbay.org/krz/octosentry.git
1fb81fcd2b9dcc46bdbc68ff52b020820cd92d4d
signed_unknown_key
author: Christian Cleberg <hello@cleberg.net> · 2026-08-22T20:37:28Z
committer: <noreply@github.com>
octosentry/AlertFilter.swift | 30 +++++++++ octosentry/AlertSortOrder.swift | 44 ++++++++++++ octosentry/PersistedState.swift | 14 ++-- octosentry/SecurityEvent.swift | 2 +- octosentry/SecurityEventListView.swift | 107 +++++++++++++++++++++++++++++- octosentry/SecurityEventSource.swift | 2 +- octosentry/SecurityEventStore.swift | 43 +++++++++--- octosentryTests/AlertFilterTests.swift | 67 +++++++++++++++++++ octosentryTests/AlertSortOrderTests.swift | 68 +++++++++++++++++++ octosentryTests/PersistedStateTests.swift | 12 ++-- octosentryTests/TestEvents.swift | 34 ++++++++++ 11 files changed, 402 insertions(+), 21 deletions(-) new file mode 100644 @@ -0,0 +1,30 @@ +// +// AlertFilter.swift +// octosentry +// +// Per-session narrowing of the feed by source and repo. An empty set means +// "no restriction" rather than "match nothing", so the default value shows +// everything. Severity is not here: the minimum-severity threshold already +// filters on it, and it stays a persisted floor because the badge and +// notifications key off it. +// + +import Foundation + +nonisolated struct AlertFilter: Equatable { + var sources: Set<SecurityEventSource> = [] + var repos: Set<String> = [] + + var isActive: Bool { + !sources.isEmpty || !repos.isEmpty + } + + func matches(_ event: SecurityEvent) -> Bool { + (sources.isEmpty || sources.contains(event.source)) + && (repos.isEmpty || repos.contains(event.repoFullName)) + } + + func apply(to events: [SecurityEvent]) -> [SecurityEvent] { + isActive ? events.filter(matches) : events + } +} new file mode 100644 @@ -0,0 +1,44 @@ +// +// AlertSortOrder.swift +// octosentry +// +// How the feed is ordered. Persisted (see PersistedState) because it's a +// standing preference, unlike the source/repo filters which are per-session. +// + +import Foundation + +nonisolated enum AlertSortOrder: String, Codable, CaseIterable, Hashable { + case severity + case oldest + case repo + + var displayName: String { + switch self { + case .severity: "Severity" + case .oldest: "Oldest first" + case .repo: "Repository" + } + } + + func sorted(_ events: [SecurityEvent]) -> [SecurityEvent] { + switch self { + case .severity: + events.sorted { lhs, rhs in + lhs.severity != rhs.severity + ? lhs.severity > rhs.severity + : lhs.createdAt > rhs.createdAt + } + case .oldest: + events.sorted { $0.createdAt < $1.createdAt } + case .repo: + events.sorted { lhs, rhs in + let order = lhs.repoFullName.localizedCaseInsensitiveCompare(rhs.repoFullName) + guard order == .orderedSame else { return order == .orderedAscending } + return lhs.severity != rhs.severity + ? lhs.severity > rhs.severity + : lhs.createdAt > rhs.createdAt + } + } + } +} @@ -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, and whether the current token has the -// broader "repo" scope needed to list repos. Flat JSON over SwiftData +// minimum severity filter, the feed sort order, 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 // persistence context. @@ -19,9 +19,10 @@ nonisolated struct PersistedState: Codable { var lastFetchByRepo: [String: Date] var minimumSeverity: SecurityEventSeverity var hasRepoScope: Bool + var sortOrder: AlertSortOrder enum CodingKeys: String, CodingKey { - case watchedRepos, seenEventIDs, lastFetchByRepo, minimumSeverity, hasRepoScope + case watchedRepos, seenEventIDs, lastFetchByRepo, minimumSeverity, hasRepoScope, sortOrder } init( @@ -29,17 +30,19 @@ nonisolated struct PersistedState: Codable { seenEventIDs: Set<String>, lastFetchByRepo: [String: Date], minimumSeverity: SecurityEventSeverity, - hasRepoScope: Bool = false + hasRepoScope: Bool = false, + sortOrder: AlertSortOrder = .severity ) { self.watchedRepos = watchedRepos self.seenEventIDs = seenEventIDs self.lastFetchByRepo = lastFetchByRepo self.minimumSeverity = minimumSeverity self.hasRepoScope = hasRepoScope + self.sortOrder = sortOrder } // Custom decode so existing state.json files saved before hasRepoScope - // existed still load instead of falling back to .placeholder. + // and sortOrder existed still load instead of falling back to .placeholder. init(from decoder: Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) watchedRepos = try container.decode([String].self, forKey: .watchedRepos) @@ -47,6 +50,7 @@ nonisolated struct PersistedState: Codable { lastFetchByRepo = try container.decode([String: Date].self, forKey: .lastFetchByRepo) minimumSeverity = try container.decode(SecurityEventSeverity.self, forKey: .minimumSeverity) hasRepoScope = try container.decodeIfPresent(Bool.self, forKey: .hasRepoScope) ?? false + sortOrder = try container.decodeIfPresent(AlertSortOrder.self, forKey: .sortOrder) ?? .severity } static let placeholder = PersistedState( @@ -5,7 +5,7 @@ import Foundation -struct SecurityEvent: Identifiable, Codable, Sendable { +nonisolated struct SecurityEvent: Identifiable, Codable, Sendable { let id: String let source: SecurityEventSource let repoFullName: String @@ -26,6 +26,8 @@ struct SecurityEventListView: View { } else if showingRepoManager { RepoManagerView(store: store, authStore: authStore) } else { + filterBar + Divider() content } } @@ -101,13 +103,104 @@ struct SecurityEventListView: View { .padding(12) } + private var filterBar: some View { + HStack(spacing: 8) { + Menu { + ForEach(SecurityEventSource.allCases, id: \.self) { source in + Toggle(source.displayName, isOn: binding(for: source)) + } + } label: { + FilterLabel(title: "Source", count: store.filter.sources.count) + } + .menuStyle(.borderlessButton) + .fixedSize() + + Menu { + if store.reposInFeed.isEmpty { + Text("No repos in the current feed") + } else { + ForEach(store.reposInFeed, id: \.self) { repo in + Toggle(repo, isOn: binding(for: repo)) + } + } + } label: { + FilterLabel(title: "Repo", count: store.filter.repos.count) + } + .menuStyle(.borderlessButton) + .fixedSize() + .disabled(store.reposInFeed.isEmpty) + + Menu { + Picker("Sort", selection: Binding( + get: { store.sortOrder }, + set: { newValue in Task { await store.setSortOrder(newValue) } } + )) { + ForEach(AlertSortOrder.allCases, id: \.self) { order in + Text(order.displayName).tag(order) + } + } + .pickerStyle(.inline) + .labelsHidden() + } label: { + FilterLabel(title: store.sortOrder.displayName, count: 0, systemImage: "arrow.up.arrow.down") + } + .menuStyle(.borderlessButton) + .fixedSize() + + Spacer() + + if store.filter.isActive { + Button("Clear") { + store.filter = AlertFilter() + } + .buttonStyle(.plain) + .font(.caption) + .foregroundStyle(Color.accentColor) + } + } + .padding(.horizontal, 12) + .padding(.vertical, 6) + } + + private func binding(for source: SecurityEventSource) -> Binding<Bool> { + Binding( + get: { store.filter.sources.contains(source) }, + set: { isOn in + if isOn { + store.filter.sources.insert(source) + } else { + store.filter.sources.remove(source) + } + } + ) + } + + private func binding(for repo: String) -> Binding<Bool> { + Binding( + get: { store.filter.repos.contains(repo) }, + set: { isOn in + if isOn { + store.filter.repos.insert(repo) + } else { + store.filter.repos.remove(repo) + } + } + ) + } + @ViewBuilder private var content: some View { if store.events.isEmpty && !store.errorMessages.isEmpty { StatusView(systemImage: "exclamationmark.triangle", tint: .orange, message: store.errorMessages.joined(separator: "\n\n")) } else if store.events.isEmpty && !store.isLoading { VStack(spacing: 8) { - if store.totalFetchedCount > 0 { + if store.filter.isActive && store.filteredOutCount > 0 { + StatusView( + systemImage: "line.3.horizontal.decrease.circle", + tint: .secondary, + message: "\(store.filteredOutCount) alert(s) hidden by the current filter" + ) + } else if store.totalFetchedCount > 0 { StatusView( systemImage: "line.3.horizontal.decrease.circle", tint: .secondary, @@ -298,6 +391,18 @@ private struct RepoManagerView: View { } } +private struct FilterLabel: View { + let title: String + let count: Int + var systemImage = "line.3.horizontal.decrease.circle" + + var body: some View { + Label(count > 0 ? "\(title) (\(count))" : title, systemImage: systemImage) + .font(.caption) + .foregroundStyle(count > 0 ? Color.accentColor : .secondary) + } +} + private struct UpdateBanner: View { let release: UpdateChecker.LatestRelease @@ -5,7 +5,7 @@ import Foundation -enum SecurityEventSource: String, Codable, CaseIterable { +nonisolated enum SecurityEventSource: String, Codable, CaseIterable { case dependabot case codeScanning case secretScanning @@ -24,10 +24,28 @@ final class SecurityEventStore { private(set) var errorMessages: [String] = [] private(set) var unavailableNotices: [String] = [] private(set) var minimumSeverity: SecurityEventSeverity = .low + private(set) var sortOrder: AlertSortOrder = .severity private(set) var totalFetchedCount = 0 private(set) var watchedRepos: [String] = [] private(set) var watchListErrorMessage: String? + /// Per-session narrowing, not persisted. Setting it re-derives `events`. + var filter = AlertFilter() { + didSet { applyFilters() } + } + + /// Repos represented in the current fetch, for the repo filter menu — + /// the watch list can contain repos that returned nothing. + var reposInFeed: [String] { + Set(rawEvents.map(\.repoFullName)).sorted { $0.localizedCaseInsensitiveCompare($1) == .orderedAscending } + } + + /// Alerts held back by the source/repo filter, as opposed to the + /// severity floor, so the empty state can say which one is hiding them. + var filteredOutCount: Int { + rawEvents.filter { $0.severity >= minimumSeverity }.count - events.count + } + var unseenCriticalCount: Int { rawEvents.filter { $0.severity == .critical && !$0.seenLocally }.count } @@ -45,6 +63,7 @@ final class SecurityEventStore { var state = await persistenceStore.load() let stateLoadFailure = await persistenceStore.loadFailureMessage minimumSeverity = state.minimumSeverity + sortOrder = state.sortOrder watchedRepos = state.watchedRepos guard let token = KeychainTokenStore.load() else { @@ -99,7 +118,7 @@ final class SecurityEventStore { return event } totalFetchedCount = rawEvents.count - applyMinimumSeverityFilter() + applyFilters() errorMessages = errors unavailableNotices = notices @@ -108,13 +127,22 @@ final class SecurityEventStore { func setMinimumSeverity(_ severity: SecurityEventSeverity) async { minimumSeverity = severity - applyMinimumSeverityFilter() + applyFilters() var state = await persistenceStore.load() state.minimumSeverity = severity await persistenceStore.save(state) } + func setSortOrder(_ order: AlertSortOrder) async { + sortOrder = order + applyFilters() + + var state = await persistenceStore.load() + state.sortOrder = order + await persistenceStore.save(state) + } + func addRepo(_ input: String) async { watchListErrorMessage = nil let trimmed = input.trimmingCharacters(in: .whitespacesAndNewlines) @@ -163,7 +191,7 @@ final class SecurityEventStore { rawEvents.removeAll { $0.id == eventID } totalFetchedCount = rawEvents.count - applyMinimumSeverityFilter() + applyFilters() } func removeRepo(_ repoFullName: String) async { @@ -190,12 +218,9 @@ final class SecurityEventStore { } } - private func applyMinimumSeverityFilter() { - events = rawEvents - .filter { $0.severity >= minimumSeverity } - .sorted { lhs, rhs in - lhs.severity != rhs.severity ? lhs.severity > rhs.severity : lhs.createdAt > rhs.createdAt - } + private func applyFilters() { + let admitted = rawEvents.filter { $0.severity >= minimumSeverity } + events = sortOrder.sorted(filter.apply(to: admitted)) } private enum SourceOutcome { new file mode 100644 @@ -0,0 +1,67 @@ +// +// AlertFilterTests.swift +// octosentryTests +// + +import Foundation +import Testing +@testable import octosentry + +struct AlertFilterTests { + + private let dependabotHello = TestEvents.event(id: "a", source: .dependabot, repo: "octocat/hello-world") + private let codeScanningHello = TestEvents.event(id: "b", source: .codeScanning, repo: "octocat/hello-world") + private let secretScanningSpoon = TestEvents.event(id: "c", source: .secretScanning, repo: "octocat/spoon-knife") + + private var allEvents: [SecurityEvent] { + [dependabotHello, codeScanningHello, secretScanningSpoon] + } + + @Test func emptyFilterIsInactiveAndMatchesEverything() { + let filter = AlertFilter() + + #expect(filter.isActive == false) + #expect(filter.apply(to: allEvents).map(\.id) == ["a", "b", "c"]) + } + + @Test func filtersBySource() { + var filter = AlertFilter() + filter.sources = [.dependabot] + + #expect(filter.isActive) + #expect(filter.apply(to: allEvents).map(\.id) == ["a"]) + } + + @Test func sourceFilterUnionsSelectedSources() { + var filter = AlertFilter() + filter.sources = [.dependabot, .secretScanning] + + #expect(filter.apply(to: allEvents).map(\.id) == ["a", "c"]) + } + + @Test func filtersByRepo() { + var filter = AlertFilter() + filter.repos = ["octocat/spoon-knife"] + + #expect(filter.apply(to: allEvents).map(\.id) == ["c"]) + } + + // Source and repo intersect: an event has to satisfy both. + @Test func sourceAndRepoAreCombinedWithAnd() { + var filter = AlertFilter() + filter.sources = [.dependabot] + filter.repos = ["octocat/spoon-knife"] + + #expect(filter.apply(to: allEvents).isEmpty) + + filter.repos = ["octocat/hello-world"] + #expect(filter.apply(to: allEvents).map(\.id) == ["a"]) + } + + @Test func filterPreservesInputOrder() { + var filter = AlertFilter() + filter.repos = ["octocat/hello-world"] + + #expect(filter.apply(to: allEvents.reversed()).map(\.id) == ["b", "a"]) + } +} new file mode 100644 @@ -0,0 +1,68 @@ +// +// AlertSortOrderTests.swift +// octosentryTests +// + +import Foundation +import Testing +@testable import octosentry + +struct AlertSortOrderTests { + + // Deliberately out of order on every axis. + private let events = [ + TestEvents.event(id: "old-low", repo: "zulu/repo", severity: .low, ageInHours: 500), + TestEvents.event(id: "new-critical", repo: "alpha/repo", severity: .critical, ageInHours: 1), + TestEvents.event(id: "old-critical", repo: "mike/repo", severity: .critical, ageInHours: 100), + TestEvents.event(id: "new-medium", repo: "alpha/repo", severity: .medium, ageInHours: 2), + ] + + @Test func severityOrdersHighestFirstThenNewest() { + let sorted = AlertSortOrder.severity.sorted(events) + + #expect(sorted.map(\.id) == ["new-critical", "old-critical", "new-medium", "old-low"]) + } + + @Test func oldestOrdersLongestOpenFirst() { + let sorted = AlertSortOrder.oldest.sorted(events) + + #expect(sorted.map(\.id) == ["old-low", "old-critical", "new-medium", "new-critical"]) + } + + @Test func repoGroupsByNameThenSeverityWithinRepo() { + let sorted = AlertSortOrder.repo.sorted(events) + + #expect(sorted.map(\.repoFullName) == ["alpha/repo", "alpha/repo", "mike/repo", "zulu/repo"]) + // Within alpha/repo, critical sorts above medium. + #expect(sorted.prefix(2).map(\.id) == ["new-critical", "new-medium"]) + } + + @Test func repoOrderIsCaseInsensitive() { + let mixedCase = [ + TestEvents.event(id: "upper", repo: "Zulu/repo"), + TestEvents.event(id: "lower", repo: "alpha/repo"), + ] + + #expect(AlertSortOrder.repo.sorted(mixedCase).map(\.id) == ["lower", "upper"]) + } + + @Test func sortingNeverDropsOrDuplicatesEvents() { + for order in AlertSortOrder.allCases { + #expect(Set(order.sorted(events).map(\.id)) == Set(events.map(\.id))) + #expect(order.sorted(events).count == events.count) + } + } + + @Test func sortingAnEmptyFeedIsEmpty() { + for order in AlertSortOrder.allCases { + #expect(order.sorted([]).isEmpty) + } + } + + // Raw values are persisted in state.json. + @Test func rawValuesAreStable() { + #expect(AlertSortOrder.severity.rawValue == "severity") + #expect(AlertSortOrder.oldest.rawValue == "oldest") + #expect(AlertSortOrder.repo.rawValue == "repo") + } +} @@ -31,7 +31,8 @@ struct PersistedStateTests { seenEventIDs: ["dependabot-octocat/hello-world-1", "codeScanning-octocat/spoon-knife-7"], lastFetchByRepo: ["octocat/hello-world": fetchedAt], minimumSeverity: .high, - hasRepoScope: true + hasRepoScope: true, + sortOrder: .repo ) let decoded = try Self.decoder.decode( @@ -44,6 +45,7 @@ struct PersistedStateTests { #expect(decoded.lastFetchByRepo == original.lastFetchByRepo) #expect(decoded.minimumSeverity == original.minimumSeverity) #expect(decoded.hasRepoScope == original.hasRepoScope) + #expect(decoded.sortOrder == original.sortOrder) } @Test func encodesTheKeysOnDiskReadersDependOn() throws { @@ -53,12 +55,12 @@ struct PersistedStateTests { ) #expect(Set(object.keys) == [ - "watchedRepos", "seenEventIDs", "lastFetchByRepo", "minimumSeverity", "hasRepoScope", + "watchedRepos", "seenEventIDs", "lastFetchByRepo", "minimumSeverity", "hasRepoScope", "sortOrder", ]) } - // A state.json written before hasRepoScope existed must still load. - @Test func decodesLegacyStateWithoutRepoScope() throws { + // A state.json written before hasRepoScope and sortOrder existed must still load. + @Test func decodesLegacyStateWithoutRepoScopeOrSortOrder() throws { let legacy = """ { "watchedRepos": ["octocat/hello-world"], @@ -74,6 +76,7 @@ struct PersistedStateTests { #expect(state.seenEventIDs == ["dependabot-octocat/hello-world-1"]) #expect(state.minimumSeverity == .medium) #expect(state.hasRepoScope == false) + #expect(state.sortOrder == .severity) } @Test func rejectsStateMissingARequiredField() { @@ -91,5 +94,6 @@ struct PersistedStateTests { #expect(PersistedState.placeholder.lastFetchByRepo.isEmpty) #expect(PersistedState.placeholder.minimumSeverity == .low) #expect(PersistedState.placeholder.hasRepoScope == false) + #expect(PersistedState.placeholder.sortOrder == .severity) } } new file mode 100644 @@ -0,0 +1,34 @@ +// +// TestEvents.swift +// octosentryTests +// + +import Foundation +@testable import octosentry + +enum TestEvents { + static let referenceDate = Date(timeIntervalSince1970: 1_785_000_000) + + static func event( + id: String, + source: SecurityEventSource = .dependabot, + repo: String = "octocat/hello-world", + severity: SecurityEventSeverity = .high, + summary: String = "A summary", + ageInHours: Double = 0 + ) -> SecurityEvent { + let createdAt = referenceDate.addingTimeInterval(-ageInHours * 3600) + return SecurityEvent( + id: id, + source: source, + repoFullName: repo, + severity: severity, + nativeSeverityLabel: severity.displayName, + summary: summary, + detailURL: URL(string: "https://github.com/\(repo)/security/\(id)")!, + createdAt: createdAt, + updatedAt: createdAt, + seenLocally: false + ) + } +}