summaryrefslogtreecommitdiff
path: root/octosentry/SecurityEventStore.swift
diff options
context:
space:
mode:
authorChristian Cleberg <[email protected]>2026-07-17 16:06:47 -0500
committerChristian Cleberg <[email protected]>2026-07-17 16:06:47 -0500
commitd8862a9b78b0d6cf4e11cf537be7678794e08811 (patch)
tree4af615370bb122e6706bbefc6deaa044e02b477b /octosentry/SecurityEventStore.swift
downloadoctosentry-0.1.0.tar.gz
octosentry-0.1.0.tar.bz2
octosentry-0.1.0.zip
Scaffold octosentry MVP: unified GitHub security alert feed0.1.0
MenuBarExtra popover aggregating Dependabot, code scanning, and secret scanning alerts for a single repo into one severity-ranked stream. Swift 6 strict concurrency, zero third-party dependencies, PAT-via-env-var auth as a dev-only shortcut ahead of the device authorization flow.
Diffstat (limited to 'octosentry/SecurityEventStore.swift')
-rw-r--r--octosentry/SecurityEventStore.swift93
1 files changed, 93 insertions, 0 deletions
diff --git a/octosentry/SecurityEventStore.swift b/octosentry/SecurityEventStore.swift
new file mode 100644
index 0000000..a45d5c7
--- /dev/null
+++ b/octosentry/SecurityEventStore.swift
@@ -0,0 +1,93 @@
+//
+// 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).
+//
+// 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.
+//
+
+import Foundation
+import Observation
+
+@Observable
+final class SecurityEventStore {
+ private(set) var events: [SecurityEvent] = []
+ private(set) var isLoading = false
+ private(set) var errorMessages: [String] = []
+ private(set) var unavailableNotices: [String] = []
+
+ private let owner = "ccleberg"
+ private let repo = "cleberg.net"
+
+ func refresh() async {
+ isLoading = true
+ errorMessages = []
+ unavailableNotices = []
+ defer { isLoading = false }
+
+ guard let token = ProcessInfo.processInfo.environment["GITHUB_TOKEN"], !token.isEmpty else {
+ errorMessages = [GitHubAPIError.missingToken.errorDescription ?? "Missing GITHUB_TOKEN."]
+ return
+ }
+
+ 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)")
+ }
+ }
+
+ events = fetchedEvents.sorted { lhs, rhs in
+ lhs.severity != rhs.severity ? lhs.severity > rhs.severity : lhs.createdAt > rhs.createdAt
+ }
+ errorMessages = errors
+ unavailableNotices = notices
+ }
+
+ private enum SourceOutcome {
+ case events([SecurityEvent])
+ case unavailable(label: String)
+ case failed(label: String, message: String)
+ }
+
+ private func fetchSource(
+ label: String,
+ _ operation: () async throws -> [SecurityEvent]
+ ) async -> SourceOutcome {
+ do {
+ return .events(try await operation())
+ } catch GitHubAPIError.forbidden, GitHubAPIError.notFound {
+ return .unavailable(label: label)
+ } catch {
+ let description = (error as? LocalizedError)?.errorDescription ?? error.localizedDescription
+ return .failed(label: label, message: description)
+ }
+ }
+}