From dd4d9691df8af45bf2ec4b8bc2a24895177d8834 Mon Sep 17 00:00:00 2001 From: Peyton Spencer Date: Wed, 1 Jul 2026 12:00:06 -0400 Subject: [PATCH] fix: improve org runner reauth UX --- Sources/Services/GHCLIService.swift | 41 +++++++- Sources/Views/AddRunnerView.swift | 109 +++++++++++++++++++++- Tests/MacRunnerTests/MacRunnerTests.swift | 13 +++ 3 files changed, 158 insertions(+), 5 deletions(-) diff --git a/Sources/Services/GHCLIService.swift b/Sources/Services/GHCLIService.swift index 2130385..e1b208b 100644 --- a/Sources/Services/GHCLIService.swift +++ b/Sources/Services/GHCLIService.swift @@ -44,6 +44,11 @@ struct GitHubAuthState: Sendable, Equatable { } } +struct GitHubAuthRecovery: Sendable, Equatable { + static let adminOrgScope = "admin:org" + static let adminOrgRefreshCommand = "gh auth refresh -h github.com -s admin:org" +} + final class GHCLIService: Sendable { static let shared = GHCLIService() @@ -130,6 +135,20 @@ final class GHCLIService: Sendable { } } + func openAdminOrgReauth() throws { + let process = Process() + process.executableURL = URL(fileURLWithPath: ghPath) + process.arguments = [ + "auth", "refresh", + "-h", "github.com", + "-s", GitHubAuthRecovery.adminOrgScope, + "--clipboard" + ] + process.standardOutput = FileHandle.nullDevice + process.standardError = FileHandle.nullDevice + try process.run() + } + func authStatus() async -> String { let state = await validateAuth() return state.isAuthenticated ? state.statusMessage : state.recoveryMessage @@ -270,11 +289,31 @@ final class GHCLIService: Sendable { "--jq", ".token" ]) guard result.exitCode == 0, !result.stdout.isEmpty else { - throw GHError.apiFailed("Failed to get registration token: \(result.stderr)") + let detail = Self.registrationTokenFailureMessage(from: result.stderr) + throw GHError.apiFailed("Failed to get registration token: \(detail)") } return result.stdout } + static func registrationTokenFailureMessage(from stderr: String) -> String { + guard requiresAdminOrgScope(stderr) else { + return stderr + } + + return """ + \(stderr) + + To request the missing scope, run: + \(GitHubAuthRecovery.adminOrgRefreshCommand) + """ + } + + static func requiresAdminOrgScope(_ message: String) -> Bool { + let lowercased = message.lowercased() + return lowercased.contains(GitHubAuthRecovery.adminOrgScope) + || lowercased.contains("runners and runner groups") + } + func listRemoteRunners(for repo: String) async throws -> [RemoteRunner] { try await listRemoteRunners(for: RunnerTarget(scope: .repo, identifier: repo)) } diff --git a/Sources/Views/AddRunnerView.swift b/Sources/Views/AddRunnerView.swift index 70c3969..bf97a46 100644 --- a/Sources/Views/AddRunnerView.swift +++ b/Sources/Views/AddRunnerView.swift @@ -1,3 +1,4 @@ +import AppKit import SwiftUI struct AddRunnerView: View { @@ -20,6 +21,7 @@ struct AddRunnerView: View { @State private var isAdding = false @State private var addingProgress: (current: Int, total: Int)? @State private var errorMessage: String? + @State private var recoveryStatusMessage: String? enum IsolationSelection: String, CaseIterable, Identifiable { case global = "Global (from settings)" @@ -50,6 +52,11 @@ struct AddRunnerView: View { } } + private var shouldShowAdminOrgRecovery: Bool { + guard let errorMessage else { return false } + return GHCLIService.requiresAdminOrgScope(errorMessage) + } + var body: some View { VStack(spacing: 0) { // Header @@ -234,9 +241,14 @@ struct AddRunnerView: View { } if let error = errorMessage { - Text(error) - .foregroundColor(.red) - .font(.caption) + ErrorRecoveryView( + message: error, + showAdminOrgRecovery: shouldShowAdminOrgRecovery, + recoveryStatusMessage: recoveryStatusMessage, + onCopyError: { copyToPasteboard(error) }, + onCopyCommand: { copyToPasteboard(GitHubAuthRecovery.adminOrgRefreshCommand) }, + onOpenReauth: openAdminOrgReauth + ) } } .padding() @@ -273,7 +285,7 @@ struct AddRunnerView: View { } .padding() } - .frame(width: 400, height: 620) + .frame(width: 420, height: 680) } @ViewBuilder @@ -405,6 +417,7 @@ struct AddRunnerView: View { isLoadingRepos = true defer { isLoadingRepos = false } errorMessage = nil + recoveryStatusMessage = nil do { if scope == .org { @@ -438,6 +451,7 @@ struct AddRunnerView: View { addingProgress = nil } errorMessage = nil + recoveryStatusMessage = nil let baseName = name.isEmpty ? "mac-runner-\(Int.random(in: 1000...9999))" @@ -493,4 +507,91 @@ struct AddRunnerView: View { errorMessage = error.localizedDescription } } + + private func copyToPasteboard(_ value: String) { + NSPasteboard.general.clearContents() + NSPasteboard.general.setString(value, forType: .string) + recoveryStatusMessage = "Copied." + } + + private func openAdminOrgReauth() { + do { + try GHCLIService.shared.openAdminOrgReauth() + recoveryStatusMessage = "Opened GitHub reauth. The device code is on your clipboard." + } catch { + recoveryStatusMessage = "Could not open reauth: \(error.localizedDescription)" + } + } +} + +private struct ErrorRecoveryView: View { + let message: String + let showAdminOrgRecovery: Bool + let recoveryStatusMessage: String? + let onCopyError: () -> Void + let onCopyCommand: () -> Void + let onOpenReauth: () -> Void + + var body: some View { + VStack(alignment: .leading, spacing: 10) { + HStack(alignment: .top, spacing: 8) { + Image(systemName: "exclamationmark.triangle.fill") + .foregroundColor(.red) + Text(message) + .font(.caption) + .foregroundColor(.red) + .textSelection(.enabled) + .fixedSize(horizontal: false, vertical: true) + } + + if showAdminOrgRecovery { + VStack(alignment: .leading, spacing: 8) { + Text("Request the missing org-admin scope:") + .font(.caption) + .foregroundColor(.secondary) + + Text(GitHubAuthRecovery.adminOrgRefreshCommand) + .font(.system(.caption, design: .monospaced)) + .textSelection(.enabled) + .padding(8) + .frame(maxWidth: .infinity, alignment: .leading) + .background(Color.secondary.opacity(0.12)) + .clipShape(RoundedRectangle(cornerRadius: 6)) + + HStack { + Button { + onCopyCommand() + } label: { + Label("Copy Command", systemImage: "doc.on.doc") + } + + Button { + onOpenReauth() + } label: { + Label("Open Reauth", systemImage: "safari") + } + + Spacer() + } + } + } + + HStack { + Button { + onCopyError() + } label: { + Label("Copy Error", systemImage: "doc.on.clipboard") + } + + if let recoveryStatusMessage { + Text(recoveryStatusMessage) + .font(.caption) + .foregroundColor(.secondary) + } + } + } + .padding(10) + .background(Color.red.opacity(0.08)) + .clipShape(RoundedRectangle(cornerRadius: 8)) + } } diff --git a/Tests/MacRunnerTests/MacRunnerTests.swift b/Tests/MacRunnerTests/MacRunnerTests.swift index 7648566..49cb45f 100644 --- a/Tests/MacRunnerTests/MacRunnerTests.swift +++ b/Tests/MacRunnerTests/MacRunnerTests.swift @@ -336,6 +336,19 @@ final class MacRunnerTests: XCTestCase { XCTAssertEqual(state.recoveryMessage, "GitHub authentication expired or is invalid. Run: gh auth login") } + func testRegistrationTokenFailureAddsAdminOrgRecoveryCommand() { + let message = """ + gh: You must be an org admin or have the runners and runner groups fine-grained permission. (HTTP 403) + This API operation needs the "admin:org" scope. + """ + + let recovery = GHCLIService.registrationTokenFailureMessage(from: message) + + XCTAssertTrue(GHCLIService.requiresAdminOrgScope(recovery)) + XCTAssertTrue(recovery.contains(GitHubAuthRecovery.adminOrgRefreshCommand)) + XCTAssertTrue(recovery.contains(message)) + } + func testJobNotificationPayloadFactoryBuildsStartedPayload() { let runner = Runner(name: "runner-1", repo: "omniaura/mac-runner") let run = WorkflowRunSummary(