gitbay/Builds/BuildDetailViewModel.swift

v1.7.0
gitbay-ios/gitbay/Builds/BuildDetailViewModel.swift history · blame · raw

100 lines · 3258 bytes

  1import Foundation
  2import Observation
  3
  4nonisolated struct BuildDetail: Sendable {
  5    let build: Build
  6    let log: String
  7    let lines: BuildLog
  8
  9    init(build: Build, log: String) {
 10        self.build = build
 11        self.log = log
 12        self.lines = BuildLog(log, build: build)
 13    }
 14}
 15
 16/// One build: `build show` for what it was, `build log` for what it did.
 17/// The web's build page shows both; the log alone leaves the screen
 18/// unable to say whether the build passed.
 19@Observable
 20@MainActor
 21final class BuildDetailViewModel {
 22
 23    private(set) var state: LoadState<BuildDetail> = .loading
 24    private(set) var actionError: String?
 25    private(set) var working = false
 26
 27    private let client: GitbayClient
 28    let repoPath: String
 29    let number: Int64
 30    private let pollInterval: Duration
 31
 32    init(client: GitbayClient, repoPath: String, number: Int64, pollInterval: Duration = .seconds(3)) {
 33        self.client = client
 34        self.repoPath = repoPath
 35        self.number = number
 36        self.pollInterval = pollInterval
 37    }
 38
 39    func load() async {
 40        do {
 41            state = .loaded(try await fetch())
 42        } catch {
 43            state = .from(error)
 44        }
 45    }
 46
 47    private func fetch() async throws -> BuildDetail {
 48        let ref = [repoPath, String(number)]
 49        async let build = client.read(["build", "show"] + ref, as: Build.self)
 50        // A queued build has no log yet; that is not a failure.
 51        async let log = client.readText(["build", "log"] + ref)
 52        return BuildDetail(build: try await build, log: (try? await log) ?? "")
 53    }
 54
 55    static func isLive(status: String) -> Bool {
 56        status == "pending" || status == "queued" || status == "running"
 57    }
 58
 59    /// Reload every `pollInterval` until the build finishes or the task is
 60    /// cancelled. A failed reload keeps what is on screen and tries again.
 61    func poll() async {
 62        while let build = state.value?.build, Self.isLive(status: build.status) {
 63            guard (try? await Task.sleep(for: pollInterval)) != nil else { return }
 64            if let detail = try? await fetch() {
 65                state = .loaded(detail)
 66            }
 67        }
 68    }
 69
 70    /// Only `pending` or `running` builds can be cancelled server-side
 71    /// (`internal/control/build.go:750`). The refusal message there says
 72    /// "queued", but the wire status is always "pending" — matching on
 73    /// "queued" would hide this action forever with no error.
 74    static func isCancellable(status: String) -> Bool {
 75        status == "pending" || status == "running"
 76    }
 77
 78    var isCancellable: Bool {
 79        guard let build = state.value?.build else { return false }
 80        return Self.isCancellable(status: build.status)
 81    }
 82
 83    func cancel() async {
 84        await perform(["build", "cancel", repoPath, String(number)])
 85    }
 86
 87    private func perform(_ argv: [String]) async {
 88        working = true
 89        actionError = nil
 90        defer { working = false }
 91        do {
 92            try await client.run(argv)
 93            await load()
 94        } catch let error as GitbayError {
 95            actionError = error.userFacingMessage
 96        } catch {
 97            actionError = GitbayError.transport(error).userFacingMessage
 98        }
 99    }
100}