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

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

496 lines · 19558 bytes

MR 3: Instance-wide search Implementation Plan

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.

Goal: Close the search issues and merge requests parity row — one query across repositories, issues and merge requests, from the Explore tab.

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.

Tech Stack: Swift 6, default MainActor isolation, iOS 26.5, SwiftUI, Swift Testing, @Observable.

Spec: docs/superpowers/specs/2026-09-06-ios-parity-design.md

Global Constraints

  • Swift 6 language mode, default MainActor isolation. Wire models are nonisolated structs.
  • Swift Testing only — never XCTest.
  • gitbayTests is hermetic and offline; network goes through StubProtocol.
  • New files under gitbay/ and gitbayTests/ need no project.pbxproj edit.
  • The label model is IssueLabel, never Label.
  • Never mention Claude, LLMs or AI in commits, comments, or the merge request. No Co-Authored-By trailer.
  • Never commit to main.

The command, verbatim from the registry:

search <query> [--kind repo|issue|mr]

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

The result shape, from internal/control/search.go:

{"kind":"issue","repo":"krz/gitbay","number":168,"title":"...","author":"cmc",
 "state":"closed","updated_at":"2026-09-06T16:53:52.595Z"}

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.

Build and test command:

xcodebuild -project gitbay.xcodeproj -scheme gitbay \
  -destination 'platform=iOS Simulator,name=iPhone 17' \
  test -only-testing:gitbayTests

Baseline: 243 passed, 0 failed, 1 skipped (LiveInstanceTests — expected).


File Structure

File Responsibility
gitbay/Discovery/SearchViewModel.swift (create) SearchResult wire model, SearchKind, and the query/debounce view model
gitbayTests/SearchTests.swift (create) Every test in this plan
gitbay/Views/Discovery/ExploreView.swift (modify) .searchable, kind picker, result rows, routing

Task 1: The search model and view model

Files:

  • Create: gitbay/Discovery/SearchViewModel.swift
  • Test: gitbayTests/SearchTests.swift

Interfaces produced:

  • nonisolated struct SearchResult: Decodable, Sendable, Hashable, Identifiable — kind: String, repo: String, number: Int64?, title: String, author: String?, state: String?, updatedAt: Date?; var id: String
  • nonisolated enum SearchKind: String, CaseIterable, Identifiable, Sendable — all, repo, issue, mr; var label: String; var flag: [String]
  • @Observable @MainActor final class SearchViewModel — query: String, kind: SearchKind, state: LoadState<[SearchResult]>, var isSearching: Bool, func clear()

Behaviour, each pinned by a test:

  1. 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.
  2. Typing debounces at 300ms, matching RepoListViewModel and the list filters — one request per pause, not per keystroke.
  3. kind == .all sends no --kind flag; the other three send --kind repo|issue|mr with the wire spelling.
  4. Changing the kind while a query is present re-searches immediately — it is a picker, not typing.
  5. The query is passed through unquoted. The server quotes FTS5 term by term, so c++ is a word to match.
  6. id is unique across kinds and repos — a repo row and an issue row in the same repository must not collide.
  • Step 1: Write the failing tests

Create gitbayTests/SearchTests.swift:

import Foundation
import Testing
@testable import gitbay

private func makeClient() throws -> (GitbayClient, StubProtocol.Box) {
    let box = StubProtocol.box()
    let client = GitbayClient(
        instance: try GitbayInstance(url: "https://gitbay.org"),
        token: "test-token",
        session: box.session()
    )
    return (client, box)
}

private func argvFrom(_ url: URL) -> [String] {
    URLComponents(url: url, resolvingAgainstBaseURL: false)?
        .queryItems?.filter { $0.name == "argv" }.compactMap(\.value) ?? []
}

private let mixedResults = """
    {"protocol_version":1,"data":[\
    {"kind":"repo","repo":"krz/gitbay","title":"krz/gitbay"},\
    {"kind":"issue","repo":"krz/gitbay","number":168,"title":"a bug",\
    "author":"cmc","state":"closed","updated_at":"2026-09-06T16:53:52.595Z"},\
    {"kind":"mr","repo":"krz/gitbay","number":282,"title":"a change",\
    "author":"cmc","state":"open","updated_at":"2026-09-06T15:17:13.062Z"}\
    ],"exit_code":0}
    """

struct SearchResultDecodingTests {

    /// Matches the established direct-decode pattern in
    /// `DiscoveryTests.swift`: a plain decoder with `.iso8601` dates.
    private func decode(_ json: String) throws -> [SearchResult] {
        let decoder = JSONDecoder()
        decoder.dateDecodingStrategy = .iso8601
        return try decoder.decode([SearchResult].self, from: Data(json.utf8))
    }

    private let rows = """
        [{"kind":"repo","repo":"krz/gitbay","title":"krz/gitbay"},\
        {"kind":"issue","repo":"krz/gitbay","number":168,"title":"a bug",\
        "author":"cmc","state":"closed","updated_at":"2026-09-06T16:53:52Z"},\
        {"kind":"mr","repo":"krz/gitbay","number":282,"title":"a change",\
        "author":"cmc","state":"open","updated_at":"2026-09-06T15:17:13Z"}]
        """

    @Test func decodesAllThreeKinds() throws {
        let results = try decode(rows)
        #expect(results.map(\.kind) == ["repo", "issue", "mr"])
        #expect(results[1].number == 168)
        #expect(results[1].author == "cmc")
        #expect(results[1].state == "closed")
    }

    /// A repository result carries no number, author, state or time —
    /// they are omitempty and absent, not null.
    @Test func aRepositoryResultHasNoNumberOrAuthor() throws {
        let repo = try #require(try decode(rows).first)
        #expect(repo.number == nil)
        #expect(repo.author == nil)
        #expect(repo.state == nil)
        #expect(repo.updatedAt == nil)
    }

    /// A repo row and an issue row in the same repository must not
    /// collide, or SwiftUI reuses one for the other.
    @Test func idsAreUniqueAcrossKinds() throws {
        let ids = try decode(rows).map(\.id)
        #expect(Set(ids).count == ids.count)
    }
}

struct SearchKindTests {

    @Test func allSendsNoFlag() {
        #expect(SearchKind.all.flag == [])
    }

    @Test func theOtherThreeSendTheirWireSpelling() {
        #expect(SearchKind.repo.flag == ["--kind", "repo"])
        #expect(SearchKind.issue.flag == ["--kind", "issue"])
        #expect(SearchKind.mr.flag == ["--kind", "mr"])
    }

    @Test func everyKindTheCommandAcceptsIsOffered() {
        #expect(SearchKind.allCases.map(\.rawValue) == ["all", "repo", "issue", "mr"])
    }
}

@MainActor
struct SearchViewModelTests {

    @Test func anEmptyQuerySearchesNothing() async throws {
        let (client, stub) = try makeClient()
        let model = SearchViewModel(client: client)

        model.query = "   "
        try await Task.sleep(for: .milliseconds(500))

        #expect(stub.seen.isEmpty)
        #expect(model.isSearching == false)
    }

    @Test func aQueryReachesTheSearchCommand() async throws {
        let (client, stub) = try makeClient()
        stub.enqueue(.init(status: 200, json: mixedResults))
        let model = SearchViewModel(client: client)

        model.query = "parity"
        try await Task.sleep(for: .milliseconds(600))

        let read = try #require(stub.seen.last)
        #expect(argvFrom(read.url) == ["search", "parity"])
        #expect(model.state.value?.count == 3)
        #expect(model.isSearching)
    }

    @Test func typingDebouncesIntoOneRequest() async throws {
        let (client, stub) = try makeClient()
        stub.enqueue(.init(status: 200, json: mixedResults))
        let model = SearchViewModel(client: client)

        for text in ["p", "pa", "par", "pari", "parity"] { model.query = text }
        try await Task.sleep(for: .milliseconds(700))

        #expect(stub.seen.count == 1)
        #expect(argvFrom(try #require(stub.seen.last).url) == ["search", "parity"])
    }

    @Test func narrowingByKindAddsTheFlag() async throws {
        let (client, stub) = try makeClient()
        stub.enqueue(.init(status: 200, json: mixedResults))
        let model = SearchViewModel(client: client)
        model.query = "parity"
        try await Task.sleep(for: .milliseconds(600))

        stub.enqueue(.init(status: 200, json: mixedResults))
        model.kind = .issue
        try await Task.sleep(for: .milliseconds(150))

        #expect(argvFrom(try #require(stub.seen.last).url)
            == ["search", "parity", "--kind", "issue"])
        // A picker, not typing: two requests by now, not one.
        #expect(stub.seen.count == 2)
    }

    /// Changing the kind with no query must not search.
    @Test func kindAloneSearchesNothing() async throws {
        let (client, stub) = try makeClient()
        let model = SearchViewModel(client: client)

        model.kind = .mr
        try await Task.sleep(for: .milliseconds(400))

        #expect(stub.seen.isEmpty)
    }

    /// The server quotes FTS5 terms itself, so an operator is a word.
    @Test func theQueryIsPassedThroughUnquoted() async throws {
        let (client, stub) = try makeClient()
        stub.enqueue(.init(status: 200, json: mixedResults))
        let model = SearchViewModel(client: client)

        model.query = "c++"
        try await Task.sleep(for: .milliseconds(600))

        #expect(argvFrom(try #require(stub.seen.last).url) == ["search", "c++"])
    }

    @Test func clearingStopsSearchingAndDropsResults() async throws {
        let (client, stub) = try makeClient()
        stub.enqueue(.init(status: 200, json: mixedResults))
        let model = SearchViewModel(client: client)
        model.query = "parity"
        try await Task.sleep(for: .milliseconds(600))
        #expect(model.isSearching)

        model.clear()

        #expect(model.isSearching == false)
        #expect(model.query.isEmpty)
    }

    @Test func nothingFoundIsAnEmptyStateNotAFailure() async throws {
        let (client, stub) = try makeClient()
        stub.enqueue(.init(status: 200, json: """
            {"protocol_version":1,"data":[],"exit_code":0}
            """))
        let model = SearchViewModel(client: client)

        model.query = "zzzznothing"
        try await Task.sleep(for: .milliseconds(600))

        guard case .empty = model.state else {
            Testing.Issue.record("expected empty, got \(model.state)")
            return
        }
    }
}

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.

  • Step 2: Run to verify failure — expect cannot find type 'SearchResult' in scope.

  • Step 3: Implement

Create gitbay/Discovery/SearchViewModel.swift:

import Foundation
import Observation

/// One row of `search` — a repository, an issue or a merge request.
/// Everything but `kind`, `repo` and `title` is omitempty on the wire: a
/// repository result carries none of it.
nonisolated struct SearchResult: Decodable, Sendable, Hashable, Identifiable {
    let kind: String
    let repo: String
    let number: Int64?
    let title: String
    let author: String?
    let state: String?
    let updatedAt: Date?

    enum CodingKeys: String, CodingKey {
        case kind, repo, number, title, author, state
        case updatedAt = "updated_at"
    }

    /// Unique across kinds: a repo row and an issue row in the same
    /// repository would otherwise share an identity.
    var id: String { "\(kind)/\(repo)/\(number.map(String.init) ?? "")" }

    var isOpen: Bool { state == "open" }
}

/// What `search` is narrowed to. `all` is the absence of `--kind`.
nonisolated enum SearchKind: String, CaseIterable, Identifiable, Sendable {
    case all, repo, issue, mr

    var id: String { rawValue }

    var label: String {
        switch self {
        case .all: "All"
        case .repo: "Repos"
        case .issue: "Issues"
        case .mr: "MRs"
        }
    }

    var flag: [String] { self == .all ? [] : ["--kind", rawValue] }
}

/// `search <query> [--kind ...]` across everything the caller can read.
/// Not paginated — the command takes no cursor.
@Observable
@MainActor
final class SearchViewModel {

    var query = "" {
        didSet {
            guard query != oldValue else { return }
            scheduleSearch()
        }
    }

    /// A picker, so it re-searches at once rather than debouncing.
    var kind: SearchKind = .all {
        didSet {
            guard kind != oldValue, isSearching else { return }
            searchTask?.cancel()
            searchTask = Task { await run() }
        }
    }

    private(set) var state: LoadState<[SearchResult]> = .loading

    /// True once a non-blank query is present. The Explore listing shows
    /// through whenever this is false.
    var isSearching: Bool {
        !query.trimmingCharacters(in: .whitespaces).isEmpty
    }

    private let client: GitbayClient
    private var searchTask: Task<Void, Never>?

    init(client: GitbayClient) {
        self.client = client
    }

    func clear() {
        searchTask?.cancel()
        query = ""
    }

    private func scheduleSearch() {
        searchTask?.cancel()
        guard isSearching else { return }
        state = .loading
        searchTask = Task {
            // Debounce a person typing, the way the repository list does.
            guard (try? await Task.sleep(for: .milliseconds(300))) != nil else { return }
            guard !Task.isCancelled else { return }
            await run()
        }
    }

    private func run() async {
        let text = query.trimmingCharacters(in: .whitespaces)
        guard !text.isEmpty else { return }
        do {
            // Passed through unquoted: the server quotes FTS5 term by
            // term, so an operator is a word to match, not a syntax error.
            let results = try await client.readList(
                ["search", text] + kind.flag, of: SearchResult.self
            )
            guard !Task.isCancelled else { return }
            state = results.isEmpty ? .empty("Nothing matches “\(text)”.") : .loaded(results)
        } catch {
            guard !Task.isCancelled else { return }
            state = .from(error)
        }
    }
}
  • Step 4: Run the tests — expect PASS and the full suite green.
  • Step 5: Commit
git add gitbay/Discovery/SearchViewModel.swift gitbayTests/SearchTests.swift
git commit -m "Instance-wide search across repositories, issues and merge requests"

Task 2: Search on the Explore screen

Files: Modify gitbay/Views/Discovery/ExploreView.swift

No unit tests — UI. Verification is the build plus the suite staying green.

ExploreView currently holds one PagedListModel<PublicRepo> over explore. Add a SearchViewModel beside it and switch on search.isSearching:

  • Not searching: the existing listing, unchanged. Do not disturb it.
  • Searching: a segmented Picker over SearchKind.allCases using kind.label, then the results, then LoadStateOverlay(state: search.state).

.searchable(text: Bindable(search).query, prompt: "Search this instance") on the List.

A result row shows, by kind:

  • repo — the path, styled like the existing Explore rows (reuse the path(of:) helper's approach).
  • 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.

Routing by kind, using the existing route enums:

  • repo → RepoRoute.repo(result.repo)
  • issue → IssueRoute.issue(repo: result.repo, number: n)
  • mr → MRRoute.mr(repo: result.repo, number: n)

A row whose kind needs a number but has none must not be tappable — render it as a plain row rather than force-unwrapping.

  • Step 1: Add the search model and .searchable
  • Step 2: Add the kind picker and result rows, switched on isSearching
  • Step 3: Route each kind to its destination
  • Step 4: Build and run the full suite. Expect 243+ passing, no drop.
  • Step 5: Commit
git commit -m "Search the instance from the Explore tab"

Task 3: Flip the parity row and open the merge request

  • Step 1: In the Discovery table, search issues and merge requests goes to yes for iOS. Touch no other row.

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

  • Step 2: Run the full suite; record the real number.
  • Step 3: Open the merge request.
gitbay mr create --source instance-search --target main \
  --title "Search the instance from the Explore tab" --file - <<'EOF'
`search` covers repositories plus the title and body of every issue and
merge request the caller can read. The Explore tab keeps its listing when
the field is empty and swaps in results when it is not, with a segmented
control narrowing to one kind — `all` being the absence of `--kind`.

The query is passed through unquoted, since the server quotes FTS5 term
by term and an operator is a word to match. Typing debounces at 300ms;
changing the kind re-searches at once.

Closes the `search issues and merge requests` parity row.
EOF

Notes for whoever executes this

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.

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.

Do not disturb the explore listing. It is the screen's behaviour when nothing is typed, and it already works.

all sends no flag. Sending --kind all would be a server error, since the command accepts only repo, issue and mr.