Commit bfdfd9eda7

bfdfd9eda73f314db4020109e072df3c8ae48894

parent: 62763741b4

Verified · cmc

cmc <hello@cleberg.net> · 2026-09-06 19:03 UTC

Implementation plan for MR 2: list filters and search

Layout: unified · split

docs/superpowers/plans/2026-09-06-mr02-list-filters.md added +667
@@ -0,0 +1,667 @@
1# MR 2: List filters and search Implementation Plan
2
3> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
4
5**Goal:** Close three parity rows — `issue: filter by label, assignee, author, milestone`, `issue: search title and body`, and `mr: search title and body`.
6
7**Architecture:** Two value types, `IssueFilter` and `MRFilter`, each rendering itself to the flags its list command accepts. Both list view models swap their bare `StateFilter` enum for one of these and rebuild `PagedListModel.argv` from it. Typing debounces (300ms, matching `RepoListViewModel`); picker changes reload immediately. The label picker reads `IssueListViewModel.labels`, which MR 1 already loads.
8
9**Tech Stack:** Swift 6 language mode, default `MainActor` isolation, iOS 26.5, SwiftUI, Swift Testing, `@Observable` view models.
10
11**Spec:** `docs/superpowers/specs/2026-09-06-ios-parity-design.md`
12
13## Global Constraints
14
15- Swift 6 language mode, default `MainActor` isolation. Wire models are `nonisolated struct`s.
16- Swift Testing (`import Testing`, `@Test`, `#expect`, `#require`) — never XCTest.
17- `gitbayTests` is hermetic and offline; network goes through `StubProtocol`.
18- New files under `gitbay/` and `gitbayTests/` need **no** `project.pbxproj` edit — both are `PBXFileSystemSynchronizedRootGroup`s.
19- The label model is `IssueLabel`, never `Label` — SwiftUI's `Label` is used 57 times in this module.
20- Never mention Claude, LLMs or AI in commits, comments, or the merge request. No `Co-Authored-By` trailer.
21- Never commit to `main`. Work on a branch, land as a merge request.
22
23**The commands, verbatim from the registry** — these are the only flags that exist, and sending one that does not is a server error the app cannot catch:
24
25```
26issue list <owner/name> [--state open|closed|all] [--label <l>] [--assignee <user>]
27 [--author <user>] [--milestone <title>|none] [--search <text>]
28 [--limit <n>] [--cursor <c>]
29
30mr list <owner/name> [--state open|merged|closed|source_gone|all] [--author <user>]
31 [--milestone <title>|none] [--search <text>] [--limit <n>] [--cursor <c>]
32```
33
34`mr list` takes **no** `--label` and **no** `--assignee`. `MRFilter` must not offer them.
35
36**Build and test command:**
37
38```bash
39xcodebuild -project gitbay.xcodeproj -scheme gitbay \
40 -destination 'platform=iOS Simulator,name=iPhone 17' \
41 test -only-testing:gitbayTests
42```
43
44Baseline before this plan: **217 passed, 0 failed, 1 skipped** (`LiveInstanceTests`, conditionally enabled on a token — expected).
45
46---
47
48## File Structure
49
50| File | Responsibility |
51|------|----------------|
52| `gitbay/Issues/IssueFilter.swift` (create) | `IssueFilter` — state, search, label, assignee, author, milestone → flags |
53| `gitbay/MRs/MRFilter.swift` (create) | `MRFilter` — state, search, author, milestone → flags |
54| `gitbayTests/ListFilterTests.swift` (create) | Every test in this plan |
55| `gitbay/Issues/IssueListViewModel.swift` (modify) | Hold `IssueFilter`, debounce search, rebuild argv |
56| `gitbay/MRs/MRListViewModel.swift` (modify) | Hold `MRFilter`, debounce search, rebuild argv |
57| `gitbay/Views/Issues/IssueListView.swift` (modify) | `.searchable` + filter sheet |
58| `gitbay/Views/MRs/MRListView.swift` (modify) | `.searchable` + filter sheet |
59| `gitbay/Views/Shared/FilterSheet.swift` (create) | The filter form, shared by both lists |
60
61---
62
63### Task 1: The two filter value types
64
65Pure logic, no network, no UI. The whole task is argv construction, which is where the risk lives.
66
67**Files:**
68- Create: `gitbay/Issues/IssueFilter.swift`, `gitbay/MRs/MRFilter.swift`
69- Test: `gitbayTests/ListFilterTests.swift`
70
71**Interfaces produced:**
72- `struct IssueFilter: Equatable, Sendable` — `state: State` (`.open`/`.closed`/`.all`), `search: String`, `label: String?`, `assignee: String?`, `author: String?`, `milestone: String?`; `func flags() -> [String]`; `var isNarrowed: Bool`; `var emptyMessage: String`
73- `struct MRFilter: Equatable, Sendable` — `state: State` (`.open`/`.merged`/`.closed`/`.sourceGone`/`.all`), `search: String`, `author: String?`, `milestone: String?`; same three members
74- Both nest `enum State: String, CaseIterable, Identifiable, Sendable` with a `label: String` for display
75
76Rules the flags must follow, each pinned by a test:
771. `--state` is always sent, first.
782. An empty or whitespace-only value sends **no flag at all**. `--search ""` is not the same as no search, and a flag with an empty value is a different query.
793. `nil` and `""` both mean absent for the optional fields.
804. `milestone` is the one exception where a literal value matters: `"none"` is a real filter meaning "no milestone", not an absence.
815. Values are passed through unquoted. The server quotes FTS5 term by term, so `c++` is a word to match, not a syntax error — the app must not escape it.
82
83- [ ] **Step 1: Write the failing tests**
84
85Create `gitbayTests/ListFilterTests.swift`:
86
87```swift
88import Foundation
89import Testing
90@testable import gitbay
91
92struct IssueFilterTests {
93
94 @Test func theDefaultIsOpenAndNothingElse() {
95 #expect(IssueFilter().flags() == ["--state", "open"])
96 #expect(IssueFilter().isNarrowed == false)
97 }
98
99 @Test func stateIsAlwaysFirst() {
100 var f = IssueFilter()
101 f.state = .closed
102 f.author = "cmc"
103 #expect(f.flags().prefix(2) == ["--state", "closed"])
104 }
105
106 @Test func eachFieldRendersItsOwnFlag() {
107 var f = IssueFilter()
108 f.label = "bug"
109 f.assignee = "cmc"
110 f.author = "rae"
111 f.milestone = "v1.0.0"
112 f.search = "crash on launch"
113 #expect(f.flags() == [
114 "--state", "open",
115 "--search", "crash on launch",
116 "--label", "bug",
117 "--assignee", "cmc",
118 "--author", "rae",
119 "--milestone", "v1.0.0",
120 ])
121 #expect(f.isNarrowed)
122 }
123
124 /// An empty value must send no flag. `--search ""` is a different
125 /// query from no search at all.
126 @Test func emptyAndWhitespaceValuesSendNoFlag() {
127 var f = IssueFilter()
128 f.search = " "
129 f.label = ""
130 f.assignee = nil
131 f.author = " "
132 f.milestone = ""
133 #expect(f.flags() == ["--state", "open"])
134 #expect(f.isNarrowed == false)
135 }
136
137 /// "none" is a real milestone filter meaning "has no milestone".
138 @Test func milestoneNoneIsAValueNotAnAbsence() {
139 var f = IssueFilter()
140 f.milestone = "none"
141 #expect(f.flags() == ["--state", "open", "--milestone", "none"])
142 #expect(f.isNarrowed)
143 }
144
145 /// The server quotes FTS5 terms itself, so an operator is a word to
146 /// match. The app must not escape or reject it.
147 @Test func searchTextIsPassedThroughUnquoted() {
148 var f = IssueFilter()
149 f.search = "c++ AND \"quoted"
150 #expect(f.flags() == ["--state", "open", "--search", "c++ AND \"quoted"])
151 }
152
153 @Test func surroundingWhitespaceIsTrimmedFromValues() {
154 var f = IssueFilter()
155 f.search = " crash "
156 f.label = " bug "
157 #expect(f.flags() == ["--state", "open", "--search", "crash", "--label", "bug"])
158 }
159
160 @Test func theEmptyMessageNamesTheNarrowing() {
161 #expect(IssueFilter().emptyMessage == "No open issues.")
162 var all = IssueFilter()
163 all.state = .all
164 #expect(all.emptyMessage == "No issues.")
165 var narrowed = IssueFilter()
166 narrowed.label = "bug"
167 #expect(narrowed.emptyMessage == "No open issues match these filters.")
168 }
169}
170
171struct MRFilterTests {
172
173 @Test func theDefaultIsOpenAndNothingElse() {
174 #expect(MRFilter().flags() == ["--state", "open"])
175 #expect(MRFilter().isNarrowed == false)
176 }
177
178 @Test func eachFieldRendersItsOwnFlag() {
179 var f = MRFilter()
180 f.author = "cmc"
181 f.milestone = "v1.0.0"
182 f.search = "retarget"
183 #expect(f.flags() == [
184 "--state", "open",
185 "--search", "retarget",
186 "--author", "cmc",
187 "--milestone", "v1.0.0",
188 ])
189 }
190
191 /// `mr list` accepts source_gone; the spelling is the wire spelling,
192 /// not the Swift case name.
193 @Test func sourceGoneSpellsItselfTheWayTheCommandDoes() {
194 var f = MRFilter()
195 f.state = .sourceGone
196 #expect(f.flags() == ["--state", "source_gone"])
197 #expect(MRFilter.State.sourceGone.label == "Source gone")
198 }
199
200 @Test func everyStateTheCommandAcceptsIsOffered() {
201 #expect(MRFilter.State.allCases.map(\.rawValue)
202 == ["open", "merged", "closed", "source_gone", "all"])
203 }
204
205 @Test func emptyValuesSendNoFlag() {
206 var f = MRFilter()
207 f.search = " "
208 f.author = ""
209 #expect(f.flags() == ["--state", "open"])
210 }
211
212 @Test func theEmptyMessageNamesTheNarrowing() {
213 #expect(MRFilter().emptyMessage == "No open merge requests.")
214 var narrowed = MRFilter()
215 narrowed.search = "x"
216 #expect(narrowed.emptyMessage == "No open merge requests match these filters.")
217 }
218}
219```
220
221- [ ] **Step 2: Run the tests to verify they fail**
222
223Run the suite filtered to `-only-testing:gitbayTests/IssueFilterTests`.
224Expected: build failure — `cannot find type 'IssueFilter' in scope`.
225
226- [ ] **Step 3: Write the implementation**
227
228Create `gitbay/Issues/IssueFilter.swift`:
229
230```swift
231import Foundation
232
233/// What `issue list` is narrowed to. Renders itself to the flags the
234/// command accepts; an empty value sends no flag at all, since
235/// `--search ""` is a different query from no search.
236nonisolated struct IssueFilter: Equatable, Sendable {
237
238 enum State: String, CaseIterable, Identifiable, Sendable {
239 case open, closed, all
240 var id: String { rawValue }
241 var label: String { rawValue.capitalized }
242 }
243
244 var state: State = .open
245 var search = ""
246 var label: String?
247 var assignee: String?
248 var author: String?
249 /// A milestone title, or the literal "none" for issues without one.
250 var milestone: String?
251
252 func flags() -> [String] {
253 var flags = ["--state", state.rawValue]
254 flags += filterFlag("--search", search)
255 flags += filterFlag("--label", label)
256 flags += filterFlag("--assignee", assignee)
257 flags += filterFlag("--author", author)
258 flags += filterFlag("--milestone", milestone)
259 return flags
260 }
261
262 /// True when anything beyond the state picker is set, which is what
263 /// decides whether the empty state blames the filters.
264 var isNarrowed: Bool { flags().count > 2 }
265
266 var emptyMessage: String {
267 if isNarrowed {
268 return state == .all
269 ? "No issues match these filters."
270 : "No \(state.rawValue) issues match these filters."
271 }
272 return state == .all ? "No issues." : "No \(state.rawValue) issues."
273 }
274}
275
276/// One flag, or nothing when the value is absent or blank. Shared by
277/// both filters, so it is named for what it does rather than taking the
278/// bare name `flag` in the module namespace.
279nonisolated func filterFlag(_ name: String, _ value: String?) -> [String] {
280 guard let value else { return [] }
281 let trimmed = value.trimmingCharacters(in: .whitespaces)
282 return trimmed.isEmpty ? [] : [name, trimmed]
283}
284```
285
286Create `gitbay/MRs/MRFilter.swift`:
287
288```swift
289import Foundation
290
291/// What `mr list` is narrowed to. The command takes no `--label` and no
292/// `--assignee`, so this offers neither.
293nonisolated struct MRFilter: Equatable, Sendable {
294
295 enum State: String, CaseIterable, Identifiable, Sendable {
296 case open, merged, closed
297 case sourceGone = "source_gone"
298 case all
299
300 var id: String { rawValue }
301 var label: String {
302 self == .sourceGone ? "Source gone" : rawValue.capitalized
303 }
304 }
305
306 var state: State = .open
307 var search = ""
308 var author: String?
309 var milestone: String?
310
311 func flags() -> [String] {
312 var flags = ["--state", state.rawValue]
313 flags += filterFlag("--search", search)
314 flags += filterFlag("--author", author)
315 flags += filterFlag("--milestone", milestone)
316 return flags
317 }
318
319 var isNarrowed: Bool { flags().count > 2 }
320
321 var emptyMessage: String {
322 let noun = "merge requests"
323 if isNarrowed {
324 return state == .all
325 ? "No \(noun) match these filters."
326 : "No \(state.label.lowercased()) \(noun) match these filters."
327 }
328 return state == .all ? "No \(noun)." : "No \(state.label.lowercased()) \(noun)."
329 }
330}
331```
332
333- [ ] **Step 4: Run the tests to verify they pass**
334
335Expected: PASS, and the full suite still green.
336
337- [ ] **Step 5: Commit**
338
339```bash
340git add gitbay/Issues/IssueFilter.swift gitbay/MRs/MRFilter.swift gitbayTests/ListFilterTests.swift
341git commit -m "Filter value types for the issue and merge request lists"
342```
343
344---
345
346### Task 2: Wire the filters into both view models
347
348**Files:**
349- Modify: `gitbay/Issues/IssueListViewModel.swift`, `gitbay/MRs/MRListViewModel.swift`
350- Test: `gitbayTests/ListFilterTests.swift`
351
352**Interfaces produced:**
353- `IssueListViewModel.filter: IssueFilter` (replacing `filter: StateFilter`), `IssueListViewModel.milestones: [Milestone]`, `func loadMilestones() async`
354- `MRListViewModel.filter: MRFilter` (replacing `filter: StateFilter`), same two milestone members
355
356`StateFilter` is **removed** from both view models — `IssueFilter.State` and `MRFilter.State` replace it. Update the views' pickers in Task 3 accordingly.
357
358Two reload paths, and the difference matters:
359- Changing a picker reloads **immediately**.
360- Changing `filter.search` reloads **after a 300ms debounce**, matching `RepoListViewModel.scheduleSearch`. Reloading on every keystroke would fire a request per character.
361
362Because both live on one `filter` property, the view model compares old and new in `didSet`: if only `search` differs, debounce; otherwise reload now.
363
364Milestones are fetched lazily for the picker — `IssueDetailViewModel` already does this with `availableMilestones`; follow that shape. Use `["milestone", "list", repoPath, "--state", "all"]` so a closed milestone can still be filtered on.
365
366- [ ] **Step 1: Write the failing tests**
367
368Append to `gitbayTests/ListFilterTests.swift`. Reuse the file-scope helpers by declaring them here (this file is new, so it needs its own copies):
369
370```swift
371private func makeClient() throws -> (GitbayClient, StubProtocol.Box) {
372 let box = StubProtocol.box()
373 let client = GitbayClient(
374 instance: try GitbayInstance(url: "https://gitbay.org"),
375 token: "test-token",
376 session: box.session()
377 )
378 return (client, box)
379}
380
381private let emptyPage = """
382 {"protocol_version":1,"data":{"items":[]},"exit_code":0}
383 """
384private let noLabels = """
385 {"protocol_version":1,"data":[],"exit_code":0}
386 """
387
388/// Every `argv=` value from a request URL, in order.
389private func argvFrom(_ url: URL) -> [String] {
390 URLComponents(url: url, resolvingAgainstBaseURL: false)?
391 .queryItems?.filter { $0.name == "argv" }.compactMap(\.value) ?? []
392}
393
394@MainActor
395struct IssueListFilterWiringTests {
396
397 @Test func theDefaultReadIsOpenIssues() async throws {
398 let (client, stub) = try makeClient()
399 stub.enqueue(.init(status: 200, json: emptyPage, match: "argv=issue"))
400 stub.enqueue(.init(status: 200, json: noLabels, match: "argv=label"))
401
402 let model = IssueListViewModel(client: client, repoPath: "krz/gitbay")
403 await model.load()
404
405 let read = try #require(stub.seen.first { $0.url.absoluteString.contains("argv=issue") })
406 #expect(argvFrom(read.url).prefix(6) == [
407 "issue", "list", "krz/gitbay", "--state", "open", "--limit",
408 ])
409 }
410
411 @Test func aPickerChangeReloadsWithTheNewFlags() async throws {
412 let (client, stub) = try makeClient()
413 stub.enqueue(.init(status: 200, json: emptyPage, match: "argv=issue"))
414 stub.enqueue(.init(status: 200, json: noLabels, match: "argv=label"))
415 let model = IssueListViewModel(client: client, repoPath: "krz/gitbay")
416 await model.load()
417
418 stub.enqueue(.init(status: 200, json: emptyPage, match: "argv=issue"))
419 model.filter.label = "bug"
420 try await Task.sleep(for: .milliseconds(150))
421
422 let reads = stub.seen.filter { $0.url.absoluteString.contains("argv=issue") }
423 let last = try #require(reads.last)
424 #expect(argvFrom(last.url).contains("--label"))
425 #expect(argvFrom(last.url).contains("bug"))
426 // Immediate, not debounced: two reads by now.
427 #expect(reads.count == 2)
428 }
429
430 /// Typing must not fire a request per keystroke.
431 @Test func typingDebouncesIntoOneRead() async throws {
432 let (client, stub) = try makeClient()
433 stub.enqueue(.init(status: 200, json: emptyPage, match: "argv=issue"))
434 stub.enqueue(.init(status: 200, json: noLabels, match: "argv=label"))
435 let model = IssueListViewModel(client: client, repoPath: "krz/gitbay")
436 await model.load()
437
438 stub.enqueue(.init(status: 200, json: emptyPage, match: "argv=issue"))
439 for text in ["c", "cr", "cra", "cras", "crash"] {
440 model.filter.search = text
441 try await Task.sleep(for: .milliseconds(20))
442 }
443 try await Task.sleep(for: .milliseconds(500))
444
445 let reads = stub.seen.filter { $0.url.absoluteString.contains("argv=issue") }
446 #expect(reads.count == 2)
447 #expect(argvFrom(try #require(reads.last).url).contains("crash"))
448 }
449
450 @Test func theEmptyMessageComesFromTheFilter() async throws {
451 let (client, stub) = try makeClient()
452 stub.enqueue(.init(status: 200, json: emptyPage, match: "argv=issue"))
453 stub.enqueue(.init(status: 200, json: noLabels, match: "argv=label"))
454 let model = IssueListViewModel(client: client, repoPath: "krz/gitbay")
455 await model.load()
456
457 guard case .empty(let message) = model.state else {
458 Testing.Issue.record("expected empty, got \(model.state)")
459 return
460 }
461 #expect(message == "No open issues.")
462 }
463}
464
465@MainActor
466struct MRListFilterWiringTests {
467
468 @Test func searchReachesTheCommand() async throws {
469 let (client, stub) = try makeClient()
470 stub.enqueue(.init(status: 200, json: emptyPage))
471 let model = MRListViewModel(client: client, repoPath: "krz/gitbay")
472 await model.load()
473
474 stub.enqueue(.init(status: 200, json: emptyPage))
475 model.filter.search = "retarget"
476 try await Task.sleep(for: .milliseconds(500))
477
478 let last = try #require(stub.seen.last)
479 let argv = argvFrom(last.url)
480 #expect(argv.contains("--search"))
481 #expect(argv.contains("retarget"))
482 }
483
484 @Test func sourceGoneReachesTheCommandInItsWireSpelling() async throws {
485 let (client, stub) = try makeClient()
486 stub.enqueue(.init(status: 200, json: emptyPage))
487 let model = MRListViewModel(client: client, repoPath: "krz/gitbay")
488 await model.load()
489
490 stub.enqueue(.init(status: 200, json: emptyPage))
491 model.filter.state = .sourceGone
492 try await Task.sleep(for: .milliseconds(150))
493
494 #expect(argvFrom(try #require(stub.seen.last).url).contains("source_gone"))
495 }
496}
497```
498
499- [ ] **Step 2: Run to verify failure**, then
500
501- [ ] **Step 3: Implement in `IssueListViewModel`**
502
503Replace the `StateFilter` enum and `filter` property:
504
505```swift
506 var filter = IssueFilter() {
507 didSet {
508 guard filter != oldValue else { return }
509 configureList()
510 // Only the text changed: debounce, so typing does not fire a
511 // request per keystroke. Anything else is a picker: reload now.
512 var typed = oldValue
513 typed.search = filter.search
514 if typed == filter {
515 scheduleSearchReload()
516 } else {
517 reloadTask?.cancel()
518 reloadTask = Task { await list.reload() }
519 }
520 }
521 }
522
523 /// Open and closed milestones for the filter picker; fetched on first use.
524 private(set) var milestones: [Milestone] = []
525 private var reloadTask: Task<Void, Never>?
526
527 func loadMilestones() async {
528 guard milestones.isEmpty else { return }
529 milestones = (try? await client.readList(
530 ["milestone", "list", repoPath, "--state", "all"], of: Milestone.self
531 )) ?? []
532 }
533
534 private func scheduleSearchReload() {
535 reloadTask?.cancel()
536 reloadTask = Task {
537 guard (try? await Task.sleep(for: .milliseconds(300))) != nil else { return }
538 guard !Task.isCancelled else { return }
539 await list.reload()
540 }
541 }
542
543 private func configureList() {
544 list.argv = ["issue", "list", repoPath] + filter.flags()
545 list.emptyMessage = filter.emptyMessage
546 }
547```
548
549Update `init` to build the initial argv the same way: `["issue", "list", repoPath] + IssueFilter().flags()`, and `emptyMessage: IssueFilter().emptyMessage`.
550
551Apply the identical shape to `MRListViewModel` with `MRFilter`, `["mr", "list", repoPath]`, and the same `loadMilestones()`. `MRListViewModel` does not currently store `client`; add `private let client: GitbayClient` as `IssueListViewModel` already has.
552
553- [ ] **Step 4: Run the tests**, - [ ] **Step 5: Commit**
554
555```bash
556git commit -m "Both list view models filter and search through their commands"
557```
558
559---
560
561### Task 3: The filter UI on both lists
562
563**Files:**
564- Create: `gitbay/Views/Shared/FilterSheet.swift`
565- Modify: `gitbay/Views/Issues/IssueListView.swift`, `gitbay/Views/MRs/MRListView.swift`
566
567No unit tests — UI. Verification is the build plus the suite staying green.
568
569Both lists get:
5701. `.searchable(text: ..., prompt: "Search titles and bodies")` bound to `filter.search`.
5712. The existing inline state `Picker` keeps working, retargeted at `filter.state` and the new `State` enums (use `state.label`, not `rawValue.capitalized`, so `source_gone` reads "Source gone").
5723. A toolbar button (`line.3.horizontal.decrease.circle`, filled when `filter.isNarrowed`) opening a sheet.
573
574`FilterSheet` is a `Form` in a `NavigationStack` with a Done button. It takes bindings and the pickable values, so one view serves both lists:
575
576```swift
577struct FilterSheet<Extra: View>: View {
578 let title: String
579 @Binding var author: String
580 @Binding var milestone: String?
581 let milestones: [Milestone]
582 @ViewBuilder var extra: Extra
583 ...
584}
585```
586
587The issue list passes label and assignee through `extra`; the merge request list passes nothing, because `mr list` accepts neither.
588
589The label row is a `Menu` listing `model.labels` with a `GBChip(name, model.colors.color(name))` per entry plus an "Any label" entry that clears it. The milestone row is a `Menu` over `model.milestones` plus "Any milestone" and "No milestone" (the literal `"none"`). Author and assignee are plain `TextField`s with `.autocorrectionDisabled()` and `.textInputAutocapitalization(.never)` — they are usernames, and no command lists a repository's contributors.
590
591Call `.task { await model.loadMilestones() }` on the sheet so the picker fills on first open.
592
593- [ ] **Step 1: Write `FilterSheet`**
594- [ ] **Step 2: Wire it into `IssueListView`** — `.searchable`, retarget the state picker, add the toolbar button and sheet
595- [ ] **Step 3: Wire it into `MRListView`** — same, minus label and assignee
596- [ ] **Step 4: Build and run the full suite.** Expected: still green, no new tests.
597- [ ] **Step 5: Commit**
598
599```bash
600git commit -m "Filter and search controls on the issue and merge request lists"
601```
602
603---
604
605### Task 4: Flip the parity rows and open the merge request
606
607**Files:** `krz/gitbay`, `.gitbay/wiki/Parity.org`
608
609- [ ] **Step 1: Flip three rows to `yes` in the `ios` column**
610
611In the Issues table: `filter by label, assignee, author, milestone`, and `search title and body`.
612In the Merge requests table: `search title and body`.
613
614Touch no other row.
615
616**How to land it** — this repo requires signed commits, so only fast-forward merges are permitted, and its working tree usually has unrelated work in it. Use a separate worktree off current `origin/main`, and merge with `--strategy ff`:
617
618```bash
619cd /Users/cmc/git/krz/gitbay
620git fetch origin main
621git worktree add -b wiki-parity-ios-filters <tmpdir> origin/main
622# edit, commit, push
623gitbay mr create krz/gitbay --source wiki-parity-ios-filters --target main --title "..." --file -
624gitbay mr merge krz/gitbay <n> --strategy ff
625git worktree remove <tmpdir>
626```
627
628If `main` moved between branching and merging, rebase onto it and force-push with lease before merging — `squash` and `rebase` strategies are refused outright because the server cannot sign the commits it would author.
629
630- [ ] **Step 2: Run the full suite one last time.** Record the real number.
631
632- [ ] **Step 3: Open the gitbay-ios merge request**
633
634```bash
635gitbay mr create --source list-filters --target main \
636 --title "Filter and search the issue and merge request lists" --file - <<'EOF'
637`IssueFilter` and `MRFilter` render themselves to the flags their list
638commands accept, and both list view models build argv from one. An empty
639value sends no flag, since `--search ""` is a different query from no
640search; `--milestone none` is a real filter and survives as one.
641
642Typing debounces at 300ms the way the repository list already does;
643changing a picker reloads immediately.
644
645The issue list filters by label, assignee, author and milestone, with the
646label picker reading the labels MR 1 already loads. The merge request
647list offers author and milestone only, because `mr list` accepts neither
648`--label` nor `--assignee`.
649
650Closes three parity rows: issue filtering, issue search, and merge
651request search.
652EOF
653```
654
655---
656
657## Notes for whoever executes this
658
659**The flag list is the contract.** A flag `issue list` does not accept is a server error that surfaces as an empty list, not a crash — which is why Task 1's tests assert exact argv arrays rather than "contains". Do not add a flag because it seems useful; the registry quotes above are exhaustive.
660
661**`mr list` has no `--label` or `--assignee`.** If you find yourself adding either to `MRFilter`, stop.
662
663**Do not escape search text.** The server quotes FTS5 term by term, so `c++`, `AND` and a lone quote are all words to match. A test pins this.
664
665**`source_gone` is the wire spelling.** The Swift case is `sourceGone` with an explicit raw value; the picker shows "Source gone". All three differ and a test pins each.
666
667**`StateFilter` is deleted from both view models.** Task 3 must retarget the existing pickers, or the build breaks.