krz/octosentry

macOS menu bar app to monitor GitHub security alerts github macos menubar security

Commit 08428003cb

08428003cb9ede2ba3c27cae4c75e05bd1f041fc

parent: fb42c28038

Unsigned

cmc <hello@cleberg.net> · 2026-08-22 20:37 UTC
committer: <noreply@github.com>

Add source/repo filtering and sort order to the feed (#27)

Filter menus for source and repo, plus a sort control (severity, oldest
first, repository). Sort order persists via PersistedState; the
source/repo filters are per-session.

Minimum severity stays a separate persisted floor rather than becoming
part of the new severity filtering: the menu bar badge reads from it,
and notifications (#16) are specified against it, so making it
transient UI state would make those ambiguous.

SecurityEvent and SecurityEventSource are now nonisolated, matching the
other value types — the target defaults to MainActor isolation, which
blocked key paths to them from test code.

Closes #17

Layout: unified · split

octosentry/AlertFilter.swift added +30
@@ -0,0 +1,30 @@
1//
2// AlertFilter.swift
3// octosentry
4//
5// Per-session narrowing of the feed by source and repo. An empty set means
6// "no restriction" rather than "match nothing", so the default value shows
7// everything. Severity is not here: the minimum-severity threshold already
8// filters on it, and it stays a persisted floor because the badge and
9// notifications key off it.
10//
11
12import Foundation
13
14nonisolated struct AlertFilter: Equatable {
15 var sources: Set<SecurityEventSource> = []
16 var repos: Set<String> = []
17
18 var isActive: Bool {
19 !sources.isEmpty || !repos.isEmpty
20 }
21
22 func matches(_ event: SecurityEvent) -> Bool {
23 (sources.isEmpty || sources.contains(event.source))
24 && (repos.isEmpty || repos.contains(event.repoFullName))
25 }
26
27 func apply(to events: [SecurityEvent]) -> [SecurityEvent] {
28 isActive ? events.filter(matches) : events
29 }
30}
octosentry/AlertSortOrder.swift added +44
@@ -0,0 +1,44 @@
1//
2// AlertSortOrder.swift
3// octosentry
4//
5// How the feed is ordered. Persisted (see PersistedState) because it's a
6// standing preference, unlike the source/repo filters which are per-session.
7//
8
9import Foundation
10
11nonisolated enum AlertSortOrder: String, Codable, CaseIterable, Hashable {
12 case severity
13 case oldest
14 case repo
15
16 var displayName: String {
17 switch self {
18 case .severity: "Severity"
19 case .oldest: "Oldest first"
20 case .repo: "Repository"
21 }
22 }
23
24 func sorted(_ events: [SecurityEvent]) -> [SecurityEvent] {
25 switch self {
26 case .severity:
27 events.sorted { lhs, rhs in
28 lhs.severity != rhs.severity
29 ? lhs.severity > rhs.severity
30 : lhs.createdAt > rhs.createdAt
31 }
32 case .oldest:
33 events.sorted { $0.createdAt < $1.createdAt }
34 case .repo:
35 events.sorted { lhs, rhs in
36 let order = lhs.repoFullName.localizedCaseInsensitiveCompare(rhs.repoFullName)
37 guard order == .orderedSame else { return order == .orderedAscending }
38 return lhs.severity != rhs.severity
39 ? lhs.severity > rhs.severity
40 : lhs.createdAt > rhs.createdAt
41 }
42 }
43 }
44}
octosentry/PersistedState.swift +9 −5
@@ -4,8 +4,8 @@
44//
55// Everything the app remembers across launches: the repo watch list,
66// local-only seen-state per event, last-fetch timestamp per repo, the
7// minimum severity filter, and whether the current token has the
8// broader "repo" scope needed to list repos. Flat JSON over SwiftData
7// minimum severity filter, the feed sort order, and whether the current
8// token has the broader "repo" scope needed to list repos. Flat JSON over SwiftData
99// (see #1) — small, inspectable, and these are already plain Codable
1010// values passed across actor boundaries, not reference types tied to a
1111// persistence context.
@@ -19,9 +19,10 @@ nonisolated struct PersistedState: Codable {
1919 var lastFetchByRepo: [String: Date]
2020 var minimumSeverity: SecurityEventSeverity
2121 var hasRepoScope: Bool
22 var sortOrder: AlertSortOrder
2223
2324 enum CodingKeys: String, CodingKey {
24 case watchedRepos, seenEventIDs, lastFetchByRepo, minimumSeverity, hasRepoScope
25 case watchedRepos, seenEventIDs, lastFetchByRepo, minimumSeverity, hasRepoScope, sortOrder
2526 }
2627
2728 init(
@@ -29,17 +30,19 @@ nonisolated struct PersistedState: Codable {
2930 seenEventIDs: Set<String>,
3031 lastFetchByRepo: [String: Date],
3132 minimumSeverity: SecurityEventSeverity,
32 hasRepoScope: Bool = false
33 hasRepoScope: Bool = false,
34 sortOrder: AlertSortOrder = .severity
3335 ) {
3436 self.watchedRepos = watchedRepos
3537 self.seenEventIDs = seenEventIDs
3638 self.lastFetchByRepo = lastFetchByRepo
3739 self.minimumSeverity = minimumSeverity
3840 self.hasRepoScope = hasRepoScope
41 self.sortOrder = sortOrder
3942 }
4043
4144 // Custom decode so existing state.json files saved before hasRepoScope
42 // existed still load instead of falling back to .placeholder.
45 // and sortOrder existed still load instead of falling back to .placeholder.
4346 init(from decoder: Decoder) throws {
4447 let container = try decoder.container(keyedBy: CodingKeys.self)
4548 watchedRepos = try container.decode([String].self, forKey: .watchedRepos)
@@ -47,6 +50,7 @@ nonisolated struct PersistedState: Codable {
4750 lastFetchByRepo = try container.decode([String: Date].self, forKey: .lastFetchByRepo)
4851 minimumSeverity = try container.decode(SecurityEventSeverity.self, forKey: .minimumSeverity)
4952 hasRepoScope = try container.decodeIfPresent(Bool.self, forKey: .hasRepoScope) ?? false
53 sortOrder = try container.decodeIfPresent(AlertSortOrder.self, forKey: .sortOrder) ?? .severity
5054 }
5155
5256 static let placeholder = PersistedState(
octosentry/SecurityEvent.swift +1 −1
@@ -5,7 +5,7 @@
55
66import Foundation
77
8struct SecurityEvent: Identifiable, Codable, Sendable {
8nonisolated struct SecurityEvent: Identifiable, Codable, Sendable {
99 let id: String
1010 let source: SecurityEventSource
1111 let repoFullName: String
octosentry/SecurityEventListView.swift +106 −1
@@ -26,6 +26,8 @@ struct SecurityEventListView: View {
2626 } else if showingRepoManager {
2727 RepoManagerView(store: store, authStore: authStore)
2828 } else {
29 filterBar
30 Divider()
2931 content
3032 }
3133 }
@@ -101,13 +103,104 @@ struct SecurityEventListView: View {
101103 .padding(12)
102104 }
103105
106 private var filterBar: some View {
107 HStack(spacing: 8) {
108 Menu {
109 ForEach(SecurityEventSource.allCases, id: \.self) { source in
110 Toggle(source.displayName, isOn: binding(for: source))
111 }
112 } label: {
113 FilterLabel(title: "Source", count: store.filter.sources.count)
114 }
115 .menuStyle(.borderlessButton)
116 .fixedSize()
117
118 Menu {
119 if store.reposInFeed.isEmpty {
120 Text("No repos in the current feed")
121 } else {
122 ForEach(store.reposInFeed, id: \.self) { repo in
123 Toggle(repo, isOn: binding(for: repo))
124 }
125 }
126 } label: {
127 FilterLabel(title: "Repo", count: store.filter.repos.count)
128 }
129 .menuStyle(.borderlessButton)
130 .fixedSize()
131 .disabled(store.reposInFeed.isEmpty)
132
133 Menu {
134 Picker("Sort", selection: Binding(
135 get: { store.sortOrder },
136 set: { newValue in Task { await store.setSortOrder(newValue) } }
137 )) {
138 ForEach(AlertSortOrder.allCases, id: \.self) { order in
139 Text(order.displayName).tag(order)
140 }
141 }
142 .pickerStyle(.inline)
143 .labelsHidden()
144 } label: {
145 FilterLabel(title: store.sortOrder.displayName, count: 0, systemImage: "arrow.up.arrow.down")
146 }
147 .menuStyle(.borderlessButton)
148 .fixedSize()
149
150 Spacer()
151
152 if store.filter.isActive {
153 Button("Clear") {
154 store.filter = AlertFilter()
155 }
156 .buttonStyle(.plain)
157 .font(.caption)
158 .foregroundStyle(Color.accentColor)
159 }
160 }
161 .padding(.horizontal, 12)
162 .padding(.vertical, 6)
163 }
164
165 private func binding(for source: SecurityEventSource) -> Binding<Bool> {
166 Binding(
167 get: { store.filter.sources.contains(source) },
168 set: { isOn in
169 if isOn {
170 store.filter.sources.insert(source)
171 } else {
172 store.filter.sources.remove(source)
173 }
174 }
175 )
176 }
177
178 private func binding(for repo: String) -> Binding<Bool> {
179 Binding(
180 get: { store.filter.repos.contains(repo) },
181 set: { isOn in
182 if isOn {
183 store.filter.repos.insert(repo)
184 } else {
185 store.filter.repos.remove(repo)
186 }
187 }
188 )
189 }
190
104191 @ViewBuilder
105192 private var content: some View {
106193 if store.events.isEmpty && !store.errorMessages.isEmpty {
107194 StatusView(systemImage: "exclamationmark.triangle", tint: .orange, message: store.errorMessages.joined(separator: "\n\n"))
108195 } else if store.events.isEmpty && !store.isLoading {
109196 VStack(spacing: 8) {
110 if store.totalFetchedCount > 0 {
197 if store.filter.isActive && store.filteredOutCount > 0 {
198 StatusView(
199 systemImage: "line.3.horizontal.decrease.circle",
200 tint: .secondary,
201 message: "\(store.filteredOutCount) alert(s) hidden by the current filter"
202 )
203 } else if store.totalFetchedCount > 0 {
111204 StatusView(
112205 systemImage: "line.3.horizontal.decrease.circle",
113206 tint: .secondary,
@@ -298,6 +391,18 @@ private struct RepoManagerView: View {
298391 }
299392}
300393
394private struct FilterLabel: View {
395 let title: String
396 let count: Int
397 var systemImage = "line.3.horizontal.decrease.circle"
398
399 var body: some View {
400 Label(count > 0 ? "\(title) (\(count))" : title, systemImage: systemImage)
401 .font(.caption)
402 .foregroundStyle(count > 0 ? Color.accentColor : .secondary)
403 }
404}
405
301406private struct UpdateBanner: View {
302407 let release: UpdateChecker.LatestRelease
303408
octosentry/SecurityEventSource.swift +1 −1
@@ -5,7 +5,7 @@
55
66import Foundation
77
8enum SecurityEventSource: String, Codable, CaseIterable {
8nonisolated enum SecurityEventSource: String, Codable, CaseIterable {
99 case dependabot
1010 case codeScanning
1111 case secretScanning
octosentry/SecurityEventStore.swift +34 −9
@@ -24,10 +24,28 @@ final class SecurityEventStore {
2424 private(set) var errorMessages: [String] = []
2525 private(set) var unavailableNotices: [String] = []
2626 private(set) var minimumSeverity: SecurityEventSeverity = .low
27 private(set) var sortOrder: AlertSortOrder = .severity
2728 private(set) var totalFetchedCount = 0
2829 private(set) var watchedRepos: [String] = []
2930 private(set) var watchListErrorMessage: String?
3031
32 /// Per-session narrowing, not persisted. Setting it re-derives `events`.
33 var filter = AlertFilter() {
34 didSet { applyFilters() }
35 }
36
37 /// Repos represented in the current fetch, for the repo filter menu —
38 /// the watch list can contain repos that returned nothing.
39 var reposInFeed: [String] {
40 Set(rawEvents.map(\.repoFullName)).sorted { $0.localizedCaseInsensitiveCompare($1) == .orderedAscending }
41 }
42
43 /// Alerts held back by the source/repo filter, as opposed to the
44 /// severity floor, so the empty state can say which one is hiding them.
45 var filteredOutCount: Int {
46 rawEvents.filter { $0.severity >= minimumSeverity }.count - events.count
47 }
48
3149 var unseenCriticalCount: Int {
3250 rawEvents.filter { $0.severity == .critical && !$0.seenLocally }.count
3351 }
@@ -45,6 +63,7 @@ final class SecurityEventStore {
4563 var state = await persistenceStore.load()
4664 let stateLoadFailure = await persistenceStore.loadFailureMessage
4765 minimumSeverity = state.minimumSeverity
66 sortOrder = state.sortOrder
4867 watchedRepos = state.watchedRepos
4968
5069 guard let token = KeychainTokenStore.load() else {
@@ -99,7 +118,7 @@ final class SecurityEventStore {
99118 return event
100119 }
101120 totalFetchedCount = rawEvents.count
102 applyMinimumSeverityFilter()
121 applyFilters()
103122
104123 errorMessages = errors
105124 unavailableNotices = notices
@@ -108,13 +127,22 @@ final class SecurityEventStore {
108127
109128 func setMinimumSeverity(_ severity: SecurityEventSeverity) async {
110129 minimumSeverity = severity
111 applyMinimumSeverityFilter()
130 applyFilters()
112131
113132 var state = await persistenceStore.load()
114133 state.minimumSeverity = severity
115134 await persistenceStore.save(state)
116135 }
117136
137 func setSortOrder(_ order: AlertSortOrder) async {
138 sortOrder = order
139 applyFilters()
140
141 var state = await persistenceStore.load()
142 state.sortOrder = order
143 await persistenceStore.save(state)
144 }
145
118146 func addRepo(_ input: String) async {
119147 watchListErrorMessage = nil
120148 let trimmed = input.trimmingCharacters(in: .whitespacesAndNewlines)
@@ -163,7 +191,7 @@ final class SecurityEventStore {
163191
164192 rawEvents.removeAll { $0.id == eventID }
165193 totalFetchedCount = rawEvents.count
166 applyMinimumSeverityFilter()
194 applyFilters()
167195 }
168196
169197 func removeRepo(_ repoFullName: String) async {
@@ -190,12 +218,9 @@ final class SecurityEventStore {
190218 }
191219 }
192220
193 private func applyMinimumSeverityFilter() {
194 events = rawEvents
195 .filter { $0.severity >= minimumSeverity }
196 .sorted { lhs, rhs in
197 lhs.severity != rhs.severity ? lhs.severity > rhs.severity : lhs.createdAt > rhs.createdAt
198 }
221 private func applyFilters() {
222 let admitted = rawEvents.filter { $0.severity >= minimumSeverity }
223 events = sortOrder.sorted(filter.apply(to: admitted))
199224 }
200225
201226 private enum SourceOutcome {
octosentryTests/AlertFilterTests.swift added +67
@@ -0,0 +1,67 @@
1//
2// AlertFilterTests.swift
3// octosentryTests
4//
5
6import Foundation
7import Testing
8@testable import octosentry
9
10struct AlertFilterTests {
11
12 private let dependabotHello = TestEvents.event(id: "a", source: .dependabot, repo: "octocat/hello-world")
13 private let codeScanningHello = TestEvents.event(id: "b", source: .codeScanning, repo: "octocat/hello-world")
14 private let secretScanningSpoon = TestEvents.event(id: "c", source: .secretScanning, repo: "octocat/spoon-knife")
15
16 private var allEvents: [SecurityEvent] {
17 [dependabotHello, codeScanningHello, secretScanningSpoon]
18 }
19
20 @Test func emptyFilterIsInactiveAndMatchesEverything() {
21 let filter = AlertFilter()
22
23 #expect(filter.isActive == false)
24 #expect(filter.apply(to: allEvents).map(\.id) == ["a", "b", "c"])
25 }
26
27 @Test func filtersBySource() {
28 var filter = AlertFilter()
29 filter.sources = [.dependabot]
30
31 #expect(filter.isActive)
32 #expect(filter.apply(to: allEvents).map(\.id) == ["a"])
33 }
34
35 @Test func sourceFilterUnionsSelectedSources() {
36 var filter = AlertFilter()
37 filter.sources = [.dependabot, .secretScanning]
38
39 #expect(filter.apply(to: allEvents).map(\.id) == ["a", "c"])
40 }
41
42 @Test func filtersByRepo() {
43 var filter = AlertFilter()
44 filter.repos = ["octocat/spoon-knife"]
45
46 #expect(filter.apply(to: allEvents).map(\.id) == ["c"])
47 }
48
49 // Source and repo intersect: an event has to satisfy both.
50 @Test func sourceAndRepoAreCombinedWithAnd() {
51 var filter = AlertFilter()
52 filter.sources = [.dependabot]
53 filter.repos = ["octocat/spoon-knife"]
54
55 #expect(filter.apply(to: allEvents).isEmpty)
56
57 filter.repos = ["octocat/hello-world"]
58 #expect(filter.apply(to: allEvents).map(\.id) == ["a"])
59 }
60
61 @Test func filterPreservesInputOrder() {
62 var filter = AlertFilter()
63 filter.repos = ["octocat/hello-world"]
64
65 #expect(filter.apply(to: allEvents.reversed()).map(\.id) == ["b", "a"])
66 }
67}
octosentryTests/AlertSortOrderTests.swift added +68
@@ -0,0 +1,68 @@
1//
2// AlertSortOrderTests.swift
3// octosentryTests
4//
5
6import Foundation
7import Testing
8@testable import octosentry
9
10struct AlertSortOrderTests {
11
12 // Deliberately out of order on every axis.
13 private let events = [
14 TestEvents.event(id: "old-low", repo: "zulu/repo", severity: .low, ageInHours: 500),
15 TestEvents.event(id: "new-critical", repo: "alpha/repo", severity: .critical, ageInHours: 1),
16 TestEvents.event(id: "old-critical", repo: "mike/repo", severity: .critical, ageInHours: 100),
17 TestEvents.event(id: "new-medium", repo: "alpha/repo", severity: .medium, ageInHours: 2),
18 ]
19
20 @Test func severityOrdersHighestFirstThenNewest() {
21 let sorted = AlertSortOrder.severity.sorted(events)
22
23 #expect(sorted.map(\.id) == ["new-critical", "old-critical", "new-medium", "old-low"])
24 }
25
26 @Test func oldestOrdersLongestOpenFirst() {
27 let sorted = AlertSortOrder.oldest.sorted(events)
28
29 #expect(sorted.map(\.id) == ["old-low", "old-critical", "new-medium", "new-critical"])
30 }
31
32 @Test func repoGroupsByNameThenSeverityWithinRepo() {
33 let sorted = AlertSortOrder.repo.sorted(events)
34
35 #expect(sorted.map(\.repoFullName) == ["alpha/repo", "alpha/repo", "mike/repo", "zulu/repo"])
36 // Within alpha/repo, critical sorts above medium.
37 #expect(sorted.prefix(2).map(\.id) == ["new-critical", "new-medium"])
38 }
39
40 @Test func repoOrderIsCaseInsensitive() {
41 let mixedCase = [
42 TestEvents.event(id: "upper", repo: "Zulu/repo"),
43 TestEvents.event(id: "lower", repo: "alpha/repo"),
44 ]
45
46 #expect(AlertSortOrder.repo.sorted(mixedCase).map(\.id) == ["lower", "upper"])
47 }
48
49 @Test func sortingNeverDropsOrDuplicatesEvents() {
50 for order in AlertSortOrder.allCases {
51 #expect(Set(order.sorted(events).map(\.id)) == Set(events.map(\.id)))
52 #expect(order.sorted(events).count == events.count)
53 }
54 }
55
56 @Test func sortingAnEmptyFeedIsEmpty() {
57 for order in AlertSortOrder.allCases {
58 #expect(order.sorted([]).isEmpty)
59 }
60 }
61
62 // Raw values are persisted in state.json.
63 @Test func rawValuesAreStable() {
64 #expect(AlertSortOrder.severity.rawValue == "severity")
65 #expect(AlertSortOrder.oldest.rawValue == "oldest")
66 #expect(AlertSortOrder.repo.rawValue == "repo")
67 }
68}
octosentryTests/PersistedStateTests.swift +8 −4
@@ -31,7 +31,8 @@ struct PersistedStateTests {
3131 seenEventIDs: ["dependabot-octocat/hello-world-1", "codeScanning-octocat/spoon-knife-7"],
3232 lastFetchByRepo: ["octocat/hello-world": fetchedAt],
3333 minimumSeverity: .high,
34 hasRepoScope: true
34 hasRepoScope: true,
35 sortOrder: .repo
3536 )
3637
3738 let decoded = try Self.decoder.decode(
@@ -44,6 +45,7 @@ struct PersistedStateTests {
4445 #expect(decoded.lastFetchByRepo == original.lastFetchByRepo)
4546 #expect(decoded.minimumSeverity == original.minimumSeverity)
4647 #expect(decoded.hasRepoScope == original.hasRepoScope)
48 #expect(decoded.sortOrder == original.sortOrder)
4749 }
4850
4951 @Test func encodesTheKeysOnDiskReadersDependOn() throws {
@@ -53,12 +55,12 @@ struct PersistedStateTests {
5355 )
5456
5557 #expect(Set(object.keys) == [
56 "watchedRepos", "seenEventIDs", "lastFetchByRepo", "minimumSeverity", "hasRepoScope",
58 "watchedRepos", "seenEventIDs", "lastFetchByRepo", "minimumSeverity", "hasRepoScope", "sortOrder",
5759 ])
5860 }
5961
60 // A state.json written before hasRepoScope existed must still load.
61 @Test func decodesLegacyStateWithoutRepoScope() throws {
62 // A state.json written before hasRepoScope and sortOrder existed must still load.
63 @Test func decodesLegacyStateWithoutRepoScopeOrSortOrder() throws {
6264 let legacy = """
6365 {
6466 "watchedRepos": ["octocat/hello-world"],
@@ -74,6 +76,7 @@ struct PersistedStateTests {
7476 #expect(state.seenEventIDs == ["dependabot-octocat/hello-world-1"])
7577 #expect(state.minimumSeverity == .medium)
7678 #expect(state.hasRepoScope == false)
79 #expect(state.sortOrder == .severity)
7780 }
7881
7982 @Test func rejectsStateMissingARequiredField() {
@@ -91,5 +94,6 @@ struct PersistedStateTests {
9194 #expect(PersistedState.placeholder.lastFetchByRepo.isEmpty)
9295 #expect(PersistedState.placeholder.minimumSeverity == .low)
9396 #expect(PersistedState.placeholder.hasRepoScope == false)
97 #expect(PersistedState.placeholder.sortOrder == .severity)
9498 }
9599}
octosentryTests/TestEvents.swift added +34
@@ -0,0 +1,34 @@
1//
2// TestEvents.swift
3// octosentryTests
4//
5
6import Foundation
7@testable import octosentry
8
9enum TestEvents {
10 static let referenceDate = Date(timeIntervalSince1970: 1_785_000_000)
11
12 static func event(
13 id: String,
14 source: SecurityEventSource = .dependabot,
15 repo: String = "octocat/hello-world",
16 severity: SecurityEventSeverity = .high,
17 summary: String = "A summary",
18 ageInHours: Double = 0
19 ) -> SecurityEvent {
20 let createdAt = referenceDate.addingTimeInterval(-ageInHours * 3600)
21 return SecurityEvent(
22 id: id,
23 source: source,
24 repoFullName: repo,
25 severity: severity,
26 nativeSeverityLabel: severity.displayName,
27 summary: summary,
28 detailURL: URL(string: "https://github.com/\(repo)/security/\(id)")!,
29 createdAt: createdAt,
30 updatedAt: createdAt,
31 seenLocally: false
32 )
33 }
34}