krz/octosentry

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

Commit 91f87b483b

91f87b483b12d57711fb1372a31a9e8e9e141207

parent: 0e94907674

Unsigned

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

Start new installs with an empty watch list (#45)

The placeholder seeded every fresh install with ccleberg/cleberg.net,
so a new user's first fetch was someone else's repo, usually failing.
The empty state now points at the gear menu instead of claiming there
are no open alerts.

Refresh no longer treats an empty watch list as a complete poll. It
carries no information, and counting it as complete would prune every
dismissal and record every tracked alert as resolved — reachable before
by removing all repos, and the default path now.

Layout: unified · split

octosentry/PersistedState.swift +4 −1
@@ -93,8 +93,11 @@ nonisolated struct PersistedState: Codable {
93 history = try container.decodeIfPresent(AlertHistory.self, forKey: .history) ?? AlertHistory() 93 history = try container.decodeIfPresent(AlertHistory.self, forKey: .history) ?? AlertHistory()
94 } 94 }
95 95
96 /// A fresh install watches nothing until the user adds a repo. Seeding a
97 /// specific repo here meant every new install started by fetching someone
98 /// else's alerts, which it usually can't read.
96 static let placeholder = PersistedState( 99 static let placeholder = PersistedState(
97 watchedRepos: [WatchedRepo(fullName: "ccleberg/cleberg.net", accountID: 0)], 100 watchedRepos: [],
98 seenEventIDs: [], 101 seenEventIDs: [],
99 lastFetchByRepo: [:], 102 lastFetchByRepo: [:],
100 minimumSeverity: .low 103 minimumSeverity: .low
octosentry/SecurityEventListView.swift +8
@@ -277,6 +277,14 @@ struct SecurityEventListView: View {
277 tint: .secondary, 277 tint: .secondary,
278 message: "\(store.totalFetchedCount) alert(s) are below your minimum severity filter" 278 message: "\(store.totalFetchedCount) alert(s) are below your minimum severity filter"
279 ) 279 )
280 } else if store.watchedRepos.isEmpty {
281 // "No open alerts" would be misleading when nothing is
282 // being watched in the first place.
283 StatusView(
284 systemImage: "plus.circle",
285 tint: .secondary,
286 message: "No repositories watched yet — add one from the gear menu"
287 )
280 } else { 288 } else {
281 StatusView(systemImage: "checkmark.shield", tint: .green, message: "No open security alerts") 289 StatusView(systemImage: "checkmark.shield", tint: .green, message: "No open security alerts")
282 } 290 }
octosentry/SecurityEventStore.swift +5 −1
@@ -172,7 +172,11 @@ final class SecurityEventStore {
172 172
173 // Only prune against a complete picture: if a repo failed this round 173 // Only prune against a complete picture: if a repo failed this round
174 // its alerts are missing, and pruning would forget they were hidden. 174 // its alerts are missing, and pruning would forget they were hidden.
175 if fetchedEventsByRepo.count == Set(state.watchedRepos.map(\.fullName)).count { 175 // An empty watch list is not a complete picture either — it carries no
176 // information, and treating it as one would drop every dismissal and
177 // record every tracked alert as resolved.
178 let watchedRepoNames = Set(state.watchedRepos.map(\.fullName))
179 if !watchedRepoNames.isEmpty, fetchedEventsByRepo.count == watchedRepoNames.count {
176 let now = Date() 180 let now = Date()
177 state.triage = state.triage.pruned( 181 state.triage = state.triage.pruned(
178 presentEventIDs: Set(fetchedEvents.map(\.id)), 182 presentEventIDs: Set(fetchedEvents.map(\.id)),
octosentryTests/PersistedStateTests.swift +5
@@ -112,6 +112,11 @@ struct PersistedStateTests {
112 } 112 }
113 } 113 }
114 114
115 // A fresh install must not arrive watching somebody else's repo.
116 @Test func placeholderWatchesNothing() {
117 #expect(PersistedState.placeholder.watchedRepos.isEmpty)
118 }
119
115 @Test func placeholderStartsWithNoSeenStateAndNoRepoScope() { 120 @Test func placeholderStartsWithNoSeenStateAndNoRepoScope() {
116 #expect(PersistedState.placeholder.seenEventIDs.isEmpty) 121 #expect(PersistedState.placeholder.seenEventIDs.isEmpty)
117 #expect(PersistedState.placeholder.lastFetchByRepo.isEmpty) 122 #expect(PersistedState.placeholder.lastFetchByRepo.isEmpty)