Add in-app diagnostic logging + resilient inbox; bump to 1.2
Build iOS App / Build .ipa and publish source (push) Failing after 5m44s

- 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.
This commit is contained in:
Hermes
2026-08-12 18:46:08 +00:00
parent 43f4d76356
commit b51b2ee5c8
6 changed files with 202 additions and 11 deletions
+4 -4
View File
@@ -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;
+27 -7
View File
@@ -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()
+83
View File
@@ -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)
}
}
}
+5
View File
@@ -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)
}
}
+77
View File
@@ -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) {}
}
+6
View File
@@ -51,6 +51,12 @@ struct SettingsView: View {
.foregroundColor(.secondary)
}
Section("Diagnostics") {
NavigationLink("View logs") {
LogsView()
}
}
Section {
Button("Log out", role: .destructive) {
showLogoutConfirm = true