From ee3ab52fb1b31e10bb7e10497fbedf3f26fc94fa Mon Sep 17 00:00:00 2001 From: Michael Tookes Date: Wed, 18 Feb 2026 21:06:49 -0600 Subject: [PATCH 1/2] Add Codex code review logs panel and parser support --- .../CodeReviewLogsPanelWindowController.swift | 233 ++++++++++++++++++ .../StatusItemController+Actions.swift | 34 +++ .../CodexBar/StatusItemController+Menu.swift | 74 +++++- Sources/CodexBar/StatusItemController.swift | 2 + .../CodexBarCore/OpenAIDashboardModels.swift | 38 +++ .../OpenAIWeb/OpenAIDashboardFetcher.swift | 92 ++++++- .../OpenAIDashboardScrapeScript.swift | 160 ++++++++++++ ...ReviewLogsPanelWindowControllerTests.swift | 71 ++++++ .../OpenAIDashboardParserTests.swift | 35 +++ Tests/CodexBarTests/StatusMenuTests.swift | 15 ++ 10 files changed, 746 insertions(+), 8 deletions(-) create mode 100644 Sources/CodexBar/CodeReviewLogsPanelWindowController.swift create mode 100644 Tests/CodexBarTests/CodeReviewLogsPanelWindowControllerTests.swift diff --git a/Sources/CodexBar/CodeReviewLogsPanelWindowController.swift b/Sources/CodexBar/CodeReviewLogsPanelWindowController.swift new file mode 100644 index 000000000..3f69f61ca --- /dev/null +++ b/Sources/CodexBar/CodeReviewLogsPanelWindowController.swift @@ -0,0 +1,233 @@ +import AppKit +import CodexBarCore +import Foundation +import Observation +import SwiftUI + +@MainActor +@Observable +private final class CodeReviewLogsPanelModel { + var entries: [OpenAICodeReviewLogEntry] = [] +} + +@MainActor +final class CodeReviewLogsPanelWindowController: NSWindowController { + private static let defaultSize = NSSize(width: 760, height: 520) + private let model = CodeReviewLogsPanelModel() + private var hasCenteredWindow = false + + init() { + let rootView = CodeReviewLogsPanelView(model: self.model, onOpenURL: Self.openLogURL) + let hostingController = NSHostingController(rootView: rootView) + let window = NSWindow(contentViewController: hostingController) + window.title = "Code Review Logs" + window.styleMask = [.titled, .closable, .miniaturizable, .resizable] + window.minSize = NSSize(width: 620, height: 360) + window.setContentSize(Self.defaultSize) + window.isReleasedWhenClosed = false + super.init(window: window) + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + func show(entries: [OpenAICodeReviewLogEntry]) { + self.model.entries = entries + guard let window = self.window else { return } + if !self.hasCenteredWindow { + window.center() + self.hasCenteredWindow = true + } + self.showWindow(nil) + window.makeKeyAndOrderFront(nil) + NSApp.activate(ignoringOtherApps: true) + } + + private nonisolated static func openLogURL(_ url: URL) { + NSWorkspace.shared.open(url) + } + + nonisolated static func sanitizedLogURL(_ rawURL: String?) -> URL? { + guard let rawURL else { return nil } + let trimmed = rawURL.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return nil } + if let absoluteURL = URL(string: trimmed), self.isAllowedCodeReviewLogURL(absoluteURL) { + return absoluteURL + } + guard let baseURL = URL(string: "https://chatgpt.com"), + let resolved = URL(string: trimmed, relativeTo: baseURL)?.absoluteURL, + self.isAllowedCodeReviewLogURL(resolved) else { return nil } + return resolved + } + + private nonisolated static func isAllowedCodeReviewLogURL(_ url: URL) -> Bool { + guard let scheme = url.scheme?.lowercased(), + scheme == "http" || scheme == "https", + let host = url.host?.lowercased() else { return false } + + let normalizedHost = host.hasPrefix("www.") ? String(host.dropFirst(4)) : host + + if normalizedHost == "chatgpt.com" || normalizedHost.hasSuffix(".chatgpt.com") { + return true + } + + guard normalizedHost == "github.com" else { return false } + + let path = url.path.lowercased() + return path.contains("/pull/") + || path.contains("/review/") + || path.contains("/commit/") + || path.contains("/compare/") + } +} + +private struct CodeReviewLogsPanelView: View { + @Bindable var model: CodeReviewLogsPanelModel + let onOpenURL: (URL) -> Void + + var body: some View { + VStack(alignment: .leading, spacing: 12) { + HStack(spacing: 8) { + Text("Code Reviews") + .font(.title3.weight(.semibold)) + Spacer() + Text("\(self.model.entries.count) total") + .font(.caption) + .foregroundStyle(.secondary) + } + + if self.displayedEntries.isEmpty { + Text("No code review logs found yet.") + .font(.callout) + .foregroundStyle(.secondary) + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .center) + } else { + ScrollView { + LazyVStack(alignment: .leading, spacing: 10) { + ForEach(self.displayedEntries) { entry in + CodeReviewLogRowView(entry: entry, onOpenURL: self.onOpenURL) + Divider() + } + } + } + } + } + .padding(16) + .frame(minWidth: 640, minHeight: 400, alignment: .topLeading) + } + + private var displayedEntries: [OpenAICodeReviewLogEntry] { + Array(self.model.entries.prefix(200)) + } +} + +private struct CodeReviewLogRowView: View { + let entry: OpenAICodeReviewLogEntry + let onOpenURL: (URL) -> Void + + var body: some View { + let dateText = self.sanitizedText(self.entry.dateText) + let stateText = self.normalizedStateText(self.entry.stateText) + let actionText = self.sanitizedText(self.entry.actionText) + let openURL = CodeReviewLogsPanelWindowController.sanitizedLogURL(self.entry.url) + VStack(alignment: .leading, spacing: 6) { + Text(self.entry.title) + .font(.headline) + .lineLimit(2) + .frame(maxWidth: .infinity, alignment: .leading) + + if let subtitle = self.entry.subtitle, !subtitle.isEmpty { + Text(subtitle) + .font(.subheadline) + .foregroundStyle(.secondary) + .lineLimit(3) + .frame(maxWidth: .infinity, alignment: .leading) + } + + HStack(spacing: 8) { + if let dateText { + self.pill(text: dateText, style: .neutral) + } + if let bugCount = self.entry.bugCount { + let label = bugCount == 1 ? "1 bug" : "\(bugCount) bugs" + self.pill(text: label, style: .warning) + } + if let stateText { + self.pill(text: stateText, style: self.stateStyle(for: stateText)) + } + if let openURL { + Button(action: { self.onOpenURL(openURL) }, label: { + self.pill(text: actionText ?? "Open", style: .action) + }) + .buttonStyle(.plain) + } else if let actionText { + self.pill(text: actionText, style: .neutral) + } + } + } + .frame(maxWidth: .infinity, alignment: .leading) + } + + private enum PillStyle { + case neutral + case warning + case success + case error + case action + } + + private func stateStyle(for state: String) -> PillStyle { + switch state.lowercased() { + case "merged": + .success + case "closed": + .error + default: + .neutral + } + } + + @ViewBuilder + private func pill(text: String, style: PillStyle) -> some View { + let colors = self.colors(for: style) + Text(text) + .font(.caption.weight(.semibold)) + .padding(.horizontal, 8) + .padding(.vertical, 4) + .foregroundStyle(colors.foreground) + .background( + Capsule(style: .continuous) + .fill(colors.background)) + } + + private func colors(for style: PillStyle) -> (foreground: Color, background: Color) { + switch style { + case .neutral: + (Color.secondary, Color(nsColor: .quaternaryLabelColor).opacity(0.25)) + case .warning: + (Color(nsColor: .systemOrange), Color(nsColor: .systemOrange).opacity(0.16)) + case .success: + (Color(nsColor: .systemGreen), Color(nsColor: .systemGreen).opacity(0.16)) + case .error: + (Color(nsColor: .systemRed), Color(nsColor: .systemRed).opacity(0.16)) + case .action: + (Color.primary, Color(nsColor: .controlAccentColor).opacity(0.18)) + } + } + + private func sanitizedText(_ value: String?) -> String? { + guard let value else { return nil } + let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines) + return trimmed.isEmpty ? nil : trimmed + } + + private func normalizedStateText(_ value: String?) -> String? { + guard let state = self.sanitizedText(value) else { return nil } + if state.localizedCaseInsensitiveCompare("open") == .orderedSame { + return nil + } + return state + } +} diff --git a/Sources/CodexBar/StatusItemController+Actions.swift b/Sources/CodexBar/StatusItemController+Actions.swift index efe63ffd8..3286e4882 100644 --- a/Sources/CodexBar/StatusItemController+Actions.swift +++ b/Sources/CodexBar/StatusItemController+Actions.swift @@ -67,6 +67,40 @@ extension StatusItemController { self.creditsPurchaseWindow = controller } + @objc func openCodeReviewLogsPanel() { + let controller = self.codeReviewLogsWindow ?? CodeReviewLogsPanelWindowController() + let initialEntries = self.store.openAIDashboard?.codeReviewLogs ?? [] + let hadInitialEntries = !initialEntries.isEmpty + controller.show(entries: initialEntries) + self.codeReviewLogsWindow = controller + + self.codeReviewLogsRefreshTask?.cancel() + self.codeReviewLogsRefreshTask = Task { @MainActor [weak self] in + guard let self else { return } + defer { self.codeReviewLogsRefreshTask = nil } + let accountEmail = self.store.codexAccountEmailForOpenAIDashboard() + let fetcher = OpenAIDashboardFetcher() + var refreshedEntries = await fetcher.loadCodeReviewLogs( + accountEmail: accountEmail, + timeout: hadInitialEntries ? 6 : 8) + guard !Task.isCancelled else { return } + if refreshedEntries.isEmpty, !hadInitialEntries { + await ProviderInteractionContext.$current.withValue(.userInitiated) { + await self.store.refresh(forceTokenUsage: true) + } + guard !Task.isCancelled else { return } + let fallbackEmail = self.store.codexAccountEmailForOpenAIDashboard() + refreshedEntries = await fetcher.loadCodeReviewLogs( + accountEmail: fallbackEmail, + timeout: 6) + } + guard !Task.isCancelled else { return } + if !refreshedEntries.isEmpty || !hadInitialEntries { + self.codeReviewLogsWindow?.show(entries: refreshedEntries) + } + } + } + private static func sanitizedCreditsPurchaseURL(_ raw: String?) -> String? { guard let raw, let url = URL(string: raw) else { return nil } guard let host = url.host?.lowercased(), host.contains("chatgpt.com") else { return nil } diff --git a/Sources/CodexBar/StatusItemController+Menu.swift b/Sources/CodexBar/StatusItemController+Menu.swift index 2508e25f0..e82e56043 100644 --- a/Sources/CodexBar/StatusItemController+Menu.swift +++ b/Sources/CodexBar/StatusItemController+Menu.swift @@ -11,6 +11,7 @@ extension StatusItemController { private static let menuOpenRefreshDelay: Duration = .seconds(1.2) private struct OpenAIWebMenuItems { let hasUsageBreakdown: Bool + let hasCodeReviewLogs: Bool let hasCreditsHistory: Bool let hasCostHistory: Bool } @@ -221,6 +222,7 @@ extension StatusItemController { private struct OpenAIWebContext { let hasUsageBreakdown: Bool + let hasCodeReviewLogs: Bool let hasCreditsHistory: Bool let hasCostHistory: Bool let hasOpenAIWebMenuItems: Bool @@ -244,12 +246,14 @@ extension StatusItemController { dashboard != nil let hasCreditsHistory = openAIWebEligible && !(dashboard?.dailyBreakdown ?? []).isEmpty let hasUsageBreakdown = openAIWebEligible && !(dashboard?.usageBreakdown ?? []).isEmpty + let hasCodeReviewLogs = openAIWebEligible && !(dashboard?.codeReviewLogs ?? []).isEmpty let hasCostHistory = self.settings.isCostUsageEffectivelyEnabled(for: currentProvider) && (self.store.tokenSnapshot(for: currentProvider)?.daily.isEmpty == false) let hasOpenAIWebMenuItems = !showAllTokenAccounts && - (hasCreditsHistory || hasUsageBreakdown || hasCostHistory) + (hasCreditsHistory || hasUsageBreakdown || hasCodeReviewLogs || hasCostHistory) return OpenAIWebContext( hasUsageBreakdown: hasUsageBreakdown, + hasCodeReviewLogs: hasCodeReviewLogs, hasCreditsHistory: hasCreditsHistory, hasCostHistory: hasCostHistory, hasOpenAIWebMenuItems: hasOpenAIWebMenuItems) @@ -314,6 +318,7 @@ extension StatusItemController { if context.openAIContext.hasOpenAIWebMenuItems { let webItems = OpenAIWebMenuItems( hasUsageBreakdown: context.openAIContext.hasUsageBreakdown, + hasCodeReviewLogs: context.openAIContext.hasCodeReviewLogs, hasCreditsHistory: context.openAIContext.hasCreditsHistory, hasCostHistory: context.openAIContext.hasCostHistory) self.addMenuCardSections( @@ -348,6 +353,9 @@ extension StatusItemController { if context.hasUsageBreakdown { _ = self.addUsageBreakdownSubmenu(to: menu) } + if context.hasCodeReviewLogs { + _ = self.addCodeReviewLogsPanelItem(to: menu) + } if context.hasCreditsHistory { _ = self.addCreditsHistorySubmenu(to: menu) } @@ -934,6 +942,21 @@ extension StatusItemController { return item } + private func makeCodeReviewLogsPanelItem() -> NSMenuItem { + let item = NSMenuItem( + title: "Open Code Review Logs...", + action: #selector(self.openCodeReviewLogsPanel), + keyEquivalent: "") + item.target = self + item.representedObject = "openCodeReviewLogsPanel" + if let image = NSImage(systemSymbolName: "list.bullet.rectangle.portrait", accessibilityDescription: nil) { + image.isTemplate = true + image.size = NSSize(width: 16, height: 16) + item.image = image + } + return item + } + @discardableResult private func addCreditsHistorySubmenu(to menu: NSMenu) -> Bool { guard let submenu = self.makeCreditsHistorySubmenu() else { return false } @@ -954,6 +977,13 @@ extension StatusItemController { return true } + @discardableResult + private func addCodeReviewLogsPanelItem(to menu: NSMenu) -> Bool { + let item = self.makeCodeReviewLogsPanelItem() + menu.addItem(item) + return true + } + @discardableResult private func addCostHistorySubmenu(to menu: NSMenu, provider: UsageProvider) -> Bool { guard let submenu = self.makeCostHistorySubmenu(provider: provider) else { return false } @@ -969,8 +999,10 @@ extension StatusItemController { snapshot: UsageSnapshot?, webItems: OpenAIWebMenuItems) -> NSMenu? { - if provider == .codex, webItems.hasUsageBreakdown { - return self.makeUsageBreakdownSubmenu() + if provider == .codex, webItems.hasUsageBreakdown || webItems.hasCodeReviewLogs { + return self.makeCodexUsageSubmenu( + hasUsageBreakdown: webItems.hasUsageBreakdown, + hasCodeReviewLogs: webItems.hasCodeReviewLogs) } if provider == .zai { return self.makeZaiUsageDetailsSubmenu(snapshot: snapshot) @@ -978,6 +1010,29 @@ extension StatusItemController { return nil } + private func makeCodexUsageSubmenu(hasUsageBreakdown: Bool, hasCodeReviewLogs: Bool) -> NSMenu? { + let width = Self.menuCardBaseWidth + let submenu = NSMenu() + submenu.delegate = self + var addedAnyItem = false + + if hasUsageBreakdown, let item = self.makeUsageBreakdownHostedItem(width: width) { + submenu.addItem(item) + addedAnyItem = true + } + + if hasCodeReviewLogs { + let item = self.makeCodeReviewLogsPanelItem() + if addedAnyItem { + submenu.addItem(.separator()) + } + submenu.addItem(item) + addedAnyItem = true + } + + return addedAnyItem ? submenu : nil + } + private func makeZaiUsageDetailsSubmenu(snapshot: UsageSnapshot?) -> NSMenu? { guard let timeLimit = snapshot?.zaiUsage?.timeLimit else { return nil } guard !timeLimit.usageDetails.isEmpty else { return nil } @@ -1015,9 +1070,8 @@ extension StatusItemController { } private func makeUsageBreakdownSubmenu() -> NSMenu? { - let breakdown = self.store.openAIDashboard?.usageBreakdown ?? [] let width = Self.menuCardBaseWidth - guard !breakdown.isEmpty else { return nil } + guard let chartItem = self.makeUsageBreakdownHostedItem(width: width) else { return nil } if !Self.menuCardRenderingEnabled { let submenu = NSMenu() @@ -1031,6 +1085,13 @@ extension StatusItemController { let submenu = NSMenu() submenu.delegate = self + submenu.addItem(chartItem) + return submenu + } + + private func makeUsageBreakdownHostedItem(width: CGFloat) -> NSMenuItem? { + let breakdown = self.store.openAIDashboard?.usageBreakdown ?? [] + guard !breakdown.isEmpty else { return nil } let chartView = UsageBreakdownChartMenuView(breakdown: breakdown, width: width) let hosting = MenuHostingView(rootView: chartView) // Use NSHostingController for efficient size calculation without multiple layout passes @@ -1042,8 +1103,7 @@ extension StatusItemController { chartItem.view = hosting chartItem.isEnabled = false chartItem.representedObject = "usageBreakdownChart" - submenu.addItem(chartItem) - return submenu + return chartItem } private func makeCreditsHistorySubmenu() -> NSMenu? { diff --git a/Sources/CodexBar/StatusItemController.swift b/Sources/CodexBar/StatusItemController.swift index dce63bf76..7e2f96720 100644 --- a/Sources/CodexBar/StatusItemController.swift +++ b/Sources/CodexBar/StatusItemController.swift @@ -51,6 +51,8 @@ final class StatusItemController: NSObject, NSMenuDelegate, StatusItemControllin } var creditsPurchaseWindow: OpenAICreditsPurchaseWindowController? + var codeReviewLogsWindow: CodeReviewLogsPanelWindowController? + var codeReviewLogsRefreshTask: Task? var activeLoginProvider: UsageProvider? { didSet { diff --git a/Sources/CodexBarCore/OpenAIDashboardModels.swift b/Sources/CodexBarCore/OpenAIDashboardModels.swift index 367223a96..f03a254e2 100644 --- a/Sources/CodexBarCore/OpenAIDashboardModels.swift +++ b/Sources/CodexBarCore/OpenAIDashboardModels.swift @@ -3,6 +3,7 @@ import Foundation public struct OpenAIDashboardSnapshot: Codable, Equatable, Sendable { public let signedInEmail: String? public let codeReviewRemainingPercent: Double? + public let codeReviewLogs: [OpenAICodeReviewLogEntry] public let creditEvents: [CreditEvent] public let dailyBreakdown: [OpenAIDashboardDailyBreakdown] /// Usage breakdown time series from the Codex dashboard chart ("Usage breakdown", 30 days). @@ -19,6 +20,7 @@ public struct OpenAIDashboardSnapshot: Codable, Equatable, Sendable { public init( signedInEmail: String?, codeReviewRemainingPercent: Double?, + codeReviewLogs: [OpenAICodeReviewLogEntry] = [], creditEvents: [CreditEvent], dailyBreakdown: [OpenAIDashboardDailyBreakdown], usageBreakdown: [OpenAIDashboardDailyBreakdown], @@ -31,6 +33,7 @@ public struct OpenAIDashboardSnapshot: Codable, Equatable, Sendable { { self.signedInEmail = signedInEmail self.codeReviewRemainingPercent = codeReviewRemainingPercent + self.codeReviewLogs = codeReviewLogs self.creditEvents = creditEvents self.dailyBreakdown = dailyBreakdown self.usageBreakdown = usageBreakdown @@ -45,6 +48,7 @@ public struct OpenAIDashboardSnapshot: Codable, Equatable, Sendable { private enum CodingKeys: String, CodingKey { case signedInEmail case codeReviewRemainingPercent + case codeReviewLogs case creditEvents case dailyBreakdown case usageBreakdown @@ -62,6 +66,9 @@ public struct OpenAIDashboardSnapshot: Codable, Equatable, Sendable { self.codeReviewRemainingPercent = try container.decodeIfPresent( Double.self, forKey: .codeReviewRemainingPercent) + self.codeReviewLogs = try container.decodeIfPresent( + [OpenAICodeReviewLogEntry].self, + forKey: .codeReviewLogs) ?? [] self.creditEvents = try container.decodeIfPresent([CreditEvent].self, forKey: .creditEvents) ?? [] self.dailyBreakdown = try container.decodeIfPresent( [OpenAIDashboardDailyBreakdown].self, @@ -109,6 +116,37 @@ public struct OpenAIDashboardSnapshot: Codable, Equatable, Sendable { } } +public struct OpenAICodeReviewLogEntry: Codable, Equatable, Sendable, Identifiable { + public let id: String + public let title: String + public let subtitle: String? + public let url: String? + public let dateText: String? + public let bugCount: Int? + public let stateText: String? + public let actionText: String? + + public init( + id: String, + title: String, + subtitle: String? = nil, + url: String? = nil, + dateText: String? = nil, + bugCount: Int? = nil, + stateText: String? = nil, + actionText: String? = nil) + { + self.id = id + self.title = title + self.subtitle = subtitle + self.url = url + self.dateText = dateText + self.bugCount = bugCount + self.stateText = stateText + self.actionText = actionText + } +} + extension OpenAIDashboardSnapshot { public func toUsageSnapshot( provider: UsageProvider = .codex, diff --git a/Sources/CodexBarCore/OpenAIWeb/OpenAIDashboardFetcher.swift b/Sources/CodexBarCore/OpenAIWeb/OpenAIDashboardFetcher.swift index 4a8d9441d..f34ce881a 100644 --- a/Sources/CodexBarCore/OpenAIWeb/OpenAIDashboardFetcher.swift +++ b/Sources/CodexBarCore/OpenAIWeb/OpenAIDashboardFetcher.swift @@ -20,6 +20,7 @@ public struct OpenAIDashboardFetcher { } private let usageURL = URL(string: "https://chatgpt.com/codex/settings/usage")! + private let codeReviewsURL = URL(string: "https://chatgpt.com/codex?tab=code_reviews")! public init() {} @@ -221,6 +222,7 @@ public struct OpenAIDashboardFetcher { return OpenAIDashboardSnapshot( signedInEmail: scrape.signedInEmail, codeReviewRemainingPercent: codeReview, + codeReviewLogs: [], creditEvents: events, dailyBreakdown: breakdown, usageBreakdown: usageBreakdown, @@ -241,6 +243,80 @@ public struct OpenAIDashboardFetcher { throw FetchError.noDashboardData(body: lastBody ?? "") } + public func loadCodeReviewLogs( + accountEmail: String?, + logger: ((String) -> Void)? = nil, + timeout: TimeInterval = 20) async -> [OpenAICodeReviewLogEntry] + { + let store = OpenAIDashboardWebsiteDataStore.store(forAccountEmail: accountEmail) + return await self.loadCodeReviewLogs( + websiteDataStore: store, + logger: logger, + timeout: timeout) + } + + public func loadCodeReviewLogs( + websiteDataStore: WKWebsiteDataStore, + logger: ((String) -> Void)? = nil, + timeout: TimeInterval = 20) async -> [OpenAICodeReviewLogEntry] + { + guard timeout > 0 else { return [] } + guard let lease = try? await self.makeWebView(websiteDataStore: websiteDataStore, logger: logger) else { + return [] + } + defer { lease.release() } + return await self.loadCodeReviewLogs( + webView: lease.webView, + deadline: Date().addingTimeInterval(timeout), + logger: lease.log) + } + + private func loadCodeReviewLogs( + webView: WKWebView, + deadline: Date, + logger: (String) -> Void) async -> [OpenAICodeReviewLogEntry] + { + guard Date() < deadline else { return [] } + _ = webView.load(URLRequest(url: self.codeReviewsURL)) + var bestEntries: [OpenAICodeReviewLogEntry] = [] + var firstResultAt: Date? + + while Date() < deadline { + guard let scrape = try? await self.scrape(webView: webView) else { + try? await Task.sleep(for: .milliseconds(350)) + continue + } + if scrape.workspacePicker { + try? await Task.sleep(for: .milliseconds(350)) + continue + } + if scrape.loginRequired || scrape.cloudflareInterstitial { + return [] + } + if let href = scrape.href, !href.contains("tab=code_reviews") { + _ = webView.load(URLRequest(url: self.codeReviewsURL)) + try? await Task.sleep(for: .milliseconds(350)) + continue + } + if !scrape.codeReviewLogs.isEmpty { + if scrape.codeReviewLogs.count > bestEntries.count { + bestEntries = scrape.codeReviewLogs + } + if firstResultAt == nil { + firstResultAt = Date() + } else if let firstResultAt, Date().timeIntervalSince(firstResultAt) >= 1.0 { + break + } + } + try? await Task.sleep(for: .milliseconds(350)) + } + + if !bestEntries.isEmpty { + logger("code review logs rows=\(bestEntries.count)") + } + return bestEntries + } + struct CreditsHistoryWaitContext: Sendable { let now: Date let anyDashboardSignalAt: Date? @@ -343,6 +419,7 @@ public struct OpenAIDashboardFetcher { let rows: [[String]] let usageBreakdown: [OpenAIDashboardDailyBreakdown] let usageBreakdownDebug: String? + let codeReviewLogs: [OpenAICodeReviewLogEntry] let scrollY: Double let scrollHeight: Double let viewportHeight: Double @@ -366,6 +443,7 @@ public struct OpenAIDashboardFetcher { rows: [], usageBreakdown: [], usageBreakdownDebug: nil, + codeReviewLogs: [], scrollY: 0, scrollHeight: 0, viewportHeight: 0, @@ -392,9 +470,20 @@ public struct OpenAIDashboardFetcher { } } + var codeReviewLogs: [OpenAICodeReviewLogEntry] = [] + if let raw = dict["codeReviewLogsJSON"] as? String, !raw.isEmpty { + do { + let decoder = JSONDecoder() + codeReviewLogs = try decoder.decode([OpenAICodeReviewLogEntry].self, from: Data(raw.utf8)) + } catch { + codeReviewLogs = [] + } + } + var signedInEmail = dict["signedInEmail"] as? String if let bodyHTML, - signedInEmail == nil || signedInEmail?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == true + signedInEmail == nil || + signedInEmail?.trimmingCharacters(in: CharacterSet.whitespacesAndNewlines).isEmpty == true { signedInEmail = OpenAIDashboardParser.parseSignedInEmailFromClientBootstrap(html: bodyHTML) } @@ -419,6 +508,7 @@ public struct OpenAIDashboardFetcher { rows: rows, usageBreakdown: usageBreakdown, usageBreakdownDebug: usageBreakdownDebug, + codeReviewLogs: codeReviewLogs, scrollY: (dict["scrollY"] as? NSNumber)?.doubleValue ?? 0, scrollHeight: (dict["scrollHeight"] as? NSNumber)?.doubleValue ?? 0, viewportHeight: (dict["viewportHeight"] as? NSNumber)?.doubleValue ?? 0, diff --git a/Sources/CodexBarCore/OpenAIWeb/OpenAIDashboardScrapeScript.swift b/Sources/CodexBarCore/OpenAIWeb/OpenAIDashboardScrapeScript.swift index 25f32a456..aa2ad2b0c 100644 --- a/Sources/CodexBarCore/OpenAIWeb/OpenAIDashboardScrapeScript.swift +++ b/Sources/CodexBarCore/OpenAIWeb/OpenAIDashboardScrapeScript.swift @@ -356,6 +356,165 @@ let openAIDashboardScrapeScript = """ })(); const bodyText = document.body ? String(document.body.innerText || '').trim() : ''; const href = window.location ? String(window.location.href || '') : ''; + const codeReviewLogsJSON = (() => { + try { + if (!href || !href.includes('tab=code_reviews')) return null; + + const entries = []; + const seen = new Set(); + const reviewTitleRegex = /(?:\\b\\w+[\\/_-]\\w+\\b|#\\d+)/; + const reviewURLRegex = /(?:tab=code_reviews|\\/(?:pull|review|commit|compare)\\/)/i; + const onboardingRegex = + /(?:^download app$|^settings$|^docs$|^codex app$|^try in your terminal$|^try in your ide$)/i; + const onboardingExtraRegex = /(?:^tasks$|^archive$|get started with codex)/i; + const dateRegex = + /\\b(?:today|yesterday|(?:Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec)\\s+\\d{1,2})\\b/i; + const bugRegex = /\\b(\\d+)\\s+bugs?\\b/i; + const normalizeLabel = raw => String(raw || '').trim().replace(/\\s+/g, ' '); + const metadataHasSignals = metadata => + !!(metadata && ( + metadata.dateText || + metadata.bugCount !== null || + metadata.stateText || + metadata.actionText + )); + const resolveStateText = raw => { + const lower = String(raw || '').trim().toLowerCase(); + if (!lower) return null; + if (/\\bmerged\\b/.test(lower)) return 'Merged'; + if (/\\bclosed\\b/.test(lower)) return 'Closed'; + if (/\\bin\\s+review\\b/.test(lower)) return 'In review'; + if (/\\bpending\\b/.test(lower)) return 'Pending'; + return null; + }; + const parseMetadata = raw => { + const text = normalizeLabel(raw); + if (!text) { + return { dateText: null, bugCount: null, stateText: null, actionText: null }; + } + const dateMatch = text.match(dateRegex); + const bugMatch = text.match(bugRegex); + const stateCandidates = text + .split(/[·|]/) + .map(part => normalizeLabel(part)) + .filter(Boolean); + const stateText = resolveStateText(stateCandidates.join(' · ')); + let actionText = null; + for (const candidate of stateCandidates) { + const actionCandidate = candidate.toLowerCase(); + if (actionCandidate === 'fix') { + actionText = 'Fix'; + break; + } + if (actionCandidate === 'open') { + actionText = 'Open'; + } + } + const bugCount = bugMatch ? Number.parseInt(bugMatch[1], 10) : null; + return { + dateText: dateMatch ? dateMatch[0] : null, + bugCount: Number.isFinite(bugCount) ? bugCount : null, + stateText, + actionText + }; + }; + const pushEntry = (titleRaw, subtitleRaw, urlRaw, metadataRaw) => { + const title = titleRaw ? String(titleRaw).trim().replace(/\\s+/g, ' ') : ''; + if (!title || title.length < 3 || title.length > 220) return; + const lower = title.toLowerCase(); + if (lower === 'code review' || lower === 'code reviews' || lower === 'open' || lower === 'view') return; + + const subtitleText = subtitleRaw ? String(subtitleRaw).trim().replace(/\\s+/g, ' ') : ''; + const subtitle = subtitleText && subtitleText !== title ? subtitleText.slice(0, 280) : null; + const url = urlRaw ? normalizeHref(String(urlRaw)) : null; + const metadata = parseMetadata(metadataRaw || subtitle || ''); + const combined = [title, subtitle || '', metadataRaw || ''].join(' · ').toLowerCase(); + if (onboardingRegex.test(title) || onboardingRegex.test(combined)) return; + if (onboardingExtraRegex.test(title) || onboardingExtraRegex.test(combined)) return; + + const hasReviewSignals = metadataHasSignals(metadata); + const looksLikeReviewTitle = reviewTitleRegex.test(title); + const looksLikeReviewURL = url ? reviewURLRegex.test(url) : false; + const wordCount = title.split(/\\s+/).filter(Boolean).length; + if (!hasReviewSignals && !looksLikeReviewTitle && !looksLikeReviewURL && wordCount <= 4) return; + + const dedupe = `${title}\\u241f${subtitle || ''}\\u241f${metadata.dateText || ''}`.toLowerCase(); + if (seen.has(dedupe)) return; + seen.add(dedupe); + entries.push({ + id: url || `${title}-${entries.length + 1}`, + title, + subtitle, + url, + dateText: metadata.dateText, + bugCount: metadata.bugCount, + stateText: metadata.stateText, + actionText: metadata.actionText + }); + }; + + const parseRowLike = (row) => { + if (!row) return; + const cells = Array.from(row.querySelectorAll('td,[role="cell"]')) + .map(cell => textOf(cell)) + .filter(Boolean); + const link = row.querySelector('a[href]'); + const title = cells[0] || (link ? textOf(link) : ''); + const subtitleFromCells = cells.slice(1).filter(Boolean).join(' · '); + const tokenText = Array.from(row.querySelectorAll('button, a, span, div')) + .map(node => normalizeLabel(textOf(node))) + .filter(token => token.length > 0 && token.length <= 32) + .join(' · '); + let subtitle = subtitleFromCells || null; + if (!subtitle) { + const rowText = textOf(row); + if (rowText && title && rowText !== title) { + const compact = rowText.replace(title, '').trim(); + if (compact) subtitle = compact; + } + } + const metadataRaw = [subtitleFromCells, tokenText].filter(Boolean).join(' · '); + pushEntry(title, subtitle, link ? link.getAttribute('href') : null, metadataRaw); + }; + + const main = document.querySelector('main') || document.body || document.documentElement; + const tableRows = Array.from(main.querySelectorAll('table tbody tr')); + for (const row of tableRows) parseRowLike(row); + + const roleRows = Array.from(main.querySelectorAll('[role="row"]')) + .filter(row => row.querySelector('[role="cell"]')); + for (const row of roleRows) parseRowLike(row); + + if (entries.length === 0) { + const headings = Array.from(main.querySelectorAll('h1,h2,h3,h4')); + const header = headings.find(h => textOf(h).toLowerCase().includes('code review')); + const container = header ? (header.closest('section') || header.parentElement || main) : main; + const anchors = Array.from(container.querySelectorAll('a[href]')); + for (const anchor of anchors) { + const title = textOf(anchor); + const hrefValue = String(anchor.getAttribute('href') || ''); + const hrefLower = hrefValue.toLowerCase(); + const titleLower = title.toLowerCase(); + const relevant = + hrefLower.includes('review') || + hrefLower.includes('codex') || + titleLower.includes('review'); + if (!relevant) continue; + const parentText = textOf(anchor.parentElement || anchor); + let subtitle = null; + if (parentText && parentText !== title) { + const compact = parentText.replace(title, '').trim(); + if (compact) subtitle = compact; + } + pushEntry(title, subtitle, hrefValue, parentText); + } + } + + return entries.length > 0 ? JSON.stringify(entries.slice(0, 80)) : null; + } catch { + return null; + } + })(); const workspacePicker = bodyText.includes('Select a workspace'); const title = document.title ? String(document.title || '') : ''; const cloudflareInterstitial = @@ -545,6 +704,7 @@ let openAIDashboardScrapeScript = """ rows, usageBreakdownJSON, usageBreakdownDebug, + codeReviewLogsJSON, scrollY, scrollHeight, viewportHeight, diff --git a/Tests/CodexBarTests/CodeReviewLogsPanelWindowControllerTests.swift b/Tests/CodexBarTests/CodeReviewLogsPanelWindowControllerTests.swift new file mode 100644 index 000000000..f56a2b765 --- /dev/null +++ b/Tests/CodexBarTests/CodeReviewLogsPanelWindowControllerTests.swift @@ -0,0 +1,71 @@ +import Testing +@testable import CodexBar + +@Suite +struct CodeReviewLogsPanelWindowControllerTests { + @Test + func acceptsChatGPTCodeReviewURL() { + let url = CodeReviewLogsPanelWindowController + .sanitizedLogURL("https://chatgpt.com/codex?tab=code_reviews") + #expect(url?.absoluteString == "https://chatgpt.com/codex?tab=code_reviews") + } + + @Test + func resolvesRelativeChatGPTCodeReviewURL() { + let url = CodeReviewLogsPanelWindowController.sanitizedLogURL("/codex?tab=code_reviews") + #expect(url?.absoluteString == "https://chatgpt.com/codex?tab=code_reviews") + } + + @Test + func acceptsChatGPTSubdomainCodeReviewURL() { + let url = CodeReviewLogsPanelWindowController + .sanitizedLogURL("https://platform.chatgpt.com/codex?tab=code_reviews") + #expect(url?.absoluteString == "https://platform.chatgpt.com/codex?tab=code_reviews") + } + + @Test + func acceptsGitHubReviewURLs() { + let url = CodeReviewLogsPanelWindowController + .sanitizedLogURL("https://github.com/org/repo/pull/123") + #expect(url?.absoluteString == "https://github.com/org/repo/pull/123") + } + + @Test + func acceptsGitHubCommitAndCompareURLs() { + let commitURL = CodeReviewLogsPanelWindowController + .sanitizedLogURL("https://github.com/org/repo/commit/abc123") + let compareURL = CodeReviewLogsPanelWindowController + .sanitizedLogURL("https://github.com/org/repo/compare/main...feature") + #expect(commitURL?.absoluteString == "https://github.com/org/repo/commit/abc123") + #expect(compareURL?.absoluteString == "https://github.com/org/repo/compare/main...feature") + } + + @Test + func handlesWWWPrefix() { + let url = CodeReviewLogsPanelWindowController.sanitizedLogURL("https://www.chatgpt.com/codex") + #expect(url?.absoluteString == "https://www.chatgpt.com/codex") + } + + @Test + func rejectsEmptyAndWhitespaceInput() { + #expect(CodeReviewLogsPanelWindowController.sanitizedLogURL(nil) == nil) + #expect(CodeReviewLogsPanelWindowController.sanitizedLogURL("") == nil) + #expect(CodeReviewLogsPanelWindowController.sanitizedLogURL(" \n\t ") == nil) + } + + @Test + func rejectsNonReviewGitHubURLs() { + let url = CodeReviewLogsPanelWindowController.sanitizedLogURL("https://github.com/org/repo") + #expect(url == nil) + } + + @Test + func rejectsUnsupportedSchemesAndHosts() { + let javascriptURL = CodeReviewLogsPanelWindowController.sanitizedLogURL("javascript:alert(1)") + let externalURL = CodeReviewLogsPanelWindowController.sanitizedLogURL("https://example.com/review/1") + let spoofedChatGPTURL = CodeReviewLogsPanelWindowController.sanitizedLogURL("https://evil-chatgpt.com/codex") + #expect(javascriptURL == nil) + #expect(externalURL == nil) + #expect(spoofedChatGPTURL == nil) + } +} diff --git a/Tests/CodexBarTests/OpenAIDashboardParserTests.swift b/Tests/CodexBarTests/OpenAIDashboardParserTests.swift index c84391dfb..d40be419a 100644 --- a/Tests/CodexBarTests/OpenAIDashboardParserTests.swift +++ b/Tests/CodexBarTests/OpenAIDashboardParserTests.swift @@ -130,5 +130,40 @@ struct OpenAIDashboardParserTests { decoder.dateDecodingStrategy = .iso8601 let snapshot = try decoder.decode(OpenAIDashboardSnapshot.self, from: Data(json.utf8)) #expect(snapshot.usageBreakdown.isEmpty) + #expect(snapshot.codeReviewLogs.isEmpty) + } + + @Test + func decodesSnapshotWithCodeReviewLogsField() throws { + let json = """ + { + "signedInEmail": "user@example.com", + "codeReviewRemainingPercent": 42, + "codeReviewLogs": [ + { + "id": "r1", + "title": "org/repo#123", + "subtitle": "Pending", + "url": "https://chatgpt.com/codex?tab=code_reviews", + "dateText": "Feb 17", + "bugCount": 2, + "stateText": "Merged", + "actionText": "Fix" + } + ], + "creditEvents": [], + "dailyBreakdown": [], + "updatedAt": "2025-12-18T00:00:00Z" + } + """ + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .iso8601 + let snapshot = try decoder.decode(OpenAIDashboardSnapshot.self, from: Data(json.utf8)) + #expect(snapshot.codeReviewLogs.count == 1) + #expect(snapshot.codeReviewLogs.first?.title == "org/repo#123") + #expect(snapshot.codeReviewLogs.first?.dateText == "Feb 17") + #expect(snapshot.codeReviewLogs.first?.bugCount == 2) + #expect(snapshot.codeReviewLogs.first?.stateText == "Merged") + #expect(snapshot.codeReviewLogs.first?.actionText == "Fix") } } diff --git a/Tests/CodexBarTests/StatusMenuTests.swift b/Tests/CodexBarTests/StatusMenuTests.swift index 55fc217c6..40aa1de4b 100644 --- a/Tests/CodexBarTests/StatusMenuTests.swift +++ b/Tests/CodexBarTests/StatusMenuTests.swift @@ -264,8 +264,12 @@ struct StatusMenuTests { let menu = controller.makeMenu() controller.menuWillOpen(menu) let titles = Set(menu.items.map(\.title)) + let usageItem = menu.items.first { ($0.representedObject as? String) == "menuCardUsage" } #expect(!titles.contains("Credits history")) #expect(!titles.contains("Usage breakdown")) + #expect( + usageItem?.submenu?.items + .contains { ($0.representedObject as? String) == "openCodeReviewLogsPanel" } != true) } @Test @@ -305,9 +309,17 @@ struct StatusMenuTests { let events = [CreditEvent(date: date, service: "CLI", creditsUsed: 1)] let breakdown = OpenAIDashboardSnapshot.makeDailyBreakdown(from: events, maxDays: 30) + let codeReviewLogs = [ + OpenAICodeReviewLogEntry( + id: "review-1", + title: "repo/pull-request-123", + subtitle: "Pending · 2 files", + url: "https://chatgpt.com/codex?tab=code_reviews"), + ] store.openAIDashboard = OpenAIDashboardSnapshot( signedInEmail: "user@example.com", codeReviewRemainingPercent: 100, + codeReviewLogs: codeReviewLogs, creditEvents: events, dailyBreakdown: breakdown, usageBreakdown: breakdown, @@ -329,6 +341,9 @@ struct StatusMenuTests { #expect( usageItem?.submenu?.items .contains { ($0.representedObject as? String) == "usageBreakdownChart" } == true) + #expect( + usageItem?.submenu?.items + .contains { ($0.representedObject as? String) == "openCodeReviewLogsPanel" } == true) #expect( creditsItem?.submenu?.items .contains { ($0.representedObject as? String) == "creditsHistoryChart" } == true) From 3b53bb52ae93fe516707b35b8bc6d2963df95071 Mon Sep 17 00:00:00 2001 From: Michael Tookes Date: Thu, 19 Feb 2026 19:35:11 -0600 Subject: [PATCH 2/2] Fix code review logs persistence and refresh task race --- Sources/CodexBar/StatusItemController+Actions.swift | 8 +++++++- Sources/CodexBar/StatusItemController.swift | 1 + .../CodexBarCore/OpenAIWeb/OpenAIDashboardFetcher.swift | 2 +- 3 files changed, 9 insertions(+), 2 deletions(-) diff --git a/Sources/CodexBar/StatusItemController+Actions.swift b/Sources/CodexBar/StatusItemController+Actions.swift index 3286e4882..c6790ed08 100644 --- a/Sources/CodexBar/StatusItemController+Actions.swift +++ b/Sources/CodexBar/StatusItemController+Actions.swift @@ -75,9 +75,15 @@ extension StatusItemController { self.codeReviewLogsWindow = controller self.codeReviewLogsRefreshTask?.cancel() + self.codeReviewLogsRefreshGeneration &+= 1 + let refreshGeneration = self.codeReviewLogsRefreshGeneration self.codeReviewLogsRefreshTask = Task { @MainActor [weak self] in guard let self else { return } - defer { self.codeReviewLogsRefreshTask = nil } + defer { + if self.codeReviewLogsRefreshGeneration == refreshGeneration { + self.codeReviewLogsRefreshTask = nil + } + } let accountEmail = self.store.codexAccountEmailForOpenAIDashboard() let fetcher = OpenAIDashboardFetcher() var refreshedEntries = await fetcher.loadCodeReviewLogs( diff --git a/Sources/CodexBar/StatusItemController.swift b/Sources/CodexBar/StatusItemController.swift index 7e2f96720..97760f039 100644 --- a/Sources/CodexBar/StatusItemController.swift +++ b/Sources/CodexBar/StatusItemController.swift @@ -53,6 +53,7 @@ final class StatusItemController: NSObject, NSMenuDelegate, StatusItemControllin var creditsPurchaseWindow: OpenAICreditsPurchaseWindowController? var codeReviewLogsWindow: CodeReviewLogsPanelWindowController? var codeReviewLogsRefreshTask: Task? + var codeReviewLogsRefreshGeneration: UInt64 = 0 var activeLoginProvider: UsageProvider? { didSet { diff --git a/Sources/CodexBarCore/OpenAIWeb/OpenAIDashboardFetcher.swift b/Sources/CodexBarCore/OpenAIWeb/OpenAIDashboardFetcher.swift index f34ce881a..f6afd7474 100644 --- a/Sources/CodexBarCore/OpenAIWeb/OpenAIDashboardFetcher.swift +++ b/Sources/CodexBarCore/OpenAIWeb/OpenAIDashboardFetcher.swift @@ -222,7 +222,7 @@ public struct OpenAIDashboardFetcher { return OpenAIDashboardSnapshot( signedInEmail: scrape.signedInEmail, codeReviewRemainingPercent: codeReview, - codeReviewLogs: [], + codeReviewLogs: scrape.codeReviewLogs, creditEvents: events, dailyBreakdown: breakdown, usageBreakdown: usageBreakdown,