gitbay/Builds/BuildDetailViewModel.swift
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}