krz/octosentry

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

Commit 33379cb538

33379cb53832cee2900918bc4509a0775d693458

parent: 2e5bb5dd59

Unsigned

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

Notify when a poll turns up new alerts (#30)

Diffs each poll against the alert IDs seen at the last successful fetch
of the same repo and posts at most one notification: a single new alert
names it and deep-links to it, several collapse into a count that opens
the app.

The baseline is per repo and optional. nil means this install has never
completed a fetch, so the first poll seeds quietly instead of
announcing every pre-existing alert; existing state.json files decode
to nil and behave the same on upgrade. A repo with no entry is seeded
the same way, so newly watched repos don't arrive as a burst, and a
repo that failed to fetch keeps its previous entry so a transient error
doesn't re-announce its backlog.

Notifications use the existing minimum-severity floor. Authorization is
requested the first time there's something to say, not at launch; a
denial leaves polling and the badge working.

Closes #16

Layout: unified · split

octosentry/AlertDiff.swift added +56
@@ -0,0 +1,56 @@
1//
2// AlertDiff.swift
3// octosentry
4//
5// Works out what a poll turned up that wasn't there before, and what the
6// next poll should compare against. Kept separate from SecurityEventStore
7// so the rules are testable without a network or a token.
8//
9// The baseline is per repo rather than one flat set: a repo that failed to
10// fetch keeps its previous entry, so a transient error doesn't make its
11// alerts look new on the next poll.
12//
13
14import Foundation
15
16nonisolated enum AlertDiff {
17 /// Alerts present now that weren't in the baseline for the same repo,
18 /// filtered to the minimum-severity threshold so notifications and the
19 /// feed agree about what's worth surfacing.
20 ///
21 /// Returns nothing for a repo with no baseline entry — a first sync, or a
22 /// repo just added to the watch list. Its backlog isn't news, and
23 /// announcing it is how a busy repo produces a notification storm.
24 static func newlyAppeared(
25 in fetchedByRepo: [String: [SecurityEvent]],
26 baseline: [String: Set<String>]?,
27 minimumSeverity: SecurityEventSeverity
28 ) -> [SecurityEvent] {
29 guard let baseline else { return [] }
30
31 let new = fetchedByRepo.flatMap { repoFullName, events -> [SecurityEvent] in
32 guard let known = baseline[repoFullName] else { return [] }
33 return events.filter { !known.contains($0.id) && $0.severity >= minimumSeverity }
34 }
35
36 // Dictionary iteration order isn't stable, so impose one.
37 return new.sorted { lhs, rhs in
38 lhs.severity != rhs.severity ? lhs.severity > rhs.severity : lhs.id < rhs.id
39 }
40 }
41
42 /// The baseline to compare the next poll against: fresh IDs for repos that
43 /// answered, previous entries kept for repos that didn't, and entries
44 /// dropped for repos no longer watched so the file doesn't grow forever.
45 static func updatedBaseline(
46 from fetchedByRepo: [String: [SecurityEvent]],
47 watchedRepos: [String],
48 previous: [String: Set<String>]?
49 ) -> [String: Set<String>] {
50 var baseline = previous ?? [:]
51 for (repoFullName, events) in fetchedByRepo {
52 baseline[repoFullName] = Set(events.map(\.id))
53 }
54 return baseline.filter { watchedRepos.contains($0.key) }
55 }
56}
octosentry/AlertNotifier.swift added +107
@@ -0,0 +1,107 @@
1//
2// AlertNotifier.swift
3// octosentry
4//
5// Posts a notification when a poll turns up alerts that weren't there
6// before. One notification per poll, never a burst: a single new alert
7// names it and deep-links to it, several collapse into a count that opens
8// the app. That keeps the first sync of a busy repo from filling
9// Notification Centre.
10//
11// Authorization is requested lazily, the first time there is actually
12// something to say, rather than on launch. A denial is not an error —
13// polling carries on and the menu bar badge still updates.
14//
15
16import AppKit
17import Foundation
18import UserNotifications
19
20@MainActor
21final class AlertNotifier: NSObject {
22 static let shared = AlertNotifier()
23
24 private nonisolated static let detailURLKey = "detailURL"
25 private nonisolated static let summaryCategory = "octosentry.summary"
26
27 private let center = UNUserNotificationCenter.current()
28
29 /// Posts at most one notification for `newEvents`. Does nothing when the
30 /// list is empty or the user has declined notifications.
31 ///
32 /// The delegate is installed here rather than at launch on purpose:
33 /// touching UNUserNotificationCenter from the App initializer stops the
34 /// app launching at all. The cost is that a notification left over from a
35 /// previous session, clicked before this app has posted anything, just
36 /// activates octosentry instead of opening its alert.
37 func notify(about newEvents: [SecurityEvent]) async {
38 guard !newEvents.isEmpty else { return }
39
40 center.delegate = self
41 guard await requestAuthorizationIfNeeded() else { return }
42
43 let content = UNMutableNotificationContent()
44 content.sound = .default
45
46 if let only = newEvents.first, newEvents.count == 1 {
47 content.title = "\(only.severity.displayName) · \(only.source.displayName)"
48 content.subtitle = only.repoFullName
49 content.body = only.summary
50 content.userInfo = [Self.detailURLKey: only.detailURL.absoluteString]
51 } else {
52 let highest = newEvents.map(\.severity).max() ?? .low
53 content.title = "\(newEvents.count) new security alerts"
54 content.body = "Highest severity: \(highest.displayName)"
55 content.categoryIdentifier = Self.summaryCategory
56 }
57
58 let request = UNNotificationRequest(
59 identifier: UUID().uuidString,
60 content: content,
61 trigger: nil
62 )
63 try? await center.add(request)
64 }
65
66 private func requestAuthorizationIfNeeded() async -> Bool {
67 let settings = await center.notificationSettings()
68 switch settings.authorizationStatus {
69 case .authorized, .provisional:
70 return true
71 case .denied:
72 return false
73 default:
74 return (try? await center.requestAuthorization(options: [.alert, .sound])) ?? false
75 }
76 }
77}
78
79extension AlertNotifier: UNUserNotificationCenterDelegate {
80 /// Clicking a single-alert notification opens it on GitHub, matching what
81 /// clicking the row does. The summary has no single target, so it opens
82 /// the window instead.
83 nonisolated func userNotificationCenter(
84 _ center: UNUserNotificationCenter,
85 didReceive response: UNNotificationResponse
86 ) async {
87 let userInfo = response.notification.request.content.userInfo
88 let urlString = userInfo[Self.detailURLKey] as? String
89
90 await MainActor.run {
91 if let urlString, let url = URL(string: urlString) {
92 NSWorkspace.shared.open(url)
93 } else {
94 NSApp.activate(ignoringOtherApps: true)
95 }
96 }
97 }
98
99 /// Show the banner even when octosentry is the frontmost app — the
100 /// popover may well be closed.
101 nonisolated func userNotificationCenter(
102 _ center: UNUserNotificationCenter,
103 willPresent notification: UNNotification
104 ) async -> UNNotificationPresentationOptions {
105 [.banner, .sound]
106 }
107}
octosentry/PersistedState.swift +18 −3
@@ -4,8 +4,9 @@
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, the feed sort order, and whether the current
8// token has the broader "repo" scope needed to list repos. Flat JSON over SwiftData
7// minimum severity filter, the feed sort order, the per-repo alert IDs
8// the notifier has already accounted for, and whether the current token
9// has the broader "repo" scope needed to list repos. Flat JSON over SwiftData
910// (see #1) — small, inspectable, and these are already plain Codable
1011// values passed across actor boundaries, not reference types tied to a
1112// persistence context.
@@ -21,8 +22,16 @@ nonisolated struct PersistedState: Codable {
2122 var hasRepoScope: Bool
2223 var sortOrder: AlertSortOrder
2324
25 /// Alert IDs seen on the last successful fetch, per repo. nil means this
26 /// install has never completed a fetch, which is what tells the notifier
27 /// to seed quietly instead of announcing every pre-existing alert. A repo
28 /// with no entry is treated the same way, so newly watched repos don't
29 /// arrive as a burst.
30 var notifiedEventIDsByRepo: [String: Set<String>]?
31
2432 enum CodingKeys: String, CodingKey {
2533 case watchedRepos, seenEventIDs, lastFetchByRepo, minimumSeverity, hasRepoScope, sortOrder
34 case notifiedEventIDsByRepo
2635 }
2736
2837 init(
@@ -31,7 +40,8 @@ nonisolated struct PersistedState: Codable {
3140 lastFetchByRepo: [String: Date],
3241 minimumSeverity: SecurityEventSeverity,
3342 hasRepoScope: Bool = false,
34 sortOrder: AlertSortOrder = .severity
43 sortOrder: AlertSortOrder = .severity,
44 notifiedEventIDsByRepo: [String: Set<String>]? = nil
3545 ) {
3646 self.watchedRepos = watchedRepos
3747 self.seenEventIDs = seenEventIDs
@@ -39,6 +49,7 @@ nonisolated struct PersistedState: Codable {
3949 self.minimumSeverity = minimumSeverity
4050 self.hasRepoScope = hasRepoScope
4151 self.sortOrder = sortOrder
52 self.notifiedEventIDsByRepo = notifiedEventIDsByRepo
4253 }
4354
4455 // Custom decode so existing state.json files saved before hasRepoScope
@@ -51,6 +62,10 @@ nonisolated struct PersistedState: Codable {
5162 minimumSeverity = try container.decode(SecurityEventSeverity.self, forKey: .minimumSeverity)
5263 hasRepoScope = try container.decodeIfPresent(Bool.self, forKey: .hasRepoScope) ?? false
5364 sortOrder = try container.decodeIfPresent(AlertSortOrder.self, forKey: .sortOrder) ?? .severity
65 notifiedEventIDsByRepo = try container.decodeIfPresent(
66 [String: Set<String>].self,
67 forKey: .notifiedEventIDsByRepo
68 )
5469 }
5570
5671 static let placeholder = PersistedState(
octosentry/SecurityEventStore.swift +21 −1
@@ -77,6 +77,10 @@ final class SecurityEventStore {
7777 var fetchedEvents: [SecurityEvent] = []
7878 var errors: [String] = [stateLoadFailure].compactMap { $0 }
7979 var notices: [String] = []
80 // Only repos that actually answered this round; a repo that errored
81 // keeps its previous baseline so a transient failure doesn't make its
82 // alerts look new on the next poll.
83 var fetchedEventsByRepo: [String: [SecurityEvent]] = [:]
8084
8185 for repoFullName in state.watchedRepos {
8286 let parts = repoFullName.split(separator: "/", maxSplits: 1)
@@ -95,11 +99,12 @@ final class SecurityEventStore {
9599 }
96100
97101 let outcomes = await [dependabot, codeScanning, secretScanning]
102 var repoEvents: [SecurityEvent] = []
98103 var repoSucceeded = false
99104 for outcome in outcomes {
100105 switch outcome {
101106 case .events(let sourceEvents):
102 fetchedEvents += sourceEvents
107 repoEvents += sourceEvents
103108 repoSucceeded = true
104109 case .unavailable(let label):
105110 notices.append("\(label) alerts aren't available for this repo (disabled, or token lacks that permission).")
@@ -107,8 +112,10 @@ final class SecurityEventStore {
107112 errors.append("\(label): \(message)")
108113 }
109114 }
115 fetchedEvents += repoEvents
110116 if repoSucceeded {
111117 state.lastFetchByRepo[repoFullName] = Date()
118 fetchedEventsByRepo[repoFullName] = repoEvents
112119 }
113120 }
114121
@@ -122,7 +129,20 @@ final class SecurityEventStore {
122129
123130 errorMessages = errors
124131 unavailableNotices = notices
132
133 let newEvents = AlertDiff.newlyAppeared(
134 in: fetchedEventsByRepo,
135 baseline: state.notifiedEventIDsByRepo,
136 minimumSeverity: state.minimumSeverity
137 )
138 state.notifiedEventIDsByRepo = AlertDiff.updatedBaseline(
139 from: fetchedEventsByRepo,
140 watchedRepos: state.watchedRepos,
141 previous: state.notifiedEventIDsByRepo
142 )
125143 await persistenceStore.save(state)
144
145 await AlertNotifier.shared.notify(about: newEvents)
126146 }
127147
128148 func setMinimumSeverity(_ severity: SecurityEventSeverity) async {
octosentryTests/AlertDiffTests.swift added +170
@@ -0,0 +1,170 @@
1//
2// AlertDiffTests.swift
3// octosentryTests
4//
5
6import Foundation
7import Testing
8@testable import octosentry
9
10struct AlertDiffTests {
11
12 private let repo = "octocat/hello-world"
13
14 private func fetched(_ ids: [String], severity: SecurityEventSeverity = .high) -> [String: [SecurityEvent]] {
15 [repo: ids.map { TestEvents.event(id: $0, repo: repo, severity: severity) }]
16 }
17
18 // MARK: - Seeding
19
20 // The first sync of a busy repo is the case that would otherwise produce a
21 // notification storm.
22 @Test func firstEverSyncNotifiesAboutNothing() {
23 let new = AlertDiff.newlyAppeared(
24 in: fetched(["a", "b", "c"]),
25 baseline: nil,
26 minimumSeverity: .low
27 )
28
29 #expect(new.isEmpty)
30 }
31
32 @Test func newlyWatchedRepoIsSeededQuietly() {
33 let new = AlertDiff.newlyAppeared(
34 in: fetched(["a", "b"]),
35 baseline: ["octocat/other": ["z"]],
36 minimumSeverity: .low
37 )
38
39 #expect(new.isEmpty)
40 }
41
42 // MARK: - Detecting new alerts
43
44 @Test func reportsOnlyAlertsMissingFromTheBaseline() {
45 let new = AlertDiff.newlyAppeared(
46 in: fetched(["a", "b", "c"]),
47 baseline: [repo: ["a", "b"]],
48 minimumSeverity: .low
49 )
50
51 #expect(new.map(\.id) == ["c"])
52 }
53
54 @Test func reportsNothingWhenTheFeedIsUnchanged() {
55 let new = AlertDiff.newlyAppeared(
56 in: fetched(["a", "b"]),
57 baseline: [repo: ["a", "b"]],
58 minimumSeverity: .low
59 )
60
61 #expect(new.isEmpty)
62 }
63
64 // An alert that disappeared and came back is reported again — GitHub
65 // re-opening it is worth knowing about.
66 @Test func reappearingAlertIsReportedAgain() {
67 let new = AlertDiff.newlyAppeared(
68 in: fetched(["a"]),
69 baseline: [repo: ["b"]],
70 minimumSeverity: .low
71 )
72
73 #expect(new.map(\.id) == ["a"])
74 }
75
76 // MARK: - Severity threshold
77
78 @Test func respectsTheMinimumSeverityThreshold() {
79 let events = [repo: [
80 TestEvents.event(id: "low", repo: repo, severity: .low),
81 TestEvents.event(id: "critical", repo: repo, severity: .critical),
82 ]]
83
84 let new = AlertDiff.newlyAppeared(in: events, baseline: [repo: []], minimumSeverity: .high)
85
86 #expect(new.map(\.id) == ["critical"])
87 }
88
89 @Test func thresholdAdmitsAlertsExactlyAtTheFloor() {
90 let new = AlertDiff.newlyAppeared(
91 in: fetched(["a"], severity: .high),
92 baseline: [repo: []],
93 minimumSeverity: .high
94 )
95
96 #expect(new.map(\.id) == ["a"])
97 }
98
99 // MARK: - Ordering
100
101 @Test func resultsAreOrderedBySeverityThenID() {
102 let events = [
103 "b/repo": [TestEvents.event(id: "2", repo: "b/repo", severity: .medium)],
104 "a/repo": [
105 TestEvents.event(id: "3", repo: "a/repo", severity: .critical),
106 TestEvents.event(id: "1", repo: "a/repo", severity: .critical),
107 ],
108 ]
109 let baseline = ["a/repo": Set<String>(), "b/repo": Set<String>()]
110
111 let new = AlertDiff.newlyAppeared(in: events, baseline: baseline, minimumSeverity: .low)
112
113 #expect(new.map(\.id) == ["1", "3", "2"])
114 }
115
116 // MARK: - Baseline maintenance
117
118 @Test func baselineTakesFreshIDsForReposThatAnswered() {
119 let baseline = AlertDiff.updatedBaseline(
120 from: fetched(["a", "b"]),
121 watchedRepos: [repo],
122 previous: [repo: ["old"]]
123 )
124
125 #expect(baseline[repo] == ["a", "b"])
126 }
127
128 // The case that would otherwise re-announce everything after a blip.
129 @Test func baselineKeepsEntriesForReposThatFailedToFetch() {
130 let baseline = AlertDiff.updatedBaseline(
131 from: [:],
132 watchedRepos: [repo],
133 previous: [repo: ["a", "b"]]
134 )
135
136 #expect(baseline[repo] == ["a", "b"])
137 }
138
139 @Test func baselineDropsUnwatchedRepos() {
140 let baseline = AlertDiff.updatedBaseline(
141 from: [:],
142 watchedRepos: ["octocat/kept"],
143 previous: ["octocat/kept": ["a"], "octocat/removed": ["b"]]
144 )
145
146 #expect(Set(baseline.keys) == ["octocat/kept"])
147 }
148
149 @Test func baselineStartsFromNothingWhenThereIsNoPrevious() {
150 let baseline = AlertDiff.updatedBaseline(
151 from: fetched(["a"]),
152 watchedRepos: [repo],
153 previous: nil
154 )
155
156 #expect(baseline == [repo: ["a"]])
157 }
158
159 // Seed once, then the same alerts are no longer new.
160 @Test func seedingThenPollingReportsOnlyWhatArrivedAfterwards() {
161 let firstPoll = fetched(["a", "b"])
162 let seeded = AlertDiff.updatedBaseline(from: firstPoll, watchedRepos: [repo], previous: nil)
163 #expect(AlertDiff.newlyAppeared(in: firstPoll, baseline: nil, minimumSeverity: .low).isEmpty)
164
165 let secondPoll = fetched(["a", "b", "c"])
166 let new = AlertDiff.newlyAppeared(in: secondPoll, baseline: seeded, minimumSeverity: .low)
167
168 #expect(new.map(\.id) == ["c"])
169 }
170}
octosentryTests/PersistedStateTests.swift +7 −1
@@ -32,7 +32,8 @@ struct PersistedStateTests {
3232 lastFetchByRepo: ["octocat/hello-world": fetchedAt],
3333 minimumSeverity: .high,
3434 hasRepoScope: true,
35 sortOrder: .repo
35 sortOrder: .repo,
36 notifiedEventIDsByRepo: ["octocat/hello-world": ["dependabot-octocat/hello-world-1"]]
3637 )
3738
3839 let decoded = try Self.decoder.decode(
@@ -46,6 +47,7 @@ struct PersistedStateTests {
4647 #expect(decoded.minimumSeverity == original.minimumSeverity)
4748 #expect(decoded.hasRepoScope == original.hasRepoScope)
4849 #expect(decoded.sortOrder == original.sortOrder)
50 #expect(decoded.notifiedEventIDsByRepo == original.notifiedEventIDsByRepo)
4951 }
5052
5153 @Test func encodesTheKeysOnDiskReadersDependOn() throws {
@@ -57,6 +59,9 @@ struct PersistedStateTests {
5759 #expect(Set(object.keys) == [
5860 "watchedRepos", "seenEventIDs", "lastFetchByRepo", "minimumSeverity", "hasRepoScope", "sortOrder",
5961 ])
62 // notifiedEventIDsByRepo is optional and nil on the placeholder, so it
63 // encodes to nothing rather than a null.
64 #expect(object["notifiedEventIDsByRepo"] == nil)
6065 }
6166
6267 // A state.json written before hasRepoScope and sortOrder existed must still load.
@@ -77,6 +82,7 @@ struct PersistedStateTests {
7782 #expect(state.minimumSeverity == .medium)
7883 #expect(state.hasRepoScope == false)
7984 #expect(state.sortOrder == .severity)
85 #expect(state.notifiedEventIDsByRepo == nil)
8086 }
8187
8288 @Test func rejectsStateMissingARequiredField() {