Build list filters: branch, status and job !100

merged merged by cmc on 2026-09-19 02:15 UTC · krz/gitbay-ios:build-filters into main

9 files changed, +212 −177

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 @@
1import Foundation 1import Foundation
2import Observation 2import Observation
3 3
4/// `build list <repo>`. 4/// `build list <repo>`, narrowed by `filter`.
5@Observable 5@Observable
6@MainActor 6@MainActor
7final class BuildListViewModel { 7final class BuildListViewModel {
8 8
9 private(set) var state: LoadState<[Build]> = .loading 9 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>?
10 /// The jobs a trigger can name. Empty when the repo has no CI 20 /// The jobs a trigger can name. Empty when the repo has no CI
11 /// config — the web hides its trigger form in that case. 21 /// config — the web hides its trigger form in that case.
12 private(set) var jobs: [CIJob] = [] 22 private(set) var jobs: [CIJob] = []
@@ -23,9 +33,10 @@ final class BuildListViewModel {
23 33
24 func load() async { 34 func load() async {
25 do { 35 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)
27 state = builds.isEmpty 38 state = builds.isEmpty
28 ? .empty("No builds. Builds run when a push touches a repo with a .gitbay/ job file.") 39 ? .empty(filter.emptyMessage)
29 : .loaded(builds.sorted { $0.number > $1.number }) 40 : .loaded(builds.sorted { $0.number > $1.number })
30 } catch { 41 } catch {
31 state = .from(error) 42 state = .from(error)
gitbay/MRs/MRDetailViewModel.swift +2 −26
@@ -98,32 +98,8 @@ final class MRDetailViewModel {
98 await perform(argv) 98 await perform(argv)
99 } 99 }
100 100
101 /// `--by` records which merge request replaces this one; the server 101 func close() async {
102 /// refuses a closed, merged or self reference. 102 await perform(["mr", "close"] + ref)
103 func close(inFavourOf successor: Int64? = nil) async {
104 var argv = ["mr", "close"] + ref
105 if let successor { argv.append(contentsOf: ["--by", String(successor)]) }
106 await perform(argv)
107 }
108
109 /// The repository's other open merge requests, for the close-in-
110 /// favour picker. Fetched on demand and cached like `loadBranches()`;
111 /// this merge request is dropped since it cannot supersede itself.
112 private(set) var openMergeRequests: [MergeRequest]?
113 private(set) var openMergeRequestsError: String?
114
115 func loadOpenMergeRequests() async {
116 guard openMergeRequests == nil else { return }
117 openMergeRequestsError = nil
118 do {
119 let rows = try await client.readList(
120 ["mr", "list", repoPath, "--state", "open"], of: MergeRequest.self)
121 openMergeRequests = rows.filter { $0.number != number }
122 } catch let error as GitbayError {
123 openMergeRequestsError = error.userFacingMessage
124 } catch {
125 openMergeRequestsError = GitbayError.transport(error).userFacingMessage
126 }
127 } 103 }
128 104
129 func setResolved(_ thread: ReviewThread, _ resolved: Bool) async { 105 func setResolved(_ thread: ReviewThread, _ resolved: Bool) async {
gitbay/MRs/MRModels.swift −5
@@ -90,9 +90,6 @@ nonisolated struct MRDetail: Decodable, Sendable, Hashable {
90 let mergedBy: String? 90 let mergedBy: String?
91 let closedAt: Date? 91 let closedAt: Date?
92 let closedBy: String? 92 let closedBy: String?
93 /// The merge request this one was closed in favour of. Omitted by
94 /// the server when there is none.
95 let supersededBy: Int64?
96 let checks: [Check]? 93 let checks: [Check]?
97 let checksCombined: String? 94 let checksCombined: String?
98 let unresolvedThreads: Int? 95 let unresolvedThreads: Int?
@@ -118,7 +115,6 @@ nonisolated struct MRDetail: Decodable, Sendable, Hashable {
118 case mergedBy = "merged_by" 115 case mergedBy = "merged_by"
119 case closedAt = "closed_at" 116 case closedAt = "closed_at"
120 case closedBy = "closed_by" 117 case closedBy = "closed_by"
121 case supersededBy = "superseded_by"
122 case checksCombined = "checks_combined" 118 case checksCombined = "checks_combined"
123 case unresolvedThreads = "unresolved_threads" 119 case unresolvedThreads = "unresolved_threads"
124 case reviewRequests = "review_requests" 120 case reviewRequests = "review_requests"
@@ -142,7 +138,6 @@ nonisolated struct MRDetail: Decodable, Sendable, Hashable {
142 mergedBy = try container.decodeIfPresent(String.self, forKey: .mergedBy) 138 mergedBy = try container.decodeIfPresent(String.self, forKey: .mergedBy)
143 closedAt = try container.decodeIfPresent(Date.self, forKey: .closedAt) 139 closedAt = try container.decodeIfPresent(Date.self, forKey: .closedAt)
144 closedBy = try container.decodeIfPresent(String.self, forKey: .closedBy) 140 closedBy = try container.decodeIfPresent(String.self, forKey: .closedBy)
145 supersededBy = try container.decodeIfPresent(Int64.self, forKey: .supersededBy)
146 checks = try container.decodeIfPresent([Check].self, forKey: .checks) 141 checks = try container.decodeIfPresent([Check].self, forKey: .checks)
147 checksCombined = try container.decodeIfPresent(String.self, forKey: .checksCombined) 142 checksCombined = try container.decodeIfPresent(String.self, forKey: .checksCombined)
148 unresolvedThreads = try container.decodeIfPresent(Int.self, forKey: .unresolvedThreads) 143 unresolvedThreads = try container.decodeIfPresent(Int.self, forKey: .unresolvedThreads)
gitbay/Views/Builds/BuildListView.swift +93
@@ -3,6 +3,7 @@ import SwiftUI
3struct BuildListView: View { 3struct BuildListView: View {
4 4
5 @State private var model: BuildListViewModel 5 @State private var model: BuildListViewModel
6 @State private var filtering = false
6 7
7 init(client: GitbayClient, repo: String) { 8 init(client: GitbayClient, repo: String) {
8 _model = State(initialValue: BuildListViewModel(client: client, repoPath: repo)) 9 _model = State(initialValue: BuildListViewModel(client: client, repoPath: repo))
@@ -25,6 +26,16 @@ struct BuildListView: View {
25 .navigationTitle("Builds") 26 .navigationTitle("Builds")
26 .navigationBarTitleDisplayMode(.inline) 27 .navigationBarTitleDisplayMode(.inline)
27 .toolbar { 28 .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 }
28 ToolbarItem(placement: .topBarTrailing) { 39 ToolbarItem(placement: .topBarTrailing) {
29 // A menu of jobs, like the web's picker — not a name to 40 // A menu of jobs, like the web's picker — not a name to
30 // type. Nothing to trigger without a job file. 41 // type. Nothing to trigger without a job file.
@@ -41,6 +52,9 @@ struct BuildListView: View {
41 .accessibilityIdentifier("build-trigger-button") 52 .accessibilityIdentifier("build-trigger-button")
42 } 53 }
43 } 54 }
55 .sheet(isPresented: $filtering) {
56 BuildFilterSheet(filter: model.filter, jobs: model.jobs) { model.filter = $0 }
57 }
44 .task { 58 .task {
45 await model.load() 59 await model.load()
46 await model.loadJobs() 60 await model.loadJobs()
@@ -85,6 +99,85 @@ private struct BuildRow: View {
85 99
86} 100}
87 101
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
88nonisolated enum BuildRoute: Hashable { 181nonisolated enum BuildRoute: Hashable {
89 case list(repo: String) 182 case list(repo: String)
90 case detail(repo: String, number: Int64) 183 case detail(repo: String, number: Int64)
gitbay/Views/MRs/MRView.swift +1 −94
@@ -14,8 +14,6 @@ struct MRView: View {
14 @State private var editingReviewer = "" 14 @State private var editingReviewer = ""
15 @State private var retargeting = false 15 @State private var retargeting = false
16 @State private var pendingRetarget: String? 16 @State private var pendingRetarget: String?
17 @State private var superseding = false
18 @State private var pendingSuccessor: Int64?
19 17
20 init(client: GitbayClient, repo: String, number: Int64) { 18 init(client: GitbayClient, repo: String, number: Int64) {
21 _model = State(initialValue: MRDetailViewModel( 19 _model = State(initialValue: MRDetailViewModel(
@@ -43,7 +41,6 @@ struct MRView: View {
43 41
44 milestoneSection(mr) 42 milestoneSection(mr)
45 stackSection(mr) 43 stackSection(mr)
46 supersededSection(mr)
47 reviewersSection(mr) 44 reviewersSection(mr)
48 diffSection 45 diffSection
49 if !model.revisions.isEmpty { 46 if !model.revisions.isEmpty {
@@ -94,32 +91,8 @@ struct MRView: View {
94 } 91 }
95 .confirmationDialog("Close !\(model.number) without merging?", isPresented: $confirmingClose) { 92 .confirmationDialog("Close !\(model.number) without merging?", isPresented: $confirmingClose) {
96 Button("Close", role: .destructive) { Task { await model.close() } } 93 Button("Close", role: .destructive) { Task { await model.close() } }
97 Button("Close in favour of another…") { superseding = true }
98 Button("Cancel", role: .cancel) {} 94 Button("Cancel", role: .cancel) {}
99 } 95 }
100 .confirmationDialog(
101 "Close !\(model.number) in favour of !\(pendingSuccessor ?? 0)?",
102 isPresented: .init(
103 get: { pendingSuccessor != nil },
104 set: { if !$0 { pendingSuccessor = nil } }
105 ),
106 titleVisibility: .visible
107 ) {
108 Button("Close", role: .destructive) {
109 guard let successor = pendingSuccessor else { return }
110 pendingSuccessor = nil
111 Task { await model.close(inFavourOf: successor) }
112 }
113 Button("Cancel", role: .cancel) { pendingSuccessor = nil }
114 } message: {
115 Text("The page will name the merge request that replaces this one, and that one will list this as superseded.")
116 }
117 .sheet(isPresented: $superseding) {
118 SupersedeSheet(model: model) { successor in
119 superseding = false
120 pendingSuccessor = successor
121 }
122 }
123 .confirmationDialog( 96 .confirmationDialog(
124 "Retarget to \(pendingRetarget ?? "")?", 97 "Retarget to \(pendingRetarget ?? "")?",
125 isPresented: .init( 98 isPresented: .init(
@@ -202,8 +175,7 @@ struct MRView: View {
202 if let at = mr.mergedAt { 175 if let at = mr.mergedAt {
203 stampLine("merged", by: mr.mergedBy, at: at) 176 stampLine("merged", by: mr.mergedBy, at: at)
204 } else if let at = mr.closedAt { 177 } else if let at = mr.closedAt {
205 stampLine(mr.supersededBy.map { "closed in favour of !\($0)" } ?? "closed without merging", 178 stampLine("closed without merging", by: mr.closedBy, at: at)
206 by: mr.closedBy, at: at)
207 } 179 }
208 } 180 }
209 181
@@ -250,20 +222,6 @@ struct MRView: View {
250 /// Shown only when this merge request is part of a stack. Each row 222 /// Shown only when this merge request is part of a stack. Each row
251 /// spells out its own direction — "stacked on" vs. "is stacked on 223 /// spells out its own direction — "stacked on" vs. "is stacked on
252 /// this" mean opposite things and look alike if abbreviated. 224 /// this" mean opposite things and look alike if abbreviated.
253 /// The merge request that replaced this one, as a link; the stamp
254 /// line names it, this row reaches it.
255 @ViewBuilder
256 private func supersededSection(_ mr: MRDetail) -> some View {
257 if let successor = mr.supersededBy {
258 Section {
259 NavigationLink(value: MRRoute.mr(repo: model.repoPath, number: successor)) {
260 Label("Superseded by !\(successor)", systemImage: "arrow.right.to.line")
261 .font(.gbSans(.subheadline))
262 }
263 }
264 }
265 }
266
267 @ViewBuilder 225 @ViewBuilder
268 private func stackSection(_ mr: MRDetail) -> some View { 226 private func stackSection(_ mr: MRDetail) -> some View {
269 if mr.stackedOn != nil || !mr.stacked.isEmpty { 227 if mr.stackedOn != nil || !mr.stacked.isEmpty {
@@ -706,57 +664,6 @@ struct ReviewThreadView: View {
706/// `nil` while loading and `model.branchesError` distinguishes a failed 664/// `nil` while loading and `model.branchesError` distinguishes a failed
707/// `repo refs` fetch from a repository that genuinely has no other 665/// `repo refs` fetch from a repository that genuinely has no other
708/// branches — an empty list must not read as a failed load. 666/// branches — an empty list must not read as a failed load.
709/// The repository's other open merge requests, one of which will be
710/// recorded as replacing this one.
711private struct SupersedeSheet: View {
712
713 let model: MRDetailViewModel
714 let onPick: (Int64) -> Void
715
716 @Environment(\.dismiss) private var dismiss
717
718 var body: some View {
719 NavigationStack {
720 Group {
721 if let error = model.openMergeRequestsError {
722 ContentUnavailableView {
723 Label("Could not load merge requests", systemImage: "wifi.exclamationmark")
724 } description: {
725 Text(error)
726 }
727 } else if let candidates = model.openMergeRequests {
728 if candidates.isEmpty {
729 ContentUnavailableView {
730 Label("No other open merge requests", systemImage: "arrow.triangle.pull")
731 } description: {
732 Text("Only an open merge request in this repository can supersede this one.")
733 }
734 } else {
735 List(candidates) { mr in
736 Button {
737 onPick(mr.number)
738 } label: {
739 MRRow(mr: mr)
740 }
741 .tint(.primary)
742 }
743 }
744 } else {
745 ProgressView()
746 }
747 }
748 .navigationTitle("Close !\(model.number) in favour of")
749 .navigationBarTitleDisplayMode(.inline)
750 .toolbar {
751 ToolbarItem(placement: .cancellationAction) {
752 Button("Cancel") { dismiss() }
753 }
754 }
755 .task { await model.loadOpenMergeRequests() }
756 }
757 }
758}
759
760private struct RetargetSheet: View { 667private struct RetargetSheet: View {
761 668
762 let model: MRDetailViewModel 669 let model: MRDetailViewModel
gitbayTests/IssueBuildViewModelTests.swift +31
@@ -159,6 +159,37 @@ struct BuildListViewModelTests {
159 #expect(builds[0].finishedAt == nil) 159 #expect(builds[0].finishedAt == nil)
160 #expect(builds[1].finishedAt != nil) 160 #expect(builds[1].finishedAt != nil)
161 } 161 }
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 }
162} 193}
163 194
164@MainActor 195@MainActor
gitbayTests/ListFilterTests.swift +39
@@ -402,3 +402,42 @@ struct MRListFilterWiringTests {
402 #expect(argvFrom(try #require(stub.seen.last).url).contains("source_gone")) 402 #expect(argvFrom(try #require(stub.seen.last).url).contains("source_gone"))
403 } 403 }
404} 404}
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}
gitbayTests/MRLifecycleTests.swift −49
@@ -85,19 +85,6 @@ struct MRLifecycleDecodingTests {
85 #expect(row.draft == false) 85 #expect(row.draft == false)
86 #expect(row.stackedOn == nil) 86 #expect(row.stackedOn == nil)
87 } 87 }
88
89 /// `superseded_by` is omitempty and 0 means none, so the ordinary row
90 /// carries no key at all.
91 @Test func supersededByDecodesWhenPresent() throws {
92 #expect(try decodeDetail(plainDetail).supersededBy == nil)
93 let mr = try decodeDetail("""
94 {"number":7,"title":"a change","state":"closed","author":"cmc",\
95 "closed_at":"2026-09-02T00:00:00Z","closed_by":"cmc","superseded_by":9,\
96 "source":"feat","target_ref":"main","head_sha":"abc123",\
97 "created_at":"2026-09-01T00:00:00Z"}
98 """)
99 #expect(mr.supersededBy == 9)
100 }
101} 88}
102 89
103@MainActor 90@MainActor
@@ -210,42 +197,6 @@ struct MRLifecycleActionTests {
210 // 4 GETs from `loaded()`, none from a reload. 197 // 4 GETs from `loaded()`, none from a reload.
211 #expect(gets(box) == 4) 198 #expect(gets(box) == 4)
212 } 199 }
213
214 @Test func closingWithoutASuccessorSendsNoBy() async throws {
215 let (model, box) = try await loaded()
216 ok(box)
217 await model.close()
218 let write = try #require(box.seen.first { $0.method == "POST" })
219 #expect(try argvOf(write) == ["mr", "close", "krz/gitbay", "7"])
220 }
221
222 @Test func closingInFavourOfAnotherPassesItsNumberAsBy() async throws {
223 let (model, box) = try await loaded()
224 ok(box)
225 await model.close(inFavourOf: 9)
226 let write = try #require(box.seen.first { $0.method == "POST" })
227 #expect(try argvOf(write) == ["mr", "close", "krz/gitbay", "7", "--by", "9"])
228 #expect(gets(box) == 8)
229 }
230
231 /// The picker offers the repository's other open merge requests. This
232 /// one cannot supersede itself, so it is dropped before the list is
233 /// shown rather than left for the server to refuse.
234 @Test func openMergeRequestsForThePickerExcludeThisOne() async throws {
235 let (model, box) = try await loaded()
236 box.enqueue(.init(status: 200, json: """
237 {"protocol_version":1,"data":[\
238 {"number":7,"title":"a change","state":"open","author":"cmc",\
239 "source":"feat","target_ref":"main","head_sha":"abc123","created_at":"2026-09-01T00:00:00Z"},\
240 {"number":9,"title":"the redo","state":"open","author":"cmc",\
241 "source":"feat2","target_ref":"main","head_sha":"def456","created_at":"2026-09-02T00:00:00Z"}],\
242 "exit_code":0}
243 """))
244 await model.loadOpenMergeRequests()
245 let read = try #require(box.seen.last { $0.method == "GET" })
246 #expect(read.url.query() == "argv=mr&argv=list&argv=krz/gitbay&argv=--state&argv=open")
247 #expect(model.openMergeRequests?.map(\.number) == [9])
248 }
249} 200}
250 201
251@MainActor 202@MainActor