| @@ -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 | ``` |
| |
26 | search <query> [--kind repo|issue|mr] |
| |
27 | ``` |
| |
28 | |
| |
29 | 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. |
| |
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 |
| |
43 | xcodebuild -project gitbay.xcodeproj -scheme gitbay \ |
| |
44 | -destination 'platform=iOS Simulator,name=iPhone 17' \ |
| |
45 | test -only-testing:gitbayTests |
| |
46 | ``` |
| |
47 | |
| |
48 | Baseline: **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 | |
| |
73 | Behaviour, each pinned by a test: |
| |
74 | 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. |
| |
75 | 2. Typing debounces at 300ms, matching `RepoListViewModel` and the list filters — one request per pause, not per keystroke. |
| |
76 | 3. `kind == .all` sends **no `--kind` flag**; the other three send `--kind repo|issue|mr` with the wire spelling. |
| |
77 | 4. Changing the kind while a query is present re-searches **immediately** — it is a picker, not typing. |
| |
78 | 5. The query is passed through **unquoted**. The server quotes FTS5 term by term, so `c++` is a word to match. |
| |
79 | 6. `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 | |
| |
83 | Create `gitbayTests/SearchTests.swift`: |
| |
84 | |
| |
85 | ```swift |
| |
86 | import Foundation |
| |
87 | import Testing |
| |
88 | @testable import gitbay |
| |
89 | |
| |
90 | private 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 | |
| |
100 | private func argvFrom(_ url: URL) -> [String] { |
| |
101 | URLComponents(url: url, resolvingAgainstBaseURL: false)? |
| |
102 | .queryItems?.filter { $0.name == "argv" }.compactMap(\.value) ?? [] |
| |
103 | } |
| |
104 | |
| |
105 | private 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 | |
| |
115 | struct 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 | |
| |
159 | struct 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 |
| |
177 | struct 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 | |
| |
294 | Create `gitbay/Discovery/SearchViewModel.swift`: |
| |
295 | |
| |
296 | ```swift |
| |
297 | import Foundation |
| |
298 | import 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. |
| |
303 | nonisolated 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`. |
| |
325 | nonisolated 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 |
| |
346 | final 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 |
| |
419 | git add gitbay/Discovery/SearchViewModel.swift gitbayTests/SearchTests.swift |
| |
420 | git 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 | |
| |
429 | No 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 | |
| |
438 | A 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 | |
| |
442 | Routing 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 | |
| |
447 | A 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 |
| |
456 | git 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 | |
| |
465 | 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. |
| |
466 | |
| |
467 | - [ ] **Step 2:** Run the full suite; record the real number. |
| |
468 | - [ ] **Step 3:** Open the merge request. |
| |
469 | |
| |
470 | ```bash |
| |
471 | gitbay 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 |
| |
474 | merge request the caller can read. The Explore tab keeps its listing when |
| |
475 | the field is empty and swaps in results when it is not, with a segmented |
| |
476 | control narrowing to one kind — `all` being the absence of `--kind`. |
| |
477 | |
| |
478 | The query is passed through unquoted, since the server quotes FTS5 term |
| |
479 | by term and an operator is a word to match. Typing debounces at 300ms; |
| |
480 | changing the kind re-searches at once. |
| |
481 | |
| |
482 | Closes the `search issues and merge requests` parity row. |
| |
483 | EOF |
| |
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`. |