summaryrefslogtreecommitdiff
path: root/octosentry/SecurityEventStore.swift
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/SecurityEventStore.swift
parentd8862a9b78b0d6cf4e11cf537be7678794e08811 (diff)
downloadoctosentry-2f72d3d0736a6fac306d4e5d9406cb33b08bc2a0.tar.gz
octosentry-2f72d3d0736a6fac306d4e5d9406cb33b08bc2a0.tar.bz2
octosentry-2f72d3d0736a6fac306d4e5d9406cb33b08bc2a0.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/SecurityEventStore.swift')
-rw-r--r--octosentry/SecurityEventStore.swift153
1 files changed, 122 insertions, 31 deletions
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 {