import Foundation import Observation nonisolated struct BuildDetail: Sendable { let build: Build let log: String } /// One build: `build show` for what it was, `build log` for what it did. /// The web's build page shows both; the log alone leaves the screen /// unable to say whether the build passed. @Observable @MainActor final class BuildDetailViewModel { private(set) var state: LoadState = .loading private(set) var actionError: String? private(set) var working = false private let client: GitbayClient let repoPath: String let number: Int64 init(client: GitbayClient, repoPath: String, number: Int64) { self.client = client self.repoPath = repoPath self.number = number } func load() async { let ref = [repoPath, String(number)] do { async let build = client.read(["build", "show"] + ref, as: Build.self) // A queued build has no log yet; that is not a failure. async let log = client.readText(["build", "log"] + ref) state = .loaded(BuildDetail(build: try await build, log: (try? await log) ?? "")) } catch { state = .from(error) } } /// Only `pending` or `running` builds can be cancelled server-side /// (`internal/control/build.go:750`). The refusal message there says /// "queued", but the wire status is always "pending" — matching on /// "queued" would hide this action forever with no error. static func isCancellable(status: String) -> Bool { status == "pending" || status == "running" } var isCancellable: Bool { guard let build = state.value?.build else { return false } return Self.isCancellable(status: build.status) } func cancel() async { await perform(["build", "cancel", repoPath, String(number)]) } private func perform(_ argv: [String]) async { working = true actionError = nil defer { working = false } do { try await client.run(argv) await load() } catch let error as GitbayError { actionError = error.userFacingMessage } catch { actionError = GitbayError.transport(error).userFacingMessage } } }