summaryrefslogtreecommitdiff
path: root/octosentry
diff options
context:
space:
mode:
authorChristian Cleberg <[email protected]>2026-07-17 16:41:52 -0500
committerChristian Cleberg <[email protected]>2026-07-17 16:41:52 -0500
commit2f72d3d0736a6fac306d4e5d9406cb33b08bc2a0 (patch)
treeca6bfe8277883d87bc6e45a9393f19c642f3a502 /octosentry
parentd8862a9b78b0d6cf4e11cf537be7678794e08811 (diff)
downloadoctosentry-0.3.0.tar.gz
octosentry-0.3.0.tar.bz2
octosentry-0.3.0.zip
Add persistence, multi-repo watch list, and background polling0.3.0
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.
Diffstat (limited to 'octosentry')
-rw-r--r--octosentry/PersistedState.swift26
-rw-r--r--octosentry/PersistenceStore.swift46
-rw-r--r--octosentry/SecurityEventListView.swift105
-rw-r--r--octosentry/SecurityEventSeverity.swift2
-rw-r--r--octosentry/SecurityEventStore.swift153
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 {