a native ios client for gitbay

client ios swift

https://gitbay.org

builds: a detail screen and a job picker !20

merged cmc wants to merge krz/gitbay-ios:build-detail into main

11 files changed, +332 −117

gitbay/Builds/BuildDetailViewModel.swift added +39
@@ -0,0 +1,39 @@
1import Foundation
2import Observation
3
4nonisolated struct BuildDetail: Sendable {
5 let build: Build
6 let log: String
7}
8
9/// One build: `build show` for what it was, `build log` for what it did.
10/// The web's build page shows both; the log alone leaves the screen
11/// unable to say whether the build passed.
12@Observable
13@MainActor
14final class BuildDetailViewModel {
15
16 private(set) var state: LoadState<BuildDetail> = .loading
17
18 private let client: GitbayClient
19 let repoPath: String
20 let number: Int64
21
22 init(client: GitbayClient, repoPath: String, number: Int64) {
23 self.client = client
24 self.repoPath = repoPath
25 self.number = number
26 }
27
28 func load() async {
29 let ref = [repoPath, String(number)]
30 do {
31 async let build = client.read(["build", "show"] + ref, as: Build.self)
32 // A queued build has no log yet; that is not a failure.
33 async let log = client.readText(["build", "log"] + ref)
34 state = .loaded(BuildDetail(build: try await build, log: (try? await log) ?? ""))
35 } catch {
36 state = .from(error)
37 }
38 }
39}
gitbay/Builds/BuildListViewModel.swift +8 −3
@@ -7,6 +7,9 @@ import Observation
77 final class BuildListViewModel {
88
99 private(set) var state: LoadState<[Build]> = .loading
10 /// The jobs a trigger can name. Empty when the repo has no CI
11 /// config the web hides its trigger form in that case.
12 private(set) var jobs: [CIJob] = []
1013 private(set) var actionError: String?
1114 private(set) var working = false
1215
@@ -29,9 +32,11 @@ final class BuildListViewModel {
2932 }
3033 }
3134
32 /// The most recent job name, to prefill the trigger sheet.
33 var latestJob: String? {
34 state.value?.first?.job
35 /// `build jobs <owner/name>` what the picker offers. A repo
36 /// without a CI config answers "not found"; that means no jobs, not
37 /// a failure worth showing.
38 func loadJobs() async {
39 jobs = (try? await client.readList(["build", "jobs", repoPath], of: CIJob.self)) ?? []
3540 }
3641
3742 /// `build trigger <owner/name> <job>` queue a job now.
gitbay/Builds/BuildModels.swift +41
@@ -1,4 +1,5 @@
11 import Foundation
2import SwiftUI
23
34 /// One row of `build list`.
45 nonisolated struct Build: Decodable, Sendable, Hashable, Identifiable {
@@ -19,3 +20,43 @@ nonisolated struct Build: Decodable, Sendable, Hashable, Identifiable {
1920 var id: Int64 { number }
2021 var shortSHA: String { String(sha.prefix(10)) }
2122 }
23
24/// `build jobs` what a trigger can name. A repo with no CI config has
25/// none, which is an empty state rather than an error.
26nonisolated struct CIJob: Decodable, Sendable, Hashable, Identifiable {
27 let name: String
28 let schedule: String?
29 let tags: String?
30
31 var id: String { name }
32
33 /// Why this job runs, in the words the server uses.
34 var trigger: String {
35 if let schedule, !schedule.isEmpty { return "schedule \(schedule)" }
36 if let tags, !tags.isEmpty { return "tags \(tags)" }
37 return "on push"
38 }
39}
40
41extension Build {
42 /// The list row and the detail header must agree on what a status
43 /// looks like; the strings come from the registry.
44 var statusIcon: String {
45 switch status {
46 case "success": "checkmark.circle.fill"
47 case "failure", "error": "xmark.circle.fill"
48 case "running": "circle.dotted"
49 case "queued", "pending": "clock"
50 default: "questionmark.circle"
51 }
52 }
53
54 var statusColor: Color {
55 switch status {
56 case "success": .gbOK
57 case "failure", "error": .gbBad
58 case "running", "queued", "pending": .gbWarn
59 default: .secondary
60 }
61 }
62}
gitbay/ContentView.swift +2 −2
@@ -154,8 +154,8 @@ private struct RouteDestinations: ViewModifier {
154154 switch route {
155155 case .list(let repo):
156156 BuildListView(client: client, repo: repo)
157 case .log(let repo, let number):
158 BuildLogView(client: client, repo: repo, number: number)
157 case .detail(let repo, let number):
158 BuildDetailView(client: client, repo: repo, number: number)
159159 }
160160 }
161161 }
gitbay/Views/Builds/BuildDetailView.swift added +82
@@ -0,0 +1,82 @@
1import SwiftUI
2
3/// A build: what it was, then what it printed.
4struct BuildDetailView: View {
5
6 @State private var model: BuildDetailViewModel
7
8 init(client: GitbayClient, repo: String, number: Int64) {
9 _model = State(initialValue: BuildDetailViewModel(
10 client: client, repoPath: repo, number: number
11 ))
12 }
13
14 var body: some View {
15 ZStack {
16 Color.clear
17 if let detail = model.state.value {
18 VStack(spacing: 0) {
19 header(detail.build)
20 Divider()
21 log(detail.log)
22 }
23 }
24 }
25 .overlay { LoadStateOverlay(state: model.state) }
26 .navigationTitle("Build #\(model.number)")
27 .navigationBarTitleDisplayMode(.inline)
28 .task { await model.load() }
29 .refreshable { await model.load() }
30 }
31
32 private func header(_ build: Build) -> some View {
33 VStack(alignment: .leading, spacing: 6) {
34 HStack(spacing: 8) {
35 Text(build.job)
36 .font(.gbSans(.subheadline).weight(.medium))
37 GBChip(build.status, build.statusColor)
38 Spacer()
39 }
40 HStack(spacing: 6) {
41 Text(build.ref)
42 Text("·")
43 NavigationLink(value: RepoRoute.commit(repo: model.repoPath, sha: build.sha)) {
44 Text(build.shortSHA).font(.gbMono(.caption))
45 }
46 }
47 .font(.gbSans(.caption))
48 .foregroundStyle(.secondary)
49
50 Text(timing(build))
51 .font(.gbSans(.caption))
52 .foregroundStyle(.secondary)
53 }
54 .frame(maxWidth: .infinity, alignment: .leading)
55 .padding(.horizontal, 16)
56 .padding(.vertical, 12)
57 }
58
59 /// Queued always; finished only once it is.
60 private func timing(_ build: Build) -> String {
61 let queued = build.createdAt.formatted(date: .abbreviated, time: .shortened)
62 guard let finished = build.finishedAt else { return "queued \(queued)" }
63 return "queued \(queued) · finished \(finished.formatted(date: .omitted, time: .shortened))"
64 }
65
66 @ViewBuilder
67 private func log(_ text: String) -> some View {
68 if text.isEmpty {
69 ContentUnavailableView {
70 Label("No log yet", systemImage: "doc.text")
71 }
72 } else {
73 ScrollView([.horizontal, .vertical]) {
74 Text(text)
75 .font(.gbMono(.caption2))
76 .frame(maxWidth: .infinity, alignment: .leading)
77 .padding(12)
78 .textSelection(.enabled)
79 }
80 }
81 }
82}
gitbay/Views/Builds/BuildListView.swift +21 −41
@@ -3,8 +3,6 @@ import SwiftUI
33 struct BuildListView: View {
44
55 @State private var model: BuildListViewModel
6 @State private var triggering = false
7 @State private var jobName = ""
86
97 init(client: GitbayClient, repo: String) {
108 _model = State(initialValue: BuildListViewModel(client: client, repoPath: repo))
@@ -18,7 +16,7 @@ struct BuildListView: View {
1816 }
1917 }
2018 ForEach(model.state.value ?? []) { build in
21 NavigationLink(value: BuildRoute.log(repo: model.repoPath, number: build.number)) {
19 NavigationLink(value: BuildRoute.detail(repo: model.repoPath, number: build.number)) {
2220 BuildRow(build: build)
2321 }
2422 }
@@ -28,29 +26,29 @@ struct BuildListView: View {
2826 .navigationBarTitleDisplayMode(.inline)
2927 .toolbar {
3028 ToolbarItem(placement: .topBarTrailing) {
31 Button {
32 jobName = model.latestJob ?? ""
33 triggering = true
29 // A menu of jobs, like the web's picker not a name to
30 // type. Nothing to trigger without a job file.
31 Menu {
32 ForEach(model.jobs) { job in
33 Button("\(job.name)\(job.trigger)") {
34 Task { await model.trigger(job: job.name) }
35 }
36 }
3437 } label: {
3538 Image(systemName: "play.circle")
3639 }
37 .disabled(model.working)
40 .disabled(model.working || model.jobs.isEmpty)
3841 .accessibilityIdentifier("build-trigger-button")
3942 }
4043 }
41 .alert("Trigger a build", isPresented: $triggering) {
42 TextField("Job name", text: $jobName)
43 .autocorrectionDisabled()
44 .textInputAutocapitalization(.never)
45 Button("Trigger") {
46 Task { await model.trigger(job: jobName.trimmingCharacters(in: .whitespaces)) }
47 }
48 Button("Cancel", role: .cancel) {}
49 } message: {
50 Text("Runs the named job from .gitbay/ at the default branch head.")
44 .task {
45 await model.load()
46 await model.loadJobs()
47 }
48 .refreshable {
49 await model.load()
50 await model.loadJobs()
5151 }
52 .task { await model.load() }
53 .refreshable { await model.load() }
5452 }
5553 }
5654
@@ -59,8 +57,8 @@ private struct BuildRow: View {
5957
6058 var body: some View {
6159 HStack(spacing: 10) {
62 Image(systemName: icon)
63 .foregroundStyle(color)
60 Image(systemName: build.statusIcon)
61 .foregroundStyle(build.statusColor)
6462 VStack(alignment: .leading, spacing: 2) {
6563 Text("#\(build.number) \(build.job)")
6664 .font(.gbSans(.subheadline).weight(.medium))
@@ -76,7 +74,7 @@ private struct BuildRow: View {
7674 VStack(alignment: .trailing, spacing: 2) {
7775 Text(build.status)
7876 .font(.gbSans(.caption).weight(.medium))
79 .foregroundStyle(color)
77 .foregroundStyle(build.statusColor)
8078 Text(build.createdAt, format: .relative(presentation: .named))
8179 .font(.gbSans(.caption))
8280 .foregroundStyle(.secondary)
@@ -85,27 +83,9 @@ private struct BuildRow: View {
8583 .padding(.vertical, 2)
8684 }
8785
88 private var icon: String {
89 switch build.status {
90 case "success": "checkmark.circle.fill"
91 case "failure", "error": "xmark.circle.fill"
92 case "running": "circle.dotted"
93 case "queued", "pending": "clock"
94 default: "questionmark.circle"
95 }
96 }
97
98 private var color: Color {
99 switch build.status {
100 case "success": .gbOK
101 case "failure", "error": .gbBad
102 case "running", "queued", "pending": .gbWarn
103 default: .secondary
104 }
105 }
10686 }
10787
10888 nonisolated enum BuildRoute: Hashable {
10989 case list(repo: String)
110 case log(repo: String, number: Int64)
90 case detail(repo: String, number: Int64)
11191 }
gitbay/Views/Builds/BuildLogView.swift deleted −55
@@ -1,55 +0,0 @@
1import SwiftUI
2
3/// `build log` plain text, can be large. Monospaced, both-axis scroll.
4struct BuildLogView: View {
5
6 private let client: GitbayClient
7 private let repo: String
8 private let number: Int64
9 @State private var state: LoadState<String> = .loading
10
11 init(client: GitbayClient, repo: String, number: Int64) {
12 self.client = client
13 self.repo = repo
14 self.number = number
15 }
16
17 var body: some View {
18 ZStack {
19 Color.clear
20 if let log = state.value {
21 if log.isEmpty {
22 ContentUnavailableView {
23 Label("No log yet", systemImage: "doc.text")
24 }
25 } else {
26 ScrollView([.horizontal, .vertical]) {
27 Text(log)
28 .font(.gbMono(.caption2))
29 .frame(maxWidth: .infinity, alignment: .leading)
30 .padding(12)
31 .textSelection(.enabled)
32 }
33 }
34 }
35 }
36 .overlay { LoadStateOverlay(state: state) }
37 .navigationTitle("Build #\(number)")
38 .navigationBarTitleDisplayMode(.inline)
39 .task {
40 do {
41 state = .loaded(try await client.readText(["build", "log", repo, String(number)]))
42 } catch {
43 state = .from(error)
44 }
45 }
46 .refreshable {
47 state = .loading
48 do {
49 state = .loaded(try await client.readText(["build", "log", repo, String(number)]))
50 } catch {
51 state = .from(error)
52 }
53 }
54 }
55}
gitbay/Views/Discovery/FeedView.swift +1 −1
@@ -48,7 +48,7 @@ struct FeedLink: View {
4848 case .issue(let repo, let number):
4949 NavigationLink(value: IssueRoute.issue(repo: repo, number: number)) { FeedRow(event: event) }
5050 case .build(let repo, let number):
51 NavigationLink(value: BuildRoute.log(repo: repo, number: number)) { FeedRow(event: event) }
51 NavigationLink(value: BuildRoute.detail(repo: repo, number: number)) { FeedRow(event: event) }
5252 case .release(let repo, let tag):
5353 NavigationLink(value: ReleaseRoute.release(repo: repo, tag: tag)) { FeedRow(event: event) }
5454 case nil:
gitbayTests/IssueBuildViewModelTests.swift +84
@@ -160,3 +160,87 @@ struct BuildListViewModelTests {
160160 #expect(builds[1].finishedAt != nil)
161161 }
162162 }
163
164@MainActor
165struct BuildJobsTests {
166
167 @Test func offersTheJobsTheServerNames() async throws {
168 let (client, stub) = try makeClient()
169 stub.enqueue(.init(status: 200, json: """
170 {"protocol_version":1,"data":[{"name":"build"},\
171 {"name":"nightly","schedule":"0 3 * * *"},\
172 {"name":"release","tags":"v*"}],"exit_code":0}
173 """))
174 let model = BuildListViewModel(client: client, repoPath: "krz/gitbay")
175
176 await model.loadJobs()
177
178 #expect(model.jobs.map(\.name) == ["build", "nightly", "release"])
179 // The picker says why a job is not push-triggered.
180 #expect(model.jobs[0].trigger == "on push")
181 #expect(model.jobs[1].trigger == "schedule 0 3 * * *")
182 #expect(model.jobs[2].trigger == "tags v*")
183 #expect(stub.seen.first?.url.query() == "argv=build&argv=jobs&argv=krz/gitbay")
184 }
185
186 @Test func aRepoWithNoCIConfigHasNoJobsRatherThanAnError() async throws {
187 let (client, stub) = try makeClient()
188 stub.enqueue(.init(status: 404, json:
189 #"{"protocol_version":1,"error":"krz/quiet has no .gitbay/ci.yml on main","exit_code":3}"#))
190 let model = BuildListViewModel(client: client, repoPath: "krz/quiet")
191
192 await model.loadJobs()
193
194 #expect(model.jobs.isEmpty)
195 // The list itself must stay usable; only the trigger is gated.
196 #expect(model.actionError == nil)
197 }
198}
199
200@MainActor
201struct BuildDetailViewModelTests {
202
203 private let showJSON = """
204 {"protocol_version":1,"data":{"number":60,"job":"build","status":"success",\
205 "sha":"ff6271a9d4570cd46f169091637a9d2e40ad5c2a","ref":"cli-coverage",\
206 "created_at":"2026-08-28T04:42:54.000Z","finished_at":"2026-08-28T04:43:06.000Z"},\
207 "exit_code":0}
208 """
209
210 @Test func showsWhatTheBuildWasAndWhatItPrinted() async throws {
211 let (client, stub) = try makeClient()
212 stub.enqueue(.init(status: 200, json: showJSON, match: "argv=build&argv=show"))
213 stub.enqueue(.init(status: 200, json:
214 #"{"protocol_version":1,"output":"step 1 ok\n","exit_code":0}"#,
215 match: "argv=build&argv=log"))
216 let model = BuildDetailViewModel(client: client, repoPath: "krz/gitbay", number: 60)
217
218 await model.load()
219
220 let detail = try #require(model.state.value)
221 #expect(detail.build.status == "success")
222 #expect(detail.build.ref == "cli-coverage")
223 #expect(detail.build.finishedAt != nil)
224 #expect(detail.log == "step 1 ok\n")
225 }
226
227 @Test func aQueuedBuildWithNoLogYetStillShowsItsHeader() async throws {
228 let (client, stub) = try makeClient()
229 stub.enqueue(.init(status: 200, json: """
230 {"protocol_version":1,"data":{"number":61,"job":"build","status":"queued",\
231 "sha":"aa00000000","ref":"main","created_at":"2026-08-28T05:00:00.000Z"},\
232 "exit_code":0}
233 """, match: "argv=build&argv=show"))
234 stub.enqueue(.init(status: 404, json:
235 #"{"protocol_version":1,"error":"no log","exit_code":3}"#,
236 match: "argv=build&argv=log"))
237 let model = BuildDetailViewModel(client: client, repoPath: "krz/gitbay", number: 61)
238
239 await model.load()
240
241 // A missing log must not take the whole screen down.
242 let detail = try #require(model.state.value)
243 #expect(detail.build.status == "queued")
244 #expect(detail.log.isEmpty)
245 }
246}
gitbayTests/RepoManagementTests.swift −1
@@ -240,7 +240,6 @@ struct BuildTriggerTests {
240240 stub.enqueue(.init(status: 200, json: buildListJSON, match: "argv=build&argv=list"))
241241 let model = BuildListViewModel(client: client, repoPath: "krz/gitbay")
242242 await model.load()
243 #expect(model.latestJob == "ci")
244243
245244 stub.enqueue(.init(status: 200, json:
246245 #"{"protocol_version":1,"data":{"build":4,"job":"ci","sha":"aa"},"exit_code":0}"#,
gitbayUITests/LiveSmokeUITests.swift +54 −14
@@ -295,7 +295,10 @@ extension LiveSmokeUITests {
295295 XCTAssertTrue(waitForDisappearance(chip, timeout: 10), "topic did not remove")
296296
297297 let resolved = app.switches["Require threads resolved"].firstMatch
298 XCTAssertTrue(resolved.waitForExistence(timeout: 5))
298 // The merge-requirements section sits below the fold, and a List
299 // does not build rows it has not shown, so it must be scrolled
300 // into existence before it can be queried.
301 XCTAssertTrue(scrollTo(resolved), "merge requirements section not reachable")
299302 // SwiftUI exposes the row as a switch that wraps the real
300303 // control; tap the innermost switch when there is one, else the
301304 // right edge of the row.
@@ -314,20 +317,21 @@ extension LiveSmokeUITests {
314317
315318 back() // settings -> repo
316319
317 // --- build trigger error path (no .gitbay job config here) ---
320 // --- nothing to trigger without a job file ---
321 // This repo has no .gitbay/ci.yml, so `build jobs` returns none
322 // and the control is gated rather than failing after a guess.
318323 app.staticTexts["Builds"].firstMatch.tap()
319 app.descendants(matching: .any).matching(identifier: "build-trigger-button")
320 .firstMatch.tap()
321 let jobField = app.textFields.firstMatch
322 XCTAssertTrue(jobField.waitForExistence(timeout: 5), "trigger alert missing")
323 focusAndType(jobField, "ci")
324 app.buttons["Trigger"].firstMatch.tap()
325 // "no job X" for an unknown job, "has no .gitbay/ci.yml" when the
326 // repo has no CI config at all.
327 let refusal = app.staticTexts
328 .containing(NSPredicate(format: "label CONTAINS 'no job' OR label CONTAINS 'no .gitbay'")).firstMatch
329 XCTAssertTrue(refusal.waitForExistence(timeout: 15),
330 "trigger refusal not surfaced")
324 let trigger = app.descendants(matching: .any)
325 .matching(identifier: "build-trigger-button").firstMatch
326 XCTAssertTrue(trigger.waitForExistence(timeout: 15), "trigger control missing")
327 // Assert on behaviour, not on isEnabled: a disabled SwiftUI Menu
328 // still reports itself enabled to XCUITest.
329 trigger.tap()
330 let anyJob = app.buttons.containing(
331 NSPredicate(format: "label CONTAINS 'on push' OR label CONTAINS 'schedule '"))
332 .firstMatch
333 XCTAssertFalse(anyJob.waitForExistence(timeout: 3),
334 "a repo with no job file offered a job to trigger")
331335 }
332336
333337 private func waitForValue(_ element: XCUIElement, _ value: String,
@@ -834,3 +838,39 @@ extension LiveSmokeUITests {
834838 "org markup leaked into the rendered page")
835839 }
836840 }
841
842
843extension LiveSmokeUITests {
844
845 /// Builds on a repo that has a job file: the picker offers what the
846 /// server names, and the detail screen says more than the log.
847 func testBuildJobsAndDetail() throws {
848 openRepo("krz/gitbay")
849 app.staticTexts["Builds"].firstMatch.tap()
850
851 // The picker lists jobs from `build jobs` no name to type.
852 let trigger = app.descendants(matching: .any)
853 .matching(identifier: "build-trigger-button").firstMatch
854 XCTAssertTrue(trigger.waitForExistence(timeout: 20), "trigger control missing")
855 XCTAssertTrue(trigger.isEnabled, "trigger is disabled where a job file exists")
856 trigger.tap()
857 let job = app.buttons
858 .containing(NSPredicate(format: "label CONTAINS 'build' AND label CONTAINS 'on push'"))
859 .firstMatch
860 XCTAssertTrue(job.waitForExistence(timeout: 10),
861 "job menu did not list the job with why it runs")
862 // Dismiss without queueing a real build.
863 app.tap()
864
865 // The detail screen carries status, ref and timing, not just log.
866 let firstBuild = app.cells.firstMatch
867 XCTAssertTrue(firstBuild.waitForExistence(timeout: 20), "no builds listed")
868 firstBuild.tap()
869 for label in ["success", "queued"] where app.staticTexts[label].firstMatch.exists {
870 XCTAssertTrue(true)
871 }
872 XCTAssertTrue(app.staticTexts
873 .containing(NSPredicate(format: "label BEGINSWITH 'queued '")).firstMatch
874 .waitForExistence(timeout: 20), "build detail shows no timing")
875 }
876}