krz/octosentry

macOS menu bar app to monitor GitHub security alerts

clone: git clone https://gitbay.org/krz/octosentry.git

c9bb67ce2c709493cd74a6c27fec35a2b31154eb

signed_unknown_key

author: Christian Cleberg <hello@cleberg.net> · 2026-08-22T21:02:30Z
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
 octosentry/AlertDiff.swift                |  56 ++++++++++
 octosentry/AlertNotifier.swift            | 107 +++++++++++++++++++
 octosentry/PersistedState.swift           |  21 +++-
 octosentry/SecurityEventStore.swift       |  22 +++-
 octosentryTests/AlertDiffTests.swift      | 170 ++++++++++++++++++++++++++++++
 octosentryTests/PersistedStateTests.swift |   8 +-
 6 files changed, 379 insertions(+), 5 deletions(-)

diff --git a/octosentry/AlertDiff.swift b/octosentry/AlertDiff.swift
new file mode 100644
index 0000000..664a7aa
--- /dev/null
+++ b/octosentry/AlertDiff.swift
@@ -0,0 +1,56 @@
+//
+//  AlertDiff.swift
+//  octosentry
+//
+//  Works out what a poll turned up that wasn't there before, and what the
+//  next poll should compare against. Kept separate from SecurityEventStore
+//  so the rules are testable without a network or a token.
+//
+//  The baseline is per repo rather than one flat set: a repo that failed to
+//  fetch keeps its previous entry, so a transient error doesn't make its
+//  alerts look new on the next poll.
+//
+
+import Foundation
+
+nonisolated enum AlertDiff {
+    /// Alerts present now that weren't in the baseline for the same repo,
+    /// filtered to the minimum-severity threshold so notifications and the
+    /// feed agree about what's worth surfacing.
+    ///
+    /// Returns nothing for a repo with no baseline entry — a first sync, or a
+    /// repo just added to the watch list. Its backlog isn't news, and
+    /// announcing it is how a busy repo produces a notification storm.
+    static func newlyAppeared(
+        in fetchedByRepo: [String: [SecurityEvent]],
+        baseline: [String: Set<String>]?,
+        minimumSeverity: SecurityEventSeverity
+    ) -> [SecurityEvent] {
+        guard let baseline else { return [] }
+
+        let new = fetchedByRepo.flatMap { repoFullName, events -> [SecurityEvent] in
+            guard let known = baseline[repoFullName] else { return [] }
+            return events.filter { !known.contains($0.id) && $0.severity >= minimumSeverity }
+        }
+
+        // Dictionary iteration order isn't stable, so impose one.
+        return new.sorted { lhs, rhs in
+            lhs.severity != rhs.severity ? lhs.severity > rhs.severity : lhs.id < rhs.id
+        }
+    }
+
+    /// The baseline to compare the next poll against: fresh IDs for repos that
+    /// answered, previous entries kept for repos that didn't, and entries
+    /// dropped for repos no longer watched so the file doesn't grow forever.
+    static func updatedBaseline(
+        from fetchedByRepo: [String: [SecurityEvent]],
+        watchedRepos: [String],
+        previous: [String: Set<String>]?
+    ) -> [String: Set<String>] {
+        var baseline = previous ?? [:]
+        for (repoFullName, events) in fetchedByRepo {
+            baseline[repoFullName] = Set(events.map(\.id))
+        }
+        return baseline.filter { watchedRepos.contains($0.key) }
+    }
+}
diff --git a/octosentry/AlertNotifier.swift b/octosentry/AlertNotifier.swift
new file mode 100644
index 0000000..34c4db4
--- /dev/null
+++ b/octosentry/AlertNotifier.swift
@@ -0,0 +1,107 @@
+//
+//  AlertNotifier.swift
+//  octosentry
+//
+//  Posts a notification when a poll turns up alerts that weren't there
+//  before. One notification per poll, never a burst: a single new alert
+//  names it and deep-links to it, several collapse into a count that opens
+//  the app. That keeps the first sync of a busy repo from filling
+//  Notification Centre.
+//
+//  Authorization is requested lazily, the first time there is actually
+//  something to say, rather than on launch. A denial is not an error —
+//  polling carries on and the menu bar badge still updates.
+//
+
+import AppKit
+import Foundation
+import UserNotifications
+
+@MainActor
+final class AlertNotifier: NSObject {
+    static let shared = AlertNotifier()
+
+    private nonisolated static let detailURLKey = "detailURL"
+    private nonisolated static let summaryCategory = "octosentry.summary"
+
+    private let center = UNUserNotificationCenter.current()
+
+    /// Posts at most one notification for `newEvents`. Does nothing when the
+    /// list is empty or the user has declined notifications.
+    ///
+    /// The delegate is installed here rather than at launch on purpose:
+    /// touching UNUserNotificationCenter from the App initializer stops the
+    /// app launching at all. The cost is that a notification left over from a
+    /// previous session, clicked before this app has posted anything, just
+    /// activates octosentry instead of opening its alert.
+    func notify(about newEvents: [SecurityEvent]) async {
+        guard !newEvents.isEmpty else { return }
+
+        center.delegate = self
+        guard await requestAuthorizationIfNeeded() else { return }
+
+        let content = UNMutableNotificationContent()
+        content.sound = .default
+
+        if let only = newEvents.first, newEvents.count == 1 {
+            content.title = "\(only.severity.displayName) · \(only.source.displayName)"
+            content.subtitle = only.repoFullName
+            content.body = only.summary
+            content.userInfo = [Self.detailURLKey: only.detailURL.absoluteString]
+        } else {
+            let highest = newEvents.map(\.severity).max() ?? .low
+            content.title = "\(newEvents.count) new security alerts"
+            content.body = "Highest severity: \(highest.displayName)"
+            content.categoryIdentifier = Self.summaryCategory
+        }
+
+        let request = UNNotificationRequest(
+            identifier: UUID().uuidString,
+            content: content,
+            trigger: nil
+        )
+        try? await center.add(request)
+    }
+
+    private func requestAuthorizationIfNeeded() async -> Bool {
+        let settings = await center.notificationSettings()
+        switch settings.authorizationStatus {
+        case .authorized, .provisional:
+            return true
+        case .denied:
+            return false
+        default:
+            return (try? await center.requestAuthorization(options: [.alert, .sound])) ?? false
+        }
+    }
+}
+
+extension AlertNotifier: UNUserNotificationCenterDelegate {
+    /// Clicking a single-alert notification opens it on GitHub, matching what
+    /// clicking the row does. The summary has no single target, so it opens
+    /// the window instead.
+    nonisolated func userNotificationCenter(
+        _ center: UNUserNotificationCenter,
+        didReceive response: UNNotificationResponse
+    ) async {
+        let userInfo = response.notification.request.content.userInfo
+        let urlString = userInfo[Self.detailURLKey] as? String
+
+        await MainActor.run {
+            if let urlString, let url = URL(string: urlString) {
+                NSWorkspace.shared.open(url)
+            } else {
+                NSApp.activate(ignoringOtherApps: true)
+            }
+        }
+    }
+
+    /// Show the banner even when octosentry is the frontmost app — the
+    /// popover may well be closed.
+    nonisolated func userNotificationCenter(
+        _ center: UNUserNotificationCenter,
+        willPresent notification: UNNotification
+    ) async -> UNNotificationPresentationOptions {
+        [.banner, .sound]
+    }
+}
diff --git a/octosentry/PersistedState.swift b/octosentry/PersistedState.swift
index 90695ef..609d0d8 100644
--- a/octosentry/PersistedState.swift
+++ b/octosentry/PersistedState.swift
@@ -4,8 +4,9 @@
 //
 //  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, the feed sort order, 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, the per-repo alert IDs
+//  the notifier has already accounted for, 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.
@@ -21,8 +22,16 @@ nonisolated struct PersistedState: Codable {
     var hasRepoScope: Bool
     var sortOrder: AlertSortOrder
 
+    /// Alert IDs seen on the last successful fetch, per repo. nil means this
+    /// install has never completed a fetch, which is what tells the notifier
+    /// to seed quietly instead of announcing every pre-existing alert. A repo
+    /// with no entry is treated the same way, so newly watched repos don't
+    /// arrive as a burst.
+    var notifiedEventIDsByRepo: [String: Set<String>]?
+
     enum CodingKeys: String, CodingKey {
         case watchedRepos, seenEventIDs, lastFetchByRepo, minimumSeverity, hasRepoScope, sortOrder
+        case notifiedEventIDsByRepo
     }
 
     init(
@@ -31,7 +40,8 @@ nonisolated struct PersistedState: Codable {
         lastFetchByRepo: [String: Date],
         minimumSeverity: SecurityEventSeverity,
         hasRepoScope: Bool = false,
-        sortOrder: AlertSortOrder = .severity
+        sortOrder: AlertSortOrder = .severity,
+        notifiedEventIDsByRepo: [String: Set<String>]? = nil
     ) {
         self.watchedRepos = watchedRepos
         self.seenEventIDs = seenEventIDs
@@ -39,6 +49,7 @@ nonisolated struct PersistedState: Codable {
         self.minimumSeverity = minimumSeverity
         self.hasRepoScope = hasRepoScope
         self.sortOrder = sortOrder
+        self.notifiedEventIDsByRepo = notifiedEventIDsByRepo
     }
 
     // Custom decode so existing state.json files saved before hasRepoScope
@@ -51,6 +62,10 @@ nonisolated struct PersistedState: Codable {
         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
+        notifiedEventIDsByRepo = try container.decodeIfPresent(
+            [String: Set<String>].self,
+            forKey: .notifiedEventIDsByRepo
+        )
     }
 
     static let placeholder = PersistedState(
diff --git a/octosentry/SecurityEventStore.swift b/octosentry/SecurityEventStore.swift
index 6548bcd..1f2da0c 100644
--- a/octosentry/SecurityEventStore.swift
+++ b/octosentry/SecurityEventStore.swift
@@ -77,6 +77,10 @@ final class SecurityEventStore {
         var fetchedEvents: [SecurityEvent] = []
         var errors: [String] = [stateLoadFailure].compactMap { $0 }
         var notices: [String] = []
+        // Only repos that actually answered this round; a repo that errored
+        // keeps its previous baseline so a transient failure doesn't make its
+        // alerts look new on the next poll.
+        var fetchedEventsByRepo: [String: [SecurityEvent]] = [:]
 
         for repoFullName in state.watchedRepos {
             let parts = repoFullName.split(separator: "/", maxSplits: 1)
@@ -95,11 +99,12 @@ final class SecurityEventStore {
             }
 
             let outcomes = await [dependabot, codeScanning, secretScanning]
+            var repoEvents: [SecurityEvent] = []
             var repoSucceeded = false
             for outcome in outcomes {
                 switch outcome {
                 case .events(let sourceEvents):
-                    fetchedEvents += sourceEvents
+                    repoEvents += sourceEvents
                     repoSucceeded = true
                 case .unavailable(let label):
                     notices.append("\(label) alerts aren't available for this repo (disabled, or token lacks that permission).")
@@ -107,8 +112,10 @@ final class SecurityEventStore {
                     errors.append("\(label): \(message)")
                 }
             }
+            fetchedEvents += repoEvents
             if repoSucceeded {
                 state.lastFetchByRepo[repoFullName] = Date()
+                fetchedEventsByRepo[repoFullName] = repoEvents
             }
         }
 
@@ -122,7 +129,20 @@ final class SecurityEventStore {
 
         errorMessages = errors
         unavailableNotices = notices
+
+        let newEvents = AlertDiff.newlyAppeared(
+            in: fetchedEventsByRepo,
+            baseline: state.notifiedEventIDsByRepo,
+            minimumSeverity: state.minimumSeverity
+        )
+        state.notifiedEventIDsByRepo = AlertDiff.updatedBaseline(
+            from: fetchedEventsByRepo,
+            watchedRepos: state.watchedRepos,
+            previous: state.notifiedEventIDsByRepo
+        )
         await persistenceStore.save(state)
+
+        await AlertNotifier.shared.notify(about: newEvents)
     }
 
     func setMinimumSeverity(_ severity: SecurityEventSeverity) async {
diff --git a/octosentryTests/AlertDiffTests.swift b/octosentryTests/AlertDiffTests.swift
new file mode 100644
index 0000000..53b066c
--- /dev/null
+++ b/octosentryTests/AlertDiffTests.swift
@@ -0,0 +1,170 @@
+//
+//  AlertDiffTests.swift
+//  octosentryTests
+//
+
+import Foundation
+import Testing
+@testable import octosentry
+
+struct AlertDiffTests {
+
+    private let repo = "octocat/hello-world"
+
+    private func fetched(_ ids: [String], severity: SecurityEventSeverity = .high) -> [String: [SecurityEvent]] {
+        [repo: ids.map { TestEvents.event(id: $0, repo: repo, severity: severity) }]
+    }
+
+    // MARK: - Seeding
+
+    // The first sync of a busy repo is the case that would otherwise produce a
+    // notification storm.
+    @Test func firstEverSyncNotifiesAboutNothing() {
+        let new = AlertDiff.newlyAppeared(
+            in: fetched(["a", "b", "c"]),
+            baseline: nil,
+            minimumSeverity: .low
+        )
+
+        #expect(new.isEmpty)
+    }
+
+    @Test func newlyWatchedRepoIsSeededQuietly() {
+        let new = AlertDiff.newlyAppeared(
+            in: fetched(["a", "b"]),
+            baseline: ["octocat/other": ["z"]],
+            minimumSeverity: .low
+        )
+
+        #expect(new.isEmpty)
+    }
+
+    // MARK: - Detecting new alerts
+
+    @Test func reportsOnlyAlertsMissingFromTheBaseline() {
+        let new = AlertDiff.newlyAppeared(
+            in: fetched(["a", "b", "c"]),
+            baseline: [repo: ["a", "b"]],
+            minimumSeverity: .low
+        )
+
+        #expect(new.map(\.id) == ["c"])
+    }
+
+    @Test func reportsNothingWhenTheFeedIsUnchanged() {
+        let new = AlertDiff.newlyAppeared(
+            in: fetched(["a", "b"]),
+            baseline: [repo: ["a", "b"]],
+            minimumSeverity: .low
+        )
+
+        #expect(new.isEmpty)
+    }
+
+    // An alert that disappeared and came back is reported again — GitHub
+    // re-opening it is worth knowing about.
+    @Test func reappearingAlertIsReportedAgain() {
+        let new = AlertDiff.newlyAppeared(
+            in: fetched(["a"]),
+            baseline: [repo: ["b"]],
+            minimumSeverity: .low
+        )
+
+        #expect(new.map(\.id) == ["a"])
+    }
+
+    // MARK: - Severity threshold
+
+    @Test func respectsTheMinimumSeverityThreshold() {
+        let events = [repo: [
+            TestEvents.event(id: "low", repo: repo, severity: .low),
+            TestEvents.event(id: "critical", repo: repo, severity: .critical),
+        ]]
+
+        let new = AlertDiff.newlyAppeared(in: events, baseline: [repo: []], minimumSeverity: .high)
+
+        #expect(new.map(\.id) == ["critical"])
+    }
+
+    @Test func thresholdAdmitsAlertsExactlyAtTheFloor() {
+        let new = AlertDiff.newlyAppeared(
+            in: fetched(["a"], severity: .high),
+            baseline: [repo: []],
+            minimumSeverity: .high
+        )
+
+        #expect(new.map(\.id) == ["a"])
+    }
+
+    // MARK: - Ordering
+
+    @Test func resultsAreOrderedBySeverityThenID() {
+        let events = [
+            "b/repo": [TestEvents.event(id: "2", repo: "b/repo", severity: .medium)],
+            "a/repo": [
+                TestEvents.event(id: "3", repo: "a/repo", severity: .critical),
+                TestEvents.event(id: "1", repo: "a/repo", severity: .critical),
+            ],
+        ]
+        let baseline = ["a/repo": Set<String>(), "b/repo": Set<String>()]
+
+        let new = AlertDiff.newlyAppeared(in: events, baseline: baseline, minimumSeverity: .low)
+
+        #expect(new.map(\.id) == ["1", "3", "2"])
+    }
+
+    // MARK: - Baseline maintenance
+
+    @Test func baselineTakesFreshIDsForReposThatAnswered() {
+        let baseline = AlertDiff.updatedBaseline(
+            from: fetched(["a", "b"]),
+            watchedRepos: [repo],
+            previous: [repo: ["old"]]
+        )
+
+        #expect(baseline[repo] == ["a", "b"])
+    }
+
+    // The case that would otherwise re-announce everything after a blip.
+    @Test func baselineKeepsEntriesForReposThatFailedToFetch() {
+        let baseline = AlertDiff.updatedBaseline(
+            from: [:],
+            watchedRepos: [repo],
+            previous: [repo: ["a", "b"]]
+        )
+
+        #expect(baseline[repo] == ["a", "b"])
+    }
+
+    @Test func baselineDropsUnwatchedRepos() {
+        let baseline = AlertDiff.updatedBaseline(
+            from: [:],
+            watchedRepos: ["octocat/kept"],
+            previous: ["octocat/kept": ["a"], "octocat/removed": ["b"]]
+        )
+
+        #expect(Set(baseline.keys) == ["octocat/kept"])
+    }
+
+    @Test func baselineStartsFromNothingWhenThereIsNoPrevious() {
+        let baseline = AlertDiff.updatedBaseline(
+            from: fetched(["a"]),
+            watchedRepos: [repo],
+            previous: nil
+        )
+
+        #expect(baseline == [repo: ["a"]])
+    }
+
+    // Seed once, then the same alerts are no longer new.
+    @Test func seedingThenPollingReportsOnlyWhatArrivedAfterwards() {
+        let firstPoll = fetched(["a", "b"])
+        let seeded = AlertDiff.updatedBaseline(from: firstPoll, watchedRepos: [repo], previous: nil)
+        #expect(AlertDiff.newlyAppeared(in: firstPoll, baseline: nil, minimumSeverity: .low).isEmpty)
+
+        let secondPoll = fetched(["a", "b", "c"])
+        let new = AlertDiff.newlyAppeared(in: secondPoll, baseline: seeded, minimumSeverity: .low)
+
+        #expect(new.map(\.id) == ["c"])
+    }
+}
diff --git a/octosentryTests/PersistedStateTests.swift b/octosentryTests/PersistedStateTests.swift
index fd1b302..b3a637e 100644
--- a/octosentryTests/PersistedStateTests.swift
+++ b/octosentryTests/PersistedStateTests.swift
@@ -32,7 +32,8 @@ struct PersistedStateTests {
             lastFetchByRepo: ["octocat/hello-world": fetchedAt],
             minimumSeverity: .high,
             hasRepoScope: true,
-            sortOrder: .repo
+            sortOrder: .repo,
+            notifiedEventIDsByRepo: ["octocat/hello-world": ["dependabot-octocat/hello-world-1"]]
         )
 
         let decoded = try Self.decoder.decode(
@@ -46,6 +47,7 @@ struct PersistedStateTests {
         #expect(decoded.minimumSeverity == original.minimumSeverity)
         #expect(decoded.hasRepoScope == original.hasRepoScope)
         #expect(decoded.sortOrder == original.sortOrder)
+        #expect(decoded.notifiedEventIDsByRepo == original.notifiedEventIDsByRepo)
     }
 
     @Test func encodesTheKeysOnDiskReadersDependOn() throws {
@@ -57,6 +59,9 @@ struct PersistedStateTests {
         #expect(Set(object.keys) == [
             "watchedRepos", "seenEventIDs", "lastFetchByRepo", "minimumSeverity", "hasRepoScope", "sortOrder",
         ])
+        // notifiedEventIDsByRepo is optional and nil on the placeholder, so it
+        // encodes to nothing rather than a null.
+        #expect(object["notifiedEventIDsByRepo"] == nil)
     }
 
     // A state.json written before hasRepoScope and sortOrder existed must still load.
@@ -77,6 +82,7 @@ struct PersistedStateTests {
         #expect(state.minimumSeverity == .medium)
         #expect(state.hasRepoScope == false)
         #expect(state.sortOrder == .severity)
+        #expect(state.notifiedEventIDsByRepo == nil)
     }
 
     @Test func rejectsStateMissingARequiredField() {