diff --git a/Sources/Models/Runner.swift b/Sources/Models/Runner.swift index 3c81f08..cad0984 100644 --- a/Sources/Models/Runner.swift +++ b/Sources/Models/Runner.swift @@ -1,9 +1,28 @@ import Foundation +/// Whether a runner is registered against a single repository or an entire +/// organization. Org-level runners can be picked up by any repository in the +/// organization that the runner group grants access to. +enum RunnerScope: String, Codable, Sendable { + case repo + case org + + var displayName: String { + switch self { + case .repo: return "Repository" + case .org: return "Organization" + } + } +} + struct Runner: Identifiable, Codable, Sendable { let id: UUID var name: String - var repo: String // Format: "owner/repo" + /// Target identifier. For `.repo` scope this is "owner/repo"; for `.org` scope this is the org login. + var repo: String + /// Whether this runner is registered to a single repository or an entire organization. + /// Defaults to `.repo` for backward compatibility with pre-v1.11 configs. + var scope: RunnerScope var labels: [String] var enabled: Bool var status: RunnerStatus @@ -18,6 +37,7 @@ struct Runner: Identifiable, Codable, Sendable { id: UUID = UUID(), name: String, repo: String, + scope: RunnerScope = .repo, labels: [String] = ["macos", "mac-runner"], enabled: Bool = true, status: RunnerStatus = .stopped, @@ -31,6 +51,7 @@ struct Runner: Identifiable, Codable, Sendable { self.id = id self.name = name self.repo = repo + self.scope = scope self.labels = labels self.enabled = enabled self.status = status @@ -47,6 +68,8 @@ struct Runner: Identifiable, Codable, Sendable { id = try container.decode(UUID.self, forKey: .id) name = try container.decode(String.self, forKey: .name) repo = try container.decode(String.self, forKey: .repo) + // Default to .repo for configs written before scope was introduced. + scope = try container.decodeIfPresent(RunnerScope.self, forKey: .scope) ?? .repo labels = try container.decode([String].self, forKey: .labels) enabled = try container.decode(Bool.self, forKey: .enabled) status = try container.decode(RunnerStatus.self, forKey: .status) @@ -64,6 +87,11 @@ struct Runner: Identifiable, Codable, Sendable { ) } + /// Convenience target descriptor pairing this runner's scope and identifier. + var target: RunnerTarget { + RunnerTarget(scope: scope, identifier: repo) + } + /// Returns the effective isolation mode for this runner. /// /// If the runner has a specific isolation mode set, that is returned. @@ -80,6 +108,39 @@ struct Runner: Identifiable, Codable, Sendable { } } +/// A scope-aware identifier for GitHub Actions runner registration targets. +/// +/// Encapsulates the dual nature of GitHub's runner API: repository-level +/// runners live under `repos/{owner}/{repo}/...` while organization-level +/// runners live under `orgs/{org}/...`. Both share the same registration +/// download URL — `https://github.com/{identifier}` — which `config.sh` +/// uses to phone home. +struct RunnerTarget: Sendable, Equatable, Hashable { + let scope: RunnerScope + let identifier: String // "owner/repo" for .repo, "org" for .org + + /// REST API path prefix used by `gh api` calls (no leading slash). + var apiPath: String { + switch scope { + case .repo: return "repos/\(identifier)" + case .org: return "orgs/\(identifier)" + } + } + + /// URL passed to `config.sh --url` when registering the runner. + var registrationURL: String { + "https://github.com/\(identifier)" + } + + /// Human-readable description suitable for log lines and CLI output. + var displayName: String { + switch scope { + case .repo: return identifier + case .org: return "\(identifier) (org)" + } + } +} + enum RunnerStatus: String, Codable, Sendable { case running case stopped diff --git a/Sources/Services/CLIHandler.swift b/Sources/Services/CLIHandler.swift index 66f8744..86b1925 100644 --- a/Sources/Services/CLIHandler.swift +++ b/Sources/Services/CLIHandler.swift @@ -118,7 +118,7 @@ enum CLIHandler { COMMANDS: auth Show GitHub authentication status list List configured runners - add Add a new runner + add Add a new runner (repo by default; pass --org for org-level) remove Remove a runner start Start a runner stop Stop a runner @@ -128,6 +128,8 @@ enum CLIHandler { version Show version ADD OPTIONS: + --org Register an organization-level runner (target is the org login) + --repo Register a repository-level runner (default) --name Runner name (default: auto-generated) --labels Comma-separated labels (default: macos) --isolation Isolation mode: none|user|container (default: global) @@ -139,6 +141,7 @@ enum CLIHandler { EXAMPLES: mac-runner auth mac-runner add owner/repo --name my-runner --labels macos,arm64 + mac-runner add my-org --org --labels macos,arm64 mac-runner add owner/repo --isolation container mac-runner add owner/repo --isolation user mac-runner add owner/repo --enable-gui @@ -176,28 +179,35 @@ enum CLIHandler { return "\(effective.icon) \(effective.displayName)\(isInherited ? " (global)" : "")" } + // Display the scope alongside the identifier so org-level runners are + // visually distinguishable from repo-level runners with similar names. + let targets = runners.map { runner -> String in + runner.scope == .org ? "\(runner.repo) (org)" : runner.repo + } + // Table header let nameW = max(runners.map(\.name.count).max() ?? 4, 4) - let repoW = max(runners.map(\.repo.count).max() ?? 4, 4) + let repoW = max(targets.map(\.count).max() ?? 4, 6) let isoW = max(isolationTexts.map(\.count).max() ?? 9, 9) - let header = " \("NAME".padding(toLength: nameW, withPad: " ", startingAt: 0)) \("REPO".padding(toLength: repoW, withPad: " ", startingAt: 0)) STATUS \("ISOLATION".padding(toLength: isoW, withPad: " ", startingAt: 0)) GUI LABELS" + let header = " \("NAME".padding(toLength: nameW, withPad: " ", startingAt: 0)) \("TARGET".padding(toLength: repoW, withPad: " ", startingAt: 0)) STATUS \("ISOLATION".padding(toLength: isoW, withPad: " ", startingAt: 0)) GUI LABELS" print(header) - for (runner, isolationText) in zip(runners, isolationTexts) { + for ((runner, isolationText), target) in zip(zip(runners, isolationTexts), targets) { let status = "\(runner.status.icon) \(runner.status.rawValue)" let labels = runner.labels.joined(separator: ",") let guiStatus = runner.enableGUI ? "enabled " : "headless" - let line = " \(runner.name.padding(toLength: nameW, withPad: " ", startingAt: 0)) \(runner.repo.padding(toLength: repoW, withPad: " ", startingAt: 0)) \(status.padding(toLength: 10, withPad: " ", startingAt: 0)) \(isolationText.padding(toLength: isoW, withPad: " ", startingAt: 0)) \(guiStatus) \(labels)" + let line = " \(runner.name.padding(toLength: nameW, withPad: " ", startingAt: 0)) \(target.padding(toLength: repoW, withPad: " ", startingAt: 0)) \(status.padding(toLength: 10, withPad: " ", startingAt: 0)) \(isolationText.padding(toLength: isoW, withPad: " ", startingAt: 0)) \(guiStatus) \(labels)" print(line) } } @MainActor private static func handleAdd(args: [String]) async { - guard let repo = args.first, repo.contains("/") else { - print("Error: repository required in owner/repo format") + guard let target = args.first else { + print("Error: repository or organization required") print("Usage: mac-runner add [--name ] [--labels ] [--isolation ] [--enable-gui] [--open-files ]") + print(" mac-runner add --org [--name ] [--labels ] [--isolation ] [--enable-gui] [--open-files ]") return } @@ -206,11 +216,18 @@ enum CLIHandler { var isolationMode: IsolationMode? = nil var enableGUI = false var openFileLimit: Int? = nil + var scope: RunnerScope = .repo // Parse optional flags var i = 1 while i < args.count { switch args[i] { + case "--org": + scope = .org + i += 1 + case "--repo": + scope = .repo + i += 1 case "--name" where i + 1 < args.count: name = args[i + 1] i += 2 @@ -246,6 +263,20 @@ enum CLIHandler { } } + // Validate the identifier shape against the chosen scope. + switch scope { + case .repo: + guard target.contains("/") else { + print("Error: repository required in owner/repo format (or pass --org to register an organization runner)") + return + } + case .org: + guard !target.contains("/") else { + print("Error: --org expects an organization login only (no slashes)") + return + } + } + // Check auth first let authState = await GHCLIService.shared.validateAuth() guard authState.isAuthenticated else { @@ -253,12 +284,14 @@ enum CLIHandler { return } - print("Adding runner '\(name)' for \(repo)...") + let scopeLabel = scope == .org ? "\(target) (org)" : target + print("Adding runner '\(name)' for \(scopeLabel)...") let manager = RunnerManager() do { try await manager.addRunner( name: name, - repo: repo, + repo: target, + scope: scope, labels: labels, isolationMode: isolationMode, enableGUI: enableGUI, diff --git a/Sources/Services/GHCLIService.swift b/Sources/Services/GHCLIService.swift index 85ecbb5..2130385 100644 --- a/Sources/Services/GHCLIService.swift +++ b/Sources/Services/GHCLIService.swift @@ -196,6 +196,23 @@ final class GHCLIService: Sendable { return result.exitCode == 0 } + /// Verify that the authenticated `gh` user can access the given runner target. + /// + /// For repository targets this falls through to `validateRepo`. For + /// organization targets we probe `orgs/{org}` — this requires the user + /// to be a member of the org, but does NOT verify admin access. The + /// registration-token POST below will fail loudly if the user lacks + /// the org admin permission needed to register runners. + func validateTarget(_ target: RunnerTarget) async throws -> Bool { + switch target.scope { + case .repo: + return try await validateRepo(target.identifier) + case .org: + let result = try await runGH(["api", "orgs/\(target.identifier)"]) + return result.exitCode == 0 + } + } + func repositoryRootEntries(for repo: String) async throws -> Set { let result = try await runGH([ "api", "repos/\(repo)/contents", @@ -243,9 +260,13 @@ final class GHCLIService: Sendable { // MARK: - Runners func getRegistrationToken(for repo: String) async throws -> String { + try await getRegistrationToken(for: RunnerTarget(scope: .repo, identifier: repo)) + } + + func getRegistrationToken(for target: RunnerTarget) async throws -> String { let result = try await runGH([ "api", "-X", "POST", - "repos/\(repo)/actions/runners/registration-token", + "\(target.apiPath)/actions/runners/registration-token", "--jq", ".token" ]) guard result.exitCode == 0, !result.stdout.isEmpty else { @@ -255,8 +276,12 @@ final class GHCLIService: Sendable { } func listRemoteRunners(for repo: String) async throws -> [RemoteRunner] { + try await listRemoteRunners(for: RunnerTarget(scope: .repo, identifier: repo)) + } + + func listRemoteRunners(for target: RunnerTarget) async throws -> [RemoteRunner] { let result = try await runGH([ - "api", "repos/\(repo)/actions/runners", + "api", "\(target.apiPath)/actions/runners", "--jq", ".runners" ]) guard result.exitCode == 0 else { @@ -292,9 +317,16 @@ final class GHCLIService: Sendable { } func deleteRunner(repo: String, githubRunnerId: Int) async throws { + try await deleteRunner( + target: RunnerTarget(scope: .repo, identifier: repo), + githubRunnerId: githubRunnerId + ) + } + + func deleteRunner(target: RunnerTarget, githubRunnerId: Int) async throws { let result = try await runGH([ "api", "-X", "DELETE", - "repos/\(repo)/actions/runners/\(githubRunnerId)" + "\(target.apiPath)/actions/runners/\(githubRunnerId)" ]) guard result.exitCode == 0 else { throw GHError.apiFailed("Failed to delete runner: \(result.stderr)") diff --git a/Sources/Services/RunnerInstaller.swift b/Sources/Services/RunnerInstaller.swift index 4cc3e85..0aa0a7d 100644 --- a/Sources/Services/RunnerInstaller.swift +++ b/Sources/Services/RunnerInstaller.swift @@ -40,7 +40,10 @@ class RunnerInstaller { print("Runner installed successfully to: \(directory)") } - /// Configure runner with a registration token (caller obtains it via GHCLIService) + /// Configure runner with a registration token (caller obtains it via GHCLIService). + /// + /// Legacy `repo`-based overload preserved for callers that haven't been + /// updated to pass an explicit `RunnerTarget`. func configureRunner( at directory: String, repo: String, @@ -48,11 +51,29 @@ class RunnerInstaller { name: String, labels: [String], isolation: IsolationMode = .none + ) async throws { + try await configureRunner( + at: directory, + target: RunnerTarget(scope: .repo, identifier: repo), + registrationToken: registrationToken, + name: name, + labels: labels, + isolation: isolation + ) + } + + func configureRunner( + at directory: String, + target: RunnerTarget, + registrationToken: String, + name: String, + labels: [String], + isolation: IsolationMode = .none ) async throws { // Build config command var args = [ "./config.sh", - "--url", "https://github.com/\(repo)", + "--url", target.registrationURL, "--token", registrationToken, "--name", name, "--unattended", @@ -97,7 +118,9 @@ class RunnerInstaller { print("Runner configured successfully") } - /// One-click setup: Download, configure, and register runner + /// One-click setup: Download, configure, and register runner. + /// + /// Legacy repo-only overload — defaults the runner target to repository scope. @discardableResult func setupRunner( repo: String, @@ -106,6 +129,25 @@ class RunnerInstaller { labels: [String], runnerId: UUID, isolation: IsolationMode = .none + ) async throws -> String { + try await setupRunner( + target: RunnerTarget(scope: .repo, identifier: repo), + registrationToken: registrationToken, + name: name, + labels: labels, + runnerId: runnerId, + isolation: isolation + ) + } + + @discardableResult + func setupRunner( + target: RunnerTarget, + registrationToken: String, + name: String, + labels: [String], + runnerId: UUID, + isolation: IsolationMode = .none ) async throws -> String { let directory = try RunnerDirectory.path(for: runnerId, isolation: isolation) @@ -120,7 +162,7 @@ class RunnerInstaller { // Configure with GitHub try await configureRunner( at: directory, - repo: repo, + target: target, registrationToken: registrationToken, name: name, labels: labels, diff --git a/Sources/Services/RunnerManager.swift b/Sources/Services/RunnerManager.swift index a748d2e..6c460bb 100644 --- a/Sources/Services/RunnerManager.swift +++ b/Sources/Services/RunnerManager.swift @@ -414,7 +414,8 @@ class RunnerManager: ObservableObject { /// /// - Parameters: /// - name: Unique name for the runner - /// - repo: GitHub repository in "owner/repo" format + /// - repo: Target identifier — "owner/repo" for `.repo` scope, "org" for `.org` scope + /// - scope: Repository (default) or organization-level runner /// - labels: Labels to assign to the runner for workflow targeting /// - isolationMode: Optional isolation mode override (nil uses global setting) /// - enableGUI: Whether to enable GUI access for this runner (default: false, headless) @@ -423,6 +424,7 @@ class RunnerManager: ObservableObject { func addRunner( name: String, repo: String, + scope: RunnerScope = .repo, labels: [String], isolationMode: IsolationMode? = nil, enableGUI: Bool = false, @@ -434,6 +436,7 @@ class RunnerManager: ObservableObject { let runner = Runner( name: name, repo: repo, + scope: scope, labels: labels, enabled: true, status: .stopped, @@ -443,28 +446,34 @@ class RunnerManager: ObservableObject { ) let effectiveIsolation = runner.effectiveIsolationMode(global: currentSettings.isolationMode) + let target = runner.target try await toolProvisioningService.ensureGitHubCLI(isolation: effectiveIsolation) try await validateGitHubAuth(for: runner, operation: "add runner") - // Validate repo access via gh CLI - guard try await ghService.validateRepo(repo) else { + // Validate target access via gh CLI + guard try await ghService.validateTarget(target) else { throw RunnerError.invalidRepo } - try await toolProvisioningService.provisionTools( - for: repo, - settings: currentSettings.tools, - isolation: effectiveIsolation - ) + // Tool provisioning currently inspects repository contents to detect ecosystems + // (Node, Python, etc.). Org-level runners have no single repo to inspect, so we + // skip the per-repo discovery step and rely on the global extraPackages list. + if scope == .repo { + try await toolProvisioningService.provisionTools( + for: repo, + settings: currentSettings.tools, + isolation: effectiveIsolation + ) + } // Get registration token from GitHub via gh CLI - let registrationToken = try await ghService.getRegistrationToken(for: repo) + let registrationToken = try await ghService.getRegistrationToken(for: target) // Download, configure, and install runner try await RunnerInstaller.shared.setupRunner( - repo: repo, + target: target, registrationToken: registrationToken, name: name, labels: labels, @@ -474,7 +483,7 @@ class RunnerManager: ObservableObject { // Look up the GitHub-assigned runner ID so we can delete it later var registeredRunner = runner - if let remoteRunners = try? await ghService.listRemoteRunners(for: repo), + if let remoteRunners = try? await ghService.listRemoteRunners(for: target), let match = remoteRunners.first(where: { $0.name == name }) { registeredRunner.githubRunnerId = match.id } @@ -503,7 +512,7 @@ class RunnerManager: ObservableObject { // Remove from GitHub via gh CLI if let runner = runners.first(where: { $0.id == id }) { if let ghId = runner.githubRunnerId { - try? await ghService.deleteRunner(repo: runner.repo, githubRunnerId: ghId) + try? await ghService.deleteRunner(target: runner.target, githubRunnerId: ghId) } } @@ -553,9 +562,9 @@ class RunnerManager: ObservableObject { // Ensure runner binary is downloaded and configured if needsRunnerSetup { try await validateGitHubAuth(for: runner, operation: "start runner") - let registrationToken = try await ghService.getRegistrationToken(for: runner.repo) + let registrationToken = try await ghService.getRegistrationToken(for: runner.target) try await RunnerInstaller.shared.setupRunner( - repo: runner.repo, + target: runner.target, registrationToken: registrationToken, name: runner.name, labels: runner.labels, @@ -588,9 +597,11 @@ class RunnerManager: ObservableObject { } // Get registration token for container configuration - let registrationToken = try await ghService.getRegistrationToken(for: runner.repo) + let registrationToken = try await ghService.getRegistrationToken(for: runner.target) - // Create container configuration + // Create container configuration. `repositoryURL` is the value passed to + // `config.sh --url` inside the container, so it must point at the org or + // repo depending on the runner's scope. let containerConfig = ContainerRunnerConfiguration( containerImage: nil, // Use default GitHub Actions runner image cpuCount: 2, @@ -598,7 +609,7 @@ class RunnerManager: ObservableObject { diskSizeInBytes: 4 * 1024 * 1024 * 1024, // 4 GiB enableNestedVirtualization: false, workspaceURL: URL(fileURLWithPath: runnerDir), - repositoryURL: runner.repo, + repositoryURL: runner.target.registrationURL, registrationToken: registrationToken, openFileLimit: runner.effectiveOpenFileLimit(global: currentSettings.openFileLimit) ) @@ -809,16 +820,18 @@ class RunnerManager: ObservableObject { /// Groups runners by repository to minimize API calls, then updates the isBusy flag /// for each running runner based on whether it's currently executing a workflow. private func updateRunnerStatuses() async { - // Group runners by repo to minimize API calls - let runnersByRepo = Dictionary(grouping: runners) { $0.repo } + // Group runners by target (scope + identifier) to minimize API calls. We can't + // group purely by `repo` string: an org-level runner and a repo-level runner can + // share an identifier prefix, and the GitHub API endpoints differ by scope. + let runnersByTarget = Dictionary(grouping: runners) { $0.target } - for (repo, runnersInRepo) in runnersByRepo { + for (target, runnersInTarget) in runnersByTarget { // Only check runners that are currently running - let runningRunners = runnersInRepo.filter { $0.status == .running } + let runningRunners = runnersInTarget.filter { $0.status == .running } guard !runningRunners.isEmpty else { continue } // Fetch remote runner status from GitHub - guard let remoteRunners = try? await ghService.listRemoteRunners(for: repo) else { + guard let remoteRunners = try? await ghService.listRemoteRunners(for: target) else { continue } @@ -905,10 +918,11 @@ class RunnerManager: ObservableObject { pendingRunnerNames.insert(newName) defer { pendingRunnerNames.remove(newName) } - // Create duplicate with same settings, preserving isolation mode, GUI access, and resource limits + // Create duplicate with same settings, preserving scope, isolation mode, GUI access, and resource limits try await addRunner( name: newName, repo: originalRunner.repo, + scope: originalRunner.scope, labels: originalRunner.labels, isolationMode: originalRunner.isolationMode, enableGUI: originalRunner.enableGUI, @@ -938,6 +952,7 @@ class RunnerManager: ObservableObject { func addRunners( baseName: String, repo: String, + scope: RunnerScope = .repo, labels: [String], count: Int, isolationMode: IsolationMode? = nil, @@ -952,6 +967,7 @@ class RunnerManager: ObservableObject { try await addRunner( name: baseName, repo: repo, + scope: scope, labels: labels, isolationMode: isolationMode, enableGUI: enableGUI, @@ -984,6 +1000,7 @@ class RunnerManager: ObservableObject { try await addRunner( name: name, repo: repo, + scope: scope, labels: labels, isolationMode: isolationMode, enableGUI: enableGUI, @@ -1163,11 +1180,16 @@ class RunnerManager: ObservableObject { private func restoreActiveJobState(for runner: Runner) async { guard activeWorkflowJobs[runner.id] == nil else { return } + // Workflow runs are scoped to a specific repository in the GitHub API. + // Org-level runners would require scanning every repo in the org, which + // we don't do here — leave the active job indicator empty. + guard runner.scope == .repo else { return } activeWorkflowJobs[runner.id] = try? await ghService.currentJob(for: runner.repo, runnerName: runner.name) } private func handleJobStarted(for runner: Runner) async { guard activeWorkflowJobs[runner.id] == nil else { return } + guard runner.scope == .repo else { return } guard let job = try? await ghService.currentJob(for: runner.repo, runnerName: runner.name) else { return } @@ -1182,11 +1204,16 @@ class RunnerManager: ObservableObject { guard let activeJob = activeWorkflowJobs[runner.id] else { return } defer { activeWorkflowJobs.removeValue(forKey: runner.id) } - let completedJob = try? await ghService.completedJob( - for: runner.repo, - runnerName: runner.name, - runID: activeJob.run.id - ) + let completedJob: WorkflowJobSummary? + if runner.scope == .repo { + completedJob = try? await ghService.completedJob( + for: runner.repo, + runnerName: runner.name, + runID: activeJob.run.id + ) + } else { + completedJob = nil + } if currentSettings.notificationsEnabled { await jobNotificationService.notify( diff --git a/Sources/Views/AddRunnerView.swift b/Sources/Views/AddRunnerView.swift index abf0c9d..70c3969 100644 --- a/Sources/Views/AddRunnerView.swift +++ b/Sources/Views/AddRunnerView.swift @@ -5,12 +5,14 @@ struct AddRunnerView: View { @Environment(\.dismiss) var dismiss @State private var repo = "" + @State private var scope: RunnerScope = .repo @State private var name = "" @State private var labelsText = "macos, mac-runner" @State private var selectedIsolation: IsolationSelection = .global @State private var enableGUI = false @State private var openFileLimitText = "" @State private var repoSections: [(header: String, repos: [String])] = [] + @State private var orgs: [String] = [] @State private var repoSearchText = "" @State private var isLoadingRepos = false @State private var showRepoPicker = false @@ -68,14 +70,44 @@ struct AddRunnerView: View { // Form ScrollView { VStack(alignment: .leading, spacing: 16) { - // Repository + // Target Type (Repository vs Organization) VStack(alignment: .leading, spacing: 6) { - Text("Repository") + Text("Target Type") + .font(.subheadline) + .foregroundColor(.secondary) + + Picker("Target Type", selection: $scope) { + Text("Repository").tag(RunnerScope.repo) + Text("Organization").tag(RunnerScope.org) + } + .pickerStyle(.segmented) + .onChange(of: scope) { _, _ in + // Clear the identifier so users don't accidentally register + // "omniaura/mac-runner" as an org or just "omniaura" as a repo. + repo = "" + showRepoPicker = false + } + + if scope == .org { + HStack(spacing: 6) { + Image(systemName: "info.circle") + .foregroundColor(.secondary) + .font(.caption) + Text("Requires org admin permission. Jobs from any repo in the org can target this runner.") + .font(.caption) + .foregroundColor(.secondary) + } + } + } + + // Target identifier (repo or org) + VStack(alignment: .leading, spacing: 6) { + Text(scope == .org ? "Organization" : "Repository") .font(.subheadline) .foregroundColor(.secondary) HStack { - TextField("owner/repo", text: $repo) + TextField(scope == .org ? "org-login" : "owner/repo", text: $repo) .textFieldStyle(.roundedBorder) if isLoadingRepos { @@ -88,71 +120,12 @@ struct AddRunnerView: View { } } - if showRepoPicker, !repoSections.isEmpty { - VStack(spacing: 0) { - TextField("Search repos...", text: $repoSearchText) - .textFieldStyle(.roundedBorder) - .font(.caption) - .padding(6) - - Divider() - - if filteredSections.isEmpty { - Text("No matching repos") - .font(.caption) - .foregroundColor(.secondary) - .padding(8) - } else { - ScrollView { - VStack(alignment: .leading, spacing: 0) { - ForEach(filteredSections, id: \.header) { section in - Text(section.header) - .font(.system(.caption2, design: .monospaced)) - .fontWeight(.semibold) - .foregroundColor(.secondary) - .padding(.horizontal, 8) - .padding(.top, 8) - .padding(.bottom, 4) - - ForEach(section.repos, id: \.self) { r in - Button(action: { - repo = r - showRepoPicker = false - repoSearchText = "" - }) { - HStack { - Text(r) - .font(.system(.caption, design: .monospaced)) - Spacer() - if r == repo { - Image(systemName: "checkmark") - .foregroundColor(.accentColor) - .font(.caption) - } - } - .contentShape(Rectangle()) - .padding(.vertical, 4) - .padding(.horizontal, 8) - } - .buttonStyle(.plain) - } - - if section.header != filteredSections.last?.header { - Divider() - .padding(.top, 4) - } - } - } - } - .frame(maxHeight: 200) - } + if showRepoPicker { + if scope == .repo, !repoSections.isEmpty { + repoPickerList + } else if scope == .org, !orgs.isEmpty { + orgPickerList } - .background(Color.gray.opacity(0.1)) - .cornerRadius(6) - .overlay( - RoundedRectangle(cornerRadius: 6) - .stroke(Color.gray.opacity(0.3), lineWidth: 1) - ) } } @@ -303,12 +276,143 @@ struct AddRunnerView: View { .frame(width: 400, height: 620) } + @ViewBuilder + private var repoPickerList: some View { + VStack(spacing: 0) { + TextField("Search repos...", text: $repoSearchText) + .textFieldStyle(.roundedBorder) + .font(.caption) + .padding(6) + + Divider() + + if filteredSections.isEmpty { + Text("No matching repos") + .font(.caption) + .foregroundColor(.secondary) + .padding(8) + } else { + ScrollView { + VStack(alignment: .leading, spacing: 0) { + ForEach(filteredSections, id: \.header) { section in + Text(section.header) + .font(.system(.caption2, design: .monospaced)) + .fontWeight(.semibold) + .foregroundColor(.secondary) + .padding(.horizontal, 8) + .padding(.top, 8) + .padding(.bottom, 4) + + ForEach(section.repos, id: \.self) { r in + Button(action: { + repo = r + showRepoPicker = false + repoSearchText = "" + }) { + HStack { + Text(r) + .font(.system(.caption, design: .monospaced)) + Spacer() + if r == repo { + Image(systemName: "checkmark") + .foregroundColor(.accentColor) + .font(.caption) + } + } + .contentShape(Rectangle()) + .padding(.vertical, 4) + .padding(.horizontal, 8) + } + .buttonStyle(.plain) + } + + if section.header != filteredSections.last?.header { + Divider() + .padding(.top, 4) + } + } + } + } + .frame(maxHeight: 200) + } + } + .background(Color.gray.opacity(0.1)) + .cornerRadius(6) + .overlay( + RoundedRectangle(cornerRadius: 6) + .stroke(Color.gray.opacity(0.3), lineWidth: 1) + ) + } + + @ViewBuilder + private var orgPickerList: some View { + let filtered = repoSearchText.isEmpty + ? orgs + : orgs.filter { $0.lowercased().contains(repoSearchText.lowercased()) } + + VStack(spacing: 0) { + TextField("Search orgs...", text: $repoSearchText) + .textFieldStyle(.roundedBorder) + .font(.caption) + .padding(6) + + Divider() + + if filtered.isEmpty { + Text("No matching orgs") + .font(.caption) + .foregroundColor(.secondary) + .padding(8) + } else { + ScrollView { + VStack(alignment: .leading, spacing: 0) { + ForEach(filtered, id: \.self) { org in + Button(action: { + repo = org + showRepoPicker = false + repoSearchText = "" + }) { + HStack { + Text(org) + .font(.system(.caption, design: .monospaced)) + Spacer() + if org == repo { + Image(systemName: "checkmark") + .foregroundColor(.accentColor) + .font(.caption) + } + } + .contentShape(Rectangle()) + .padding(.vertical, 4) + .padding(.horizontal, 8) + } + .buttonStyle(.plain) + } + } + } + .frame(maxHeight: 200) + } + } + .background(Color.gray.opacity(0.1)) + .cornerRadius(6) + .overlay( + RoundedRectangle(cornerRadius: 6) + .stroke(Color.gray.opacity(0.3), lineWidth: 1) + ) + } + private func loadRepos() async { isLoadingRepos = true defer { isLoadingRepos = false } errorMessage = nil do { + if scope == .org { + orgs = try await GHCLIService.shared.listOrgs().sorted() + showRepoPicker = true + return + } + let allRepos = try await GHCLIService.shared.listAllRepos() var sections: [(header: String, repos: [String])] = [] @@ -355,10 +459,26 @@ struct AddRunnerView: View { return } + // Surface obvious format mistakes before hitting the network. + let trimmedRepo = repo.trimmingCharacters(in: .whitespacesAndNewlines) + switch scope { + case .repo: + guard trimmedRepo.contains("/") else { + errorMessage = "Repository must be in 'owner/repo' format." + return + } + case .org: + guard !trimmedRepo.isEmpty, !trimmedRepo.contains("/") else { + errorMessage = "Organization must be the org login only (no slashes)." + return + } + } + do { try await runnerManager.addRunners( baseName: baseName, - repo: repo, + repo: trimmedRepo, + scope: scope, labels: labels.isEmpty ? ["macos"] : labels, count: numberOfInstances, isolationMode: selectedIsolation.isolationMode, diff --git a/Sources/Views/MenuBarView.swift b/Sources/Views/MenuBarView.swift index 60cf945..f5a6dfe 100644 --- a/Sources/Views/MenuBarView.swift +++ b/Sources/Views/MenuBarView.swift @@ -69,24 +69,27 @@ struct MenuBarView: View { .frame(maxHeight: .infinity) } - /// Runners grouped by repo ("owner/repo"), sorted alphabetically by repo then by runner name. - private var groupedRunners: [(repo: String, runners: [Runner])] { - let grouped = Dictionary(grouping: runnerManager.runners) { $0.repo } + /// Runners grouped by target (scope + identifier), sorted alphabetically by identifier + /// then by runner name. Grouping by target — not by `repo` string — keeps a repo-level + /// runner for "acme/api" separate from an org-level runner for "acme" if both exist. + private var groupedRunners: [(target: RunnerTarget, runners: [Runner])] { + let grouped = Dictionary(grouping: runnerManager.runners) { $0.target } return grouped - .map { (repo: $0.key, runners: $0.value.sorted { $0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending }) } - .sorted { $0.repo.localizedCaseInsensitiveCompare($1.repo) == .orderedAscending } + .map { (target: $0.key, runners: $0.value.sorted { $0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending }) } + .sorted { $0.target.identifier.localizedCaseInsensitiveCompare($1.target.identifier) == .orderedAscending } } private var runnerList: some View { ScrollView { VStack(alignment: .leading, spacing: 4) { - ForEach(groupedRunners, id: \.repo) { group in - // Section header + ForEach(groupedRunners, id: \.target) { group in + // Section header — org-level groups use a building icon to distinguish + // them from repo-level groups at a glance. HStack(spacing: 4) { - Image(systemName: "folder") + Image(systemName: group.target.scope == .org ? "building.2" : "folder") .font(.caption2) .foregroundColor(.secondary) - Text(group.repo) + Text(group.target.displayName) .font(.caption) .fontWeight(.semibold) .foregroundColor(.secondary) @@ -264,7 +267,7 @@ struct RunnerRow: View { } } - Text(runner.repo) + Text(runner.target.displayName) .font(.caption) .foregroundColor(.secondary) diff --git a/Tests/MacRunnerTests/RunnerModelTests.swift b/Tests/MacRunnerTests/RunnerModelTests.swift index 667dea2..6ce2fc6 100644 --- a/Tests/MacRunnerTests/RunnerModelTests.swift +++ b/Tests/MacRunnerTests/RunnerModelTests.swift @@ -219,6 +219,77 @@ final class RunnerModelTests: XCTestCase { XCTAssertEqual(runner.effectiveOpenFileLimit(global: 131072), 131072) } + // MARK: - Scope Tests + + func testRunnerDefaultScopeIsRepo() { + let runner = Runner(name: "r", repo: "owner/repo") + XCTAssertEqual(runner.scope, .repo) + XCTAssertEqual(runner.target.scope, .repo) + XCTAssertEqual(runner.target.identifier, "owner/repo") + } + + func testRunnerOrgScopeTarget() { + let runner = Runner(name: "r", repo: "acme", scope: .org) + XCTAssertEqual(runner.scope, .org) + XCTAssertEqual(runner.target.scope, .org) + XCTAssertEqual(runner.target.identifier, "acme") + XCTAssertEqual(runner.target.apiPath, "orgs/acme") + XCTAssertEqual(runner.target.registrationURL, "https://github.com/acme") + XCTAssertEqual(runner.target.displayName, "acme (org)") + } + + func testRunnerRepoScopeTarget() { + let runner = Runner(name: "r", repo: "acme/api") + XCTAssertEqual(runner.target.apiPath, "repos/acme/api") + XCTAssertEqual(runner.target.registrationURL, "https://github.com/acme/api") + XCTAssertEqual(runner.target.displayName, "acme/api") + } + + func testRunnerEncodingIncludesScopeWhenOrg() throws { + let runner = Runner(name: "r", repo: "acme", scope: .org) + let data = try JSONEncoder().encode(runner) + let json = try JSONSerialization.jsonObject(with: data) as? [String: Any] + XCTAssertEqual(json?["scope"] as? String, "org") + XCTAssertEqual(json?["repo"] as? String, "acme") + } + + func testRunnerDecodingDefaultsScopeToRepoForLegacyConfigs() throws { + // Legacy config written before scope existed. + let json = """ + { + "id": "00000000-0000-0000-0000-000000000001", + "name": "legacy", + "repo": "owner/repo", + "labels": ["macos"], + "enabled": true, + "status": "stopped" + } + """.data(using: .utf8)! + + let runner = try JSONDecoder().decode(Runner.self, from: json) + XCTAssertEqual(runner.scope, .repo) + XCTAssertEqual(runner.target.apiPath, "repos/owner/repo") + } + + func testRunnerDecodingOrgScope() throws { + let json = """ + { + "id": "00000000-0000-0000-0000-000000000002", + "name": "org-runner", + "repo": "acme", + "scope": "org", + "labels": ["macos"], + "enabled": true, + "status": "running" + } + """.data(using: .utf8)! + + let runner = try JSONDecoder().decode(Runner.self, from: json) + XCTAssertEqual(runner.scope, .org) + XCTAssertEqual(runner.repo, "acme") + XCTAssertEqual(runner.target.apiPath, "orgs/acme") + } + // MARK: - Runner Status Tests func testRunnerStatusIcon() {