Commit 9a10b53c43

9a10b53c432b21e1f8432139a4a20323031bdb55

parent: 62763741b4

Unsigned

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

Filter and search the issue and merge request lists (!48)

`IssueFilter` and `MRFilter` render themselves to the flags their list
commands accept, and both list view models build argv from one. An empty
value sends no flag, since `--search ""` is a different query from no
search; `--milestone none` is a real filter and survives as one. Search
text is passed through unquoted, because the server quotes FTS5 term by
term and an operator is a word to match.

Free text debounces at 300ms — search, author and assignee — while a
picker reloads immediately. `PagedListModel` no longer paints a transport
failure when a reload is cancelled by a newer one, which otherwise
flashed a network error over a fast filter change on every list.

The issue list filters by label, assignee, author and milestone, its
label picker reading the labels already loaded for the chips. The merge
request list offers author and milestone only, because `mr list` accepts
neither `--label` nor `--assignee`; the shared filter sheet has no field
for either, so there is no path to send one.

Closes three parity rows: issue filtering, issue search, and merge
request 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.
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")))