Commit 321b57d858

321b57d858224b90dc9333567bb0ab00e9b4a23f

parent: 9a10b53c43

Verified · cmc

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

Implementation plan for MR 3: instance-wide search

Layout: unified · split

docs/superpowers/plans/2026-09-06-mr03-instance-search.md added +496
@@ -0,0 +1,496 @@
1# MR 3: Instance-wide 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 the `search issues and merge requests` parity row — one query across repositories, issues and merge requests, from the Explore tab.
6
7**Architecture:** `search` is a separate command from `explore`, so the Explore screen gains a second mode rather than a filter: an empty query keeps the existing `explore` paged listing; a typed query swaps in `search` results, with a segmented control narrowing to one kind. A `SearchResult` wire model covers all three kinds in one row shape, and each row routes to the destination its kind implies.
8
9**Tech Stack:** Swift 6, default `MainActor` isolation, iOS 26.5, SwiftUI, Swift Testing, `@Observable`.
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 only — never XCTest.
17- `gitbayTests` is hermetic and offline; network goes through `StubProtocol`.
18- New files under `gitbay/` and `gitbayTests/` need **no** `project.pbxproj` edit.
19- The label model is `IssueLabel`, never `Label`.
20- Never mention Claude, LLMs or AI in commits, comments, or the merge request. No `Co-Authored-By` trailer.
21- Never commit to `main`.
22
23**The command, verbatim from the registry:**
24
25```
26search <query> [--kind repo|issue|mr]
27```
28
29Two things follow. It takes **no `--limit` and no `--cursor`** — it is not paginated, so it uses `client.readList`, never `readPage` or `PagedListModel`. And `--kind` takes exactly one of three values; omitting it returns all three kinds.
30
31**The result shape**, from `internal/control/search.go`:
32
33```json
34{"kind":"issue","repo":"krz/gitbay","number":168,"title":"...","author":"cmc",
35 "state":"closed","updated_at":"2026-09-06T16:53:52.595Z"}
36```
37
38`kind` and `repo` and `title` are always present. `number`, `author`, `state` and `updated_at` are `omitempty` — a repository result carries none of them. Decoding must treat all four as optional; a repo row with no `number` is the common case, not an error.
39
40**Build and test command:**
41
42```bash
43xcodebuild -project gitbay.xcodeproj -scheme gitbay \
44 -destination 'platform=iOS Simulator,name=iPhone 17' \
45 test -only-testing:gitbayTests
46```
47
48Baseline: **243 passed, 0 failed, 1 skipped** (`LiveInstanceTests` — expected).
49
50---
51
52## File Structure
53
54| File | Responsibility |
55|------|----------------|
56| `gitbay/Discovery/SearchViewModel.swift` (create) | `SearchResult` wire model, `SearchKind`, and the query/debounce view model |
57| `gitbayTests/SearchTests.swift` (create) | Every test in this plan |
58| `gitbay/Views/Discovery/ExploreView.swift` (modify) | `.searchable`, kind picker, result rows, routing |
59
60---
61
62### Task 1: The search model and view model
63
64**Files:**
65- Create: `gitbay/Discovery/SearchViewModel.swift`
66- Test: `gitbayTests/SearchTests.swift`
67
68**Interfaces produced:**
69- `nonisolated struct SearchResult: Decodable, Sendable, Hashable, Identifiable` — `kind: String`, `repo: String`, `number: Int64?`, `title: String`, `author: String?`, `state: String?`, `updatedAt: Date?`; `var id: String`
70- `nonisolated enum SearchKind: String, CaseIterable, Identifiable, Sendable` — `all`, `repo`, `issue`, `mr`; `var label: String`; `var flag: [String]`
71- `@Observable @MainActor final class SearchViewModel` — `query: String`, `kind: SearchKind`, `state: LoadState<[SearchResult]>`, `var isSearching: Bool`, `func clear()`
72
73Behaviour, each pinned by a test:
741. An empty or whitespace-only query issues **no request at all** and leaves `isSearching` false. The Explore listing stays on screen; searching nothing is not a search.
752. Typing debounces at 300ms, matching `RepoListViewModel` and the list filters — one request per pause, not per keystroke.
763. `kind == .all` sends **no `--kind` flag**; the other three send `--kind repo|issue|mr` with the wire spelling.
774. Changing the kind while a query is present re-searches **immediately** — it is a picker, not typing.
785. The query is passed through **unquoted**. The server quotes FTS5 term by term, so `c++` is a word to match.
796. `id` is unique across kinds and repos — a repo row and an issue row in the same repository must not collide.
80
81- [ ] **Step 1: Write the failing tests**
82
83Create `gitbayTests/SearchTests.swift`:
84
85```swift
86import Foundation
87import Testing
88@testable import gitbay
89
90private func makeClient() throws -> (GitbayClient, StubProtocol.Box) {
91 let box = StubProtocol.box()
92 let client = GitbayClient(
93 instance: try GitbayInstance(url: "https://gitbay.org"),
94 token: "test-token",
95 session: box.session()
96 )
97 return (client, box)
98}
99
100private func argvFrom(_ url: URL) -> [String] {
101 URLComponents(url: url, resolvingAgainstBaseURL: false)?
102 .queryItems?.filter { $0.name == "argv" }.compactMap(\.value) ?? []
103}
104
105private let mixedResults = """
106 {"protocol_version":1,"data":[\
107 {"kind":"repo","repo":"krz/gitbay","title":"krz/gitbay"},\
108 {"kind":"issue","repo":"krz/gitbay","number":168,"title":"a bug",\
109 "author":"cmc","state":"closed","updated_at":"2026-09-06T16:53:52.595Z"},\
110 {"kind":"mr","repo":"krz/gitbay","number":282,"title":"a change",\
111 "author":"cmc","state":"open","updated_at":"2026-09-06T15:17:13.062Z"}\
112 ],"exit_code":0}
113 """
114
115struct SearchResultDecodingTests {
116
117 /// Matches the established direct-decode pattern in
118 /// `DiscoveryTests.swift`: a plain decoder with `.iso8601` dates.
119 private func decode(_ json: String) throws -> [SearchResult] {
120 let decoder = JSONDecoder()
121 decoder.dateDecodingStrategy = .iso8601
122 return try decoder.decode([SearchResult].self, from: Data(json.utf8))
123 }
124
125 private let rows = """
126 [{"kind":"repo","repo":"krz/gitbay","title":"krz/gitbay"},\
127 {"kind":"issue","repo":"krz/gitbay","number":168,"title":"a bug",\
128 "author":"cmc","state":"closed","updated_at":"2026-09-06T16:53:52Z"},\
129 {"kind":"mr","repo":"krz/gitbay","number":282,"title":"a change",\
130 "author":"cmc","state":"open","updated_at":"2026-09-06T15:17:13Z"}]
131 """
132
133 @Test func decodesAllThreeKinds() throws {
134 let results = try decode(rows)
135 #expect(results.map(\.kind) == ["repo", "issue", "mr"])
136 #expect(results[1].number == 168)
137 #expect(results[1].author == "cmc")
138 #expect(results[1].state == "closed")
139 }
140
141 /// A repository result carries no number, author, state or time —
142 /// they are omitempty and absent, not null.
143 @Test func aRepositoryResultHasNoNumberOrAuthor() throws {
144 let repo = try #require(try decode(rows).first)
145 #expect(repo.number == nil)
146 #expect(repo.author == nil)
147 #expect(repo.state == nil)
148 #expect(repo.updatedAt == nil)
149 }
150
151 /// A repo row and an issue row in the same repository must not
152 /// collide, or SwiftUI reuses one for the other.
153 @Test func idsAreUniqueAcrossKinds() throws {
154 let ids = try decode(rows).map(\.id)
155 #expect(Set(ids).count == ids.count)
156 }
157}
158
159struct SearchKindTests {
160
161 @Test func allSendsNoFlag() {
162 #expect(SearchKind.all.flag == [])
163 }
164
165 @Test func theOtherThreeSendTheirWireSpelling() {
166 #expect(SearchKind.repo.flag == ["--kind", "repo"])
167 #expect(SearchKind.issue.flag == ["--kind", "issue"])
168 #expect(SearchKind.mr.flag == ["--kind", "mr"])
169 }
170
171 @Test func everyKindTheCommandAcceptsIsOffered() {
172 #expect(SearchKind.allCases.map(\.rawValue) == ["all", "repo", "issue", "mr"])
173 }
174}
175
176@MainActor
177struct SearchViewModelTests {
178
179 @Test func anEmptyQuerySearchesNothing() async throws {
180 let (client, stub) = try makeClient()
181 let model = SearchViewModel(client: client)
182
183 model.query = " "
184 try await Task.sleep(for: .milliseconds(500))
185
186 #expect(stub.seen.isEmpty)
187 #expect(model.isSearching == false)
188 }
189
190 @Test func aQueryReachesTheSearchCommand() async throws {
191 let (client, stub) = try makeClient()
192 stub.enqueue(.init(status: 200, json: mixedResults))
193 let model = SearchViewModel(client: client)
194
195 model.query = "parity"
196 try await Task.sleep(for: .milliseconds(600))
197
198 let read = try #require(stub.seen.last)
199 #expect(argvFrom(read.url) == ["search", "parity"])
200 #expect(model.state.value?.count == 3)
201 #expect(model.isSearching)
202 }
203
204 @Test func typingDebouncesIntoOneRequest() async throws {
205 let (client, stub) = try makeClient()
206 stub.enqueue(.init(status: 200, json: mixedResults))
207 let model = SearchViewModel(client: client)
208
209 for text in ["p", "pa", "par", "pari", "parity"] { model.query = text }
210 try await Task.sleep(for: .milliseconds(700))
211
212 #expect(stub.seen.count == 1)
213 #expect(argvFrom(try #require(stub.seen.last).url) == ["search", "parity"])
214 }
215
216 @Test func narrowingByKindAddsTheFlag() async throws {
217 let (client, stub) = try makeClient()
218 stub.enqueue(.init(status: 200, json: mixedResults))
219 let model = SearchViewModel(client: client)
220 model.query = "parity"
221 try await Task.sleep(for: .milliseconds(600))
222
223 stub.enqueue(.init(status: 200, json: mixedResults))
224 model.kind = .issue
225 try await Task.sleep(for: .milliseconds(150))
226
227 #expect(argvFrom(try #require(stub.seen.last).url)
228 == ["search", "parity", "--kind", "issue"])
229 // A picker, not typing: two requests by now, not one.
230 #expect(stub.seen.count == 2)
231 }
232
233 /// Changing the kind with no query must not search.
234 @Test func kindAloneSearchesNothing() async throws {
235 let (client, stub) = try makeClient()
236 let model = SearchViewModel(client: client)
237
238 model.kind = .mr
239 try await Task.sleep(for: .milliseconds(400))
240
241 #expect(stub.seen.isEmpty)
242 }
243
244 /// The server quotes FTS5 terms itself, so an operator is a word.
245 @Test func theQueryIsPassedThroughUnquoted() async throws {
246 let (client, stub) = try makeClient()
247 stub.enqueue(.init(status: 200, json: mixedResults))
248 let model = SearchViewModel(client: client)
249
250 model.query = "c++"
251 try await Task.sleep(for: .milliseconds(600))
252
253 #expect(argvFrom(try #require(stub.seen.last).url) == ["search", "c++"])
254 }
255
256 @Test func clearingStopsSearchingAndDropsResults() async throws {
257 let (client, stub) = try makeClient()
258 stub.enqueue(.init(status: 200, json: mixedResults))
259 let model = SearchViewModel(client: client)
260 model.query = "parity"
261 try await Task.sleep(for: .milliseconds(600))
262 #expect(model.isSearching)
263
264 model.clear()
265
266 #expect(model.isSearching == false)
267 #expect(model.query.isEmpty)
268 }
269
270 @Test func nothingFoundIsAnEmptyStateNotAFailure() async throws {
271 let (client, stub) = try makeClient()
272 stub.enqueue(.init(status: 200, json: """
273 {"protocol_version":1,"data":[],"exit_code":0}
274 """))
275 let model = SearchViewModel(client: client)
276
277 model.query = "zzzznothing"
278 try await Task.sleep(for: .milliseconds(600))
279
280 guard case .empty = model.state else {
281 Testing.Issue.record("expected empty, got \(model.state)")
282 return
283 }
284 }
285}
286```
287
288**Note on decoding:** there is no shared test decoder in this codebase. `GitbayClient` builds a private one with a custom date strategy, and tests that decode a model directly make their own — see `FeedEventTests.decode` in `gitbayTests/DiscoveryTests.swift`, which is the pattern the block above copies. Do not invent a shared decoder.
289
290- [ ] **Step 2: Run to verify failure** — expect `cannot find type 'SearchResult' in scope`.
291
292- [ ] **Step 3: Implement**
293
294Create `gitbay/Discovery/SearchViewModel.swift`:
295
296```swift
297import Foundation
298import Observation
299
300/// One row of `search` — a repository, an issue or a merge request.
301/// Everything but `kind`, `repo` and `title` is omitempty on the wire: a
302/// repository result carries none of it.
303nonisolated struct SearchResult: Decodable, Sendable, Hashable, Identifiable {
304 let kind: String
305 let repo: String
306 let number: Int64?
307 let title: String
308 let author: String?
309 let state: String?
310 let updatedAt: Date?
311
312 enum CodingKeys: String, CodingKey {
313 case kind, repo, number, title, author, state
314 case updatedAt = "updated_at"
315 }
316
317 /// Unique across kinds: a repo row and an issue row in the same
318 /// repository would otherwise share an identity.
319 var id: String { "\(kind)/\(repo)/\(number.map(String.init) ?? "")" }
320
321 var isOpen: Bool { state == "open" }
322}
323
324/// What `search` is narrowed to. `all` is the absence of `--kind`.
325nonisolated enum SearchKind: String, CaseIterable, Identifiable, Sendable {
326 case all, repo, issue, mr
327
328 var id: String { rawValue }
329
330 var label: String {
331 switch self {
332 case .all: "All"
333 case .repo: "Repos"
334 case .issue: "Issues"
335 case .mr: "MRs"
336 }
337 }
338
339 var flag: [String] { self == .all ? [] : ["--kind", rawValue] }
340}
341
342/// `search <query> [--kind ...]` across everything the caller can read.
343/// Not paginated — the command takes no cursor.
344@Observable
345@MainActor
346final class SearchViewModel {
347
348 var query = "" {
349 didSet {
350 guard query != oldValue else { return }
351 scheduleSearch()
352 }
353 }
354
355 /// A picker, so it re-searches at once rather than debouncing.
356 var kind: SearchKind = .all {
357 didSet {
358 guard kind != oldValue, isSearching else { return }
359 searchTask?.cancel()
360 searchTask = Task { await run() }
361 }
362 }
363
364 private(set) var state: LoadState<[SearchResult]> = .loading
365
366 /// True once a non-blank query is present. The Explore listing shows
367 /// through whenever this is false.
368 var isSearching: Bool {
369 !query.trimmingCharacters(in: .whitespaces).isEmpty
370 }
371
372 private let client: GitbayClient
373 private var searchTask: Task<Void, Never>?
374
375 init(client: GitbayClient) {
376 self.client = client
377 }
378
379 func clear() {
380 searchTask?.cancel()
381 query = ""
382 }
383
384 private func scheduleSearch() {
385 searchTask?.cancel()
386 guard isSearching else { return }
387 state = .loading
388 searchTask = Task {
389 // Debounce a person typing, the way the repository list does.
390 guard (try? await Task.sleep(for: .milliseconds(300))) != nil else { return }
391 guard !Task.isCancelled else { return }
392 await run()
393 }
394 }
395
396 private func run() async {
397 let text = query.trimmingCharacters(in: .whitespaces)
398 guard !text.isEmpty else { return }
399 do {
400 // Passed through unquoted: the server quotes FTS5 term by
401 // term, so an operator is a word to match, not a syntax error.
402 let results = try await client.readList(
403 ["search", text] + kind.flag, of: SearchResult.self
404 )
405 guard !Task.isCancelled else { return }
406 state = results.isEmpty ? .empty("Nothing matches “\(text)”.") : .loaded(results)
407 } catch {
408 guard !Task.isCancelled else { return }
409 state = .from(error)
410 }
411 }
412}
413```
414
415- [ ] **Step 4: Run the tests** — expect PASS and the full suite green.
416- [ ] **Step 5: Commit**
417
418```bash
419git add gitbay/Discovery/SearchViewModel.swift gitbayTests/SearchTests.swift
420git commit -m "Instance-wide search across repositories, issues and merge requests"
421```
422
423---
424
425### Task 2: Search on the Explore screen
426
427**Files:** Modify `gitbay/Views/Discovery/ExploreView.swift`
428
429No unit tests — UI. Verification is the build plus the suite staying green.
430
431`ExploreView` currently holds one `PagedListModel<PublicRepo>` over `explore`. Add a `SearchViewModel` beside it and switch on `search.isSearching`:
432
433- **Not searching:** the existing listing, unchanged. Do not disturb it.
434- **Searching:** a segmented `Picker` over `SearchKind.allCases` using `kind.label`, then the results, then `LoadStateOverlay(state: search.state)`.
435
436`.searchable(text: Bindable(search).query, prompt: "Search this instance")` on the `List`.
437
438A result row shows, by kind:
439- `repo` — the path, styled like the existing Explore rows (reuse the `path(of:)` helper's approach).
440- `issue` / `mr` — `repo` in secondary, `#number` or `!number` monospaced, the title, and the state as a `GBChip` (`.gbOK` when open, `.gbDone` for merged, `.gbBad` otherwise), plus the relative `updatedAt` if present.
441
442Routing by kind, using the existing route enums:
443- `repo` → `RepoRoute.repo(result.repo)`
444- `issue` → `IssueRoute.issue(repo: result.repo, number: n)`
445- `mr` → `MRRoute.mr(repo: result.repo, number: n)`
446
447A row whose kind needs a number but has none must not be tappable — render it as a plain row rather than force-unwrapping.
448
449- [ ] **Step 1: Add the search model and `.searchable`**
450- [ ] **Step 2: Add the kind picker and result rows, switched on `isSearching`**
451- [ ] **Step 3: Route each kind to its destination**
452- [ ] **Step 4: Build and run the full suite.** Expect 243+ passing, no drop.
453- [ ] **Step 5: Commit**
454
455```bash
456git commit -m "Search the instance from the Explore tab"
457```
458
459---
460
461### Task 3: Flip the parity row and open the merge request
462
463- [ ] **Step 1:** In the Discovery table, `search issues and merge requests` goes to `yes` for iOS. Touch no other row.
464
465Land it via a worktree off `origin/main` in `krz/gitbay`, merged `--strategy ff` — that repo requires signed commits, so squash and rebase merges are refused, and its working tree usually holds unrelated work.
466
467- [ ] **Step 2:** Run the full suite; record the real number.
468- [ ] **Step 3:** Open the merge request.
469
470```bash
471gitbay mr create --source instance-search --target main \
472 --title "Search the instance from the Explore tab" --file - <<'EOF'
473`search` covers repositories plus the title and body of every issue and
474merge request the caller can read. The Explore tab keeps its listing when
475the field is empty and swaps in results when it is not, with a segmented
476control narrowing to one kind — `all` being the absence of `--kind`.
477
478The query is passed through unquoted, since the server quotes FTS5 term
479by term and an operator is a word to match. Typing debounces at 300ms;
480changing the kind re-searches at once.
481
482Closes the `search issues and merge requests` parity row.
483EOF
484```
485
486---
487
488## Notes for whoever executes this
489
490**`search` is not paginated.** It takes no `--limit` and no `--cursor`. Use `client.readList`. Reaching for `PagedListModel` here is wrong and will send flags the command rejects.
491
492**Four fields are optional and usually absent.** A repository result has no `number`, `author`, `state` or `updated_at`. Decoding must not require them, and the UI must not force-unwrap `number` to build a route.
493
494**Do not disturb the `explore` listing.** It is the screen's behaviour when nothing is typed, and it already works.
495
496**`all` sends no flag.** Sending `--kind all` would be a server error, since the command accepts only `repo`, `issue` and `mr`.