A build log's last lines only !161

merged merged by cmc on 2026-10-14 06:11 UTC · krz/gitbay-ios:build-log-tail into main

3 files changed, +124 −8

Layout: unified · split

gitbay/Builds/BuildDetailViewModel.swift +34 −6
@@ -5,11 +5,21 @@ nonisolated struct BuildDetail: Sendable {
5 let build: Build 5 let build: Build
6 let log: String 6 let log: String
7 let lines: BuildLog 7 let lines: BuildLog
8 /// Fetched with `--tail`.
9 let tail: Bool
8 10
9 init(build: Build, log: String) { 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) {
10 self.build = build 14 self.build = build
11 self.log = log 15 self.log = log
12 self.lines = BuildLog(log, build: build) 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)
13 } 23 }
14} 24}
15 25
@@ -21,6 +31,10 @@ nonisolated struct BuildDetail: Sendable {
21final class BuildDetailViewModel { 31final class BuildDetailViewModel {
22 32
23 private(set) var state: LoadState<BuildDetail> = .loading 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
24 private(set) var actionError: String? 38 private(set) var actionError: String?
25 private(set) var working = false 39 private(set) var working = false
26 40
@@ -38,7 +52,10 @@ final class BuildDetailViewModel {
38 52
39 func load() async { 53 func load() async {
40 do { 54 do {
41 state = .loaded(try await fetch()) 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)
42 } catch { 59 } catch {
43 state = .from(error) 60 state = .from(error)
44 } 61 }
@@ -48,8 +65,19 @@ final class BuildDetailViewModel {
48 let ref = [repoPath, String(number)] 65 let ref = [repoPath, String(number)]
49 async let build = client.read(["build", "show"] + ref, as: Build.self) 66 async let build = client.read(["build", "show"] + ref, as: Build.self)
50 // A queued build has no log yet; that is not a failure. 67 // A queued build has no log yet; that is not a failure.
51 async let log = client.readText(["build", "log"] + ref) 68 let tail = self.tail
52 return BuildDetail(build: try await build, log: (try? await log) ?? "") 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()
53 } 81 }
54 82
55 static func isLive(status: String) -> Bool { 83 static func isLive(status: String) -> Bool {
@@ -61,7 +89,7 @@ final class BuildDetailViewModel {
61 func poll() async { 89 func poll() async {
62 while let build = state.value?.build, Self.isLive(status: build.status) { 90 while let build = state.value?.build, Self.isLive(status: build.status) {
63 guard (try? await Task.sleep(for: pollInterval)) != nil else { return } 91 guard (try? await Task.sleep(for: pollInterval)) != nil else { return }
64 if let detail = try? await fetch() { 92 if let detail = try? await fetch(), detail.tail == tail {
65 state = .loaded(detail) 93 state = .loaded(detail)
66 } 94 }
67 } 95 }
gitbay/Views/Builds/BuildDetailView.swift +22 −2
@@ -29,8 +29,17 @@ struct BuildDetailView: View {
29 .padding(.vertical, 8) 29 .padding(.vertical, 8)
30 } 30 }
31 header(detail.build) 31 header(detail.build)
32 if !detail.log.isEmpty { 32 if !detail.log.isEmpty || model.tail {
33 jumpBar(failed: detail.lines.failed != nil) 33 // The tail is one block; its failure is the top of it.
34 jumpBar(failed: detail.lines.failed != nil && !model.tail)
35 }
36 if model.tail {
37 Text("The last \(BuildDetailViewModel.tailLines) lines, without the steps.")
38 .font(.gbSans(.caption))
39 .foregroundStyle(.secondary)
40 .frame(maxWidth: .infinity, alignment: .leading)
41 .padding(.horizontal, 16)
42 .padding(.bottom, 8)
34 } 43 }
35 Divider() 44 Divider()
36 log(detail) 45 log(detail)
@@ -91,6 +100,17 @@ struct BuildDetailView: View {
91 } 100 }
92 .accessibilityIdentifier("build-jump-end") 101 .accessibilityIdentifier("build-jump-end")
93 Spacer() 102 Spacer()
103 if model.tail {
104 Button("Whole log") {
105 Task { await model.showWholeLog() }
106 }
107 .accessibilityIdentifier("build-log-whole")
108 } else {
109 Button("Last \(BuildDetailViewModel.tailLines) lines") {
110 Task { await model.showTail() }
111 }
112 .accessibilityIdentifier("build-log-tail")
113 }
94 } 114 }
95 .font(.gbSans(.caption)) 115 .font(.gbSans(.caption))
96 .foregroundStyle(Color.gbAccent) 116 .foregroundStyle(Color.gbAccent)
gitbayTests/BuildLogTests.swift +68
@@ -192,3 +192,71 @@ struct BuildPollingTests {
192 #expect(model.state.value?.log == "a") 192 #expect(model.state.value?.log == "a")
193 } 193 }
194} 194}
195
196private let buildShowJSON = """
197 {"protocol_version":1,"data":{"number":1,"job":"test","status":"failure","sha":"abc",\
198 "ref":"main","created_at":"2026-08-20T10:00:00.000Z","steps":["go build ./...","go test ./..."],\
199 "failed_step":2},"exit_code":0}
200 """
201
202private func logEnvelope(_ text: String) -> String {
203 let data = try! JSONSerialization.data(withJSONObject: ["protocol_version": 1, "output": text, "exit_code": 0])
204 return String(decoding: data, as: UTF8.self)
205}
206
207@MainActor
208struct BuildLogTailTests {
209
210 @Test func theTailReadsTheLastLinesAndDrawsThemUnsplit() async throws {
211 let (client, stub) = try makeClient()
212 stub.enqueue(.init(status: 200, json: buildShowJSON, match: "argv=show"))
213 stub.enqueue(.init(status: 200, json: logEnvelope(failedLog), match: "argv=log"))
214 stub.enqueue(.init(status: 200, json: buildShowJSON, match: "argv=show"))
215 stub.enqueue(.init(status: 200, json: logEnvelope("--- FAIL: TestX\nstep 2/2 failed: exit 1\n"), match: "argv=log"))
216 let model = BuildDetailViewModel(client: client, repoPath: "krz/gitbay", number: 1)
217 await model.load()
218 #expect(model.state.value?.lines.stepped == true)
219
220 await model.showTail()
221
222 let log = try #require(stub.seen.last { $0.url.absoluteString.contains("argv=log") })
223 #expect(log.url.query()?.hasSuffix("argv=--tail&argv=\(BuildDetailViewModel.tailLines)") == true)
224 let detail = try #require(model.state.value)
225 #expect(model.tail)
226 // A step's marker may be cut off, so the tail is one plain block.
227 #expect(!detail.lines.stepped)
228 // A failed build's tail is where it failed.
229 #expect(detail.lines.failed == 0)
230 #expect(detail.lines.sections.first?.lines.map(\.text) == ["--- FAIL: TestX", "step 2/2 failed: exit 1"])
231 }
232
233 @Test func theWholeLogComesBackSplit() async throws {
234 let (client, stub) = try makeClient()
235 for _ in 0..<3 {
236 stub.enqueue(.init(status: 200, json: buildShowJSON, match: "argv=show"))
237 }
238 stub.enqueue(.init(status: 200, json: logEnvelope(failedLog), match: "argv=log"))
239 stub.enqueue(.init(status: 200, json: logEnvelope("step 2/2 failed: exit 1\n"), match: "argv=log"))
240 stub.enqueue(.init(status: 200, json: logEnvelope(failedLog), match: "argv=log"))
241 let model = BuildDetailViewModel(client: client, repoPath: "krz/gitbay", number: 1)
242 await model.load()
243 await model.showTail()
244
245 await model.showWholeLog()
246
247 #expect(!model.tail)
248 #expect(stub.seen.last { $0.url.absoluteString.contains("argv=log") }?.url.query()?.contains("tail") == false)
249 #expect(model.state.value?.lines.stepped == true)
250 }
251
252 @Test func aFetchFromTheOtherModeIsNotShown() async throws {
253 let (client, stub) = try makeClient()
254 stub.enqueue(.init(status: 200, json: buildShowJSON, match: "argv=show"))
255 stub.enqueue(.init(status: 200, json: logEnvelope(failedLog), match: "argv=log"))
256 let model = BuildDetailViewModel(client: client, repoPath: "krz/gitbay", number: 1)
257 await model.load()
258 // The detail records the mode it was fetched in.
259 #expect(model.state.value?.tail == false)
260 #expect(BuildDetail(build: try #require(model.state.value).build, log: "x\n", tail: true).tail)
261 }
262}