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 @@
4// 4//
5// Everything the app remembers across launches: the repo watch list, 5// Everything the app remembers across launches: the repo watch list,
6// local-only seen-state per event, last-fetch timestamp per repo, the 6// local-only seen-state per event, last-fetch timestamp per repo, the
7// minimum severity filter, and whether the current token has the 7// minimum severity filter, the feed sort order, and whether the current
8// broader "repo" scope needed to list repos. Flat JSON over SwiftData 8// token has the broader "repo" scope needed to list repos. Flat JSON over SwiftData
9// (see #1) — small, inspectable, and these are already plain Codable 9// (see #1) — small, inspectable, and these are already plain Codable
10// values passed across actor boundaries, not reference types tied to a 10// values passed across actor boundaries, not reference types tied to a
11// persistence context. 11// persistence context.
@@ -19,9 +19,10 @@ nonisolated struct PersistedState: Codable {
19 var lastFetchByRepo: [String: Date] 19 var lastFetchByRepo: [String: Date]
20 var minimumSeverity: SecurityEventSeverity 20 var minimumSeverity: SecurityEventSeverity
21 var hasRepoScope: Bool 21 var hasRepoScope: Bool
22 var sortOrder: AlertSortOrder
22 23
23 enum CodingKeys: String, CodingKey { 24 enum CodingKeys: String, CodingKey {
24 case watchedRepos, seenEventIDs, lastFetchByRepo, minimumSeverity, hasRepoScope 25 case watchedRepos, seenEventIDs, lastFetchByRepo, minimumSeverity, hasRepoScope, sortOrder
25 } 26 }
26 27
27 init( 28 init(
@@ -29,17 +30,19 @@ nonisolated struct PersistedState: Codable {
29 seenEventIDs: Set<String>, 30 seenEventIDs: Set<String>,
30 lastFetchByRepo: [String: Date], 31 lastFetchByRepo: [String: Date],
31 minimumSeverity: SecurityEventSeverity, 32 minimumSeverity: SecurityEventSeverity,
32 hasRepoScope: Bool = false 33 hasRepoScope: Bool = false,
34 sortOrder: AlertSortOrder = .severity
33 ) { 35 ) {
34 self.watchedRepos = watchedRepos 36 self.watchedRepos = watchedRepos
35 self.seenEventIDs = seenEventIDs 37 self.seenEventIDs = seenEventIDs
36 self.lastFetchByRepo = lastFetchByRepo 38 self.lastFetchByRepo = lastFetchByRepo
37 self.minimumSeverity = minimumSeverity 39 self.minimumSeverity = minimumSeverity
38 self.hasRepoScope = hasRepoScope 40 self.hasRepoScope = hasRepoScope
41 self.sortOrder = sortOrder
39 } 42 }
40 43
41 // Custom decode so existing state.json files saved before hasRepoScope 44 // 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.
43 init(from decoder: Decoder) throws { 46 init(from decoder: Decoder) throws {
44 let container = try decoder.container(keyedBy: CodingKeys.self) 47 let container = try decoder.container(keyedBy: CodingKeys.self)
45 watchedRepos = try container.decode([String].self, forKey: .watchedRepos) 48 watchedRepos = try container.decode([String].self, forKey: .watchedRepos)
@@ -47,6 +50,7 @@ nonisolated struct PersistedState: Codable {
47 lastFetchByRepo = try container.decode([String: Date].self, forKey: .lastFetchByRepo) 50 lastFetchByRepo = try container.decode([String: Date].self, forKey: .lastFetchByRepo)
48 minimumSeverity = try container.decode(SecurityEventSeverity.self, forKey: .minimumSeverity) 51 minimumSeverity = try container.decode(SecurityEventSeverity.self, forKey: .minimumSeverity)
49 hasRepoScope = try container.decodeIfPresent(Bool.self, forKey: .hasRepoScope) ?? false 52 hasRepoScope = try container.decodeIfPresent(Bool.self, forKey: .hasRepoScope) ?? false
53 sortOrder = try container.decodeIfPresent(AlertSortOrder.self, forKey: .sortOrder) ?? .severity
50 } 54 }
51 55
52 static let placeholder = PersistedState( 56 static let placeholder = PersistedState(
octosentry/SecurityEvent.swift +1 −1
@@ -5,7 +5,7 @@
5 5
6import Foundation 6import Foundation
7 7
8struct SecurityEvent: Identifiable, Codable, Sendable { 8nonisolated struct SecurityEvent: Identifiable, Codable, Sendable {
9 let id: String 9 let id: String
10 let source: SecurityEventSource 10 let source: SecurityEventSource
11 let repoFullName: String 11 let repoFullName: String
octosentry/SecurityEventListView.swift +106 −1
@@ -26,6 +26,8 @@ struct SecurityEventListView: View {
26 } else if showingRepoManager { 26 } else if showingRepoManager {
27 RepoManagerView(store: store, authStore: authStore) 27 RepoManagerView(store: store, authStore: authStore)
28 } else { 28 } else {
29 filterBar
30 Divider()
29 content 31 content
30 } 32 }
31 } 33 }
@@ -101,13 +103,104 @@ struct SecurityEventListView: View {
101 .padding(12) 103 .padding(12)
102 } 104 }
103 105
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
104 @ViewBuilder 191 @ViewBuilder
105 private var content: some View { 192 private var content: some View {
106 if store.events.isEmpty && !store.errorMessages.isEmpty { 193 if store.events.isEmpty && !store.errorMessages.isEmpty {
107 StatusView(systemImage: "exclamationmark.triangle", tint: .orange, message: store.errorMessages.joined(separator: "\n\n")) 194 StatusView(systemImage: "exclamationmark.triangle", tint: .orange, message: store.errorMessages.joined(separator: "\n\n"))
108 } else if store.events.isEmpty && !store.isLoading { 195 } else if store.events.isEmpty && !store.isLoading {
109 VStack(spacing: 8) { 196 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 {
111 StatusView( 204 StatusView(
112 systemImage: "line.3.horizontal.decrease.circle", 205 systemImage: "line.3.horizontal.decrease.circle",
113 tint: .secondary, 206 tint: .secondary,
@@ -298,6 +391,18 @@ private struct RepoManagerView: View {
298 } 391 }
299} 392}
300 393
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
301private struct UpdateBanner: View { 406private struct UpdateBanner: View {
302 let release: UpdateChecker.LatestRelease 407 let release: UpdateChecker.LatestRelease
303 408
octosentry/SecurityEventSource.swift +1 −1
@@ -5,7 +5,7 @@
5 5
6import Foundation 6import Foundation
7 7
8enum SecurityEventSource: String, Codable, CaseIterable { 8nonisolated enum SecurityEventSource: String, Codable, CaseIterable {
9 case dependabot 9 case dependabot
10 case codeScanning 10 case codeScanning
11 case secretScanning 11 case secretScanning
octosentry/SecurityEventStore.swift +34 −9
@@ -24,10 +24,28 @@ final class SecurityEventStore {
24 private(set) var errorMessages: [String] = [] 24 private(set) var errorMessages: [String] = []
25 private(set) var unavailableNotices: [String] = [] 25 private(set) var unavailableNotices: [String] = []
26 private(set) var minimumSeverity: SecurityEventSeverity = .low 26 private(set) var minimumSeverity: SecurityEventSeverity = .low
27 private(set) var sortOrder: AlertSortOrder = .severity
27 private(set) var totalFetchedCount = 0 28 private(set) var totalFetchedCount = 0
28 private(set) var watchedRepos: [String] = [] 29 private(set) var watchedRepos: [String] = []
29 private(set) var watchListErrorMessage: String? 30 private(set) var watchListErrorMessage: String?
30 31
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
31 var unseenCriticalCount: Int { 49 var unseenCriticalCount: Int {
32 rawEvents.filter { $0.severity == .critical && !$0.seenLocally }.count 50 rawEvents.filter { $0.severity == .critical && !$0.seenLocally }.count
33 } 51 }
@@ -45,6 +63,7 @@ final class SecurityEventStore {
45 var state = await persistenceStore.load() 63 var state = await persistenceStore.load()
46 let stateLoadFailure = await persistenceStore.loadFailureMessage 64 let stateLoadFailure = await persistenceStore.loadFailureMessage
47 minimumSeverity = state.minimumSeverity 65 minimumSeverity = state.minimumSeverity
66 sortOrder = state.sortOrder
48 watchedRepos = state.watchedRepos 67 watchedRepos = state.watchedRepos
49 68
50 guard let token = KeychainTokenStore.load() else { 69 guard let token = KeychainTokenStore.load() else {
@@ -99,7 +118,7 @@ final class SecurityEventStore {
99 return event 118 return event
100 } 119 }
101 totalFetchedCount = rawEvents.count 120 totalFetchedCount = rawEvents.count
102 applyMinimumSeverityFilter() 121 applyFilters()
103 122
104 errorMessages = errors 123 errorMessages = errors
105 unavailableNotices = notices 124 unavailableNotices = notices
@@ -108,13 +127,22 @@ final class SecurityEventStore {
108 127
109 func setMinimumSeverity(_ severity: SecurityEventSeverity) async { 128 func setMinimumSeverity(_ severity: SecurityEventSeverity) async {
110 minimumSeverity = severity 129 minimumSeverity = severity
111 applyMinimumSeverityFilter() 130 applyFilters()
112 131
113 var state = await persistenceStore.load() 132 var state = await persistenceStore.load()
114 state.minimumSeverity = severity 133 state.minimumSeverity = severity
115 await persistenceStore.save(state) 134 await persistenceStore.save(state)
116 } 135 }
117 136
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
118 func addRepo(_ input: String) async { 146 func addRepo(_ input: String) async {
119 watchListErrorMessage = nil 147 watchListErrorMessage = nil
120 let trimmed = input.trimmingCharacters(in: .whitespacesAndNewlines) 148 let trimmed = input.trimmingCharacters(in: .whitespacesAndNewlines)
@@ -163,7 +191,7 @@ final class SecurityEventStore {
163 191
164 rawEvents.removeAll { $0.id == eventID } 192 rawEvents.removeAll { $0.id == eventID }
165 totalFetchedCount = rawEvents.count 193 totalFetchedCount = rawEvents.count
166 applyMinimumSeverityFilter() 194 applyFilters()
167 } 195 }
168 196
169 func removeRepo(_ repoFullName: String) async { 197 func removeRepo(_ repoFullName: String) async {
@@ -190,12 +218,9 @@ final class SecurityEventStore {
190 } 218 }
191 } 219 }
192 220
193 private func applyMinimumSeverityFilter() { 221 private func applyFilters() {
194 events = rawEvents 222 let admitted = rawEvents.filter { $0.severity >= minimumSeverity }
195 .filter { $0.severity >= minimumSeverity } 223 events = sortOrder.sorted(filter.apply(to: admitted))
196 .sorted { lhs, rhs in
197 lhs.severity != rhs.severity ? lhs.severity > rhs.severity : lhs.createdAt > rhs.createdAt
198 }
199 } 224 }
200 225
201 private enum SourceOutcome { 226 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 {
31 seenEventIDs: ["dependabot-octocat/hello-world-1", "codeScanning-octocat/spoon-knife-7"], 31 seenEventIDs: ["dependabot-octocat/hello-world-1", "codeScanning-octocat/spoon-knife-7"],
32 lastFetchByRepo: ["octocat/hello-world": fetchedAt], 32 lastFetchByRepo: ["octocat/hello-world": fetchedAt],
33 minimumSeverity: .high, 33 minimumSeverity: .high,
34 hasRepoScope: true 34 hasRepoScope: true,
35 sortOrder: .repo
35 ) 36 )
36 37
37 let decoded = try Self.decoder.decode( 38 let decoded = try Self.decoder.decode(
@@ -44,6 +45,7 @@ struct PersistedStateTests {
44 #expect(decoded.lastFetchByRepo == original.lastFetchByRepo) 45 #expect(decoded.lastFetchByRepo == original.lastFetchByRepo)
45 #expect(decoded.minimumSeverity == original.minimumSeverity) 46 #expect(decoded.minimumSeverity == original.minimumSeverity)
46 #expect(decoded.hasRepoScope == original.hasRepoScope) 47 #expect(decoded.hasRepoScope == original.hasRepoScope)
48 #expect(decoded.sortOrder == original.sortOrder)
47 } 49 }
48 50
49 @Test func encodesTheKeysOnDiskReadersDependOn() throws { 51 @Test func encodesTheKeysOnDiskReadersDependOn() throws {
@@ -53,12 +55,12 @@ struct PersistedStateTests {
53 ) 55 )
54 56
55 #expect(Set(object.keys) == [ 57 #expect(Set(object.keys) == [
56 "watchedRepos", "seenEventIDs", "lastFetchByRepo", "minimumSeverity", "hasRepoScope", 58 "watchedRepos", "seenEventIDs", "lastFetchByRepo", "minimumSeverity", "hasRepoScope", "sortOrder",
57 ]) 59 ])
58 } 60 }
59 61
60 // A state.json written before hasRepoScope existed must still load. 62 // A state.json written before hasRepoScope and sortOrder existed must still load.
61 @Test func decodesLegacyStateWithoutRepoScope() throws { 63 @Test func decodesLegacyStateWithoutRepoScopeOrSortOrder() throws {
62 let legacy = """ 64 let legacy = """
63 { 65 {
64 "watchedRepos": ["octocat/hello-world"], 66 "watchedRepos": ["octocat/hello-world"],
@@ -74,6 +76,7 @@ struct PersistedStateTests {
74 #expect(state.seenEventIDs == ["dependabot-octocat/hello-world-1"]) 76 #expect(state.seenEventIDs == ["dependabot-octocat/hello-world-1"])
75 #expect(state.minimumSeverity == .medium) 77 #expect(state.minimumSeverity == .medium)
76 #expect(state.hasRepoScope == false) 78 #expect(state.hasRepoScope == false)
79 #expect(state.sortOrder == .severity)
77 } 80 }
78 81
79 @Test func rejectsStateMissingARequiredField() { 82 @Test func rejectsStateMissingARequiredField() {
@@ -91,5 +94,6 @@ struct PersistedStateTests {
91 #expect(PersistedState.placeholder.lastFetchByRepo.isEmpty) 94 #expect(PersistedState.placeholder.lastFetchByRepo.isEmpty)
92 #expect(PersistedState.placeholder.minimumSeverity == .low) 95 #expect(PersistedState.placeholder.minimumSeverity == .low)
93 #expect(PersistedState.placeholder.hasRepoScope == false) 96 #expect(PersistedState.placeholder.hasRepoScope == false)
97 #expect(PersistedState.placeholder.sortOrder == .severity)
94 } 98 }
95} 99}
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}