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