A build log's last lines only !161
3 files changed, +124 −8
Layout: unified · split
gitbay/Builds/BuildDetailViewModel.swift +34 −6
| @@ -5,11 +5,21 @@ nonisolated struct BuildDetail: Sendable { | ||
| 5 | 5 | let build: Build |
| 6 | 6 | let log: String |
| 7 | 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 | 14 | self.build = build |
| 11 | 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 { | ||
| 21 | 31 | final class BuildDetailViewModel { |
| 22 | 32 | |
| 23 | 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 | 38 | private(set) var actionError: String? |
| 25 | 39 | private(set) var working = false |
| 26 | 40 | |
| @@ -38,7 +52,10 @@ final class BuildDetailViewModel { | ||
| 38 | 52 | |
| 39 | 53 | func load() async { |
| 40 | 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 | 59 | } catch { |
| 43 | 60 | state = .from(error) |
| 44 | 61 | } |
| @@ -48,8 +65,19 @@ final class BuildDetailViewModel { | ||
| 48 | 65 | let ref = [repoPath, String(number)] |
| 49 | 66 | async let build = client.read(["build", "show"] + ref, as: Build.self) |
| 50 | 67 | // 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() | |
| 53 | 81 | } |
| 54 | 82 | |
| 55 | 83 | static func isLive(status: String) -> Bool { |
| @@ -61,7 +89,7 @@ final class BuildDetailViewModel { | ||
| 61 | 89 | func poll() async { |
| 62 | 90 | while let build = state.value?.build, Self.isLive(status: build.status) { |
| 63 | 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 | 93 | state = .loaded(detail) |
| 66 | 94 | } |
| 67 | 95 | } |
gitbay/Views/Builds/BuildDetailView.swift +22 −2
| @@ -29,8 +29,17 @@ struct BuildDetailView: View { | ||
| 29 | 29 | .padding(.vertical, 8) |
| 30 | 30 | } |
| 31 | 31 | 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) | |
| 34 | 43 | } |
| 35 | 44 | Divider() |
| 36 | 45 | log(detail) |
| @@ -91,6 +100,17 @@ struct BuildDetailView: View { | ||
| 91 | 100 | } |
| 92 | 101 | .accessibilityIdentifier("build-jump-end") |
| 93 | 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 | 115 | .font(.gbSans(.caption)) |
| 96 | 116 | .foregroundStyle(Color.gbAccent) |
gitbayTests/BuildLogTests.swift +68
| @@ -192,3 +192,71 @@ struct BuildPollingTests { | ||
| 192 | 192 | #expect(model.state.value?.log == "a") |
| 193 | 193 | } |
| 194 | 194 | } |
| 195 | ||
| 196 | private 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 | ||
| 202 | private 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 | |
| 208 | struct 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 | } | |