Commit d0d608bdb6

d0d608bdb6f97d13ee79f508d5c7a44407425c97

parent: f7f6759a51

Unsigned

cmc <hello@cleberg.net> · 2026-09-19 02:15 UTC

Build list filters: branch, status and job (!100)

Parity row "build list filters (ref, status, job)" (krz/gitbay 7de4e7b).

- `BuildFilter` renders `--ref`, `--status` and `--job`; a blank branch sends no flag.
- `BuildListViewModel.filter` reloads on change with the flags appended; the empty state says whether filters apply.
- A filter button on the builds screen opens a sheet with a branch field, a status menu (pending, running, success, failure, cancelled) and a job menu fed by `build jobs`, hidden when the repository has no CI config.

Layout: unified · split

gitbay/Builds/BuildFilter.swift added +32
@@ -0,0 +1,32 @@
1import Foundation
2
3/// What `build list` is narrowed to: `--ref`, `--status` and `--job`.
4/// There is no state flag, so the default sends the bare command.
5nonisolated struct BuildFilter: Equatable, Sendable {
6
7 /// The vocabulary `--status` accepts; anything else is a usage error.
8 enum Status: String, CaseIterable, Identifiable, Sendable {
9 case pending, running, success, failure, cancelled
10
11 var id: String { rawValue }
12 }
13
14 var ref = ""
15 var status: Status?
16 var job: String?
17
18 func flags() -> [String] {
19 var flags = filterFlag("--ref", ref)
20 flags += filterFlag("--status", status?.rawValue)
21 flags += filterFlag("--job", job)
22 return flags
23 }
24
25 var isNarrowed: Bool { !flags().isEmpty }
26
27 var emptyMessage: String {
28 isNarrowed
29 ? "No builds match these filters."
30 : "No builds. Builds run when a push touches a repo with a .gitbay/ job file."
31 }
32}
gitbay/Builds/BuildListViewModel.swift +14 −3
@@ -1,12 +1,22 @@
11import Foundation
22import Observation
33
4/// `build list <repo>`.
4/// `build list <repo>`, narrowed by `filter`.
55@Observable
66@MainActor
77final class BuildListViewModel {
88
99 private(set) var state: LoadState<[Build]> = .loading
10 /// A change reloads. The sheet edits a copy and assigns once, so a
11 /// typed branch does not fire a request per keystroke.
12 var filter = BuildFilter() {
13 didSet {
14 guard filter != oldValue else { return }
15 reloadTask?.cancel()
16 reloadTask = Task { await load() }
17 }
18 }
19 private var reloadTask: Task<Void, Never>?
1020 /// The jobs a trigger can name. Empty when the repo has no CI
1121 /// config — the web hides its trigger form in that case.
1222 private(set) var jobs: [CIJob] = []
@@ -23,9 +33,10 @@ final class BuildListViewModel {
2333
2434 func load() async {
2535 do {
26 let builds = try await client.readList(["build", "list", repoPath], of: Build.self)
36 let builds = try await client.readList(
37 ["build", "list", repoPath] + filter.flags(), of: Build.self)
2738 state = builds.isEmpty
28 ? .empty("No builds. Builds run when a push touches a repo with a .gitbay/ job file.")
39 ? .empty(filter.emptyMessage)
2940 : .loaded(builds.sorted { $0.number > $1.number })
3041 } catch {
3142 state = .from(error)
gitbay/Views/Builds/BuildListView.swift +93
@@ -3,6 +3,7 @@ import SwiftUI
33struct BuildListView: View {
44
55 @State private var model: BuildListViewModel
6 @State private var filtering = false
67
78 init(client: GitbayClient, repo: String) {
89 _model = State(initialValue: BuildListViewModel(client: client, repoPath: repo))
@@ -25,6 +26,16 @@ struct BuildListView: View {
2526 .navigationTitle("Builds")
2627 .navigationBarTitleDisplayMode(.inline)
2728 .toolbar {
29 ToolbarItem(placement: .topBarTrailing) {
30 Button {
31 filtering = true
32 } label: {
33 Image(systemName: model.filter.isNarrowed
34 ? "line.3.horizontal.decrease.circle.fill"
35 : "line.3.horizontal.decrease.circle")
36 }
37 .accessibilityIdentifier("build-filter-button")
38 }
2839 ToolbarItem(placement: .topBarTrailing) {
2940 // A menu of jobs, like the web's picker — not a name to
3041 // type. Nothing to trigger without a job file.
@@ -41,6 +52,9 @@ struct BuildListView: View {
4152 .accessibilityIdentifier("build-trigger-button")
4253 }
4354 }
55 .sheet(isPresented: $filtering) {
56 BuildFilterSheet(filter: model.filter, jobs: model.jobs) { model.filter = $0 }
57 }
4458 .task {
4559 await model.load()
4660 await model.loadJobs()
@@ -85,6 +99,85 @@ private struct BuildRow: View {
8599
86100}
87101
102/// Branch, status and job, the three flags `build list` takes. The
103/// sheet edits a copy and hands it back on Done, so the list reloads
104/// once rather than on every keystroke of the branch field.
105private struct BuildFilterSheet: View {
106
107 let jobs: [CIJob]
108 let onApply: (BuildFilter) -> Void
109 @State private var draft: BuildFilter
110
111 @Environment(\.dismiss) private var dismiss
112
113 init(filter: BuildFilter, jobs: [CIJob], onApply: @escaping (BuildFilter) -> Void) {
114 _draft = State(initialValue: filter)
115 self.jobs = jobs
116 self.onApply = onApply
117 }
118
119 var body: some View {
120 NavigationStack {
121 Form {
122 Section("Branch") {
123 TextField("Branch", text: $draft.ref)
124 .autocorrectionDisabled()
125 .textInputAutocapitalization(.never)
126 .accessibilityIdentifier("build-filter-ref")
127 }
128 Section("Status") {
129 Menu {
130 Button("Any status") { draft.status = nil }
131 ForEach(BuildFilter.Status.allCases) { status in
132 Button(status.rawValue) { draft.status = status }
133 }
134 } label: {
135 menuLabel(draft.status?.rawValue ?? "Any status")
136 }
137 .accessibilityIdentifier("build-filter-status-menu")
138 }
139 // A repository without a CI config has no jobs to name.
140 if !jobs.isEmpty {
141 Section("Job") {
142 Menu {
143 Button("Any job") { draft.job = nil }
144 ForEach(jobs) { job in
145 Button(job.name) { draft.job = job.name }
146 }
147 } label: {
148 menuLabel(draft.job ?? "Any job")
149 }
150 .accessibilityIdentifier("build-filter-job-menu")
151 }
152 }
153 Section {
154 Button("Clear filters", role: .destructive) { draft = BuildFilter() }
155 }
156 }
157 .navigationTitle("Filter Builds")
158 .navigationBarTitleDisplayMode(.inline)
159 .toolbar {
160 ToolbarItem(placement: .confirmationAction) {
161 Button("Done") {
162 onApply(draft)
163 dismiss()
164 }
165 }
166 }
167 }
168 }
169
170 private func menuLabel(_ text: String) -> some View {
171 HStack {
172 Text(text)
173 Spacer()
174 Image(systemName: "chevron.up.chevron.down")
175 .font(.gbSans(.caption2))
176 .foregroundStyle(.secondary)
177 }
178 }
179}
180
88181nonisolated enum BuildRoute: Hashable {
89182 case list(repo: String)
90183 case detail(repo: String, number: Int64)
gitbayTests/IssueBuildViewModelTests.swift +31
@@ -159,6 +159,37 @@ struct BuildListViewModelTests {
159159 #expect(builds[0].finishedAt == nil)
160160 #expect(builds[1].finishedAt != nil)
161161 }
162
163 @Test func changingTheFilterReloadsWithItsFlags() async throws {
164 let (client, stub) = try makeClient()
165 stub.enqueue(.init(status: 200, json: buildListJSON))
166 let model = BuildListViewModel(client: client, repoPath: "krz/gitbay")
167 await model.load()
168
169 stub.enqueue(.init(status: 200, json: """
170 {"protocol_version":1,"data":[],"exit_code":0}
171 """))
172 var filter = BuildFilter()
173 filter.status = .failure
174 filter.job = "test"
175 model.filter = filter
176 // `load()` leaves the previous list showing until the reply lands
177 // rather than blanking it, so a "not loading" check would pass
178 // before the reload finished: wait for the result itself.
179 await until {
180 if case .empty = model.state { return true }
181 return false
182 }
183
184 let second = try #require(stub.seen.dropFirst().first)
185 #expect(second.url.query()
186 == "argv=build&argv=list&argv=krz/gitbay&argv=--status&argv=failure&argv=--job&argv=test")
187 guard case .empty(let message) = model.state else {
188 Issue.record("expected .empty after filtering, got \(model.state)")
189 return
190 }
191 #expect(message == "No builds match these filters.")
192 }
162193}
163194
164195@MainActor
gitbayTests/ListFilterTests.swift +39
@@ -402,3 +402,42 @@ struct MRListFilterWiringTests {
402402 #expect(argvFrom(try #require(stub.seen.last).url).contains("source_gone"))
403403 }
404404}
405
406struct BuildFilterTests {
407
408 /// `build list` has no state flag, so nothing is narrowed by default
409 /// and the bare command is sent.
410 @Test func theDefaultSendsNoFlags() {
411 #expect(BuildFilter().flags().isEmpty)
412 #expect(BuildFilter().isNarrowed == false)
413 }
414
415 @Test func eachFieldRendersItsOwnFlag() {
416 var f = BuildFilter()
417 f.ref = "main"
418 f.status = .failure
419 f.job = "test"
420 #expect(f.flags() == ["--ref", "main", "--status", "failure", "--job", "test"])
421 #expect(f.isNarrowed)
422 }
423
424 @Test func aBlankBranchSendsNoFlag() {
425 var f = BuildFilter()
426 f.ref = " "
427 #expect(f.flags().isEmpty)
428 #expect(f.isNarrowed == false)
429 }
430
431 @Test func theStatusVocabularyIsTheServersFive() {
432 #expect(BuildFilter.Status.allCases.map(\.rawValue)
433 == ["pending", "running", "success", "failure", "cancelled"])
434 }
435
436 @Test func theEmptyMessageSaysWhetherFiltersApply() {
437 #expect(BuildFilter().emptyMessage
438 == "No builds. Builds run when a push touches a repo with a .gitbay/ job file.")
439 var f = BuildFilter()
440 f.status = .success
441 #expect(f.emptyMessage == "No builds match these filters.")
442 }
443}