import Foundation import Observation /// `repo secret list`, `set` and `remove`. Names only: a value goes to /// the server on stdin and never comes back. @Observable @MainActor final class BuildSecretsViewModel { private(set) var state: LoadState<[String]> = .loading private(set) var actionError: String? private(set) var working = false private let client: GitbayClient let repoPath: String init(client: GitbayClient, repoPath: String) { self.client = client self.repoPath = repoPath } func load() async { do { let names = try await client.readList(["repo", "secret", "list", repoPath], of: String.self) state = names.isEmpty ? .empty("No build secrets yet.") : .loaded(names) } catch { state = .from(error) } } /// Sets or replaces a secret. The name is the user's, so a refusal /// of it is shown as the server words it. func set(name: String, value: String) async -> Bool { let name = name.trimmingCharacters(in: .whitespaces) guard !name.isEmpty, !value.isEmpty else { return false } return await perform(["repo", "secret", "set", repoPath, name], stdin: value) } func remove(_ name: String) async { await perform(["repo", "secret", "remove", repoPath, name]) } @discardableResult private func perform(_ argv: [String], stdin: String? = nil) async -> Bool { working = true actionError = nil defer { working = false } do { try await client.run(argv, stdin: stdin) await load() return true } catch let error as GitbayError { actionError = error.messageForTypedInput } catch { actionError = GitbayError.transport(error).userFacingMessage } // A refusal may mean the list changed elsewhere, e.g. a row // removed from another device. await load() return false } } /// One row of `repo deploy-key list`. nonisolated struct DeployKey: Decodable, Sendable, Hashable, Identifiable { let fingerprint: String let algo: String /// `ro` or `rw`. let mode: String /// The key's comment; empty when it had none. let label: String var lastUsedAt: Date? = nil var expiresAt: Date? = nil var id: String { fingerprint } var canPush: Bool { mode == "rw" } enum CodingKeys: String, CodingKey { case fingerprint, algo, mode, label case lastUsedAt = "last_used_at" case expiresAt = "expires_at" } } /// `repo deploy-key list`, `add` and `remove`: keys bound to this one /// repository, read-only unless added with `--rw`. @Observable @MainActor final class DeployKeysViewModel { private(set) var state: LoadState<[DeployKey]> = .loading private(set) var actionError: String? private(set) var working = false private let client: GitbayClient let repoPath: String init(client: GitbayClient, repoPath: String) { self.client = client self.repoPath = repoPath } func load() async { do { let keys = try await client.readList(["repo", "deploy-key", "list", repoPath], of: DeployKey.self) state = keys.isEmpty ? .empty("No deploy keys yet.") : .loaded(keys) } catch { state = .from(error) } } /// `repo deploy-key add [--rw] [--ttl ] < key.pub`. func add(publicKey: String, canPush: Bool, ttl: String?) async -> Bool { let key = publicKey.trimmingCharacters(in: .whitespacesAndNewlines) guard !key.isEmpty else { return false } var argv = ["repo", "deploy-key", "add", repoPath] if canPush { argv.append("--rw") } if let ttl { argv += ["--ttl", ttl] } return await perform(argv, stdin: key) } func remove(_ key: DeployKey) async { await perform(["repo", "deploy-key", "remove", repoPath, key.fingerprint]) } @discardableResult private func perform(_ argv: [String], stdin: String? = nil) async -> Bool { working = true actionError = nil defer { working = false } do { try await client.run(argv, stdin: stdin) await load() return true } catch let error as GitbayError { actionError = error.messageForTypedInput } catch { actionError = GitbayError.transport(error).userFacingMessage } // A refusal may mean the list changed elsewhere, e.g. a row // removed from another device. await load() return false } }