krz/octosentry

macOS menu bar app to monitor GitHub security alerts

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

2f72d3d0736a6fac306d4e5d9406cb33b08bc2a0

verified · cmc

author: Christian Cleberg <hello@cleberg.net> · 2026-07-17T21:41:52Z

Add persistence, multi-repo watch list, and background polling

Closes #1-#5 (milestones 0.2.0, 0.3.0).

- Flat JSON persistence (PersistedState/PersistenceStore) in Application
  Support, chosen over SwiftData since the dataset is small and the
  existing model types are plain Codable value types passed across actor
  boundaries.
- Repo watch list, per-event seen-state, and last-fetch timestamps are
  now persisted instead of living only in memory.
- Configurable minimum severity filter, applied from a cached raw fetch
  so changing it doesn't require a network round-trip.
- Multi-repo support: SecurityEventStore now loops over a persisted watch
  list instead of one hardcoded repo, with an in-popover UI (gear button)
  to add/remove repos.
- Background polling every 15 minutes, layered on top of the existing
  refresh-on-open and manual refresh, so the feed stays fresh even while
  the popover is closed.
 octosentry/PersistedState.swift        |  26 ++++++
 octosentry/PersistenceStore.swift      |  46 ++++++++++
 octosentry/SecurityEventListView.swift | 105 ++++++++++++++++++++--
 octosentry/SecurityEventSeverity.swift |   2 +-
 octosentry/SecurityEventStore.swift    | 153 ++++++++++++++++++++++++++-------
 5 files changed, 295 insertions(+), 37 deletions(-)

diff --git a/octosentry/PersistedState.swift b/octosentry/PersistedState.swift
new file mode 100644
index 0000000..3ef4234
--- /dev/null
+++ b/octosentry/PersistedState.swift
@@ -0,0 +1,26 @@
+//
+//  PersistedState.swift
+//  octosentry
+//
+//  Everything the app remembers across launches: the repo watch list,
+//  local-only seen-state per event, last-fetch timestamp per repo, and the
+//  minimum severity filter. 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.
+//
+
+import Foundation
+
+nonisolated struct PersistedState: Codable {
+    var watchedRepos: [String]
+    var seenEventIDs: Set<String>
+    var lastFetchByRepo: [String: Date]
+    var minimumSeverity: SecurityEventSeverity
+
+    static let placeholder = PersistedState(
+        watchedRepos: ["ccleberg/cleberg.net"],
+        seenEventIDs: [],
+        lastFetchByRepo: [:],
+        minimumSeverity: .low
+    )
+}
diff --git a/octosentry/PersistenceStore.swift b/octosentry/PersistenceStore.swift
new file mode 100644
index 0000000..ca86eec
--- /dev/null
+++ b/octosentry/PersistenceStore.swift
@@ -0,0 +1,46 @@
+//
+//  PersistenceStore.swift
+//  octosentry
+//
+//  Loads and saves PersistedState as JSON in the app's Application Support
+//  container. No entitlement needed — sandboxed apps always get a private
+//  Application Support directory in their own container.
+//
+
+import Foundation
+
+actor PersistenceStore {
+    private let fileURL: URL
+
+    private static let decoder: JSONDecoder = {
+        let decoder = JSONDecoder()
+        decoder.dateDecodingStrategy = .iso8601
+        return decoder
+    }()
+
+    private static let encoder: JSONEncoder = {
+        let encoder = JSONEncoder()
+        encoder.dateEncodingStrategy = .iso8601
+        return encoder
+    }()
+
+    init() {
+        let appSupport = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0]
+        let directory = appSupport.appendingPathComponent("octosentry", isDirectory: true)
+        try? FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
+        fileURL = directory.appendingPathComponent("state.json")
+    }
+
+    func load() -> PersistedState {
+        guard let data = try? Data(contentsOf: fileURL),
+              let state = try? Self.decoder.decode(PersistedState.self, from: data) else {
+            return .placeholder
+        }
+        return state
+    }
+
+    func save(_ state: PersistedState) {
+        guard let data = try? Self.encoder.encode(state) else { return }
+        try? data.write(to: fileURL, options: .atomic)
+    }
+}
diff --git a/octosentry/SecurityEventListView.swift b/octosentry/SecurityEventListView.swift
index 4542275..a5dba3e 100644
--- a/octosentry/SecurityEventListView.swift
+++ b/octosentry/SecurityEventListView.swift
@@ -8,16 +8,22 @@ import SwiftUI
 
 struct SecurityEventListView: View {
     var store: SecurityEventStore
+    @State private var showingRepoManager = false
 
     var body: some View {
         VStack(alignment: .leading, spacing: 0) {
             header
             Divider()
-            content
+            if showingRepoManager {
+                RepoManagerView(store: store)
+            } else {
+                content
+            }
         }
         .frame(width: 380, height: 420)
         .task {
             await store.refresh()
+            store.startPolling()
         }
     }
 
@@ -33,13 +39,34 @@ struct SecurityEventListView: View {
 
             Spacer()
 
+            if !showingRepoManager {
+                Picker("Minimum severity", selection: Binding(
+                    get: { store.minimumSeverity },
+                    set: { newValue in Task { await store.setMinimumSeverity(newValue) } }
+                )) {
+                    ForEach(SecurityEventSeverity.allCases, id: \.self) { severity in
+                        Text(severity.displayName).tag(severity)
+                    }
+                }
+                .pickerStyle(.menu)
+                .labelsHidden()
+                .fixedSize()
+
+                Button {
+                    Task { await store.refresh() }
+                } label: {
+                    Image(systemName: "arrow.clockwise")
+                }
+                .buttonStyle(.plain)
+                .disabled(store.isLoading)
+            }
+
             Button {
-                Task { await store.refresh() }
+                showingRepoManager.toggle()
             } label: {
-                Image(systemName: "arrow.clockwise")
+                Image(systemName: showingRepoManager ? "xmark.circle" : "gearshape")
             }
             .buttonStyle(.plain)
-            .disabled(store.isLoading)
 
             Button("Quit") {
                 NSApplication.shared.terminate(nil)
@@ -56,7 +83,15 @@ struct SecurityEventListView: View {
             StatusView(systemImage: "exclamationmark.triangle", tint: .orange, message: store.errorMessages.joined(separator: "\n\n"))
         } else if store.events.isEmpty && !store.isLoading {
             VStack(spacing: 8) {
-                StatusView(systemImage: "checkmark.shield", tint: .green, message: "No open security alerts")
+                if store.totalFetchedCount > 0 {
+                    StatusView(
+                        systemImage: "line.3.horizontal.decrease.circle",
+                        tint: .secondary,
+                        message: "\(store.totalFetchedCount) alert(s) are below your minimum severity filter"
+                    )
+                } else {
+                    StatusView(systemImage: "checkmark.shield", tint: .green, message: "No open security alerts")
+                }
                 if !store.unavailableNotices.isEmpty {
                     NoticeBanner(messages: store.unavailableNotices)
                         .padding(.horizontal)
@@ -84,6 +119,66 @@ struct SecurityEventListView: View {
     }
 }
 
+private struct RepoManagerView: View {
+    var store: SecurityEventStore
+    @State private var newRepoText = ""
+
+    var body: some View {
+        VStack(alignment: .leading, spacing: 10) {
+            Text("Watched Repositories")
+                .font(.subheadline.weight(.semibold))
+
+            if store.watchedRepos.isEmpty {
+                Text("No repos watched yet.")
+                    .font(.callout)
+                    .foregroundStyle(.secondary)
+            } else {
+                ForEach(store.watchedRepos, id: \.self) { repo in
+                    HStack {
+                        Text(repo)
+                            .font(.callout)
+                        Spacer()
+                        Button {
+                            Task { await store.removeRepo(repo) }
+                        } label: {
+                            Image(systemName: "minus.circle.fill")
+                                .foregroundStyle(.red)
+                        }
+                        .buttonStyle(.plain)
+                    }
+                }
+            }
+
+            Divider()
+
+            HStack {
+                TextField("owner/repo", text: $newRepoText)
+                    .textFieldStyle(.roundedBorder)
+                    .onSubmit(addRepo)
+
+                Button("Add", action: addRepo)
+                    .disabled(newRepoText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
+            }
+
+            if let errorMessage = store.watchListErrorMessage {
+                Text(errorMessage)
+                    .font(.caption2)
+                    .foregroundStyle(.red)
+            }
+
+            Spacer()
+        }
+        .padding(12)
+        .frame(maxWidth: .infinity, alignment: .leading)
+    }
+
+    private func addRepo() {
+        let text = newRepoText
+        newRepoText = ""
+        Task { await store.addRepo(text) }
+    }
+}
+
 private struct ErrorBanner: View {
     let messages: [String]
 
diff --git a/octosentry/SecurityEventSeverity.swift b/octosentry/SecurityEventSeverity.swift
index f975639..102c14b 100644
--- a/octosentry/SecurityEventSeverity.swift
+++ b/octosentry/SecurityEventSeverity.swift
@@ -5,7 +5,7 @@
 
 import SwiftUI
 
-enum SecurityEventSeverity: Int, Codable, Comparable, CaseIterable {
+nonisolated enum SecurityEventSeverity: Int, Codable, Comparable, CaseIterable, Hashable {
     case low
     case medium
     case high
diff --git a/octosentry/SecurityEventStore.swift b/octosentry/SecurityEventStore.swift
index a45d5c7..11f813c 100644
--- a/octosentry/SecurityEventStore.swift
+++ b/octosentry/SecurityEventStore.swift
@@ -2,14 +2,16 @@
 //  SecurityEventStore.swift
 //  octosentry
 //
-//  Holds the fetched event stream for the popover. MVP scope: one
-//  hardcoded repo, in-memory only, PAT read from GITHUB_TOKEN (spec §13).
+//  Holds the fetched event stream for the popover. Watch list, seen-state,
+//  and last-fetch timestamps are persisted (see PersistedState); the PAT
+//  is still read from GITHUB_TOKEN as a dev-only shortcut (spec §13).
 //
-//  Each alert source is fetched independently so a problem with one
-//  endpoint doesn't blank out the other two. A 403/404 on a single source
-//  usually just means that alert type is disabled for the repo (or the
-//  token lacks that one permission) — not a real failure — so those are
-//  reported as quiet "unavailable" notices rather than alarming errors.
+//  Each alert source is fetched independently, per repo, so a problem
+//  with one endpoint (or one repo) doesn't blank out the rest. A 403/404
+//  on a single source usually just means that alert type is disabled for
+//  the repo (or the token lacks that one permission) — not a real
+//  failure — so those are reported as quiet "unavailable" notices rather
+//  than alarming errors.
 //
 
 import Foundation
@@ -21,9 +23,14 @@ final class SecurityEventStore {
     private(set) var isLoading = false
     private(set) var errorMessages: [String] = []
     private(set) var unavailableNotices: [String] = []
+    private(set) var minimumSeverity: SecurityEventSeverity = .low
+    private(set) var totalFetchedCount = 0
+    private(set) var watchedRepos: [String] = []
+    private(set) var watchListErrorMessage: String?
 
-    private let owner = "ccleberg"
-    private let repo = "cleberg.net"
+    private let persistenceStore = PersistenceStore()
+    private var rawEvents: [SecurityEvent] = []
+    private var pollingTask: Task<Void, Never>?
 
     func refresh() async {
         isLoading = true
@@ -31,6 +38,10 @@ final class SecurityEventStore {
         unavailableNotices = []
         defer { isLoading = false }
 
+        var state = await persistenceStore.load()
+        minimumSeverity = state.minimumSeverity
+        watchedRepos = state.watchedRepos
+
         guard let token = ProcessInfo.processInfo.environment["GITHUB_TOKEN"], !token.isEmpty else {
             errorMessages = [GitHubAPIError.missingToken.errorDescription ?? "Missing GITHUB_TOKEN."]
             return
@@ -38,37 +49,117 @@ final class SecurityEventStore {
 
         let client = GitHubSecurityAPIClient(token: token)
 
-        async let dependabot = fetchSource(label: "Dependabot") {
-            try await client.fetchDependabotAlerts(owner: self.owner, repo: self.repo)
-        }
-        async let codeScanning = fetchSource(label: "Code scanning") {
-            try await client.fetchCodeScanningAlerts(owner: self.owner, repo: self.repo)
-        }
-        async let secretScanning = fetchSource(label: "Secret scanning") {
-            try await client.fetchSecretScanningAlerts(owner: self.owner, repo: self.repo)
-        }
-
-        let outcomes = await [dependabot, codeScanning, secretScanning]
-
         var fetchedEvents: [SecurityEvent] = []
         var errors: [String] = []
         var notices: [String] = []
-        for outcome in outcomes {
-            switch outcome {
-            case .events(let sourceEvents):
-                fetchedEvents += sourceEvents
-            case .unavailable(let label):
-                notices.append("\(label) alerts aren't available for this repo (disabled, or token lacks that permission).")
-            case .failed(let label, let message):
-                errors.append("\(label): \(message)")
+
+        for repoFullName in state.watchedRepos {
+            let parts = repoFullName.split(separator: "/", maxSplits: 1)
+            guard parts.count == 2 else { continue }
+            let owner = String(parts[0])
+            let repo = String(parts[1])
+
+            async let dependabot = fetchSource(label: "\(repoFullName) · Dependabot") {
+                try await client.fetchDependabotAlerts(owner: owner, repo: repo)
+            }
+            async let codeScanning = fetchSource(label: "\(repoFullName) · Code scanning") {
+                try await client.fetchCodeScanningAlerts(owner: owner, repo: repo)
+            }
+            async let secretScanning = fetchSource(label: "\(repoFullName) · Secret scanning") {
+                try await client.fetchSecretScanningAlerts(owner: owner, repo: repo)
+            }
+
+            let outcomes = await [dependabot, codeScanning, secretScanning]
+            var repoSucceeded = false
+            for outcome in outcomes {
+                switch outcome {
+                case .events(let sourceEvents):
+                    fetchedEvents += sourceEvents
+                    repoSucceeded = true
+                case .unavailable(let label):
+                    notices.append("\(label) alerts aren't available for this repo (disabled, or token lacks that permission).")
+                case .failed(let label, let message):
+                    errors.append("\(label): \(message)")
+                }
+            }
+            if repoSucceeded {
+                state.lastFetchByRepo[repoFullName] = Date()
             }
         }
 
-        events = fetchedEvents.sorted { lhs, rhs in
-            lhs.severity != rhs.severity ? lhs.severity > rhs.severity : lhs.createdAt > rhs.createdAt
+        rawEvents = fetchedEvents.map { event in
+            var event = event
+            event.seenLocally = state.seenEventIDs.contains(event.id)
+            return event
         }
+        totalFetchedCount = rawEvents.count
+        applyMinimumSeverityFilter()
+
         errorMessages = errors
         unavailableNotices = notices
+        await persistenceStore.save(state)
+    }
+
+    func setMinimumSeverity(_ severity: SecurityEventSeverity) async {
+        minimumSeverity = severity
+        applyMinimumSeverityFilter()
+
+        var state = await persistenceStore.load()
+        state.minimumSeverity = severity
+        await persistenceStore.save(state)
+    }
+
+    func addRepo(_ input: String) async {
+        watchListErrorMessage = nil
+        let trimmed = input.trimmingCharacters(in: .whitespacesAndNewlines)
+        let parts = trimmed.split(separator: "/", omittingEmptySubsequences: true)
+        guard parts.count == 2 else {
+            watchListErrorMessage = "Enter a repo as \"owner/repo\"."
+            return
+        }
+
+        var state = await persistenceStore.load()
+        guard !state.watchedRepos.contains(trimmed) else {
+            watchListErrorMessage = "\(trimmed) is already watched."
+            return
+        }
+        state.watchedRepos.append(trimmed)
+        await persistenceStore.save(state)
+        watchedRepos = state.watchedRepos
+
+        await refresh()
+    }
+
+    func removeRepo(_ repoFullName: String) async {
+        var state = await persistenceStore.load()
+        state.watchedRepos.removeAll { $0 == repoFullName }
+        state.lastFetchByRepo.removeValue(forKey: repoFullName)
+        await persistenceStore.save(state)
+        watchedRepos = state.watchedRepos
+
+        await refresh()
+    }
+
+    /// Keeps the feed reasonably fresh even while the popover is closed,
+    /// without hammering GitHub's rate limit (5000/hr authenticated).
+    /// Idempotent — safe to call every time the popover opens.
+    func startPolling(interval: Duration = .seconds(900)) {
+        guard pollingTask == nil else { return }
+        pollingTask = Task {
+            while !Task.isCancelled {
+                try? await Task.sleep(for: interval)
+                guard !Task.isCancelled else { return }
+                await refresh()
+            }
+        }
+    }
+
+    private func applyMinimumSeverityFilter() {
+        events = rawEvents
+            .filter { $0.severity >= minimumSeverity }
+            .sorted { lhs, rhs in
+                lhs.severity != rhs.severity ? lhs.severity > rhs.severity : lhs.createdAt > rhs.createdAt
+            }
     }
 
     private enum SourceOutcome {