Filter and search the issue and merge request lists !48

merged merged by cmc on 2026-09-06 19:46 UTC · krz/gitbay-ios:list-filters into main

12 files changed, +1415 −32

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.
gitbay/Issues/IssueFilter.swift added +53
@@ -0,0 +1,53 @@
1import Foundation
2
3/// What `issue list` is narrowed to. Renders itself to the flags the
4/// command accepts; an empty value sends no flag at all, since
5/// `--search ""` is a different query from no search.
6nonisolated struct IssueFilter: Equatable, Sendable {
7
8 enum State: String, CaseIterable, Identifiable, Sendable {
9 case open, closed, all
10 var id: String { rawValue }
11 var label: String { rawValue.capitalized }
12 }
13
14 var state: State = .open
15 var search = ""
16 var label: String?
17 var assignee: String?
18 var author: String?
19 /// A milestone title, or the literal "none" for issues without one.
20 var milestone: String?
21
22 func flags() -> [String] {
23 var flags = ["--state", state.rawValue]
24 flags += filterFlag("--search", search)
25 flags += filterFlag("--label", label)
26 flags += filterFlag("--assignee", assignee)
27 flags += filterFlag("--author", author)
28 flags += filterFlag("--milestone", milestone)
29 return flags
30 }
31
32 /// True when anything beyond the state picker is set, which is what
33 /// decides whether the empty state blames the filters.
34 var isNarrowed: Bool { flags().count > 2 }
35
36 var emptyMessage: String {
37 if isNarrowed {
38 return state == .all
39 ? "No issues match these filters."
40 : "No \(state.rawValue) issues match these filters."
41 }
42 return state == .all ? "No issues." : "No \(state.rawValue) issues."
43 }
44}
45
46/// One flag, or nothing when the value is absent or blank. Shared by
47/// both filters, so it is named for what it does rather than taking the
48/// bare name `flag` in the module namespace.
49nonisolated func filterFlag(_ name: String, _ value: String?) -> [String] {
50 guard let value else { return [] }
51 let trimmed = value.trimmingCharacters(in: .whitespaces)
52 return trimmed.isEmpty ? [] : [name, trimmed]
53}
gitbay/Issues/IssueListViewModel.swift +39 −11
@@ -6,19 +6,26 @@ import Observation
66@MainActor
77final class IssueListViewModel {
88
9 enum StateFilter: String, CaseIterable, Identifiable, Sendable {
10 case open, closed, all
11 var id: String { rawValue }
12 }
13
149 let list: PagedListModel<Issue>
1510 let repoPath: String
1611 private let client: GitbayClient
17 var filter: StateFilter = .open {
12 var filter = IssueFilter() {
1813 didSet {
1914 guard filter != oldValue else { return }
2015 configureList()
21 Task { await list.reload() }
16 // Only the free-text fields changed: debounce, so typing does
17 // not fire a request per keystroke. Anything else — a picker —
18 // reloads now.
19 var typed = oldValue
20 typed.search = filter.search
21 typed.author = filter.author
22 typed.assignee = filter.assignee
23 if typed == filter {
24 scheduleSearchReload()
25 } else {
26 reloadTask?.cancel()
27 reloadTask = Task { await list.reload() }
28 }
2229 }
2330 }
2431
@@ -31,19 +38,24 @@ final class IssueListViewModel {
3138 /// not on every reload.
3239 private var labelsLoaded = false
3340
41 /// Open and closed milestones for the filter picker; fetched on first use.
42 private(set) var milestones: [Milestone] = []
43 private var reloadTask: Task<Void, Never>?
44
3445 init(client: GitbayClient, repoPath: String) {
3546 self.client = client
3647 self.repoPath = repoPath
3748 list = PagedListModel(
3849 client: client,
39 argv: ["issue", "list", repoPath, "--state", StateFilter.open.rawValue],
40 emptyMessage: "No open issues."
50 argv: ["issue", "list", repoPath] + IssueFilter().flags(),
51 emptyMessage: IssueFilter().emptyMessage
4152 )
4253 }
4354
4455 var state: LoadState<[Issue]> { list.state }
4556
4657 func load() async {
58 reloadTask?.cancel()
4759 await list.reload()
4860 if case .failed = list.state { return }
4961 guard !labelsLoaded else { return }
@@ -52,12 +64,28 @@ final class IssueListViewModel {
5264 colors = LabelColors(labels)
5365 }
5466
67 func loadMilestones() async {
68 guard milestones.isEmpty else { return }
69 milestones = (try? await client.readList(
70 ["milestone", "list", repoPath, "--state", "all"], of: Milestone.self
71 )) ?? []
72 }
73
5574 private func labelList() async -> [IssueLabel] {
5675 (try? await client.readList(["label", "list", repoPath], of: IssueLabel.self)) ?? []
5776 }
5877
78 private func scheduleSearchReload() {
79 reloadTask?.cancel()
80 reloadTask = Task {
81 guard (try? await Task.sleep(for: .milliseconds(300))) != nil else { return }
82 guard !Task.isCancelled else { return }
83 await list.reload()
84 }
85 }
86
5987 private func configureList() {
60 list.argv = ["issue", "list", repoPath, "--state", filter.rawValue]
61 list.emptyMessage = "No \(filter == .all ? "" : filter.rawValue + " ")issues."
88 list.argv = ["issue", "list", repoPath] + filter.flags()
89 list.emptyMessage = filter.emptyMessage
6290 }
6391}
gitbay/MRs/MRFilter.swift added +42
@@ -0,0 +1,42 @@
1import Foundation
2
3/// What `mr list` is narrowed to. The command takes no `--label` and no
4/// `--assignee`, so this offers neither.
5nonisolated struct MRFilter: Equatable, Sendable {
6
7 enum State: String, CaseIterable, Identifiable, Sendable {
8 case open, merged, closed
9 case sourceGone = "source_gone"
10 case all
11
12 var id: String { rawValue }
13 var label: String {
14 self == .sourceGone ? "Source gone" : rawValue.capitalized
15 }
16 }
17
18 var state: State = .open
19 var search = ""
20 var author: String?
21 var milestone: String?
22
23 func flags() -> [String] {
24 var flags = ["--state", state.rawValue]
25 flags += filterFlag("--search", search)
26 flags += filterFlag("--author", author)
27 flags += filterFlag("--milestone", milestone)
28 return flags
29 }
30
31 var isNarrowed: Bool { flags().count > 2 }
32
33 var emptyMessage: String {
34 let noun = "merge requests"
35 if isNarrowed {
36 return state == .all
37 ? "No \(noun) match these filters."
38 : "No \(state.label.lowercased()) \(noun) match these filters."
39 }
40 return state == .all ? "No \(noun)." : "No \(state.label.lowercased()) \(noun)."
41 }
42}
gitbay/MRs/MRListViewModel.swift +41 −12
@@ -6,39 +6,68 @@ import Observation
66@MainActor
77final class MRListViewModel {
88
9 enum StateFilter: String, CaseIterable, Identifiable, Sendable {
10 case open, merged, closed, all
11 var id: String { rawValue }
12 }
13
149 let list: PagedListModel<MergeRequest>
1510 let repoPath: String
16 var filter: StateFilter = .open {
11 private let client: GitbayClient
12 var filter = MRFilter() {
1713 didSet {
1814 guard filter != oldValue else { return }
1915 configureList()
20 Task { await list.reload() }
16 // Only the free-text fields changed: debounce, so typing does
17 // not fire a request per keystroke. Anything else — a picker —
18 // reloads now. MRFilter has no `assignee` field: `mr list`
19 // rejects it.
20 var typed = oldValue
21 typed.search = filter.search
22 typed.author = filter.author
23 if typed == filter {
24 scheduleSearchReload()
25 } else {
26 reloadTask?.cancel()
27 reloadTask = Task { await list.reload() }
28 }
2129 }
2230 }
2331
32 /// Open and closed milestones for the filter picker; fetched on first use.
33 private(set) var milestones: [Milestone] = []
34 private var reloadTask: Task<Void, Never>?
35
2436 init(client: GitbayClient, repoPath: String) {
37 self.client = client
2538 self.repoPath = repoPath
2639 list = PagedListModel(
2740 client: client,
28 argv: ["mr", "list", repoPath, "--state", StateFilter.open.rawValue],
29 emptyMessage: "No open merge requests."
41 argv: ["mr", "list", repoPath] + MRFilter().flags(),
42 emptyMessage: MRFilter().emptyMessage
3043 )
3144 }
3245
3346 var state: LoadState<[MergeRequest]> { list.state }
3447
3548 func load() async {
49 reloadTask?.cancel()
3650 await list.reload()
3751 }
3852
53 func loadMilestones() async {
54 guard milestones.isEmpty else { return }
55 milestones = (try? await client.readList(
56 ["milestone", "list", repoPath, "--state", "all"], of: Milestone.self
57 )) ?? []
58 }
59
60 private func scheduleSearchReload() {
61 reloadTask?.cancel()
62 reloadTask = Task {
63 guard (try? await Task.sleep(for: .milliseconds(300))) != nil else { return }
64 guard !Task.isCancelled else { return }
65 await list.reload()
66 }
67 }
68
3969 private func configureList() {
40 list.argv = ["mr", "list", repoPath, "--state", filter.rawValue]
41 list.emptyMessage =
42 "No \(filter == .all ? "" : filter.rawValue + " ")merge requests."
70 list.argv = ["mr", "list", repoPath] + filter.flags()
71 list.emptyMessage = filter.emptyMessage
4372 }
4473}
gitbay/Repos/PagedListModel.swift +5 −2
@@ -59,8 +59,11 @@ final class PagedListModel<Element: Decodable & Sendable> {
5959 } catch {
6060 // A failed first page is the screen's state; a failed later
6161 // page keeps what is on screen and leaves the cursor for a
62 // retry from the same trigger.
63 if existing.isEmpty {
62 // retry from the same trigger. A cancelled fetch (superseded
63 // by a newer reload, e.g. a fast filter change) is neither —
64 // it must not paint a stale failure over whatever the
65 // replacement fetch already showed or is about to show.
66 if existing.isEmpty, !Task.isCancelled {
6467 state = .from(error)
6568 }
6669 }
gitbay/Views/Issues/IssueListView.swift +35 −3
@@ -5,6 +5,7 @@ struct IssueListView: View {
55 @State private var model: IssueListViewModel
66 @State private var createModel: IssueCreateViewModel
77 @State private var composing = false
8 @State private var filtering = false
89 @State private var draftTitle = ""
910 @State private var draftBody = ""
1011
@@ -15,9 +16,9 @@ struct IssueListView: View {
1516
1617 var body: some View {
1718 List {
18 Picker("State", selection: Bindable(model).filter) {
19 ForEach(IssueListViewModel.StateFilter.allCases) { filter in
20 Text(filter.rawValue.capitalized).tag(filter)
19 Picker("State", selection: Bindable(model).filter.state) {
20 ForEach(IssueFilter.State.allCases) { filter in
21 Text(filter.label).tag(filter)
2122 }
2223 }
2324 .pickerStyle(.segmented)
@@ -32,9 +33,20 @@ struct IssueListView: View {
3233 PageFooter(list: model.list)
3334 }
3435 .overlay { LoadStateOverlay(state: model.state) }
36 .searchable(text: Bindable(model).filter.search, prompt: "Search titles and bodies")
3537 .navigationTitle("Issues")
3638 .navigationBarTitleDisplayMode(.inline)
3739 .toolbar {
40 ToolbarItem(placement: .topBarTrailing) {
41 Button {
42 filtering = true
43 } label: {
44 Image(systemName: model.filter.isNarrowed
45 ? "line.3.horizontal.decrease.circle.fill"
46 : "line.3.horizontal.decrease.circle")
47 }
48 .accessibilityIdentifier("issue-filter-button")
49 }
3850 ToolbarItem(placement: .topBarTrailing) {
3951 Button {
4052 composing = true
@@ -44,6 +56,26 @@ struct IssueListView: View {
4456 .accessibilityIdentifier("issue-create-button")
4557 }
4658 }
59 .sheet(isPresented: $filtering) {
60 FilterSheet(
61 title: "Filter Issues",
62 author: Bindable(model).filter.author,
63 milestone: Bindable(model).filter.milestone,
64 milestones: model.milestones,
65 onClear: {
66 let state = model.filter.state
67 model.filter = IssueFilter(state: state)
68 }
69 ) {
70 IssueFilterExtra(
71 label: Bindable(model).filter.label,
72 assignee: Bindable(model).filter.assignee,
73 labels: model.labels,
74 colors: model.colors
75 )
76 }
77 .task { await model.loadMilestones() }
78 }
4779 .sheet(isPresented: $composing) {
4880 ComposeSheet(
4981 heading: "New Issue",
gitbay/Views/MRs/MRListView.swift +28 −3
@@ -5,6 +5,7 @@ struct MRListView: View {
55 @State private var model: MRListViewModel
66 @State private var createModel: MRCreateViewModel
77 @State private var composing = false
8 @State private var filtering = false
89
910 init(client: GitbayClient, repo: String) {
1011 _model = State(initialValue: MRListViewModel(client: client, repoPath: repo))
@@ -13,9 +14,9 @@ struct MRListView: View {
1314
1415 var body: some View {
1516 List {
16 Picker("State", selection: Bindable(model).filter) {
17 ForEach(MRListViewModel.StateFilter.allCases) { filter in
18 Text(filter.rawValue.capitalized).tag(filter)
17 Picker("State", selection: Bindable(model).filter.state) {
18 ForEach(MRFilter.State.allCases) { filter in
19 Text(filter.label).tag(filter)
1920 }
2021 }
2122 .pickerStyle(.segmented)
@@ -30,9 +31,20 @@ struct MRListView: View {
3031 PageFooter(list: model.list)
3132 }
3233 .overlay { LoadStateOverlay(state: model.state) }
34 .searchable(text: Bindable(model).filter.search, prompt: "Search titles and bodies")
3335 .navigationTitle("Merge Requests")
3436 .navigationBarTitleDisplayMode(.inline)
3537 .toolbar {
38 ToolbarItem(placement: .topBarTrailing) {
39 Button {
40 filtering = true
41 } label: {
42 Image(systemName: model.filter.isNarrowed
43 ? "line.3.horizontal.decrease.circle.fill"
44 : "line.3.horizontal.decrease.circle")
45 }
46 .accessibilityIdentifier("mr-filter-button")
47 }
3648 ToolbarItem(placement: .topBarTrailing) {
3749 Button {
3850 composing = true
@@ -42,6 +54,19 @@ struct MRListView: View {
4254 .accessibilityIdentifier("mr-create-button")
4355 }
4456 }
57 .sheet(isPresented: $filtering) {
58 FilterSheet(
59 title: "Filter Merge Requests",
60 author: Bindable(model).filter.author,
61 milestone: Bindable(model).filter.milestone,
62 milestones: model.milestones,
63 onClear: {
64 let state = model.filter.state
65 model.filter = MRFilter(state: state)
66 }
67 ) {}
68 .task { await model.loadMilestones() }
69 }
4570 .sheet(isPresented: $composing) {
4671 MRCreateSheet(model: createModel) {
4772 composing = false
gitbay/Views/Shared/FilterSheet.swift added +115
@@ -0,0 +1,115 @@
1import SwiftUI
2
3/// The issue and MR lists' filter sheet. A `Form` with the fields both
4/// lists share — author and milestone — plus an `extra` slot for the
5/// rows only issues have, since `mr list` takes neither `--label` nor
6/// `--assignee`.
7struct FilterSheet<Extra: View>: View {
8
9 let title: String
10 @Binding var author: String?
11 @Binding var milestone: String?
12 let milestones: [Milestone]
13 let onClear: () -> Void
14 @ViewBuilder var extra: Extra
15
16 @Environment(\.dismiss) private var dismiss
17
18 var body: some View {
19 NavigationStack {
20 Form {
21 extra
22 Section("Author") {
23 TextField("Username", text: $author.orEmpty)
24 .autocorrectionDisabled()
25 .textInputAutocapitalization(.never)
26 .accessibilityIdentifier("filter-author")
27 }
28 Section("Milestone") {
29 Menu {
30 Button("Any milestone") { milestone = nil }
31 Button("No milestone") { milestone = "none" }
32 ForEach(milestones) { entry in
33 Button(entry.title) { milestone = entry.title }
34 }
35 } label: {
36 HStack {
37 Text(milestone ?? "Any milestone")
38 Spacer()
39 Image(systemName: "chevron.up.chevron.down")
40 .font(.gbSans(.caption2))
41 .foregroundStyle(.secondary)
42 }
43 }
44 .accessibilityIdentifier("filter-milestone-menu")
45 }
46 Section {
47 Button("Clear filters", role: .destructive, action: onClear)
48 }
49 }
50 .navigationTitle(title)
51 .navigationBarTitleDisplayMode(.inline)
52 .toolbar {
53 ToolbarItem(placement: .confirmationAction) {
54 Button("Done") { dismiss() }
55 }
56 }
57 }
58 }
59}
60
61/// The label and assignee rows the issue list's sheet adds through
62/// `FilterSheet`'s `extra` slot.
63struct IssueFilterExtra: View {
64
65 @Binding var label: String?
66 @Binding var assignee: String?
67 let labels: [IssueLabel]
68 let colors: LabelColors
69
70 var body: some View {
71 Section("Label") {
72 Menu {
73 Button("Any label") { label = nil }
74 ForEach(labels) { entry in
75 Button {
76 label = entry.name
77 } label: {
78 Text(entry.name)
79 }
80 }
81 } label: {
82 HStack {
83 if let label {
84 GBChip(label, colors.color(label))
85 } else {
86 Text("Any label")
87 }
88 Spacer()
89 Image(systemName: "chevron.up.chevron.down")
90 .font(.gbSans(.caption2))
91 .foregroundStyle(.secondary)
92 }
93 }
94 .accessibilityIdentifier("filter-label-menu")
95 }
96 Section("Assignee") {
97 TextField("Username", text: $assignee.orEmpty)
98 .autocorrectionDisabled()
99 .textInputAutocapitalization(.never)
100 .accessibilityIdentifier("filter-assignee")
101 }
102 }
103}
104
105extension Binding where Value == String? {
106 /// Bridges an optional filter field to a plain `TextField`: empty
107 /// text writes back `nil` rather than `""`, since the two are
108 /// distinct filter values (no flag sent versus `--flag ""`).
109 var orEmpty: Binding<String> {
110 Binding<String>(
111 get: { self.wrappedValue ?? "" },
112 set: { self.wrappedValue = $0.isEmpty ? nil : $0 }
113 )
114 }
115}
gitbayTests/ListFilterTests.swift added +373
@@ -0,0 +1,373 @@
1import Foundation
2import Testing
3@testable import gitbay
4
5struct IssueFilterTests {
6
7 @Test func theDefaultIsOpenAndNothingElse() {
8 #expect(IssueFilter().flags() == ["--state", "open"])
9 #expect(IssueFilter().isNarrowed == false)
10 }
11
12 @Test func stateIsAlwaysFirst() {
13 var f = IssueFilter()
14 f.state = .closed
15 f.author = "cmc"
16 #expect(f.flags().prefix(2) == ["--state", "closed"])
17 }
18
19 @Test func eachFieldRendersItsOwnFlag() {
20 var f = IssueFilter()
21 f.label = "bug"
22 f.assignee = "cmc"
23 f.author = "rae"
24 f.milestone = "v1.0.0"
25 f.search = "crash on launch"
26 #expect(f.flags() == [
27 "--state", "open",
28 "--search", "crash on launch",
29 "--label", "bug",
30 "--assignee", "cmc",
31 "--author", "rae",
32 "--milestone", "v1.0.0",
33 ])
34 #expect(f.isNarrowed)
35 }
36
37 /// An empty value must send no flag. `--search ""` is a different
38 /// query from no search at all.
39 @Test func emptyAndWhitespaceValuesSendNoFlag() {
40 var f = IssueFilter()
41 f.search = " "
42 f.label = ""
43 f.assignee = nil
44 f.author = " "
45 f.milestone = ""
46 #expect(f.flags() == ["--state", "open"])
47 #expect(f.isNarrowed == false)
48 }
49
50 /// "none" is a real milestone filter meaning "has no milestone".
51 @Test func milestoneNoneIsAValueNotAnAbsence() {
52 var f = IssueFilter()
53 f.milestone = "none"
54 #expect(f.flags() == ["--state", "open", "--milestone", "none"])
55 #expect(f.isNarrowed)
56 }
57
58 /// The server quotes FTS5 terms itself, so an operator is a word to
59 /// match. The app must not escape or reject it.
60 @Test func searchTextIsPassedThroughUnquoted() {
61 var f = IssueFilter()
62 f.search = "c++ AND \"quoted"
63 #expect(f.flags() == ["--state", "open", "--search", "c++ AND \"quoted"])
64 }
65
66 @Test func surroundingWhitespaceIsTrimmedFromValues() {
67 var f = IssueFilter()
68 f.search = " crash "
69 f.label = " bug "
70 #expect(f.flags() == ["--state", "open", "--search", "crash", "--label", "bug"])
71 }
72
73 @Test func theEmptyMessageNamesTheNarrowing() {
74 #expect(IssueFilter().emptyMessage == "No open issues.")
75 var all = IssueFilter()
76 all.state = .all
77 #expect(all.emptyMessage == "No issues.")
78 var narrowed = IssueFilter()
79 narrowed.label = "bug"
80 #expect(narrowed.emptyMessage == "No open issues match these filters.")
81 }
82}
83
84struct MRFilterTests {
85
86 @Test func theDefaultIsOpenAndNothingElse() {
87 #expect(MRFilter().flags() == ["--state", "open"])
88 #expect(MRFilter().isNarrowed == false)
89 }
90
91 @Test func eachFieldRendersItsOwnFlag() {
92 var f = MRFilter()
93 f.author = "cmc"
94 f.milestone = "v1.0.0"
95 f.search = "retarget"
96 #expect(f.flags() == [
97 "--state", "open",
98 "--search", "retarget",
99 "--author", "cmc",
100 "--milestone", "v1.0.0",
101 ])
102 }
103
104 /// `mr list` accepts source_gone; the spelling is the wire spelling,
105 /// not the Swift case name.
106 @Test func sourceGoneSpellsItselfTheWayTheCommandDoes() {
107 var f = MRFilter()
108 f.state = .sourceGone
109 #expect(f.flags() == ["--state", "source_gone"])
110 #expect(MRFilter.State.sourceGone.label == "Source gone")
111 }
112
113 @Test func everyStateTheCommandAcceptsIsOffered() {
114 #expect(MRFilter.State.allCases.map(\.rawValue)
115 == ["open", "merged", "closed", "source_gone", "all"])
116 }
117
118 @Test func emptyValuesSendNoFlag() {
119 var f = MRFilter()
120 f.search = " "
121 f.author = ""
122 #expect(f.flags() == ["--state", "open"])
123 }
124
125 @Test func theEmptyMessageNamesTheNarrowing() {
126 #expect(MRFilter().emptyMessage == "No open merge requests.")
127 var narrowed = MRFilter()
128 narrowed.search = "x"
129 #expect(narrowed.emptyMessage == "No open merge requests match these filters.")
130 }
131}
132
133private func makeClient() throws -> (GitbayClient, StubProtocol.Box) {
134 let box = StubProtocol.box()
135 let client = GitbayClient(
136 instance: try GitbayInstance(url: "https://gitbay.org"),
137 token: "test-token",
138 session: box.session()
139 )
140 return (client, box)
141}
142
143private let emptyPage = """
144 {"protocol_version":1,"data":{"items":[]},"exit_code":0}
145 """
146private let noLabels = """
147 {"protocol_version":1,"data":[],"exit_code":0}
148 """
149private let pageWithCursor = """
150 {"protocol_version":1,"data":{"items":[\
151 {"number":7,"title":"a bug","state":"open","author":"cmc",\
152 "created_at":"2026-09-01T00:00:00Z"}],"next":"c1"},"exit_code":0}
153 """
154
155/// Every `argv=` value from a request URL, in order.
156private func argvFrom(_ url: URL) -> [String] {
157 URLComponents(url: url, resolvingAgainstBaseURL: false)?
158 .queryItems?.filter { $0.name == "argv" }.compactMap(\.value) ?? []
159}
160
161@MainActor
162struct IssueListFilterWiringTests {
163
164 @Test func theDefaultReadIsOpenIssues() async throws {
165 let (client, stub) = try makeClient()
166 stub.enqueue(.init(status: 200, json: emptyPage, match: "argv=issue"))
167 stub.enqueue(.init(status: 200, json: noLabels, match: "argv=label"))
168
169 let model = IssueListViewModel(client: client, repoPath: "krz/gitbay")
170 await model.load()
171
172 let read = try #require(stub.seen.first { $0.url.absoluteString.contains("argv=issue") })
173 #expect(argvFrom(read.url).prefix(6) == [
174 "issue", "list", "krz/gitbay", "--state", "open", "--limit",
175 ])
176 }
177
178 @Test func aPickerChangeReloadsWithTheNewFlags() async throws {
179 let (client, stub) = try makeClient()
180 stub.enqueue(.init(status: 200, json: emptyPage, match: "argv=issue"))
181 stub.enqueue(.init(status: 200, json: noLabels, match: "argv=label"))
182 let model = IssueListViewModel(client: client, repoPath: "krz/gitbay")
183 await model.load()
184
185 stub.enqueue(.init(status: 200, json: emptyPage, match: "argv=issue"))
186 model.filter.label = "bug"
187 try await Task.sleep(for: .milliseconds(150))
188
189 let reads = stub.seen.filter { $0.url.absoluteString.contains("argv=issue") }
190 let last = try #require(reads.last)
191 #expect(argvFrom(last.url).contains("--label"))
192 #expect(argvFrom(last.url).contains("bug"))
193 // Immediate, not debounced: two reads by now.
194 #expect(reads.count == 2)
195 }
196
197 /// Typing must not fire a request per keystroke.
198 @Test func typingDebouncesIntoOneRead() async throws {
199 let (client, stub) = try makeClient()
200 stub.enqueue(.init(status: 200, json: emptyPage, match: "argv=issue"))
201 stub.enqueue(.init(status: 200, json: noLabels, match: "argv=label"))
202 let model = IssueListViewModel(client: client, repoPath: "krz/gitbay")
203 await model.load()
204
205 stub.enqueue(.init(status: 200, json: emptyPage, match: "argv=issue"))
206 // No sleeps between keystrokes: `Task.sleep` guarantees a minimum
207 // wait, not a maximum, so any inter-keystroke delay is a window
208 // for the debounce timer to fire early on a loaded machine. Typing
209 // the whole string with no `await` between assignments removes
210 // that window; only the final wait — comfortably past the 300ms
211 // debounce — needs headroom.
212 for text in ["c", "cr", "cra", "cras", "crash"] {
213 model.filter.search = text
214 }
215 try await Task.sleep(for: .milliseconds(600))
216
217 let reads = stub.seen.filter { $0.url.absoluteString.contains("argv=issue") }
218 #expect(reads.count == 2)
219 #expect(argvFrom(try #require(reads.last).url).contains("crash"))
220 }
221
222 /// Typing into `author` must debounce too — it is free text, exactly
223 /// like `search`, and firing a request per keystroke would hit the
224 /// rate limit on an 8-character username.
225 @Test func authorDebouncesIntoOneRead() async throws {
226 let (client, stub) = try makeClient()
227 stub.enqueue(.init(status: 200, json: emptyPage, match: "argv=issue"))
228 stub.enqueue(.init(status: 200, json: noLabels, match: "argv=label"))
229 let model = IssueListViewModel(client: client, repoPath: "krz/gitbay")
230 await model.load()
231
232 stub.enqueue(.init(status: 200, json: emptyPage, match: "argv=issue"))
233 for text in ["c", "cm", "cmc"] {
234 model.filter.author = text
235 }
236 try await Task.sleep(for: .milliseconds(600))
237
238 let reads = stub.seen.filter { $0.url.absoluteString.contains("argv=issue") }
239 #expect(reads.count == 2)
240 let argv = argvFrom(try #require(reads.last).url)
241 #expect(argv.contains("--author"))
242 #expect(argv.contains("cmc"))
243 }
244
245 /// Same for `assignee`, issue-list-only.
246 @Test func assigneeDebouncesIntoOneRead() async throws {
247 let (client, stub) = try makeClient()
248 stub.enqueue(.init(status: 200, json: emptyPage, match: "argv=issue"))
249 stub.enqueue(.init(status: 200, json: noLabels, match: "argv=label"))
250 let model = IssueListViewModel(client: client, repoPath: "krz/gitbay")
251 await model.load()
252
253 stub.enqueue(.init(status: 200, json: emptyPage, match: "argv=issue"))
254 for text in ["c", "cm", "cmc"] {
255 model.filter.assignee = text
256 }
257 try await Task.sleep(for: .milliseconds(600))
258
259 let reads = stub.seen.filter { $0.url.absoluteString.contains("argv=issue") }
260 #expect(reads.count == 2)
261 let argv = argvFrom(try #require(reads.last).url)
262 #expect(argv.contains("--assignee"))
263 #expect(argv.contains("cmc"))
264 }
265
266 /// A filter change reloads rather than pages, so it must not carry
267 /// forward a cursor from whatever page was on screen.
268 @Test func aFilterChangeDoesNotCarryAStaleCursor() async throws {
269 let (client, stub) = try makeClient()
270 stub.enqueue(.init(status: 200, json: pageWithCursor, match: "argv=issue"))
271 stub.enqueue(.init(status: 200, json: noLabels, match: "argv=label"))
272 let model = IssueListViewModel(client: client, repoPath: "krz/gitbay")
273 await model.load()
274 #expect(model.list.hasMore)
275
276 stub.enqueue(.init(status: 200, json: emptyPage, match: "argv=issue"))
277 model.filter.label = "bug"
278 try await Task.sleep(for: .milliseconds(150))
279
280 let reads = stub.seen.filter { $0.url.absoluteString.contains("argv=issue") }
281 let last = try #require(reads.last)
282 #expect(!argvFrom(last.url).contains("--cursor"))
283 }
284
285 /// Two rapid picker changes cancel the first reload's task. The
286 /// cancelled fetch must not paint a bogus "check your connection"
287 /// failure over the correct state the second reload lands.
288 @Test func rapidFilterChangesDoNotLeaveTheScreenFailed() async throws {
289 let (client, stub) = try makeClient()
290 stub.enqueue(.init(status: 200, json: emptyPage, match: "argv=issue"))
291 stub.enqueue(.init(status: 200, json: noLabels, match: "argv=label"))
292 let model = IssueListViewModel(client: client, repoPath: "krz/gitbay")
293 await model.load()
294
295 stub.enqueue(.init(status: 200, json: emptyPage, match: "argv=issue"))
296 stub.enqueue(.init(status: 200, json: emptyPage, match: "argv=issue"))
297 model.filter.label = "bug"
298 model.filter.label = "feature"
299 try await Task.sleep(for: .milliseconds(200))
300
301 if case .failed(let message) = model.state {
302 Testing.Issue.record("rapid filter changes left the screen failed: \(message)")
303 }
304 }
305
306 @Test func theEmptyMessageComesFromTheFilter() async throws {
307 let (client, stub) = try makeClient()
308 stub.enqueue(.init(status: 200, json: emptyPage, match: "argv=issue"))
309 stub.enqueue(.init(status: 200, json: noLabels, match: "argv=label"))
310 let model = IssueListViewModel(client: client, repoPath: "krz/gitbay")
311 await model.load()
312
313 guard case .empty(let message) = model.state else {
314 Testing.Issue.record("expected empty, got \(model.state)")
315 return
316 }
317 #expect(message == "No open issues.")
318 }
319}
320
321@MainActor
322struct MRListFilterWiringTests {
323
324 @Test func searchReachesTheCommand() async throws {
325 let (client, stub) = try makeClient()
326 stub.enqueue(.init(status: 200, json: emptyPage))
327 let model = MRListViewModel(client: client, repoPath: "krz/gitbay")
328 await model.load()
329
330 stub.enqueue(.init(status: 200, json: emptyPage))
331 model.filter.search = "retarget"
332 try await Task.sleep(for: .milliseconds(500))
333
334 let last = try #require(stub.seen.last)
335 let argv = argvFrom(last.url)
336 #expect(argv.contains("--search"))
337 #expect(argv.contains("retarget"))
338 }
339
340 /// Typing into `author` must debounce into one read, same as `search`
341 /// — and, unlike the other tests in this struct, actually count the
342 /// requests rather than just inspecting the last one.
343 @Test func authorDebouncesIntoOneRead() async throws {
344 let (client, stub) = try makeClient()
345 stub.enqueue(.init(status: 200, json: emptyPage))
346 let model = MRListViewModel(client: client, repoPath: "krz/gitbay")
347 await model.load()
348
349 stub.enqueue(.init(status: 200, json: emptyPage))
350 for text in ["c", "cm", "cmc"] {
351 model.filter.author = text
352 }
353 try await Task.sleep(for: .milliseconds(600))
354
355 #expect(stub.seen.count == 2)
356 let argv = argvFrom(try #require(stub.seen.last).url)
357 #expect(argv.contains("--author"))
358 #expect(argv.contains("cmc"))
359 }
360
361 @Test func sourceGoneReachesTheCommandInItsWireSpelling() async throws {
362 let (client, stub) = try makeClient()
363 stub.enqueue(.init(status: 200, json: emptyPage))
364 let model = MRListViewModel(client: client, repoPath: "krz/gitbay")
365 await model.load()
366
367 stub.enqueue(.init(status: 200, json: emptyPage))
368 model.filter.state = .sourceGone
369 try await Task.sleep(for: .milliseconds(150))
370
371 #expect(argvFrom(try #require(stub.seen.last).url).contains("source_gone"))
372 }
373}
gitbayTests/MRViewModelTests.swift +1 −1
@@ -171,7 +171,7 @@ struct MRListViewModelTests {
171171 let model = MRListViewModel(client: client, repoPath: "krz/gitbay")
172172 await model.load()
173173
174 model.filter = .merged
174 model.filter.state = .merged
175175 // The reload happens in a spawned task; give it a beat.
176176 try await Task.sleep(for: .milliseconds(300))
177177
gitbayTests/PagedListTests.swift +16
@@ -111,6 +111,22 @@ struct PagedListModelTests {
111111 #expect(list.state.value?.map(\.number) == [1, 2, 3])
112112 }
113113
114 /// A cancelled reload (e.g. superseded by a newer one from a fast
115 /// filter change) must not write a `.failed` state — whatever error
116 /// the cancellation surfaces as, it belongs to a fetch nobody wants
117 /// the result of any more.
118 @Test func aCancelledReloadDoesNotWriteAFailedState() async throws {
119 let (list, _) = try makeList()
120
121 let task = Task { await list.reload() }
122 task.cancel()
123 await task.value
124
125 if case .failed(let message) = list.state {
126 Issue.record("cancelled reload left the screen failed: \(message)")
127 }
128 }
129
114130 @Test func reloadDropsTheOldCursorAndItems() async throws {
115131 let (list, stub) = try makeList()
116132 stub.enqueue(.init(status: 200, json: page([1, 2], next: "c1")))