docs/superpowers/plans/2026-09-06-mr03-instance-search.md

v1.1.0
gitbay-ios/docs/superpowers/plans/2026-09-06-mr03-instance-search.md rendered · source · history · blame · raw

496 lines · 19558 bytes

  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`.