Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 40 additions & 1 deletion Sources/Services/GHCLIService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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()

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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))
}
Expand Down
109 changes: 105 additions & 4 deletions Sources/Views/AddRunnerView.swift
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import AppKit
import SwiftUI

struct AddRunnerView: View {
Expand All @@ -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)"
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -273,7 +285,7 @@ struct AddRunnerView: View {
}
.padding()
}
.frame(width: 400, height: 620)
.frame(width: 420, height: 680)
}

@ViewBuilder
Expand Down Expand Up @@ -405,6 +417,7 @@ struct AddRunnerView: View {
isLoadingRepos = true
defer { isLoadingRepos = false }
errorMessage = nil
recoveryStatusMessage = nil

do {
if scope == .org {
Expand Down Expand Up @@ -438,6 +451,7 @@ struct AddRunnerView: View {
addingProgress = nil
}
errorMessage = nil
recoveryStatusMessage = nil

let baseName = name.isEmpty
? "mac-runner-\(Int.random(in: 1000...9999))"
Expand Down Expand Up @@ -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))
}
}
13 changes: 13 additions & 0 deletions Tests/MacRunnerTests/MacRunnerTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
Loading