Commit 1ce9486b6f

1ce9486b6f23fc199b55befd6eb6ea75dacd022b

parent: 10bbec9546

Verified · cmc

cmc <hello@cleberg.net> · 2026-09-22 21:26 UTC

mr: filter the list by label

mr list takes --label; MRFilter's comment said it did not.

Layout: unified · split

gitbay/MRs/MRFilter.swift +4 −2
@@ -1,7 +1,7 @@
11import Foundation
22
3/// What `mr list` is narrowed to. The command takes no `--label` and no
4/// `--assignee`, so this offers neither.
3/// What `mr list` is narrowed to. The command takes no `--assignee`, so
4/// this offers none.
55nonisolated struct MRFilter: Equatable, Sendable {
66
77 enum State: String, CaseIterable, Identifiable, Sendable {
@@ -20,12 +20,14 @@ nonisolated struct MRFilter: Equatable, Sendable {
2020
2121 var state: State = .open
2222 var search = ""
23 var label: String?
2324 var author: String?
2425 var milestone: String?
2526
2627 func flags() -> [String] {
2728 var flags = ["--state", state.rawValue]
2829 flags += filterFlag("--search", search)
30 flags += filterFlag("--label", label)
2931 flags += filterFlag("--author", author)
3032 flags += filterFlag("--milestone", milestone)
3133 return flags
gitbay/MRs/MRListViewModel.swift +13
@@ -57,6 +57,19 @@ final class MRListViewModel {
5757 )) ?? []
5858 }
5959
60 /// The repository's labels for the filter picker and its chips;
61 /// fetched on first use, like the milestones.
62 private(set) var labels: [IssueLabel] = []
63 private(set) var colors = LabelColors()
64 private var labelsLoaded = false
65
66 func loadLabels() async {
67 guard !labelsLoaded else { return }
68 labelsLoaded = true
69 labels = (try? await client.readList(["label", "list", repoPath], of: IssueLabel.self)) ?? []
70 colors = LabelColors(labels)
71 }
72
6073 private func scheduleSearchReload() {
6174 reloadTask?.cancel()
6275 reloadTask = Task {
gitbay/Views/MRs/MRListView.swift +11 −2
@@ -64,8 +64,17 @@ struct MRListView: View {
6464 let state = model.filter.state
6565 model.filter = MRFilter(state: state)
6666 }
67 ) {}
68 .task { await model.loadMilestones() }
67 ) {
68 LabelFilterSection(
69 label: Bindable(model).filter.label,
70 labels: model.labels,
71 colors: model.colors
72 )
73 }
74 .task {
75 await model.loadMilestones()
76 await model.loadLabels()
77 }
6978 }
7079 .sheet(isPresented: $composing) {
7180 MRCreateSheet(model: createModel) {
gitbayTests/MRLabelTests.swift +55
@@ -87,3 +87,58 @@ struct MRLabelTests {
8787 #expect(model.colors.hex("bug") == LabelPalette.hex(name: "bug", stored: "#d73a4a"))
8888 }
8989}
90
91struct MRLabelFilterFlagTests {
92
93 /// Same position as in IssueFilter: after --search, before --author.
94 @Test func aLabelSendsTheLabelFlag() {
95 var filter = MRFilter()
96 filter.search = "gate"
97 filter.label = "bug"
98 filter.author = "cmc"
99 #expect(filter.flags() == ["--state", "open", "--search", "gate", "--label", "bug", "--author", "cmc"])
100 #expect(filter.isNarrowed)
101 }
102
103 @Test func noLabelSendsNoFlag() {
104 #expect(!MRFilter().flags().contains("--label"))
105 }
106}
107
108@MainActor
109struct MRLabelFilterTests {
110
111 @Test func pickingALabelReloadsWithIt() async throws {
112 let (client, stub) = try makeClient()
113 stub.enqueue(.init(status: 200, json: #"{"protocol_version":1,"data":{"items":[]},"exit_code":0}"#))
114 let model = MRListViewModel(client: client, repoPath: "krz/gitbay")
115 await model.load()
116
117 stub.enqueue(.init(status: 200, json: #"{"protocol_version":1,"data":{"items":[]},"exit_code":0}"#))
118 model.filter.label = "bug"
119 await until {
120 guard stub.seen.count == 2 else { return false }
121 if case .loading = model.state { return false }
122 return true
123 }
124
125 let query = try #require(stub.seen.last?.url.query())
126 #expect(query.contains("argv=--label&argv=bug"))
127 }
128
129 @Test func thePickerOffersTheRepositorysLabels() async throws {
130 let (client, stub) = try makeClient()
131 stub.enqueue(.init(status: 200, json: """
132 {"protocol_version":1,"data":[{"name":"bug","color":"#d73a4a","issues":2},\
133 {"name":"ios","issues":0}],"exit_code":0}
134 """, match: "argv=label&argv=list"))
135 let model = MRListViewModel(client: client, repoPath: "krz/gitbay")
136
137 await model.loadLabels()
138 await model.loadLabels()
139
140 #expect(model.labels.map(\.name) == ["bug", "ios"])
141 #expect(model.colors.hex("bug") == LabelPalette.hex(name: "bug", stored: "#d73a4a"))
142 #expect(stub.seen.count == 1)
143 }
144}
gitbayUITests/LiveSmokeUITests.swift +49
@@ -81,6 +81,15 @@ final class LiveSmokeUITests: XCTestCase {
8181 closeSmokeMergeRequests()
8282 try openSupersedeFixtures()
8383 }
84 if name.contains("testMRLabelFlow") {
85 try requireFixtureRepo()
86 try requireFixtureBranch()
87 closeSmokeMergeRequests()
88 _ = runCommand(["mr", "create", Self.fixtureRepo, "--source", "ui-smoke", "--target", "main",
89 "--title", Self.labelledTitle])
90 try XCTSkipUnless(openSmokeMergeRequests().map { $0.title }.contains(Self.labelledTitle),
91 "could not open the label fixture merge request on \(Self.fixtureRepo)")
92 }
8493 }
8594
8695 override func tearDownWithError() throws {
@@ -93,6 +102,10 @@ final class LiveSmokeUITests: XCTestCase {
93102 if name.contains("testCloseInFavourFlow") {
94103 closeSmokeMergeRequests()
95104 }
105 if name.contains("testMRLabelFlow") {
106 closeSmokeMergeRequests()
107 _ = runCommand(["label", "remove", Self.fixtureRepo, Self.smokeMRLabel])
108 }
96109 }
97110
98111 // MARK: - Fixtures for the v1.2.0 flows
@@ -138,6 +151,8 @@ final class LiveSmokeUITests: XCTestCase {
138151
139152 static let supersededTitle = "UI smoke: superseded"
140153 static let successorTitle = "UI smoke: successor"
154 static let labelledTitle = "UI smoke: labels"
155 static let smokeMRLabel = "ui-smoke-mr-label"
141156
142157 /// Close in favour needs two open merge requests. The fixture branch
143158 /// gives one (ui-smoke -> main); main has commits ui-smoke lacks, so
@@ -1835,4 +1850,38 @@ extension LiveSmokeUITests {
18351850 XCTAssertTrue(app.staticTexts[Self.successorTitle].firstMatch.waitForExistence(timeout: 20),
18361851 "link did not open the successor")
18371852 }
1853
1854 /// A label added from the merge request screen shows as a chip, the
1855 /// list filters to it, and removing it clears the chip.
1856 func testMRLabelFlow() throws {
1857 openRepo(Self.fixtureRepo)
1858 app.staticTexts["Merge Requests"].firstMatch.tap()
1859 let row = app.staticTexts[Self.labelledTitle].firstMatch
1860 XCTAssertTrue(row.waitForExistence(timeout: 15), "fixture MR not listed")
1861 row.tap()
1862
1863 let field = app.textFields["mr-label-field"].firstMatch
1864 XCTAssertTrue(scrollTo(field), "no label field on the merge request")
1865 field.tap()
1866 field.typeText(Self.smokeMRLabel)
1867 app.buttons["mr-label-add"].firstMatch.tap()
1868 let remove = app.buttons["mr-label-remove-\(Self.smokeMRLabel)"].firstMatch
1869 XCTAssertTrue(remove.waitForExistence(timeout: 15), "added label has no chip")
1870
1871 back()
1872 app.descendants(matching: .any).matching(identifier: "mr-filter-button").firstMatch.tap()
1873 let menu = app.descendants(matching: .any).matching(identifier: "filter-label-menu").firstMatch
1874 XCTAssertTrue(menu.waitForExistence(timeout: 10), "MR filter sheet has no label picker")
1875 menu.tap()
1876 let choice = app.buttons[Self.smokeMRLabel].firstMatch
1877 XCTAssertTrue(choice.waitForExistence(timeout: 10), "label not offered in the picker")
1878 choice.tap()
1879 app.buttons["Done"].firstMatch.tap()
1880 XCTAssertTrue(row.waitForExistence(timeout: 15), "label filter dropped the labelled MR")
1881
1882 row.tap()
1883 XCTAssertTrue(scrollTo(remove), "chip missing on reopen")
1884 remove.tap()
1885 XCTAssertTrue(remove.waitForNonExistence(timeout: 15), "removed label still shown")
1886 }
18381887}