gitbay/Builds/BuildDetailViewModel.swift

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

128 lines · 4251 bytes

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