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>
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(-) new file mode 100644 @@ -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) } + } +} new file mode 100644 @@ -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] + } +} @@ -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( @@ -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 { new file mode 100644 @@ -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"]) + } +} @@ -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() {