Build list filters: branch, status and job !100
9 files changed, +212 −177
Layout: unified · split
gitbay/Builds/BuildFilter.swift added +32
| @@ -0,0 +1,32 @@ | |||
| 1 | import 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. | ||
| 5 | nonisolated 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 @@ | |||
| 1 | import Foundation | 1 | import Foundation |
| 2 | import Observation | 2 | import Observation |
| 3 | 3 | ||
| 4 | /// `build list <repo>`. | 4 | /// `build list <repo>`, narrowed by `filter`. |
| 5 | @Observable | 5 | @Observable |
| 6 | @MainActor | 6 | @MainActor |
| 7 | final class BuildListViewModel { | 7 | final 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 | |||
| 3 | struct BuildListView: View { | 3 | struct 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. | ||
| 105 | private 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 | |||
| 88 | nonisolated enum BuildRoute: Hashable { | 181 | nonisolated 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. | ||
| 711 | private 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 | |||
| 760 | private struct RetargetSheet: View { | 667 | private 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 | |||
| 406 | struct 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 |