From b51b2ee5c82384e7b493ee1779cd1ef2511102e4 Mon Sep 17 00:00:00 2001 From: Hermes Date: Wed, 12 Aug 2026 18:46:08 +0000 Subject: [PATCH] Add in-app diagnostic logging + resilient inbox; bump to 1.2 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - LogStore: bounded in-memory + persisted log of API calls and errors, viewable and shareable from Settings → View logs. - GiteaClient: log every request/response status (esp. 404s). - Inbox: a single failing repo no longer wipes the whole inbox — per-repo errors are caught, logged, and the rest still load. - Bump to 1.2 so SideStore offers the update. --- GiteaPR.xcodeproj/project.pbxproj | 8 +-- GiteaPR/Inbox/InboxViewModel.swift | 34 +++++++++--- GiteaPR/Logging/LogStore.swift | 83 ++++++++++++++++++++++++++++ GiteaPR/Networking/GiteaClient.swift | 5 ++ GiteaPR/Settings/LogsView.swift | 77 ++++++++++++++++++++++++++ GiteaPR/Settings/SettingsView.swift | 6 ++ 6 files changed, 202 insertions(+), 11 deletions(-) create mode 100644 GiteaPR/Logging/LogStore.swift create mode 100644 GiteaPR/Settings/LogsView.swift diff --git a/GiteaPR.xcodeproj/project.pbxproj b/GiteaPR.xcodeproj/project.pbxproj index 5d29b16..fc3b3c8 100644 --- a/GiteaPR.xcodeproj/project.pbxproj +++ b/GiteaPR.xcodeproj/project.pbxproj @@ -187,7 +187,7 @@ buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 2; + CURRENT_PROJECT_VERSION = 3; DEVELOPMENT_TEAM = ""; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_KEY_CFBundleDisplayName = "GiteaPR"; @@ -197,7 +197,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 1.1; + MARKETING_VERSION = 1.2; PRODUCT_BUNDLE_IDENTIFIER = com.bueso.giteapr; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_EMIT_LOC_STRINGS = YES; @@ -211,7 +211,7 @@ buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 2; + CURRENT_PROJECT_VERSION = 3; DEVELOPMENT_TEAM = ""; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_KEY_CFBundleDisplayName = "GiteaPR"; @@ -221,7 +221,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 1.1; + MARKETING_VERSION = 1.2; PRODUCT_BUNDLE_IDENTIFIER = com.bueso.giteapr; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_EMIT_LOC_STRINGS = YES; diff --git a/GiteaPR/Inbox/InboxViewModel.swift b/GiteaPR/Inbox/InboxViewModel.swift index d845ce5..d1a11be 100644 --- a/GiteaPR/Inbox/InboxViewModel.swift +++ b/GiteaPR/Inbox/InboxViewModel.swift @@ -70,25 +70,45 @@ final class InboxViewModel: ObservableObject { do { let repos = try await client.repos() - // Fetch open PRs across all repos concurrently. + LogStore.shared.log(.info, "Inbox: \(repos.count) repos") + // Fetch open PRs across all repos concurrently. A single repo that + // fails (e.g. 404) must NOT wipe the whole inbox — catch per-repo + // errors, log them, and keep whatever succeeded. var rows: [PRRow] = [] - try await withThrowingTaskGroup(of: [PRRow].self) { group in + var failures: [String] = [] + await withTaskGroup(of: Result<[PRRow], Error>.self) { group in for repo in repos { group.addTask { - let prs = try await client.pullRequests(owner: repo.owner.login, - repo: repo.name) - return try await self.enrich(prs, in: repo, client: client) + do { + let prs = try await client.pullRequests(owner: repo.owner.login, + repo: repo.name) + return .success(try await self.enrich(prs, in: repo, client: client)) + } catch { + return .failure(error) + } } } - for try await batch in group { - rows.append(contentsOf: batch) + for await result in group { + switch result { + case .success(let batch): + rows.append(contentsOf: batch) + case .failure(let error): + failures.append(error.localizedDescription) + } } } + if !failures.isEmpty { + LogStore.shared.log(.warn, "Inbox: \(failures.count) repo(s) failed: \(failures.joined(separator: "; "))") + } // Sort: most recently updated first. rows.sort { ($0.pr.updatedAt ?? .distantPast) > ($1.pr.updatedAt ?? .distantPast) } allRows = rows recomputeDisplayed() + if rows.isEmpty && !failures.isEmpty { + errorMessage = "Couldn't load any PRs (\(failures.count) repo(s) failed). Check the Logs screen." + } } catch { + LogStore.shared.log(.error, "Inbox load failed: \(error.localizedDescription)") errorMessage = error.localizedDescription allRows = [] recomputeDisplayed() diff --git a/GiteaPR/Logging/LogStore.swift b/GiteaPR/Logging/LogStore.swift new file mode 100644 index 0000000..eb06d58 --- /dev/null +++ b/GiteaPR/Logging/LogStore.swift @@ -0,0 +1,83 @@ +import Foundation +import SwiftUI + +/// In-app diagnostic log. Keeps a bounded in-memory ring buffer and persists +/// the last N entries to UserDefaults so they survive relaunches and can be +/// shared from the Logs screen in Settings. +@MainActor +final class LogStore: ObservableObject { + static let shared = LogStore() + + struct Entry: Identifiable, Equatable { + let id: UUID + let timestamp: Date + let level: Level + let message: String + + enum Level: String { + case info = "INFO" + case warn = "WARN" + case error = "ERROR" + } + } + + @Published private(set) var entries: [Entry] = [] + + private let maxEntries = 500 + private let persistKey = "giteapr.log.entries" + + private init() { + load() + } + + func log(_ level: Entry.Level, _ message: String) { + let entry = Entry(id: UUID(), timestamp: Date(), level: level, message: message) + entries.append(entry) + if entries.count > maxEntries { + entries.removeFirst(entries.count - maxEntries) + } + persist() + } + + func clear() { + entries.removeAll() + UserDefaults.standard.removeObject(forKey: persistKey) + } + + /// Plain-text rendering for sharing / copy. + var text: String { + let f = ISO8601DateFormatter() + f.formatOptions = [.withInternetDateTime] + return entries.map { entry in + "[\(f.string(from: entry.timestamp))] \(entry.level.rawValue) \(entry.message)" + }.joined(separator: "\n") + } + + private func persist() { + let f = ISO8601DateFormatter() + f.formatOptions = [.withInternetDateTime] + let payload = entries.map { entry in + ["t": f.string(from: entry.timestamp), + "l": entry.level.rawValue, + "m": entry.message] + } + if let data = try? JSONSerialization.data(withJSONObject: payload) { + UserDefaults.standard.set(data, forKey: persistKey) + } + } + + private func load() { + guard let data = UserDefaults.standard.data(forKey: persistKey), + let payload = try? JSONSerialization.jsonObject(with: data) as? [[String: String]] else { + return + } + let f = ISO8601DateFormatter() + f.formatOptions = [.withInternetDateTime] + entries = payload.compactMap { dict in + guard let t = dict["t"], let date = f.date(from: t), + let l = dict["l"], let m = dict["m"], + let level = Entry.Level(rawValue: l) else { return nil } + return Entry(id: UUID(), timestamp: date, level: level, message: m) + } + } +} diff --git a/GiteaPR/Networking/GiteaClient.swift b/GiteaPR/Networking/GiteaClient.swift index f8ae9f1..5509701 100644 --- a/GiteaPR/Networking/GiteaClient.swift +++ b/GiteaPR/Networking/GiteaClient.swift @@ -78,18 +78,23 @@ struct GiteaClient { let (data, response) = try await session.data(for: req) guard let http = response as? HTTPURLResponse else { + LogStore.shared.log(.error, "\(method) \(path) → invalid response") throw APIError.invalidResponse } switch http.statusCode { case 200...299: + LogStore.shared.log(.info, "\(method) \(path) → \(http.statusCode)") return data case 401, 403: + LogStore.shared.log(.error, "\(method) \(path) → \(http.statusCode) unauthorized") throw APIError.unauthorized case 404: + LogStore.shared.log(.error, "\(method) \(path) → 404 not found") throw APIError.notFound default: let message = (try? Self.decoder.decode([String: String].self, from: data))?["message"] + LogStore.shared.log(.error, "\(method) \(path) → \(http.statusCode) \(message ?? "")") throw APIError.httpStatus(http.statusCode, message) } } diff --git a/GiteaPR/Settings/LogsView.swift b/GiteaPR/Settings/LogsView.swift new file mode 100644 index 0000000..d18ec78 --- /dev/null +++ b/GiteaPR/Settings/LogsView.swift @@ -0,0 +1,77 @@ +import SwiftUI +import UIKit + +/// Diagnostic log viewer. Shows the in-memory/persisted log entries with a +/// Share button (so the user can send logs to Hermes) and a Clear button. +struct LogsView: View { + @ObservedObject private var store = LogStore.shared + @State private var showShare = false + + var body: some View { + Group { + if store.entries.isEmpty { + ContentUnavailableView( + "No logs yet", + systemImage: "doc.text", + description: Text("Run the app and reproduce the issue, then come back here.")) + } else { + List { + ForEach(store.entries.reversed()) { entry in + VStack(alignment: .leading, spacing: 2) { + HStack { + Text(entry.level.rawValue) + .font(.caption2.weight(.bold)) + .foregroundColor(color(for: entry.level)) + Text(entry.timestamp, style: .time) + .font(.caption2) + .foregroundColor(.secondary) + } + Text(entry.message) + .font(.caption.monospaced()) + .textSelection(.enabled) + } + .padding(.vertical, 2) + } + } + } + } + .navigationTitle("Logs") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .primaryAction) { + Button { + showShare = true + } label: { + Image(systemName: "square.and.arrow.up") + } + .disabled(store.entries.isEmpty) + } + ToolbarItem(placement: .cancellationAction) { + Button("Clear") { store.clear() } + .disabled(store.entries.isEmpty) + } + } + .sheet(isPresented: $showShare) { + ShareSheet(text: store.text) + } + } + + private func color(for level: LogStore.Entry.Level) -> Color { + switch level { + case .info: return .secondary + case .warn: return .orange + case .error: return .red + } + } +} + +/// Minimal share sheet wrapper (ShareLink is iOS 16+; this is explicit). +struct ShareSheet: UIViewControllerRepresentable { + let text: String + + func makeUIViewController(context: Context) -> UIActivityViewController { + UIActivityViewController(activityItems: [text], applicationActivities: nil) + } + func updateUIViewController(_ uiViewController: UIActivityViewController, + context: Context) {} +} diff --git a/GiteaPR/Settings/SettingsView.swift b/GiteaPR/Settings/SettingsView.swift index 345a19b..ae385c2 100644 --- a/GiteaPR/Settings/SettingsView.swift +++ b/GiteaPR/Settings/SettingsView.swift @@ -51,6 +51,12 @@ struct SettingsView: View { .foregroundColor(.secondary) } + Section("Diagnostics") { + NavigationLink("View logs") { + LogsView() + } + } + Section { Button("Log out", role: .destructive) { showLogoutConfirm = true