import Foundation import Observation nonisolated struct BuildDetail: Sendable { let build: Build let log: String let lines: BuildLog init(build: Build, log: String) { self.build = build self.log = log self.lines = BuildLog(log, build: build) } } /// 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 private let pollInterval: Duration init(client: GitbayClient, repoPath: String, number: Int64, pollInterval: Duration = .seconds(3)) { self.client = client self.repoPath = repoPath self.number = number self.pollInterval = pollInterval } func load() async { do { state = .loaded(try await fetch()) } catch { state = .from(error) } } private func fetch() async throws -> BuildDetail { let ref = [repoPath, String(number)] 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) return BuildDetail(build: try await build, log: (try? await log) ?? "") } static func isLive(status: String) -> Bool { status == "pending" || status == "queued" || status == "running" } /// Reload every `pollInterval` until the build finishes or the task is /// cancelled. A failed reload keeps what is on screen and tries again. func poll() async { while let build = state.value?.build, Self.isLive(status: build.status) { guard (try? await Task.sleep(for: pollInterval)) != nil else { return } if let detail = try? await fetch() { state = .loaded(detail) } } } /// 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 } } }