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 {
55 let build: Build
66 let log: String
77 let lines: BuildLog
8 /// Fetched with `--tail`.
9 let tail: Bool
810
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) {
1014 self.build = build
1115 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)
1323 }
1424}
1525
@@ -21,6 +31,10 @@ nonisolated struct BuildDetail: Sendable {
2131final class BuildDetailViewModel {
2232
2333 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
2438 private(set) var actionError: String?
2539 private(set) var working = false
2640
@@ -38,7 +52,10 @@ final class BuildDetailViewModel {
3852
3953 func load() async {
4054 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)
4259 } catch {
4360 state = .from(error)
4461 }
@@ -48,8 +65,19 @@ final class BuildDetailViewModel {
4865 let ref = [repoPath, String(number)]
4966 async let build = client.read(["build", "show"] + ref, as: Build.self)
5067 // 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) ?? "")
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()
5381 }
5482
5583 static func isLive(status: String) -> Bool {
@@ -61,7 +89,7 @@ final class BuildDetailViewModel {
6189 func poll() async {
6290 while let build = state.value?.build, Self.isLive(status: build.status) {
6391 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 {
6593 state = .loaded(detail)
6694 }
6795 }
gitbay/Views/Builds/BuildDetailView.swift +22 −2
@@ -29,8 +29,17 @@ struct BuildDetailView: View {
2929 .padding(.vertical, 8)
3030 }
3131 header(detail.build)
32 if !detail.log.isEmpty {
33 jumpBar(failed: detail.lines.failed != nil)
32 if !detail.log.isEmpty || model.tail {
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)
3443 }
3544 Divider()
3645 log(detail)
@@ -91,6 +100,17 @@ struct BuildDetailView: View {
91100 }
92101 .accessibilityIdentifier("build-jump-end")
93102 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 }
94114 }
95115 .font(.gbSans(.caption))
96116 .foregroundStyle(Color.gbAccent)
gitbayTests/BuildLogTests.swift +68
@@ -192,3 +192,71 @@ struct BuildPollingTests {
192192 #expect(model.state.value?.log == "a")
193193 }
194194}
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}