From f64abc1c813a87c4c912d68b60ebfbc1781404ed Mon Sep 17 00:00:00 2001 From: sirily11 <32106111+sirily11@users.noreply.github.com> Date: Sat, 30 May 2026 01:20:34 +0800 Subject: [PATCH] feat: add action support --- .../RxCodeCore/Models/AutopilotModels.swift | 126 ++++++++++ RxCode.xcodeproj/project.pbxproj | 2 +- RxCode/App/AppState+CIStatus.swift | 218 ++++++++++++++++++ RxCode/App/AppState+Lifecycle.swift | 5 + RxCode/App/AppState.swift | 27 +++ RxCode/App/RxCodeApp.swift | 182 ++++++++++++--- RxCode/Resources/Localizable.xcstrings | 35 +++ RxCode/Services/AutopilotService.swift | 28 +++ RxCode/Services/NotificationService.swift | 57 +++++ RxCode/Services/RxAuthService.swift | 9 + RxCode/Utilities/KeychainHelper.swift | 104 ++++++++- RxCode/Views/CIStatusPresentation.swift | 33 +++ .../RunProfile/RunProfileDetailForm.swift | 1 + RxCode/Views/SettingsView.swift | 13 ++ RxCode/Views/Sidebar/BriefingView.swift | 45 ++++ 15 files changed, 852 insertions(+), 33 deletions(-) create mode 100644 RxCode/App/AppState+CIStatus.swift create mode 100644 RxCode/Views/CIStatusPresentation.swift diff --git a/Packages/Sources/RxCodeCore/Models/AutopilotModels.swift b/Packages/Sources/RxCodeCore/Models/AutopilotModels.swift index 8bfd70e..d411083 100644 --- a/Packages/Sources/RxCodeCore/Models/AutopilotModels.swift +++ b/Packages/Sources/RxCodeCore/Models/AutopilotModels.swift @@ -138,3 +138,129 @@ public struct AutopilotInstallUrlResponse: Codable, Sendable { } } +// MARK: - CI Status DTOs + +/// One repo+branch lookup sent in the `POST /api/v1/ci-status/batch` body. +/// `branch` is optional; when nil autopilot reports the latest run on any +/// branch and echoes back which branch it matched. +public struct CIStatusQuery: Codable, Sendable, Hashable { + public let owner: String + public let repo: String + public let branch: String? + + public init(owner: String, repo: String, branch: String?) { + self.owner = owner + self.repo = repo + self.branch = branch + } +} + +public struct CIStatusBatchRequest: Codable, Sendable { + public let repos: [CIStatusQuery] + + public init(repos: [CIStatusQuery]) { + self.repos = repos + } +} + +/// Aggregated CI state for a repo+branch. Decodes leniently — unknown raw +/// values fall back to `.unknown` so a server-side addition never breaks +/// the client. +public enum CIOverallState: String, Codable, Sendable, Hashable { + case success + case failure + case pending + case unknown + + public init(from decoder: Decoder) throws { + let raw = try decoder.singleValueContainer().decode(String.self) + self = CIOverallState(rawValue: raw) ?? .unknown + } +} + +/// Latest run for a single workflow definition on the queried branch. +public struct CIWorkflowStatus: Codable, Sendable, Hashable { + public let workflowName: String? + public let workflowId: Int? + public let status: String? + public let conclusion: String? + public let htmlUrl: String? + public let runNumber: Int? + public let updatedAt: String? + + public init( + workflowName: String?, + workflowId: Int?, + status: String?, + conclusion: String?, + htmlUrl: String?, + runNumber: Int?, + updatedAt: String? + ) { + self.workflowName = workflowName + self.workflowId = workflowId + self.status = status + self.conclusion = conclusion + self.htmlUrl = htmlUrl + self.runNumber = runNumber + self.updatedAt = updatedAt + } +} + +/// A failing workflow surfaced for quick linking in the UI / fix prompt. +public struct CIFailingWorkflow: Codable, Sendable, Hashable { + public let workflowName: String? + public let htmlUrl: String? + + public init(workflowName: String?, htmlUrl: String?) { + self.workflowName = workflowName + self.htmlUrl = htmlUrl + } +} + +/// Per-repo result from `POST /api/v1/ci-status/batch`. +public struct ProjectCIStatus: Codable, Sendable, Hashable { + public let owner: String + public let repo: String + public let branch: String? + public let found: Bool + public let overallState: CIOverallState + public let lastUpdated: String? + public let headSha: String? + public let prNumber: Int? + public let workflows: [CIWorkflowStatus] + public let failing: [CIFailingWorkflow] + + public init( + owner: String, + repo: String, + branch: String?, + found: Bool, + overallState: CIOverallState, + lastUpdated: String?, + headSha: String?, + prNumber: Int?, + workflows: [CIWorkflowStatus], + failing: [CIFailingWorkflow] + ) { + self.owner = owner + self.repo = repo + self.branch = branch + self.found = found + self.overallState = overallState + self.lastUpdated = lastUpdated + self.headSha = headSha + self.prNumber = prNumber + self.workflows = workflows + self.failing = failing + } +} + +public struct CIStatusBatchResponse: Codable, Sendable { + public let results: [ProjectCIStatus] + + public init(results: [ProjectCIStatus]) { + self.results = results + } +} + diff --git a/RxCode.xcodeproj/project.pbxproj b/RxCode.xcodeproj/project.pbxproj index 411f414..03669a9 100644 --- a/RxCode.xcodeproj/project.pbxproj +++ b/RxCode.xcodeproj/project.pbxproj @@ -1367,7 +1367,6 @@ ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; CODE_SIGN_ENTITLEMENTS = RxCode/RxCode.entitlements; CODE_SIGN_STYLE = Manual; - PROVISIONING_PROFILE_SPECIFIER = RxCode; COMBINE_HIDPI_IMAGES = YES; CURRENT_PROJECT_VERSION = 24; DEVELOPMENT_TEAM = P9KK452K8P; @@ -1386,6 +1385,7 @@ MARKETING_VERSION = 1.8.0; PRODUCT_BUNDLE_IDENTIFIER = com.rxlab.RxCode; PRODUCT_NAME = "$(TARGET_NAME)"; + PROVISIONING_PROFILE_SPECIFIER = RxCode; REGISTER_APP_GROUPS = YES; STRING_CATALOG_GENERATE_SYMBOLS = YES; SWIFT_APPROACHABLE_CONCURRENCY = YES; diff --git a/RxCode/App/AppState+CIStatus.swift b/RxCode/App/AppState+CIStatus.swift new file mode 100644 index 0000000..8b91047 --- /dev/null +++ b/RxCode/App/AppState+CIStatus.swift @@ -0,0 +1,218 @@ +import Foundation +import RxCodeCore +import os + +extension AppState { + + /// How often the CI poller refreshes GitHub Actions status for open projects. + static let ciStatusPollInterval: TimeInterval = 10 + + private static let ciSignaturesDefaultsKey = "ciHandledFailureSignatures" + + // MARK: - Poller lifecycle + + /// Start (or restart) the periodic CI-status poll. Idempotent — a prior loop + /// is cancelled first. Safe to call before sign-in; ticks no-op until signed in. + func startCIStatusPoller() { + ciStatusPollerTask?.cancel() + loadHandledCIFailureSignatures() + ciStatusPollerTask = Task { [weak self] in + while !Task.isCancelled { + await self?.refreshCIStatusOnce() + let interval = AppState.ciStatusPollInterval + try? await Task.sleep(nanoseconds: UInt64(interval * 1_000_000_000)) + } + } + } + + func stopCIStatusPoller() { + ciStatusPollerTask?.cancel() + ciStatusPollerTask = nil + } + + // MARK: - Display helpers + + /// True when any open project's current branch is red on CI. Drives the + /// menubar failure indicator. + var anyCIFailing: Bool { + ciStatusByProject.values.contains { $0.overallState == .failure } + } + + /// Projects that have a known CI status, paired with it, sorted failures + /// first then by project name. Used by the menubar popover and briefing. + func ciStatusList() -> [(project: Project, status: ProjectCIStatus)] { + projects + .compactMap { project in + ciStatusByProject[project.id].map { (project, $0) } + } + .sorted { lhs, rhs in + if (lhs.1.overallState == .failure) != (rhs.1.overallState == .failure) { + return lhs.1.overallState == .failure + } + return lhs.0.name.localizedCaseInsensitiveCompare(rhs.0.name) == .orderedAscending + } + } + + // MARK: - Refresh + + /// One poll cycle: resolve each GitHub project's current branch, batch-query + /// autopilot, update `ciStatusByProject`, and react to new failures. + func refreshCIStatusOnce() async { + guard isSignedIn else { return } + + // Build one query per project that has a GitHub repo and a resolvable + // current branch. Keep the originating project alongside each query so we + // can map the response back without ambiguity. + var pairs: [(project: Project, query: CIStatusQuery)] = [] + for project in projects { + guard let slug = project.gitHubRepo else { continue } + let parts = slug.split(separator: "/", maxSplits: 1).map(String.init) + guard parts.count == 2, !parts[0].isEmpty, !parts[1].isEmpty else { continue } + guard let branch = await GitHelper.currentBranch(at: project.path) else { continue } + pairs.append((project, CIStatusQuery(owner: parts[0], repo: parts[1], branch: branch))) + } + + guard !pairs.isEmpty else { + if !ciStatusByProject.isEmpty { + ciStatusByProject = [:] + ciStatusRevision &+= 1 + } + return + } + + let response: CIStatusBatchResponse + do { + response = try await autopilot.batchCIStatus(pairs.map(\.query)) + } catch { + logger.error("CI status fetch failed: \(error.localizedDescription)") + return + } + + // Index results by owner/repo/branch (and a looser owner/repo fallback in + // case the server echoes a different/absent branch). + var byKey: [String: ProjectCIStatus] = [:] + var byRepo: [String: ProjectCIStatus] = [:] + for result in response.results { + byKey[Self.ciKey(owner: result.owner, repo: result.repo, branch: result.branch)] = result + byRepo[Self.ciRepoKey(owner: result.owner, repo: result.repo)] = result + } + + var next: [UUID: ProjectCIStatus] = [:] + for pair in pairs { + let key = Self.ciKey(owner: pair.query.owner, repo: pair.query.repo, branch: pair.query.branch) + let repoKey = Self.ciRepoKey(owner: pair.query.owner, repo: pair.query.repo) + guard let status = byKey[key] ?? byRepo[repoKey] else { continue } + next[pair.project.id] = status + await handleCITransition(for: pair.project, status: status) + } + + // Drop status for projects no longer queried (e.g. removed). + ciStatusByProject = next + ciStatusRevision &+= 1 + } + + // MARK: - Failure handling + + /// Notify (always) and, when enabled, silently spawn a fix thread — but only + /// once per distinct failure (keyed by head sha / latest failing run). + private func handleCITransition(for project: Project, status: ProjectCIStatus) async { + guard status.overallState == .failure else { + // Reset so a future failure on this project fires again. + if lastHandledCIFailureSignature[project.id] != nil { + lastHandledCIFailureSignature[project.id] = nil + persistHandledCIFailureSignatures() + } + return + } + + let signature = Self.ciFailureSignature(status) + guard lastHandledCIFailureSignature[project.id] != signature else { return } + lastHandledCIFailureSignature[project.id] = signature + persistHandledCIFailureSignatures() + + let failingNames = status.failing.compactMap(\.workflowName) + + if notificationsEnabled { + await NotificationService.shared.postCIFailed( + projectName: project.name, + projectId: project.id, + failingWorkflowNames: failingNames + ) + } + + if enableAutoCIFix { + await startAutoCIFix(for: project, status: status) + } + } + + /// Fire-and-forget fix thread using the project's default agent/model/permission. + private func startAutoCIFix(for project: Project, status: ProjectCIStatus) async { + let branch = status.branch ?? "the current branch" + let prLine = status.prNumber.map { "PR #\($0) — " } ?? "" + let failingList = status.failing + .map { wf in + let name = wf.workflowName ?? "workflow" + if let url = wf.htmlUrl { return "- \(name): \(url)" } + return "- \(name)" + } + .joined(separator: "\n") + + let prompt = """ + GitHub Actions CI is failing on branch `\(branch)`. \(prLine)Please fix it. + + Failing workflow run(s): + \(failingList.isEmpty ? "- (see the GitHub Actions tab for details)" : failingList) + + Investigate the failure (read the run logs at the URLs above if helpful), \ + find the root cause, apply a fix, and run the relevant checks locally to \ + confirm CI will pass before finishing. + """ + + do { + _ = try await sendCrossProject( + projectId: project.id, + threadId: nil, + prompt: prompt, + waitForResponse: false + ) + logger.info("Started auto CI-fix thread for project \(project.name, privacy: .public)") + } catch { + logger.error("Failed to start auto CI-fix thread: \(error.localizedDescription)") + } + } + + // MARK: - Helpers + + private static func ciKey(owner: String, repo: String, branch: String?) -> String { + "\(owner.lowercased())/\(repo.lowercased())#\(branch ?? "")" + } + + private static func ciRepoKey(owner: String, repo: String) -> String { + "\(owner.lowercased())/\(repo.lowercased())" + } + + /// A value that changes when the failure changes commit/run, so we re-notify on + /// genuinely new failures but stay quiet while the same red state persists. + private static func ciFailureSignature(_ status: ProjectCIStatus) -> String { + if let sha = status.headSha, !sha.isEmpty { return "sha:\(sha)" } + let maxRun = status.failing.isEmpty + ? status.workflows.compactMap(\.runNumber).max() + : status.workflows.filter { $0.conclusion != nil && $0.conclusion != "success" }.compactMap(\.runNumber).max() + if let maxRun { return "run:\(maxRun)" } + return "updated:\(status.lastUpdated ?? "")" + } + + private func loadHandledCIFailureSignatures() { + guard let raw = UserDefaults.standard.dictionary(forKey: Self.ciSignaturesDefaultsKey) as? [String: String] else { return } + var restored: [UUID: String] = [:] + for (key, value) in raw { + if let id = UUID(uuidString: key) { restored[id] = value } + } + lastHandledCIFailureSignature = restored + } + + private func persistHandledCIFailureSignatures() { + let raw = Dictionary(uniqueKeysWithValues: lastHandledCIFailureSignature.map { ($0.key.uuidString, $0.value) }) + UserDefaults.standard.set(raw, forKey: Self.ciSignaturesDefaultsKey) + } +} diff --git a/RxCode/App/AppState+Lifecycle.swift b/RxCode/App/AppState+Lifecycle.swift index 1cbc2da..b08aa99 100644 --- a/RxCode/App/AppState+Lifecycle.swift +++ b/RxCode/App/AppState+Lifecycle.swift @@ -180,6 +180,11 @@ extension AppState { Task { [weak self] in await self?.loadRepos() } } + // Periodically pull GitHub Actions CI status for open projects (no-ops + // until signed in). Notifies on failure and, when enabled, auto-starts a + // fix thread. + startCIStatusPoller() + // React to RxAuthSwift session expiry by clearing autopilot repos. // `isSignedIn`/`rxUser` are computed from the manager, so they update // automatically when `OAuthManager` flips to `.unauthenticated`. diff --git a/RxCode/App/AppState.swift b/RxCode/App/AppState.swift index ba8aefd..e629c40 100644 --- a/RxCode/App/AppState.swift +++ b/RxCode/App/AppState.swift @@ -416,6 +416,33 @@ final class AppState { didSet { UserDefaults.standard.set(notificationsEnabled, forKey: "notificationsEnabled") } } + // MARK: - GitHub Actions CI + + /// When enabled, a failing CI run on a project's current branch silently + /// spawns a fix thread (via `sendCrossProject`) so an agent starts repairing + /// it on the user's behalf. The failure notification fires regardless of this + /// toggle — only the auto-fix behavior is gated. Off by default (spends tokens + /// unprompted). + var enableAutoCIFix: Bool = (UserDefaults.standard.object(forKey: "enableAutoCIFix") as? Bool) ?? false { + didSet { UserDefaults.standard.set(enableAutoCIFix, forKey: "enableAutoCIFix") } + } + + /// Latest CI status per project (current branch), refreshed by the poller in + /// `AppState+CIStatus.swift`. Held in memory only. + var ciStatusByProject: [UUID: ProjectCIStatus] = [:] + + /// Bumped after every CI refresh so views observing CI status recompute, + /// mirroring the `branchBriefingRevision` pattern. + var ciStatusRevision: Int = 0 + + /// The CI poller loop. Cancelled on teardown / restarted on sign-in. + @ObservationIgnored var ciStatusPollerTask: Task? + + /// Per-project signature of the last CI failure we already notified / auto-fixed, + /// so a steady-state red branch doesn't re-fire every 30s. Keyed by project id; + /// persisted to UserDefaults so a fix isn't re-triggered across relaunches. + @ObservationIgnored var lastHandledCIFailureSignature: [UUID: String] = [:] + // MARK: - Focus Mode var focusMode: Bool = (UserDefaults.standard.object(forKey: "focusMode") as? Bool) ?? false { diff --git a/RxCode/App/RxCodeApp.swift b/RxCode/App/RxCodeApp.swift index d8a0284..647972c 100644 --- a/RxCode/App/RxCodeApp.swift +++ b/RxCode/App/RxCodeApp.swift @@ -117,8 +117,10 @@ private struct MenuBarLabel: View { let provider = appState.selectedAgentProvider let usage = provider == .codex ? appState.latestCodexRateLimitUsage : appState.latestRateLimitUsage let fiveHour = usage?.fiveHourPercent + let _ = appState.ciStatusRevision + let ciFailing = appState.anyCIFailing - if let image = Self.renderLabelImage(agentText: Self.agentText(for: provider), fiveHour: fiveHour, inProgress: inProgress) { + if let image = Self.renderLabelImage(agentText: Self.agentText(for: provider), fiveHour: fiveHour, inProgress: inProgress, ciFailing: ciFailing) { Image(nsImage: image) } else { Image(systemName: "message") @@ -126,11 +128,12 @@ private struct MenuBarLabel: View { } @MainActor - private static func renderLabelImage(agentText: String, fiveHour: Double?, inProgress: Int) -> NSImage? { + private static func renderLabelImage(agentText: String, fiveHour: Double?, inProgress: Int, ciFailing: Bool) -> NSImage? { let content = MenuBarLabelContent( agentText: agentText, fiveHourText: fiveHour.map { "\(formatPercent($0))%" } ?? "—%", - statusText: inProgress > 0 ? "\(inProgress)job\(inProgress == 1 ? "" : "s")" : "IDLE" + statusText: inProgress > 0 ? "\(inProgress)job\(inProgress == 1 ? "" : "s")" : "IDLE", + ciFailing: ciFailing ) let renderer = ImageRenderer(content: content) renderer.scale = NSScreen.main?.backingScaleFactor ?? 2 @@ -164,6 +167,7 @@ private struct MenuBarLabelContent: View { let agentText: String let fiveHourText: String let statusText: String + let ciFailing: Bool var body: some View { HStack(alignment: .center, spacing: 8) { @@ -183,6 +187,14 @@ private struct MenuBarLabelContent: View { .fixedSize(horizontal: true, vertical: false) } .fixedSize(horizontal: true, vertical: false) + + // Template-rendered, so this shows as a monochrome glyph rather than + // a red dot — the icon shape signals the CI failure. + if ciFailing { + Image(systemName: "xmark.octagon.fill") + .font(.system(size: Self.textSize + 1, weight: .bold)) + .frame(height: 18, alignment: .center) + } } .padding(.vertical, 1) .fixedSize(horizontal: true, vertical: true) @@ -268,6 +280,11 @@ private struct MenuBarContentView: View { chatActivitySection + if !ciStatusRows.isEmpty { + Divider() + ciStatusSection + } + Divider() footer @@ -308,30 +325,6 @@ private struct MenuBarContentView: View { } } - private var agentPicker: some View { - Picker("Client", selection: Binding( - get: { appState.selectedAgentProvider }, - set: { provider in - appState.setDefaultAgentProvider(provider) - Task { await appState.refreshSelectedAgentRateLimitUsage() } - } - )) { - ForEach(AgentProvider.allCases, id: \.self) { provider in - Text(provider.displayName) - .tag(provider) - } - } - .pickerStyle(.segmented) - } - - private var emptyUsageText: String { - switch appState.selectedAgentProvider { - case .claudeCode: return "Sign in to Claude Code to see usage" - case .codex: return "Sign in to Codex to see usage" - case .acp: return "Usage tracking not supported by ACP" - } - } - private var chatActivitySection: some View { VStack(alignment: .leading, spacing: 8) { HStack(spacing: 8) { @@ -362,6 +355,72 @@ private struct MenuBarContentView: View { } } + private var agentPicker: some View { + Picker("Client", selection: Binding( + get: { appState.selectedAgentProvider }, + set: { provider in + appState.setDefaultAgentProvider(provider) + Task { await appState.refreshSelectedAgentRateLimitUsage() } + } + )) { + ForEach(AgentProvider.allCases, id: \.self) { provider in + Text(provider.displayName) + .tag(provider) + } + } + .pickerStyle(.segmented) + } + + private var emptyUsageText: String { + switch appState.selectedAgentProvider { + case .claudeCode: return "Sign in to Claude Code to see usage" + case .codex: return "Sign in to Codex to see usage" + case .acp: return "Usage tracking not supported by ACP" + } + } + + private var ciStatusRows: [(project: Project, status: ProjectCIStatus)] { + _ = appState.ciStatusRevision + return appState.ciStatusList() + } + + private var ciStatusSection: some View { + VStack(alignment: .leading, spacing: 4) { + Text("CI Status") + .font(.system(size: ClaudeTheme.size(12), weight: .semibold)) + .foregroundStyle(.secondary) + .textCase(.uppercase) + .padding(.bottom, 4) + + ForEach(ciStatusRows, id: \.project.id) { row in + CIStatusRow( + row: row, + destinationURL: ciDestinationURL(for: row.status), + help: ciRowHelp(for: row.status) + ) + } + } + } + + /// Where a CI row should navigate when clicked: the pull request if one is + /// associated with the branch, otherwise the failing workflow run on GitHub. + private func ciDestinationURL(for status: ProjectCIStatus) -> URL? { + if let prNumber = status.prNumber { + return URL(string: "https://github.com/\(status.owner)/\(status.repo)/pull/\(prNumber)") + } + if let urlString = status.failing.first?.htmlUrl { + return URL(string: urlString) + } + return nil + } + + private func ciRowHelp(for status: ProjectCIStatus) -> String { + if let prNumber = status.prNumber { + return "Open PR #\(prNumber) on GitHub" + } + return "Open failing run on GitHub" + } + private var footer: some View { HStack { Button("Open RxCode") { @@ -383,6 +442,75 @@ private struct MenuBarContentView: View { } } +/// A single CI-status row in the menubar popover. Clickable rows (those with a +/// `destinationURL`) draw a menu-style highlight on hover — the `.window` +/// MenuBarExtra style gives no automatic hover effect, so we track it manually. +private struct CIStatusRow: View { + let row: (project: Project, status: ProjectCIStatus) + let destinationURL: URL? + let help: String + + @State private var isHovering = false + + private var isLink: Bool { destinationURL != nil } + + var body: some View { + content + .contentShape(Rectangle()) + .padding(.vertical, 4) + .padding(.horizontal, 6) + .background( + RoundedRectangle(cornerRadius: 6, style: .continuous) + .fill(isHovering && isLink ? Color.primary.opacity(0.1) : .clear) + ) + // Extend the highlight past the row's text inset, like a native menu item. + .padding(.horizontal, -6) + .onHover { hovering in + isHovering = hovering + guard isLink else { return } + if hovering { + NSCursor.pointingHand.push() + } else { + NSCursor.pop() + } + } + .onTapGesture { + if let url = destinationURL { NSWorkspace.shared.open(url) } + } + .help(isLink ? help : "") + } + + private var content: some View { + HStack(spacing: 8) { + Image(systemName: row.status.overallState.sfSymbolName) + .font(.system(size: ClaudeTheme.size(11))) + .foregroundStyle(row.status.overallState.displayColor) + VStack(alignment: .leading, spacing: 0) { + Text(row.project.name) + .font(.system(size: ClaudeTheme.size(12))) + .foregroundStyle(ClaudeTheme.textPrimary) + .lineLimit(1) + if let branch = row.status.branch, !branch.isEmpty { + Text(branch) + .font(.system(size: ClaudeTheme.size(10))) + .foregroundStyle(.secondary) + .lineLimit(1) + } + } + Spacer(minLength: 4) + if isLink { + Image(systemName: "arrow.up.right.square") + .font(.system(size: ClaudeTheme.size(11))) + .foregroundStyle(.secondary) + } else { + Text(row.status.overallState.label) + .font(.system(size: ClaudeTheme.size(11))) + .foregroundStyle(.secondary) + } + } + } +} + // MARK: - Usage Bar private struct MenuBarUsageBar: View { diff --git a/RxCode/Resources/Localizable.xcstrings b/RxCode/Resources/Localizable.xcstrings index 8536a08..89ba7bf 100644 --- a/RxCode/Resources/Localizable.xcstrings +++ b/RxCode/Resources/Localizable.xcstrings @@ -210,6 +210,17 @@ } } }, + "%d workflows failed: %@" : { + "comment" : "CI failure notification body for multiple failing workflows. %1$d is the count, %2$@ is a comma-separated list of names.", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "%1$d workflows failed: %2$@" + } + } + } + }, "%lld" : { "localizations" : { "zh-Hans" : { @@ -452,6 +463,9 @@ } } }, + "A workflow failed" : { + "comment" : "CI failure notification body when the workflow name is unknown." + }, "Absolute or project-relative path. Leave empty to use the project root." : { "localizations" : { "zh-Hans" : { @@ -1381,6 +1395,9 @@ } } } + }, + "Auto-fix CI failures" : { + }, "Auto-preview Attachments" : { "localizations" : { @@ -1720,6 +1737,15 @@ } } } + }, + "CI failed%@" : { + "comment" : "Notification title when GitHub Actions CI fails on a project's current branch. %@ is replaced with \" — \" or empty string." + }, + "CI failing — open the failing run on GitHub" : { + + }, + "CI Status" : { + }, "Claude CLI Installation Check" : { "extractionState" : "stale", @@ -3614,6 +3640,9 @@ } } } + }, + "GitHub Actions" : { + }, "GitHub Repository" : { "localizations" : { @@ -8226,6 +8255,9 @@ }, "Welcome to RxCode" : { + }, + "When CI fails on a project's current branch, automatically start a thread so an agent can fix it. CI failures are always notified; this only controls the automatic fix." : { + }, "Wipe cached embeddings and re-embed every thread for semantic search. Use this if global search results look stale or empty." : { "localizations" : { @@ -8247,6 +8279,9 @@ } } }, + "Workflow “%@” failed" : { + "comment" : "CI failure notification body for a single failing workflow. %@ is the workflow name." + }, "Working Directory" : { "localizations" : { "zh-Hans" : { diff --git a/RxCode/Services/AutopilotService.swift b/RxCode/Services/AutopilotService.swift index 6268aed..966a59a 100644 --- a/RxCode/Services/AutopilotService.swift +++ b/RxCode/Services/AutopilotService.swift @@ -102,6 +102,16 @@ final class AutopilotService { return try await get(url: url) } + /// `POST /api/v1/ci-status/batch` — given a set of repo+branch lookups, + /// returns the current aggregated GitHub Actions status for each (overall + /// state, the latest run per workflow, and the failing ones). CI data is + /// synced into autopilot via GitHub webhooks, so this never touches + /// api.github.com. + func batchCIStatus(_ repos: [CIStatusQuery]) async throws -> CIStatusBatchResponse { + let url = baseURL.appendingPathComponent("/api/v1/ci-status/batch") + return try await post(url: url, body: CIStatusBatchRequest(repos: repos)) + } + // MARK: - Internal private func get(url: URL) async throws -> T { @@ -114,6 +124,24 @@ final class AutopilotService { } } + private func post(url: URL, body: Body) async throws -> T { + let payload: Data + do { + payload = try JSONEncoder().encode(body) + } catch { + throw ServiceError.decodingError(error.localizedDescription) + } + return try await performWithRetry { token in + var request = URLRequest(url: url) + request.httpMethod = "POST" + request.setValue("application/json", forHTTPHeaderField: "Accept") + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") + request.httpBody = payload + return request + } + } + /// Sign the request with the current bearer, hit the network, and retry /// exactly once after a refresh if the server returned 401. A second 401 /// posts `.rxAuthSessionExpired` so the UI can re-present sign-in. diff --git a/RxCode/Services/NotificationService.swift b/RxCode/Services/NotificationService.swift index d8a87d0..063843c 100644 --- a/RxCode/Services/NotificationService.swift +++ b/RxCode/Services/NotificationService.swift @@ -183,6 +183,63 @@ final class NotificationService: NSObject { } } + /// Post a "CI failed" notification when a project's current branch goes red + /// on GitHub Actions. Fans out to mobile and (when authorized) shows a local + /// banner. Uses a per-project identifier so a newer failure replaces the + /// previous banner instead of stacking. + func postCIFailed(projectName: String?, projectId: UUID?, failingWorkflowNames: [String]) async { + let body = Self.ciFailureBody(failingWorkflowNames) + let projectSuffix: String = projectName.map { " — \($0)" } ?? "" + await fanoutToMobile(.init( + kind: .generic, + title: "CI failed\(projectSuffix)", + body: body, + sessionID: nil, + projectID: projectId + )) + let settings = await UNUserNotificationCenter.current().notificationSettings() + switch settings.authorizationStatus { + case .authorized, .provisional: + break + default: + return + } + + let content = UNMutableNotificationContent() + let titleFormat = NSLocalizedString( + "CI failed%@", + comment: "Notification title when GitHub Actions CI fails on a project's current branch. %@ is replaced with \" — \" or empty string." + ) + content.title = String(format: titleFormat, projectSuffix) + content.body = body + content.sound = .default + if let projectId { content.userInfo = ["projectId": projectId.uuidString] } + + // Stable per-project id: a fresh failure replaces the prior banner. + let identifier = projectId.map { "ci-failed-\($0.uuidString)" } ?? "ci-failed-\(UUID().uuidString)" + let request = UNNotificationRequest(identifier: identifier, content: content, trigger: nil) + + do { + try await UNUserNotificationCenter.current().add(request) + } catch { + logger.error("Failed to post CI failure notification: \(error.localizedDescription)") + } + } + + private static func ciFailureBody(_ names: [String]) -> String { + let cleaned = names.filter { !$0.trimmingCharacters(in: .whitespaces).isEmpty } + switch cleaned.count { + case 0: + return NSLocalizedString("A workflow failed", comment: "CI failure notification body when the workflow name is unknown.") + case 1: + let format = NSLocalizedString("Workflow “%@” failed", comment: "CI failure notification body for a single failing workflow. %@ is the workflow name.") + return String(format: format, cleaned[0]) + default: + let format = NSLocalizedString("%d workflows failed: %@", comment: "CI failure notification body for multiple failing workflows. %1$d is the count, %2$@ is a comma-separated list of names.") + return String(format: format, cleaned.count, cleaned.joined(separator: ", ")) + } + } + /// Post a local banner after a paired mobile device remotely changed the /// desktop's skill / ACP / MCP configuration. Silently no-ops if the user /// has not authorized notifications. diff --git a/RxCode/Services/RxAuthService.swift b/RxCode/Services/RxAuthService.swift index e3a7fca..fc440a2 100644 --- a/RxCode/Services/RxAuthService.swift +++ b/RxCode/Services/RxAuthService.swift @@ -51,12 +51,21 @@ final class RxAuthService { /// Returns a current bearer token, refreshing first if it's expired. /// Returns `nil` when the user is signed out or refresh failed. func accessToken() async -> String? { + let clock = ContinuousClock() + let start = clock.now do { + // RxAuthSwift reads/writes its own keychain item internally here; a + // long elapsed time below points the keychain prompt at the SDK + // rather than our `KeychainHelper.read` further down. try await manager.refreshTokenIfNeeded() } catch { logger.warning("RxAuth refresh failed: \(error.localizedDescription, privacy: .public)") return nil } + let refreshElapsed = clock.now - start + let ms = Double(refreshElapsed.components.attoseconds) / 1e15 + + Double(refreshElapsed.components.seconds) * 1e3 + logger.debug("accessToken: refreshTokenIfNeeded returned in \(ms, privacy: .public)ms") return KeychainBackedTokenReader.readAccessToken( service: "com.rxtech.rxcode.rxauth" ) diff --git a/RxCode/Utilities/KeychainHelper.swift b/RxCode/Utilities/KeychainHelper.swift index 60c02f3..ee6483c 100644 --- a/RxCode/Utilities/KeychainHelper.swift +++ b/RxCode/Utilities/KeychainHelper.swift @@ -1,11 +1,56 @@ import Foundation +import os import Security enum KeychainHelper { + private static let logger = Logger(subsystem: "com.claudework", category: "Keychain") + + /// Above this, the SecItem call almost certainly blocked on a macOS + /// "wants to use confidential information" permission dialog — a synchronous + /// keychain read returns in microseconds otherwise. Use this to pin down + /// which caller triggers the prompt. + private static let promptSuspicionThreshold: Duration = .milliseconds(250) + + private nonisolated static func describe(_ status: OSStatus) -> String { + let message = (SecCopyErrorMessageString(status, nil) as String?) ?? "unknown" + return "\(status) (\(message))" + } + + private nonisolated static func logTiming( + op: String, + service: String, + account: String?, + status: OSStatus, + elapsed: Duration, + caller: String, + file: String, + line: Int + ) { + let acct = account ?? "" + let location = "\(file):\(line) \(caller)" + let ms = Double(elapsed.components.attoseconds) / 1e15 + + Double(elapsed.components.seconds) * 1e3 + if elapsed > promptSuspicionThreshold { + logger.warning( + "\(op, privacy: .public) service=\(service, privacy: .public) account=\(acct, privacy: .public) status=\(describe(status), privacy: .public) elapsed=\(ms, privacy: .public)ms ⚠️ SLOW — likely a keychain permission prompt — caller=\(location, privacy: .public)" + ) + } else { + logger.debug( + "\(op, privacy: .public) service=\(service, privacy: .public) account=\(acct, privacy: .public) status=\(describe(status), privacy: .public) elapsed=\(ms, privacy: .public)ms caller=\(location, privacy: .public)" + ) + } + } + // MARK: - Read (SecItem API — same process that wrote the item) - nonisolated static func read(service: String, account: String? = nil) -> Data? { + nonisolated static func read( + service: String, + account: String? = nil, + caller: String = #function, + file: String = #fileID, + line: Int = #line + ) -> Data? { var query: [String: Any] = [ kSecClass as String: kSecClassGenericPassword, kSecAttrService as String: service, @@ -16,20 +61,44 @@ enum KeychainHelper { query[kSecAttrAccount as String] = account } + let clock = ContinuousClock() + let start = clock.now var result: AnyObject? let status = SecItemCopyMatching(query as CFDictionary, &result) + let elapsed = clock.now - start + + logTiming( + op: "read", service: service, account: account, status: status, + elapsed: elapsed, caller: caller, file: file, line: line + ) + guard status == errSecSuccess else { return nil } return result as? Data } - nonisolated static func readString(service: String, account: String? = nil) -> String? { - guard let data = read(service: service, account: account) else { return nil } + nonisolated static func readString( + service: String, + account: String? = nil, + caller: String = #function, + file: String = #fileID, + line: Int = #line + ) -> String? { + guard let data = read(service: service, account: account, caller: caller, file: file, line: line) else { + return nil + } return String(data: data, encoding: .utf8) } // MARK: - Write / Delete (SecItem API — own app items) - nonisolated static func save(_ data: Data, service: String, account: String) throws { + nonisolated static func save( + _ data: Data, + service: String, + account: String, + caller: String = #function, + file: String = #fileID, + line: Int = #line + ) throws { let query: [String: Any] = [ kSecClass as String: kSecClassGenericPassword, kSecAttrService as String: service, @@ -37,26 +106,51 @@ enum KeychainHelper { ] let updateAttributes: [String: Any] = [kSecValueData as String: data] + let clock = ContinuousClock() + let start = clock.now var status = SecItemUpdate(query as CFDictionary, updateAttributes as CFDictionary) + var op = "save(update)" if status == errSecItemNotFound { var addQuery = query addQuery[kSecValueData as String] = data status = SecItemAdd(addQuery as CFDictionary, nil) + op = "save(add)" } + let elapsed = clock.now - start + + logTiming( + op: op, service: service, account: account, status: status, + elapsed: elapsed, caller: caller, file: file, line: line + ) guard status == errSecSuccess else { throw KeychainError.operationFailed(status) } } - nonisolated static func delete(service: String, account: String) throws { + nonisolated static func delete( + service: String, + account: String, + caller: String = #function, + file: String = #fileID, + line: Int = #line + ) throws { let query: [String: Any] = [ kSecClass as String: kSecClassGenericPassword, kSecAttrService as String: service, kSecAttrAccount as String: account, ] + let clock = ContinuousClock() + let start = clock.now let status = SecItemDelete(query as CFDictionary) + let elapsed = clock.now - start + + logTiming( + op: "delete", service: service, account: account, status: status, + elapsed: elapsed, caller: caller, file: file, line: line + ) + guard status == errSecSuccess || status == errSecItemNotFound else { throw KeychainError.operationFailed(status) } diff --git a/RxCode/Views/CIStatusPresentation.swift b/RxCode/Views/CIStatusPresentation.swift new file mode 100644 index 0000000..6cf4340 --- /dev/null +++ b/RxCode/Views/CIStatusPresentation.swift @@ -0,0 +1,33 @@ +import SwiftUI +import RxCodeCore + +/// Shared presentation mapping for GitHub Actions CI state, used by the menubar +/// popover and the briefing cards so the dot color / icon / label stay consistent. +extension CIOverallState { + var displayColor: Color { + switch self { + case .success: return .green + case .failure: return .red + case .pending: return .yellow + case .unknown: return .secondary + } + } + + var sfSymbolName: String { + switch self { + case .success: return "checkmark.circle.fill" + case .failure: return "xmark.circle.fill" + case .pending: return "clock.fill" + case .unknown: return "questionmark.circle" + } + } + + var label: String { + switch self { + case .success: return "Passing" + case .failure: return "Failing" + case .pending: return "Running" + case .unknown: return "No status" + } + } +} diff --git a/RxCode/Views/RunProfile/RunProfileDetailForm.swift b/RxCode/Views/RunProfile/RunProfileDetailForm.swift index a6c3584..20d0d9e 100644 --- a/RxCode/Views/RunProfile/RunProfileDetailForm.swift +++ b/RxCode/Views/RunProfile/RunProfileDetailForm.swift @@ -326,6 +326,7 @@ struct RunProfileDetailForm: View { || installedPackageManagers.contains(manager) Text(installed ? manager.displayName : "\(manager.displayName) (not installed)") .tag(manager) + .disabled(!installed) } } scriptField(pkg: pkg) diff --git a/RxCode/Views/SettingsView.swift b/RxCode/Views/SettingsView.swift index 55ab01f..bfa2d12 100644 --- a/RxCode/Views/SettingsView.swift +++ b/RxCode/Views/SettingsView.swift @@ -104,6 +104,8 @@ struct GeneralSettingsTab: View { Divider() notificationsSection(appState: $appState.notificationsEnabled) Divider() + githubActionsSection(isOn: $appState.enableAutoCIFix) + Divider() menuBarSection Divider() searchIndexSection @@ -274,6 +276,17 @@ struct GeneralSettingsTab: View { ) } + // MARK: - GitHub Actions Section + + private func githubActionsSection(isOn: Binding) -> some View { + toggleSection( + title: "GitHub Actions", + label: "Auto-fix CI failures", + detail: "When CI fails on a project's current branch, automatically start a thread so an agent can fix it. CI failures are always notified; this only controls the automatic fix.", + isOn: isOn + ) + } + // MARK: - Theme Section private var themeSection: some View { diff --git a/RxCode/Views/Sidebar/BriefingView.swift b/RxCode/Views/Sidebar/BriefingView.swift index 696c1fb..9c11ff5 100644 --- a/RxCode/Views/Sidebar/BriefingView.swift +++ b/RxCode/Views/Sidebar/BriefingView.swift @@ -454,6 +454,7 @@ struct BriefingView: View { .truncationMode(.middle) chip(icon: "arrow.triangle.branch", text: group.branch, accented: true) + ciChip(for: group) } Text("Updated \(Self.compactDate(group.updatedAt))") .font(.system(size: 10.5, weight: .medium)) @@ -571,6 +572,50 @@ struct BriefingView: View { .help("Actions for \(project.name)") } + /// CI status chip, shown only on the card matching the project's current + /// branch (CI status is tracked per project for its current branch). Failing + /// states link to the failing run on GitHub. + @ViewBuilder + private func ciChip(for group: BriefingGroup) -> some View { + let _ = appState.ciStatusRevision + if currentBranchByProject[group.projectId] == group.branch, + let status = appState.ciStatusByProject[group.projectId] { + let state = status.overallState + if state == .failure, + let urlString = status.failing.first?.htmlUrl, + let url = URL(string: urlString) { + Link(destination: url) { + ciChipLabel(state: state) + } + .buttonStyle(.plain) + .help("CI failing — open the failing run on GitHub") + } else { + ciChipLabel(state: state) + } + } + } + + private func ciChipLabel(state: CIOverallState) -> some View { + HStack(spacing: 4) { + Image(systemName: state.sfSymbolName) + .font(.system(size: 9, weight: .semibold)) + Text(state.label) + .font(.system(size: 10.5, weight: .medium)) + .lineLimit(1) + } + .foregroundStyle(state.displayColor) + .padding(.horizontal, 7) + .padding(.vertical, 2) + .background( + Capsule(style: .continuous) + .fill(state.displayColor.opacity(0.12)) + ) + .overlay( + Capsule(style: .continuous) + .strokeBorder(state.displayColor.opacity(0.25), lineWidth: 0.5) + ) + } + private func chip(icon: String, text: String, accented: Bool = false) -> some View { HStack(spacing: 4) { Image(systemName: icon)